OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 Google Inc. All Rights Reserved. |
| 2 # |
| 3 # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 # you may not use this file except in compliance with the License. |
| 5 # You may obtain a copy of the License at |
| 6 # |
| 7 # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 # |
| 9 # Unless required by applicable law or agreed to in writing, software |
| 10 # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 # See the License for the specific language governing permissions and |
| 13 # limitations under the License. |
| 14 |
| 15 """Configuration manager to store API configurations.""" |
| 16 |
| 17 # pylint: disable=g-bad-name |
| 18 import base64 |
| 19 import logging |
| 20 import re |
| 21 import threading |
| 22 import urllib |
| 23 |
| 24 import discovery_service |
| 25 |
| 26 |
| 27 # Internal constants |
| 28 _PATH_VARIABLE_PATTERN = r'[a-zA-Z_][a-zA-Z_.\d]*' |
| 29 _PATH_VALUE_PATTERN = r'[^:/?#\[\]{}]*' |
| 30 |
| 31 |
| 32 class ApiConfigManager(object): |
| 33 """Manages loading api configs and method lookup.""" |
| 34 |
| 35 def __init__(self): |
| 36 self._rpc_method_dict = {} |
| 37 self._rest_methods = [] |
| 38 self._configs = {} |
| 39 self._config_lock = threading.Lock() |
| 40 |
| 41 @property |
| 42 def configs(self): |
| 43 """Return a dict with the current configuration mappings. |
| 44 |
| 45 Returns: |
| 46 A dict with the current configuration mappings. |
| 47 """ |
| 48 with self._config_lock: |
| 49 return self._configs.copy() |
| 50 |
| 51 def process_api_config_response(self, config_json): |
| 52 """Parses a JSON API config and registers methods for dispatch. |
| 53 |
| 54 Side effects: |
| 55 Parses method name, etc. for all methods and updates the indexing |
| 56 data structures with the information. |
| 57 |
| 58 Args: |
| 59 config_json: A dict, the JSON body of the getApiConfigs response. |
| 60 """ |
| 61 with self._config_lock: |
| 62 self._add_discovery_config() |
| 63 for config in config_json.get('items', []): |
| 64 lookup_key = config.get('name', ''), config.get('version', '') |
| 65 self._configs[lookup_key] = config |
| 66 |
| 67 for config in self._configs.itervalues(): |
| 68 name = config.get('name', '') |
| 69 version = config.get('version', '') |
| 70 sorted_methods = self._get_sorted_methods(config.get('methods', {})) |
| 71 |
| 72 for method_name, method in sorted_methods: |
| 73 self._save_rpc_method(method_name, version, method) |
| 74 self._save_rest_method(method_name, name, version, method) |
| 75 |
| 76 def _get_sorted_methods(self, methods): |
| 77 """Get a copy of 'methods' sorted the way they would be on the live server. |
| 78 |
| 79 Args: |
| 80 methods: JSON configuration of an API's methods. |
| 81 |
| 82 Returns: |
| 83 The same configuration with the methods sorted based on what order |
| 84 they'll be checked by the server. |
| 85 """ |
| 86 if not methods: |
| 87 return methods |
| 88 |
| 89 # Comparison function we'll use to sort the methods: |
| 90 def _sorted_methods_comparison(method_info1, method_info2): |
| 91 """Sort method info by path and http_method. |
| 92 |
| 93 Args: |
| 94 method_info1: Method name and info for the first method to compare. |
| 95 method_info2: Method name and info for the method to compare to. |
| 96 |
| 97 Returns: |
| 98 Negative if the first method should come first, positive if the |
| 99 first method should come after the second. Zero if they're |
| 100 equivalent. |
| 101 """ |
| 102 |
| 103 def _score_path(path): |
| 104 """Calculate the score for this path, used for comparisons. |
| 105 |
| 106 Higher scores have priority, and if scores are equal, the path text |
| 107 is sorted alphabetically. Scores are based on the number and location |
| 108 of the constant parts of the path. The server has some special handling |
| 109 for variables with regexes, which we don't handle here. |
| 110 |
| 111 Args: |
| 112 path: The request path that we're calculating a score for. |
| 113 |
| 114 Returns: |
| 115 The score for the given path. |
| 116 """ |
| 117 score = 0 |
| 118 parts = path.split('/') |
| 119 for part in parts: |
| 120 score <<= 1 |
| 121 if not part or part[0] != '{': |
| 122 # Found a constant. |
| 123 score += 1 |
| 124 # Shift by 31 instead of 32 because some (!) versions of Python like |
| 125 # to convert the int to a long if we shift by 32, and the sorted() |
| 126 # function that uses this blows up if it receives anything but an int. |
| 127 score <<= 31 - len(parts) |
| 128 return score |
| 129 |
| 130 # Higher path scores come first. |
| 131 path_score1 = _score_path(method_info1[1].get('path', '')) |
| 132 path_score2 = _score_path(method_info2[1].get('path', '')) |
| 133 if path_score1 != path_score2: |
| 134 return path_score2 - path_score1 |
| 135 |
| 136 # Compare by path text next, sorted alphabetically. |
| 137 path_result = cmp(method_info1[1].get('path', ''), |
| 138 method_info2[1].get('path', '')) |
| 139 if path_result != 0: |
| 140 return path_result |
| 141 |
| 142 # All else being equal, sort by HTTP method. |
| 143 method_result = cmp(method_info1[1].get('httpMethod', ''), |
| 144 method_info2[1].get('httpMethod', '')) |
| 145 return method_result |
| 146 |
| 147 return sorted(methods.items(), _sorted_methods_comparison) |
| 148 |
| 149 @staticmethod |
| 150 def _get_path_params(match): |
| 151 """Gets path parameters from a regular expression match. |
| 152 |
| 153 Args: |
| 154 match: A regular expression Match object for a path. |
| 155 |
| 156 Returns: |
| 157 A dictionary containing the variable names converted from base64. |
| 158 """ |
| 159 result = {} |
| 160 for var_name, value in match.groupdict().iteritems(): |
| 161 actual_var_name = ApiConfigManager._from_safe_path_param_name(var_name) |
| 162 result[actual_var_name] = urllib.unquote_plus(value) |
| 163 return result |
| 164 |
| 165 def lookup_rpc_method(self, method_name, version): |
| 166 """Lookup the JsonRPC method at call time. |
| 167 |
| 168 The method is looked up in self._rpc_method_dict, the dictionary that |
| 169 it is saved in for SaveRpcMethod(). |
| 170 |
| 171 Args: |
| 172 method_name: A string containing the name of the method. |
| 173 version: A string containing the version of the API. |
| 174 |
| 175 Returns: |
| 176 Method descriptor as specified in the API configuration. |
| 177 """ |
| 178 with self._config_lock: |
| 179 method = self._rpc_method_dict.get((method_name, version)) |
| 180 return method |
| 181 |
| 182 def lookup_rest_method(self, path, http_method): |
| 183 """Look up the rest method at call time. |
| 184 |
| 185 The method is looked up in self._rest_methods, the list it is saved |
| 186 in for SaveRestMethod. |
| 187 |
| 188 Args: |
| 189 path: A string containing the path from the URL of the request. |
| 190 http_method: A string containing HTTP method of the request. |
| 191 |
| 192 Returns: |
| 193 Tuple of (<method name>, <method>, <params>) |
| 194 Where: |
| 195 <method name> is the string name of the method that was matched. |
| 196 <method> is the descriptor as specified in the API configuration. -and- |
| 197 <params> is a dict of path parameters matched in the rest request. |
| 198 """ |
| 199 with self._config_lock: |
| 200 path = urllib.unquote(path) |
| 201 for compiled_path_pattern, unused_path, methods in self._rest_methods: |
| 202 match = compiled_path_pattern.match(path) |
| 203 if match: |
| 204 params = self._get_path_params(match) |
| 205 method_key = http_method.lower() |
| 206 method_name, method = methods.get(method_key, (None, None)) |
| 207 if method: |
| 208 break |
| 209 else: |
| 210 logging.warn('No endpoint found for path: %s', path) |
| 211 method_name = None |
| 212 method = None |
| 213 params = None |
| 214 return method_name, method, params |
| 215 |
| 216 def _add_discovery_config(self): |
| 217 """Add the Discovery configuration to our list of configs. |
| 218 |
| 219 This should only be called with self._config_lock. The code here assumes |
| 220 the lock is held. |
| 221 """ |
| 222 lookup_key = (discovery_service.DiscoveryService.API_CONFIG['name'], |
| 223 discovery_service.DiscoveryService.API_CONFIG['version']) |
| 224 self._configs[lookup_key] = discovery_service.DiscoveryService.API_CONFIG |
| 225 |
| 226 def save_config(self, lookup_key, config): |
| 227 """Save a configuration to the cache of configs. |
| 228 |
| 229 Args: |
| 230 lookup_key: A string containing the cache lookup key. |
| 231 config: The dict containing the configuration to save to the cache. |
| 232 """ |
| 233 with self._config_lock: |
| 234 self._configs[lookup_key] = config |
| 235 |
| 236 @staticmethod |
| 237 def _to_safe_path_param_name(matched_parameter): |
| 238 """Creates a safe string to be used as a regex group name. |
| 239 |
| 240 Only alphanumeric characters and underscore are allowed in variable name |
| 241 tokens, and numeric are not allowed as the first character. |
| 242 |
| 243 We cast the matched_parameter to base32 (since the alphabet is safe), |
| 244 strip the padding (= not safe) and prepend with _, since we know a token |
| 245 can begin with underscore. |
| 246 |
| 247 Args: |
| 248 matched_parameter: A string containing the parameter matched from the URL |
| 249 template. |
| 250 |
| 251 Returns: |
| 252 A string that's safe to be used as a regex group name. |
| 253 """ |
| 254 return '_' + base64.b32encode(matched_parameter).rstrip('=') |
| 255 |
| 256 @staticmethod |
| 257 def _from_safe_path_param_name(safe_parameter): |
| 258 """Takes a safe regex group name and converts it back to the original value. |
| 259 |
| 260 Only alphanumeric characters and underscore are allowed in variable name |
| 261 tokens, and numeric are not allowed as the first character. |
| 262 |
| 263 The safe_parameter is a base32 representation of the actual value. |
| 264 |
| 265 Args: |
| 266 safe_parameter: A string that was generated by _to_safe_path_param_name. |
| 267 |
| 268 Returns: |
| 269 A string, the parameter matched from the URL template. |
| 270 """ |
| 271 assert safe_parameter.startswith('_') |
| 272 safe_parameter_as_base32 = safe_parameter[1:] |
| 273 |
| 274 padding_length = - len(safe_parameter_as_base32) % 8 |
| 275 padding = '=' * padding_length |
| 276 return base64.b32decode(safe_parameter_as_base32 + padding) |
| 277 |
| 278 @staticmethod |
| 279 def _compile_path_pattern(pattern): |
| 280 r"""Generates a compiled regex pattern for a path pattern. |
| 281 |
| 282 e.g. '/MyApi/v1/notes/{id}' |
| 283 returns re.compile(r'/MyApi/v1/notes/(?P<id>[^:/?#\[\]{}]*)') |
| 284 |
| 285 Args: |
| 286 pattern: A string, the parameterized path pattern to be checked. |
| 287 |
| 288 Returns: |
| 289 A compiled regex object to match this path pattern. |
| 290 """ |
| 291 |
| 292 def replace_variable(match): |
| 293 """Replaces a {variable} with a regex to match it by name. |
| 294 |
| 295 Changes the string corresponding to the variable name to the base32 |
| 296 representation of the string, prepended by an underscore. This is |
| 297 necessary because we can have message variable names in URL patterns |
| 298 (e.g. via {x.y}) but the character '.' can't be in a regex group name. |
| 299 |
| 300 Args: |
| 301 match: A regex match object, the matching regex group as sent by |
| 302 re.sub(). |
| 303 |
| 304 Returns: |
| 305 A string regex to match the variable by name, if the full pattern was |
| 306 matched. |
| 307 """ |
| 308 if match.lastindex > 1: |
| 309 var_name = ApiConfigManager._to_safe_path_param_name(match.group(2)) |
| 310 return '%s(?P<%s>%s)' % (match.group(1), var_name, |
| 311 _PATH_VALUE_PATTERN) |
| 312 return match.group(0) |
| 313 |
| 314 pattern = re.sub('(/|^){(%s)}(?=/|$)' % _PATH_VARIABLE_PATTERN, |
| 315 replace_variable, pattern) |
| 316 return re.compile(pattern + '/?$') |
| 317 |
| 318 def _save_rpc_method(self, method_name, version, method): |
| 319 """Store JsonRpc api methods in a map for lookup at call time. |
| 320 |
| 321 (rpcMethodName, apiVersion) => method. |
| 322 |
| 323 Args: |
| 324 method_name: A string containing the name of the API method. |
| 325 version: A string containing the version of the API. |
| 326 method: A dict containing the method descriptor (as in the api config |
| 327 file). |
| 328 """ |
| 329 self._rpc_method_dict[(method_name, version)] = method |
| 330 |
| 331 def _save_rest_method(self, method_name, api_name, version, method): |
| 332 """Store Rest api methods in a list for lookup at call time. |
| 333 |
| 334 The list is self._rest_methods, a list of tuples: |
| 335 [(<compiled_path>, <path_pattern>, <method_dict>), ...] |
| 336 where: |
| 337 <compiled_path> is a compiled regex to match against the incoming URL |
| 338 <path_pattern> is a string representing the original path pattern, |
| 339 checked on insertion to prevent duplicates. -and- |
| 340 <method_dict> is a dict of httpMethod => (method_name, method) |
| 341 |
| 342 This structure is a bit complex, it supports use in two contexts: |
| 343 Creation time: |
| 344 - SaveRestMethod is called repeatedly, each method will have a path, |
| 345 which we want to be compiled for fast lookup at call time |
| 346 - We want to prevent duplicate incoming path patterns, so store the |
| 347 un-compiled path, not counting on a compiled regex being a stable |
| 348 comparison as it is not documented as being stable for this use. |
| 349 - Need to store the method that will be mapped at calltime. |
| 350 - Different methods may have the same path but different http method. |
| 351 Call time: |
| 352 - Quickly scan through the list attempting .match(path) on each |
| 353 compiled regex to find the path that matches. |
| 354 - When a path is matched, look up the API method from the request |
| 355 and get the method name and method config for the matching |
| 356 API method and method name. |
| 357 |
| 358 Args: |
| 359 method_name: A string containing the name of the API method. |
| 360 api_name: A string containing the name of the API. |
| 361 version: A string containing the version of the API. |
| 362 method: A dict containing the method descriptor (as in the api config |
| 363 file). |
| 364 """ |
| 365 path_pattern = '/'.join((api_name, version, method.get('path', ''))) |
| 366 http_method = method.get('httpMethod', '').lower() |
| 367 for _, path, methods in self._rest_methods: |
| 368 if path == path_pattern: |
| 369 methods[http_method] = method_name, method |
| 370 break |
| 371 else: |
| 372 self._rest_methods.append( |
| 373 (self._compile_path_pattern(path_pattern), |
| 374 path_pattern, |
| 375 {http_method: (method_name, method)})) |
OLD | NEW |