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