Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/google/api_core/path_template.py: 16%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

126 statements  

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 

28import copy 

29import functools 

30import re 

31import urllib.parse 

32from collections import deque 

33 

34# Regular expression for extracting variable parts from a path template. 

35# The variables can be expressed as: 

36# 

37# - "*": a single-segment positional variable, for example: "books/*" 

38# - "**": a multi-segment positional variable, for example: "shelf/**/book/*" 

39# - "{name}": a single-segment wildcard named variable, for example 

40# "books/{name}" 

41# - "{name=*}: same as above. 

42# - "{name=**}": a multi-segment wildcard named variable, for example 

43# "shelf/{name=**}" 

44# - "{name=/path/*/**}": a multi-segment named variable with a sub-template. 

45_VARIABLE_RE = re.compile( 

46 r""" 

47 ( # Capture the entire variable expression 

48 (?P<positional>\*\*?) # Match & capture * and ** positional variables. 

49 | 

50 # Match & capture named variables {name} 

51 { 

52 (?P<name>[^/]+?) 

53 # Optionally match and capture the named variable's template. 

54 (?:=(?P<template>.+?))? 

55 } 

56 ) 

57 """, 

58 re.VERBOSE, 

59) 

60 

61# Segment expressions used for validating paths against a template. 

62_SINGLE_SEGMENT_PATTERN = r"([^/]+)" 

63_MULTI_SEGMENT_PATTERN = r"(.+)" 

64 

65 

66def _extract_and_validate_wildcards( 

67 val: str, template_str: str | None, property_name: str = "property" 

68) -> None: 

69 """Extract and validate wildcard variables against path traversal. 

70 

71 Ensures that values matched against single-segment wildcards ('*') are 

72 not '.' or '..', and values matched against multi-segment wildcards ('**') 

73 do not contain '.' or '..' segments. 

74 

75 If the value does not match the sub-template structure, validation is 

76 deferred to allow subsequent bindings to be evaluated. 

77 

78 Args: 

79 val (str): The raw string value to validate. 

80 template_str (str | None): The template string of the variable (e.g. 

81 'projects/*/locations/*'). 

82 property_name (str): The name of the property being validated, for use 

83 in error messages. 

84 

85 Raises: 

86 ValueError: If a wildcard value contains invalid dot segments. 

87 """ 

88 tmpl = template_str or "*" 

89 m = re.fullmatch(_generate_pattern_for_template(tmpl), val) 

90 if m is not None: 

91 groups = m.groups() 

92 for g in groups: 

93 if "." in g and any(seg in (".", "..") for seg in g.split("/")): 

94 if "**" in tmpl or "/" in tmpl: 

95 raise ValueError( 

96 f"Value for {property_name} must not contain segments that are exactly . or .. ." 

97 ) 

98 raise ValueError(f"Invalid value {g} for {property_name}.") 

99 

100 

101def _expand_variable_match(positional_vars, named_vars, match): 

102 """Expand a matched variable with its value. 

103 

104 Args: 

105 positional_vars (list): A list of positional variables. This list will 

106 be modified. 

107 named_vars (dict): A dictionary of named variables. 

108 match (re.Match): A regular expression match. 

109 

110 Returns: 

111 str: The expanded variable to replace the match. 

112 

113 Raises: 

114 ValueError: If a positional or named variable is required by the 

115 template but not specified or if an unexpected template expression 

116 is encountered. 

117 """ 

118 positional = match.group("positional") 

119 name = match.group("name") 

120 template = match.group("template") 

121 

122 if name is not None: 

123 try: 

124 val = str(named_vars[name]) 

125 _extract_and_validate_wildcards(val, template, name) 

126 return val 

127 except KeyError: 

128 raise ValueError( 

129 "Named variable '{}' not specified and needed by template " 

130 "`{}` at position {}".format(name, match.string, match.start()) 

131 ) 

132 elif positional is not None: 

133 try: 

134 val = str(positional_vars.pop(0)) 

135 _extract_and_validate_wildcards(val, positional, "positional variable") 

136 return val 

137 except IndexError: 

138 raise ValueError( 

139 "Positional variable not specified and needed by template " 

140 "`{}` at position {}".format(match.string, match.start()) 

141 ) 

