• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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    )
347