codekingpro/portable-devtools
114k
1# Copyright 2017 Google LLC2#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 at6#7# http://www.apache.org/licenses/LICENSE-2.08#9# Unless required by applicable law or agreed to in writing, software10# 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 and13# limitations under the License.14 15"""Expand and validate URL path templates.16 17This module provides the :func:`expand` and :func:`validate` functions for18interacting with Google-style URL `path templates`_ which are commonly used19in Google APIs for `resource names`_.20 21.. _path templates: https://github.com/googleapis/googleapis/blob22 /57e2d376ac7ef48681554204a3ba78a414f2c533/google/api/http.proto#L21223.. _resource names: https://cloud.google.com/apis/design/resource_names24"""25 26from __future__ import unicode_literals27 28from collections import deque29import copy30import functools31import re32 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 example39# "books/{name}"40# - "{name=*}: same as above.41# - "{name=**}": a multi-segment wildcard named variable, for example42# "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 expression47 (?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 will70 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 the79 template but not specified or if an unexpected template expression80 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:: python108 109 >>> expand('users/*/messages/*', 'me', '123')110 users/me/messages/123111 >>> expand('/v1/{name=shelves/*/books/*}', name='shelves/1/books/3')112 /v1/shelves/1/books/3113 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 path121 122 Raises:123 ValueError: If a positional or named variable is required by the124 template but not specified or if an unexpected template expression125 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 match136 137 Returns:138 str: A regular expression pattern that can be used to validate the139 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_PATTERN156 elif positional == "**":157 return _MULTI_SEGMENT_PATTERN158 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 template167 168 Returns:169 str: A regular expression pattern that can be used to validate an170 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 = request187 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 return195 return value196 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 return213 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 return221 else:222 request.pop(part, None)223 224 225def validate(tmpl, path):226 """Validate a path against the path template.227 228 .. code-block:: python229 230 >>> validate('users/*/messages/*', 'users/me/messages/123')231 True232 >>> validate('users/*/messages/*', 'users/me/drafts/123')233 False234 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/books/3)235 True236 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/tapes/3)237 False238 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 False248 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-L312253 254 Args:255 http_options (list(dict)): A list of dicts which consist of these keys,256 'method' (str): The http method257 'uri' (str): The path template258 '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 object263 264 Returns:265 dict: The transcoded request with these keys,266 'method' (str) : The http method267 'uri' (str) : The expanded uri268 '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 values270 271 Raises:272 ValueError: If the request does not match the given template.273 """274 transcoded_value = message or request_kwargs275 bindings = []276 for http_option in http_options:277 request = {}278 279 # Assign path280 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 continue292 293 # Remove fields used in uri path from request294 leftovers = copy.deepcopy(transcoded_value)295 for path_field, _ in fields:296 delete_field(leftovers, path_field)297 298 # Assign body and query params299 body = http_option.get("body")300 301 if body:302 if body == "*":303 request["body"] = leftovers304 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 continue317 request["query_params"] = leftovers318 else:319 request["query_params"] = leftovers320 request["method"] = http_option["method"]321 return request322 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 fields331 ]332 ),333 )334 for uri, fields in bindings335 ]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 