142 else: 

143 raise ValueError("Unknown template expression {}".format(match.group(0))) 

144 

145 

146def expand(tmpl, *args, **kwargs): 

147 """Expand a path template with the given variables. 

148 

149 .. code-block:: python 

150 

151 >>> expand('users/*/messages/*', 'me', '123') 

152 users/me/messages/123 

153 >>> expand('/v1/{name=shelves/*/books/*}', name='shelves/1/books/3') 

154 /v1/shelves/1/books/3 

155 

156 Args: 

157 tmpl (str): The path template. 

158 args: The positional variables for the path. 

159 kwargs: The named variables for the path. 

160 

161 Returns: 

162 str: The expanded path 

163 

164 Raises: 

165 ValueError: If a positional or named variable is required by the 

166 template but not specified or if an unexpected template expression 

167 is encountered. 

168 """ 

169 replacer = functools.partial(_expand_variable_match, list(args), kwargs) 

170 return _VARIABLE_RE.sub(replacer, tmpl) 

171 

172 

173def _replace_variable_with_pattern(match): 

174 """Replace a variable match with a pattern that can be used to validate it. 

175 

176 Args: 

177 match (re.Match): A regular expression match 

178 

179 Returns: 

180 str: A regular expression pattern that can be used to validate the 

181 variable in an expanded path. 

182 

183 Raises: 

184 ValueError: If an unexpected template expression is encountered. 

185 """ 

186 positional = match.group("positional") 

187 name = match.group("name") 

188 template = match.group("template") 

189 if name is not None: 

190 if not template: 

191 return _SINGLE_SEGMENT_PATTERN.format(name) 

192 elif template == "**": 

193 return _MULTI_SEGMENT_PATTERN.format(name) 

194 else: 

195 return _generate_pattern_for_template(template) 

196 elif positional == "*": 

197 return _SINGLE_SEGMENT_PATTERN 

198 elif positional == "**": 

199 return _MULTI_SEGMENT_PATTERN 

200 else: 

201 raise ValueError("Unknown template expression {}".format(match.group(0))) 

202 

203 

204@functools.lru_cache(maxsize=256) 

205def _generate_pattern_for_template(tmpl): 

206 """Generate a pattern that can validate a path template. 

207 

208 Args: 

209 tmpl (str): The path template 

210 

211 Returns: 

212 str: A regular expression pattern that can be used to validate an 

213 expanded path template. 

214 """ 

215 return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl) 

216 

217 

218def get_field(request, field, encode=False): 

219 """Get the value of a field from a given dictionary. 

220 

221 Args: 

222 request (dict | Message): A dictionary or a Message object. 

223 field (str): The key to the request in dot notation. 

224 encode (bool): Whether to percent-encode the field value. If enabled, 

225 will encode all characters except `[-_.~/0-9a-zA-Z]` for URI path 

226 variable parts per 

227 https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44-L312. 

228 Defaults to False. 

229 

230 Returns: 

231 The value of the field. 

232 """ 

233 parts = field.split(".") 

234 value = request 

235 

236 for part in parts: 

237 if not isinstance(value, dict): 

238 value = getattr(value, part, None) 

239 else: 

240 value = value.get(part) 

241 if isinstance(value, dict): 

242 return 

243 if encode and value is not None: 

244 return urllib.parse.quote(str(value), safe="/") 

245 return value 

246 

247 

248def delete_field(request, field): 

249 """Delete the value of a field from a given dictionary. 

250 

251 Args: 

252 request (dict | Message): A dictionary object or a Message. 

253 field (str): The key to the request in dot notation. 

254 """ 

255 parts = deque(field.split(".")) 

256 while len(parts) > 1: 

257 part = parts.popleft() 

258 if not isinstance(request, dict): 

259 if hasattr(request, part): 

260 request = getattr(request, part, None) 

261 else: 

262 return 

263 else: 

264 request = request.get(part) 

265 part = parts.popleft() 

266 if not isinstance(request, dict): 

267 if hasattr(request, part): 

268 request.ClearField(part) 

269 else: 

270 return 

271 else: 

