1# Copyright 2017 Google LLC
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"""Expand and validate URL path templates.
16
17This module provides the :func:`expand` and :func:`validate` functions for
18interacting with Google-style URL `path templates`_ which are commonly used
19in Google APIs for `resource names`_.
20
21.. _path templates: https://github.com/googleapis/googleapis/blob
22 /57e2d376ac7ef48681554204a3ba78a414f2c533/google/api/http.proto#L212
23.. _resource names: https://cloud.google.com/apis/design/resource_names
24"""
25
26from __future__ import unicode_literals
27
28from collections import deque
29import copy
30import functools
31import re
32
33# Regular expression for extracting variable parts from a path template.
34# The variables can be expressed as:
35#
36# - "*": a single-segment positional variable, for example: "books/*"
37# - "**": a multi-segment positional variable, for example: "shelf/**/book/*"
38# - "{name}": a single-segment wildcard named variable, for example
39# "books/{name}"
40# - "{name=*}: same as above.
41# - "{name=**}": a multi-segment wildcard named variable, for example
42# "shelf/{name=**}"
43# - "{name=/path/*/**}": a multi-segment named variable with a sub-template.
44_VARIABLE_RE = re.compile(
45 r"""
46 ( # Capture the entire variable expression
47 (?P<positional>\*\*?) # Match & capture * and ** positional variables.
48 |
49 # Match & capture named variables {name}
50 {
51 (?P<name>[^/]+?)
52 # Optionally match and capture the named variable's template.
53 (?:=(?P<template>.+?))?
54 }
55 )
56 """,
57 re.VERBOSE,
58)
59
60# Segment expressions used for validating paths against a template.
61_SINGLE_SEGMENT_PATTERN = r"([^/]+)"
62_MULTI_SEGMENT_PATTERN = r"(.+)"
63
64
65def _expand_variable_match(positional_vars, named_vars, match):
66 """Expand a matched variable with its value.
67
68 Args:
69 positional_vars (list): A list of positional variables. This list will
70 be modified.
71 named_vars (dict): A dictionary of named variables.
72 match (re.Match): A regular expression match.
73
74 Returns:
75 str: The expanded variable to replace the match.
76
77 Raises:
78 ValueError: If a positional or named variable is required by the
79 template but not specified or if an unexpected template expression
80 is encountered.
81 """
82 positional = match.group("positional")
83 name = match.group("name")
84 if name is not None:
85 try:
86 return str(named_vars[name])
87 except KeyError:
88 raise ValueError(
89 "Named variable '{}' not specified and needed by template "
90 "`{}` at position {}".format(name, match.string, match.start())
91 )
92 elif positional is not None:
93 try:
94 return str(positional_vars.pop(0))
95 except IndexError:
96 raise ValueError(
97 "Positional variable not specified and needed by template "
98 "`{}` at position {}".format(match.string, match.start())
99 )
100 else:
101 raise ValueError("Unknown template expression {}".format(match.group(0)))
102
103
104def expand(tmpl, *args, **kwargs):
105 """Expand a path template with the given variables.
106
107 .. code-block:: python
108
109 >>> expand('users/*/messages/*', 'me', '123')
110 users/me/messages/123
111 >>> expand('/v1/{name=shelves/*/books/*}', name='shelves/1/books/3')
112 /v1/shelves/1/books/3
113
114 Args:
115 tmpl (str): The path template.
116 args: The positional variables for the path.
117 kwargs: The named variables for the path.
118
119 Returns:
120 str: The expanded path
121
122 Raises:
123 ValueError: If a positional or named variable is required by the
124 template but not specified or if an unexpected template expression
125 is encountered.
126 """
127 replacer = functools.partial(_expand_variable_match, list(args), kwargs)
128 return _VARIABLE_RE.sub(replacer, tmpl)
129
130
131def _replace_variable_with_pattern(match):
132 """Replace a variable match with a pattern that can be used to validate it.
133
134 Args:
135 match (re.Match): A regular expression match
136
137 Returns:
138 str: A regular expression pattern that can be used to validate the
139 variable in an expanded path.
140
141 Raises:
142 ValueError: If an unexpected template expression is encountered.
143 """
144 positional = match.group("positional")
145 name = match.group("name")
146 template = match.group("template")
147 if name is not None:
148 if not template:
149 return _SINGLE_SEGMENT_PATTERN.format(name)
150 elif template == "**":
151 return _MULTI_SEGMENT_PATTERN.format(name)
152 else:
153 return _generate_pattern_for_template(template)
154 elif positional == "*":
155 return _SINGLE_SEGMENT_PATTERN
156 elif positional == "**":
157 return _MULTI_SEGMENT_PATTERN
158 else:
159 raise ValueError("Unknown template expression {}".format(match.group(0)))
160
161
162def _generate_pattern_for_template(tmpl):
163 """Generate a pattern that can validate a path template.
164
165 Args:
166 tmpl (str): The path template
167
168 Returns:
169 str: A regular expression pattern that can be used to validate an
170 expanded path template.
171 """
172 return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl)
173
174
175def get_field(request, field):
176 """Get the value of a field from a given dictionary.
177
178 Args:
179 request (dict | Message): A dictionary or a Message object.
180 field (str): The key to the request in dot notation.
181
182 Returns:
183 The value of the field.
184 """
185 parts = field.split(".")
186 value = request
187
188 for part in parts:
189 if not isinstance(value, dict):
190 value = getattr(value, part, None)
191 else:
192 value = value.get(part)
193 if isinstance(value, dict):
194 return
195 return value
196
197
198def delete_field(request, field):
199 """Delete the value of a field from a given dictionary.
200
201 Args:
202 request (dict | Message): A dictionary object or a Message.
203 field (str): The key to the request in dot notation.
204 """
205 parts = deque(field.split("."))
206 while len(parts) > 1:
207 part = parts.popleft()
208 if not isinstance(request, dict):
209 if hasattr(request, part):
210 request = getattr(request, part, None)
211 else:
212 return
213 else:
214 request = request.get(part)
215 part = parts.popleft()
216 if not isinstance(request, dict):
217 if hasattr(request, part):
218 request.ClearField(part)
219 else:
220 return
221 else:
222 request.pop(part, None)
223
224
225def validate(tmpl, path):
226 """Validate a path against the path template.
227
228 .. code-block:: python
229
230 >>> validate('users/*/messages/*', 'users/me/messages/123')
231 True
232 >>> validate('users/*/messages/*', 'users/me/drafts/123')
233 False
234 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/books/3)
235 True
236 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/tapes/3)
237 False
238
239 Args:
240 tmpl (str): The path template.
241 path (str): The expanded path.
242
243 Returns:
244 bool: True if the path matches.
245 """
246 pattern = _generate_pattern_for_template(tmpl) + "$"
247 return True if re.match(pattern, path) is not None else False
248
249
250def transcode(http_options, message=None, **request_kwargs):
251 """Transcodes a grpc request pattern into a proper HTTP request following the rules outlined here,
252 https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44-L312
253
254 Args:
255 http_options (list(dict)): A list of dicts which consist of these keys,
256 'method' (str): The http method
257 'uri' (str): The path template
258 'body' (str): The body field name (optional)
259 (This is a simplified representation of the proto option `google.api.http`)
260
261 message (Message) : A request object (optional)
262 request_kwargs (dict) : A dict representing the request object
263
264 Returns:
265 dict: The transcoded request with these keys,
266 'method' (str) : The http method
267 'uri' (str) : The expanded uri
268 'body' (dict | Message) : A dict or a Message representing the body (optional)
269 'query_params' (dict | Message) : A dict or Message mapping query parameter variables and values
270
271 Raises:
272 ValueError: If the request does not match the given template.
273 """
274 transcoded_value = message or request_kwargs
275 bindings = []
276 for http_option in http_options:
277 request = {}
278
279 # Assign path
280 uri_template = http_option["uri"]
281 fields = [
282 (m.group("name"), m.group("template"))
283 for m in _VARIABLE_RE.finditer(uri_template)
284 ]
285 bindings.append((uri_template, fields))
286
287 path_args = {field: get_field(transcoded_value, field) for field, _ in fields}
288 request["uri"] = expand(uri_template, **path_args)
289
290 if not validate(uri_template, request["uri"]) or not all(path_args.values()):
291 continue
292
293 # Remove fields used in uri path from request
294 leftovers = copy.deepcopy(transcoded_value)
295 for path_field, _ in fields:
296 delete_field(leftovers, path_field)
297
298 # Assign body and query params
299 body = http_option.get("body")
300
301 if body:
302 if body == "*":
303 request["body"] = leftovers
304 if message:
305 request["query_params"] = message.__class__()
306 else:
307 request["query_params"] = {}
308 else:
309 try:
310 if message:
311 request["body"] = getattr(leftovers, body)
312 delete_field(leftovers, body)
313 else:
314 request["body"] = leftovers.pop(body)
315 except (KeyError, AttributeError):
316 continue
317 request["query_params"] = leftovers
318 else:
319 request["query_params"] = leftovers
320 request["method"] = http_option["method"]
321 return request
322
323 bindings_description = [
324 '\n\tURI: "{}"'
325 "\n\tRequired request fields:\n\t\t{}".format(
326 uri,
327 "\n\t\t".join(
328 [
329 'field: "{}", pattern: "{}"'.format(n, p if p else "*")
330 for n, p in fields
331 ]
332 ),
333 )
334 for uri, fields in bindings
335 ]
336
337 raise ValueError(
338 "Invalid request."
339 "\nSome of the fields of the request message are either not initialized or "
340 "initialized with an invalid value."
341 "\nPlease make sure your request matches at least one accepted HTTP binding."
342 "\nTo match a binding the request message must have all the required fields "
343 "initialized with values matching their patterns as listed below:{}".format(
344 "\n".join(bindings_description)
345 )
346 )