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): A dictionary 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 for part in parts: 188 if not isinstance(value, dict): 189 return 190 value = value.get(part) 191 if isinstance(value, dict): 192 return 193 return value 194 195 196def delete_field(request, field): 197 """Delete the value of a field from a given dictionary. 198 199 Args: 200 request (dict): A dictionary object. 201 field (str): The key to the request in dot notation. 202 """ 203 parts = deque(field.split(".")) 204 while len(parts) > 1: 205 if not isinstance(request, dict): 206 return 207 part = parts.popleft() 208 request = request.get(part) 209 part = parts.popleft() 210 if not isinstance(request, dict): 211 return 212 request.pop(part, None) 213 214 215def validate(tmpl, path): 216 """Validate a path against the path template. 217 218 .. code-block:: python 219 220 >>> validate('users/*/messages/*', 'users/me/messages/123') 221 True 222 >>> validate('users/*/messages/*', 'users/me/drafts/123') 223 False 224 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/books/3) 225 True 226 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/tapes/3) 227 False 228 229 Args: 230 tmpl (str): The path template. 231 path (str): The expanded path. 232 233 Returns: 234 bool: True if the path matches. 235 """ 236 pattern = _generate_pattern_for_template(tmpl) + "$" 237 return True if re.match(pattern, path) is not None else False 238 239 240def transcode(http_options, **request_kwargs): 241 """Transcodes a grpc request pattern into a proper HTTP request following the rules outlined here, 242 https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44-L312 243 244 Args: 245 http_options (list(dict)): A list of dicts which consist of these keys, 246 'method' (str): The http method 247 'uri' (str): The path template 248 'body' (str): The body field name (optional) 249 (This is a simplified representation of the proto option `google.api.http`) 250 251 request_kwargs (dict) : A dict representing the request object 252 253 Returns: 254 dict: The transcoded request with these keys, 255 'method' (str) : The http method 256 'uri' (str) : The expanded uri 257 'body' (dict) : A dict representing the body (optional) 258 'query_params' (dict) : A dict mapping query parameter variables and values 259 260 Raises: 261 ValueError: If the request does not match the given template. 262 """ 263 for http_option in http_options: 264 request = {} 265 266 # Assign path 267 uri_template = http_option["uri"] 268 path_fields = [ 269 match.group("name") for match in _VARIABLE_RE.finditer(uri_template) 270 ] 271 path_args = {field: get_field(request_kwargs, field) for field in path_fields} 272 request["uri"] = expand(uri_template, **path_args) 273 274 # Remove fields used in uri path from request 275 leftovers = copy.deepcopy(request_kwargs) 276 for path_field in path_fields: 277 delete_field(leftovers, path_field) 278 279 if not validate(uri_template, request["uri"]) or not all(path_args.values()): 280 continue 281 282 # Assign body and query params 283 body = http_option.get("body") 284 285 if body: 286 if body == "*": 287 request["body"] = leftovers 288 request["query_params"] = {} 289 else: 290 try: 291 request["body"] = leftovers.pop(body) 292 except KeyError: 293 continue 294 request["query_params"] = leftovers 295 else: 296 request["query_params"] = leftovers 297 request["method"] = http_option["method"] 298 return request 299 300 raise ValueError("Request obj does not match any template") 301