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