blob: 08f2c8e9a159ede0e0c06931b653f48fa1655703 [file] [log] [blame]
# 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)}))