| # Copyright 2017 Google LLC |
| # |
| # 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. |
| |
| """Expand and validate URL path templates. |
| |
| This module provides the :func:`expand` and :func:`validate` functions for |
| interacting with Google-style URL `path templates`_ which are commonly used |
| in Google APIs for `resource names`_. |
| |
| .. _path templates: https://github.com/googleapis/googleapis/blob |
| /57e2d376ac7ef48681554204a3ba78a414f2c533/google/api/http.proto#L212 |
| .. _resource names: https://cloud.google.com/apis/design/resource_names |
| """ |
| |
| from __future__ import unicode_literals |
| |
| from collections import deque |
| import copy |
| import functools |
| import re |
| |
| # Regular expression for extracting variable parts from a path template. |
| # The variables can be expressed as: |
| # |
| # - "*": a single-segment positional variable, for example: "books/*" |
| # - "**": a multi-segment positional variable, for example: "shelf/**/book/*" |
| # - "{name}": a single-segment wildcard named variable, for example |
| # "books/{name}" |
| # - "{name=*}: same as above. |
| # - "{name=**}": a multi-segment wildcard named variable, for example |
| # "shelf/{name=**}" |
| # - "{name=/path/*/**}": a multi-segment named variable with a sub-template. |
| _VARIABLE_RE = re.compile( |
| r""" |
| ( # Capture the entire variable expression |
| (?P<positional>\*\*?) # Match & capture * and ** positional variables. |
| | |
| # Match & capture named variables {name} |
| { |
| (?P<name>[^/]+?) |
| # Optionally match and capture the named variable's template. |
| (?:=(?P<template>.+?))? |
| } |
| ) |
| """, |
| re.VERBOSE, |
| ) |
| |
| # Segment expressions used for validating paths against a template. |
| _SINGLE_SEGMENT_PATTERN = r"([^/]+)" |
| _MULTI_SEGMENT_PATTERN = r"(.+)" |
| |
| |
| def _expand_variable_match(positional_vars, named_vars, match): |
| """Expand a matched variable with its value. |
| |
| Args: |
| positional_vars (list): A list of positional variables. This list will |
| be modified. |
| named_vars (dict): A dictionary of named variables. |
| match (re.Match): A regular expression match. |
| |
| Returns: |
| str: The expanded variable to replace the match. |
| |
| Raises: |
| ValueError: If a positional or named variable is required by the |
| template but not specified or if an unexpected template expression |
| is encountered. |
| """ |
| positional = match.group("positional") |
| name = match.group("name") |
| if name is not None: |
| try: |
| return str(named_vars[name]) |
| except KeyError: |
| raise ValueError( |
| "Named variable '{}' not specified and needed by template " |
| "`{}` at position {}".format(name, match.string, match.start()) |
| ) |
| elif positional is not None: |
| try: |
| return str(positional_vars.pop(0)) |
| except IndexError: |
| raise ValueError( |
| "Positional variable not specified and needed by template " |
| "`{}` at position {}".format(match.string, match.start()) |
| ) |
| else: |
| raise ValueError("Unknown template expression {}".format(match.group(0))) |
| |
| |
| def expand(tmpl, *args, **kwargs): |
| """Expand a path template with the given variables. |
| |
| .. code-block:: python |
| |
| >>> expand('users/*/messages/*', 'me', '123') |
| users/me/messages/123 |
| >>> expand('/v1/{name=shelves/*/books/*}', name='shelves/1/books/3') |
| /v1/shelves/1/books/3 |
| |
| Args: |
| tmpl (str): The path template. |
| args: The positional variables for the path. |
| kwargs: The named variables for the path. |
| |
| Returns: |
| str: The expanded path |
| |
| Raises: |
| ValueError: If a positional or named variable is required by the |
| template but not specified or if an unexpected template expression |
| is encountered. |
| """ |
| replacer = functools.partial(_expand_variable_match, list(args), kwargs) |
| return _VARIABLE_RE.sub(replacer, tmpl) |
| |
| |
| def _replace_variable_with_pattern(match): |
| """Replace a variable match with a pattern that can be used to validate it. |
| |
| Args: |
| match (re.Match): A regular expression match |
| |
| Returns: |
| str: A regular expression pattern that can be used to validate the |
| variable in an expanded path. |
| |
| Raises: |
| ValueError: If an unexpected template expression is encountered. |
| """ |
| positional = match.group("positional") |
| name = match.group("name") |
| template = match.group("template") |
| if name is not None: |
| if not template: |
| return _SINGLE_SEGMENT_PATTERN.format(name) |
| elif template == "**": |
| return _MULTI_SEGMENT_PATTERN.format(name) |
| else: |
| return _generate_pattern_for_template(template) |
| elif positional == "*": |
| return _SINGLE_SEGMENT_PATTERN |
| elif positional == "**": |
| return _MULTI_SEGMENT_PATTERN |
| else: |
| raise ValueError("Unknown template expression {}".format(match.group(0))) |
| |
| |
| def _generate_pattern_for_template(tmpl): |
| """Generate a pattern that can validate a path template. |
| |
| Args: |
| tmpl (str): The path template |
| |
| Returns: |
| str: A regular expression pattern that can be used to validate an |
| expanded path template. |
| """ |
| return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl) |
| |
| |
| def get_field(request, field): |
| """Get the value of a field from a given dictionary. |
| |
| Args: |
| request (dict): A dictionary object. |
| field (str): The key to the request in dot notation. |
| |
| Returns: |
| The value of the field. |
| """ |
| parts = field.split(".") |
| value = request |
| for part in parts: |
| if not isinstance(value, dict): |
| return |
| value = value.get(part) |
| if isinstance(value, dict): |
| return |
| return value |
| |
| |
| def delete_field(request, field): |
| """Delete the value of a field from a given dictionary. |
| |
| Args: |
| request (dict): A dictionary object. |
| field (str): The key to the request in dot notation. |
| """ |
| parts = deque(field.split(".")) |
| while len(parts) > 1: |
| if not isinstance(request, dict): |
| return |
| part = parts.popleft() |
| request = request.get(part) |
| part = parts.popleft() |
| if not isinstance(request, dict): |
| return |
| request.pop(part, None) |
| |
| |
| def validate(tmpl, path): |
| """Validate a path against the path template. |
| |
| .. code-block:: python |
| |
| >>> validate('users/*/messages/*', 'users/me/messages/123') |
| True |
| >>> validate('users/*/messages/*', 'users/me/drafts/123') |
| False |
| >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/books/3) |
| True |
| >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/tapes/3) |
| False |
| |
| Args: |
| tmpl (str): The path template. |
| path (str): The expanded path. |
| |
| Returns: |
| bool: True if the path matches. |
| """ |
| pattern = _generate_pattern_for_template(tmpl) + "$" |
| return True if re.match(pattern, path) is not None else False |
| |
| |
| def transcode(http_options, **request_kwargs): |
| """Transcodes a grpc request pattern into a proper HTTP request following the rules outlined here, |
| https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44-L312 |
| |
| Args: |
| http_options (list(dict)): A list of dicts which consist of these keys, |
| 'method' (str): The http method |
| 'uri' (str): The path template |
| 'body' (str): The body field name (optional) |
| (This is a simplified representation of the proto option `google.api.http`) |
| |
| request_kwargs (dict) : A dict representing the request object |
| |
| Returns: |
| dict: The transcoded request with these keys, |
| 'method' (str) : The http method |
| 'uri' (str) : The expanded uri |
| 'body' (dict) : A dict representing the body (optional) |
| 'query_params' (dict) : A dict mapping query parameter variables and values |
| |
| Raises: |
| ValueError: If the request does not match the given template. |
| """ |
| for http_option in http_options: |
| request = {} |
| |
| # Assign path |
| uri_template = http_option["uri"] |
| path_fields = [ |
| match.group("name") for match in _VARIABLE_RE.finditer(uri_template) |
| ] |
| path_args = {field: get_field(request_kwargs, field) for field in path_fields} |
| request["uri"] = expand(uri_template, **path_args) |
| |
| # Remove fields used in uri path from request |
| leftovers = copy.deepcopy(request_kwargs) |
| for path_field in path_fields: |
| delete_field(leftovers, path_field) |
| |
| if not validate(uri_template, request["uri"]) or not all(path_args.values()): |
| continue |
| |
| # Assign body and query params |
| body = http_option.get("body") |
| |
| if body: |
| if body == "*": |
| request["body"] = leftovers |
| request["query_params"] = {} |
| else: |
| try: |
| request["body"] = leftovers.pop(body) |
| except KeyError: |
| continue |
| request["query_params"] = leftovers |
| else: |
| request["query_params"] = leftovers |
| request["method"] = http_option["method"] |
| return request |
| |
| raise ValueError("Request obj does not match any template") |