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)}))