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