blob: 08f2c8e9a159ede0e0c06931b653f48fa1655703 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# 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
18from __future__ import absolute_import
19
20import base64
21import logging
22import re
23import threading
24from six.moves import urllib
25
26from . 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
35class 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)}))