Merge branch 'main' into avm99963-monorail
Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266
GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
diff --git a/third_party/endpoints/api_config_manager.py b/third_party/endpoints/api_config_manager.py
new file mode 100644
index 0000000..08f2c8e
--- /dev/null
+++ b/third_party/endpoints/api_config_manager.py
@@ -0,0 +1,350 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Configuration manager to store API configurations."""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+import base64
+import logging
+import re
+import threading
+from six.moves import urllib
+
+from . import discovery_service
+
+_logger = logging.getLogger(__name__)
+
+# Internal constants
+_PATH_VARIABLE_PATTERN = r'[a-zA-Z_][a-zA-Z_.\d]*'
+_PATH_VALUE_PATTERN = r'[^/?#\[\]{}]*'
+
+
+class ApiConfigManager(object):
+ """Manages loading api configs and method lookup."""
+
+ def __init__(self):
+ self._rest_methods = []
+ self._configs = {}
+ self._config_lock = threading.Lock()
+
+ @property
+ def configs(self):
+ """Return a dict with the current configuration mappings.
+
+ Returns:
+ A dict with the current configuration mappings.
+ """
+ with self._config_lock:
+ return self._configs.copy()
+
+ def process_api_config_response(self, config_json):
+ """Parses a JSON API config and registers methods for dispatch.
+
+ Side effects:
+ Parses method name, etc. for all methods and updates the indexing
+ data structures with the information.
+
+ Args:
+ config_json: A dict, the JSON body of the getApiConfigs response.
+ """
+ with self._config_lock:
+ self._add_discovery_config()
+ for config in config_json.get('items', []):
+ lookup_key = config.get('name', ''), config.get('version', '')
+ self._configs[lookup_key] = config
+
+ for config in self._configs.values():
+ name = config.get('name', '')
+ api_version = config.get('api_version', '')
+ path_version = config.get('path_version', '')
+ sorted_methods = self._get_sorted_methods(config.get('methods', {}))
+
+
+ for method_name, method in sorted_methods:
+ self._save_rest_method(method_name, name, path_version, method)
+
+ def _get_sorted_methods(self, methods):
+ """Get a copy of 'methods' sorted the way they would be on the live server.
+
+ Args:
+ methods: JSON configuration of an API's methods.
+
+ Returns:
+ The same configuration with the methods sorted based on what order
+ they'll be checked by the server.
+ """
+ if not methods:
+ return methods
+
+ # Comparison function we'll use to sort the methods:
+ def _sorted_methods_comparison(method_info1, method_info2):
+ """Sort method info by path and http_method.
+
+ Args:
+ method_info1: Method name and info for the first method to compare.
+ method_info2: Method name and info for the method to compare to.
+
+ Returns:
+ Negative if the first method should come first, positive if the
+ first method should come after the second. Zero if they're
+ equivalent.
+ """
+
+ def _score_path(path):
+ """Calculate the score for this path, used for comparisons.
+
+ Higher scores have priority, and if scores are equal, the path text
+ is sorted alphabetically. Scores are based on the number and location
+ of the constant parts of the path. The server has some special handling
+ for variables with regexes, which we don't handle here.
+
+ Args:
+ path: The request path that we're calculating a score for.
+
+ Returns:
+ The score for the given path.
+ """
+ score = 0
+ parts = path.split('/')
+ for part in parts:
+ score <<= 1
+ if not part or part[0] != '{':
+ # Found a constant.
+ score += 1
+ # Shift by 31 instead of 32 because some (!) versions of Python like
+ # to convert the int to a long if we shift by 32, and the sorted()
+ # function that uses this blows up if it receives anything but an int.
+ score <<= 31 - len(parts)
+ return score
+
+ # Higher path scores come first.
+ path_score1 = _score_path(method_info1[1].get('path', ''))
+ path_score2 = _score_path(method_info2[1].get('path', ''))
+ if path_score1 != path_score2:
+ return path_score2 - path_score1
+
+ # Compare by path text next, sorted alphabetically.
+ path_result = cmp(method_info1[1].get('path', ''),
+ method_info2[1].get('path', ''))
+ if path_result != 0:
+ return path_result
+
+ # All else being equal, sort by HTTP method.
+ method_result = cmp(method_info1[1].get('httpMethod', ''),
+ method_info2[1].get('httpMethod', ''))
+ return method_result
+
+ return sorted(methods.items(), _sorted_methods_comparison)
+
+ @staticmethod
+ def _get_path_params(match):
+ """Gets path parameters from a regular expression match.
+
+ Args:
+ match: A regular expression Match object for a path.
+
+ Returns:
+ A dictionary containing the variable names converted from base64.
+ """
+ result = {}
+ for var_name, value in match.groupdict().items():
+ actual_var_name = ApiConfigManager._from_safe_path_param_name(var_name)
+ result[actual_var_name] = urllib.parse.unquote_plus(value)
+ return result
+
+ def lookup_rest_method(self, path, request_uri, http_method):
+ """Look up the rest method at call time.
+
+ The method is looked up in self._rest_methods, the list it is saved
+ in for SaveRestMethod.
+
+ Args:
+ path: A string containing the path from the URL of the request.
+ http_method: A string containing HTTP method of the request.
+
+ Returns:
+ Tuple of (<method name>, <method>, <params>)
+ Where:
+ <method name> is the string name of the method that was matched.
+ <method> is the descriptor as specified in the API configuration. -and-
+ <params> is a dict of path parameters matched in the rest request.
+ """
+ method_key = http_method.lower()
+ with self._config_lock:
+ for compiled_path_pattern, unused_path, methods in self._rest_methods:
+ if method_key not in methods:
+ continue
+ candidate_method_info = methods[method_key]
+ match_against = request_uri if candidate_method_info[1].get('useRequestUri') else path
+ match = compiled_path_pattern.match(match_against)
+ if match:
+ params = self._get_path_params(match)
+ method_name, method = candidate_method_info
+ break
+ else:
+ _logger.warn('No endpoint found for path: %r, method: %r', path, http_method)
+ method_name = None
+ method = None
+ params = None
+ return method_name, method, params
+
+ def _add_discovery_config(self):
+ """Add the Discovery configuration to our list of configs.
+
+ This should only be called with self._config_lock. The code here assumes
+ the lock is held.
+ """
+ lookup_key = (discovery_service.DiscoveryService.API_CONFIG['name'],
+ discovery_service.DiscoveryService.API_CONFIG['version'])
+ self._configs[lookup_key] = discovery_service.DiscoveryService.API_CONFIG
+
+ def save_config(self, lookup_key, config):
+ """Save a configuration to the cache of configs.
+
+ Args:
+ lookup_key: A string containing the cache lookup key.
+ config: The dict containing the configuration to save to the cache.
+ """
+ with self._config_lock:
+ self._configs[lookup_key] = config
+
+ @staticmethod
+ def _to_safe_path_param_name(matched_parameter):
+ """Creates a safe string to be used as a regex group name.
+
+ Only alphanumeric characters and underscore are allowed in variable name
+ tokens, and numeric are not allowed as the first character.
+
+ We cast the matched_parameter to base32 (since the alphabet is safe),
+ strip the padding (= not safe) and prepend with _, since we know a token
+ can begin with underscore.
+
+ Args:
+ matched_parameter: A string containing the parameter matched from the URL
+ template.
+
+ Returns:
+ A string that's safe to be used as a regex group name.
+ """
+ return '_' + base64.b32encode(matched_parameter).rstrip('=')
+
+ @staticmethod
+ def _from_safe_path_param_name(safe_parameter):
+ """Takes a safe regex group name and converts it back to the original value.
+
+ Only alphanumeric characters and underscore are allowed in variable name
+ tokens, and numeric are not allowed as the first character.
+
+ The safe_parameter is a base32 representation of the actual value.
+
+ Args:
+ safe_parameter: A string that was generated by _to_safe_path_param_name.
+
+ Returns:
+ A string, the parameter matched from the URL template.
+ """
+ assert safe_parameter.startswith('_')
+ safe_parameter_as_base32 = safe_parameter[1:]
+
+ padding_length = - len(safe_parameter_as_base32) % 8
+ padding = '=' * padding_length
+ return base64.b32decode(safe_parameter_as_base32 + padding)
+
+ @staticmethod
+ def _compile_path_pattern(pattern):
+ r"""Generates a compiled regex pattern for a path pattern.
+
+ e.g. '/MyApi/v1/notes/{id}'
+ returns re.compile(r'/MyApi/v1/notes/(?P<id>[^/?#\[\]{}]*)')
+
+ Args:
+ pattern: A string, the parameterized path pattern to be checked.
+
+ Returns:
+ A compiled regex object to match this path pattern.
+ """
+
+ def replace_variable(match):
+ """Replaces a {variable} with a regex to match it by name.
+
+ Changes the string corresponding to the variable name to the base32
+ representation of the string, prepended by an underscore. This is
+ necessary because we can have message variable names in URL patterns
+ (e.g. via {x.y}) but the character '.' can't be in a regex group name.
+
+ Args:
+ match: A regex match object, the matching regex group as sent by
+ re.sub().
+
+ Returns:
+ A string regex to match the variable by name, if the full pattern was
+ matched.
+ """
+ if match.lastindex > 1:
+ var_name = ApiConfigManager._to_safe_path_param_name(match.group(2))
+ return '%s(?P<%s>%s)' % (match.group(1), var_name,
+ _PATH_VALUE_PATTERN)
+ return match.group(0)
+
+ pattern = re.sub('(/|^){(%s)}(?=/|$|:)' % _PATH_VARIABLE_PATTERN,
+ replace_variable, pattern)
+ return re.compile(pattern + '/?$')
+
+ def _save_rest_method(self, method_name, api_name, version, method):
+ """Store Rest api methods in a list for lookup at call time.
+
+ The list is self._rest_methods, a list of tuples:
+ [(<compiled_path>, <path_pattern>, <method_dict>), ...]
+ where:
+ <compiled_path> is a compiled regex to match against the incoming URL
+ <path_pattern> is a string representing the original path pattern,
+ checked on insertion to prevent duplicates. -and-
+ <method_dict> is a dict of httpMethod => (method_name, method)
+
+ This structure is a bit complex, it supports use in two contexts:
+ Creation time:
+ - SaveRestMethod is called repeatedly, each method will have a path,
+ which we want to be compiled for fast lookup at call time
+ - We want to prevent duplicate incoming path patterns, so store the
+ un-compiled path, not counting on a compiled regex being a stable
+ comparison as it is not documented as being stable for this use.
+ - Need to store the method that will be mapped at calltime.
+ - Different methods may have the same path but different http method.
+ Call time:
+ - Quickly scan through the list attempting .match(path) on each
+ compiled regex to find the path that matches.
+ - When a path is matched, look up the API method from the request
+ and get the method name and method config for the matching
+ API method and method name.
+
+ Args:
+ method_name: A string containing the name of the API method.
+ api_name: A string containing the name of the API.
+ version: A string containing the version of the API.
+ method: A dict containing the method descriptor (as in the api config
+ file).
+ """
+ path_pattern = '/'.join((api_name, version, method.get('path', '')))
+ http_method = method.get('httpMethod', '').lower()
+ for _, path, methods in self._rest_methods:
+ if path == path_pattern:
+ methods[http_method] = method_name, method
+ break
+ else:
+ self._rest_methods.append(
+ (self._compile_path_pattern(path_pattern),
+ path_pattern,
+ {http_method: (method_name, method)}))