272 request.pop(part, None) 

273 

274 

275def validate(tmpl, path): 

276 """Validate a path against the path template. 

277 

278 .. code-block:: python 

279 

280 >>> validate('users/*/messages/*', 'users/me/messages/123') 

281 True 

282 >>> validate('users/*/messages/*', 'users/me/drafts/123') 

283 False 

284 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/books/3) 

285 True 

286 >>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/tapes/3) 

287 False 

288 

289 Args: 

290 tmpl (str): The path template. 

291 path (str): The expanded path. 

292 

293 Returns: 

294 bool: True if the path matches. 

295 """ 

296 pattern = _generate_pattern_for_template(tmpl) + "$" 

297 return True if re.match(pattern, path) is not None else False 

298 

299 

300def transcode(http_options, message=None, **request_kwargs): 

301 """Transcodes a grpc request pattern into a proper HTTP request following the rules outlined here, 

302 https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44-L312 

303 

304 Args: 

305 http_options (list(dict)): A list of dicts which consist of these keys, 

306 'method' (str): The http method 

307 'uri' (str): The path template 

308 'body' (str): The body field name (optional) 

309 (This is a simplified representation of the proto option `google.api.http`) 

310 

311 message (Message) : A request object (optional) 

312 request_kwargs (dict) : A dict representing the request object 

313 

314 Returns: 

315 dict: The transcoded request with these keys, 

316 'method' (str) : The http method 

317 'uri' (str) : The expanded uri 

318 'body' (dict | Message) : A dict or a Message representing the body (optional) 

319 'query_params' (dict | Message) : A dict or Message mapping query parameter variables and values 

320 

321 Raises: 

322 ValueError: If the request does not match the given template. 

323 """ 

324 transcoded_value = message or request_kwargs 

325 bindings = [] 

326 for http_option in http_options: 

327 request = {} 

328 

329 # Assign path 

330 uri_template = http_option["uri"] 

331 fields = [ 

332 (m.group("name"), m.group("template")) 

333 for m in _VARIABLE_RE.finditer(uri_template) 

334 ] 

335 bindings.append((uri_template, fields)) 

336 

337 path_args = { 

338 field: get_field(transcoded_value, field, encode=True) 

339 for field, _ in fields 

340 } 

341 request["uri"] = expand(uri_template, **path_args) 

342 

343 if not validate(uri_template, request["uri"]) or not all(path_args.values()): 

344 continue 

345 

346 # Remove fields used in uri path from request 

347 leftovers = copy.deepcopy(transcoded_value) 

348 for path_field, _ in fields: 

349 delete_field(leftovers, path_field) 

350 

351 # Assign body and query params 

352 body = http_option.get("body") 

353 

354 if body: 

355 if body == "*": 

356 request["body"] = leftovers 

357 if message: 

358 request["query_params"] = message.__class__() 

359 else: 

360 request["query_params"] = {} 

361 else: 

362 try: 

363 if message: 

364 request["body"] = getattr(leftovers, body) 

365 delete_field(leftovers, body) 

366 else: 

367 request["body"] = leftovers.pop(body) 

368 except (KeyError, AttributeError): 

369 continue 

370 request["query_params"] = leftovers 

371 else: 

372 request["query_params"] = leftovers 

373 request["method"] = http_option["method"] 

374 return request 

375 

376 bindings_description = [ 

377 '\n\tURI: "{}"\n\tRequired request fields:\n\t\t{}'.format( 

378 uri, 

379 "\n\t\t".join( 

380 [ 

381 'field: "{}", pattern: "{}"'.format(n, p if p else "*") 

382 for n, p in fields 

383 ] 

384 ), 

385 ) 

386 for uri, fields in bindings 

387 ] 

388 

389 raise ValueError( 

390 "Invalid request." 

391 "\nSome of the fields of the request message are either not initialized or " 

392 "initialized with an invalid value." 

393 "\nPlease make sure your request matches at least one accepted HTTP binding." 

394 "\nTo match a binding the request message must have all the required fields " 

395 "initialized with values matching their patterns as listed below:{}".format( 

396 "\n".join(bindings_description) 

397 ) 

398 )