Skip to content

Commit 357bfeb

Browse files
author
Jon Wayne Parrott
authored
Add google.api.core.path_template (#3851)
1 parent f67bea4 commit 357bfeb

File tree

2 files changed

+288
-0
lines changed

2 files changed

+288
-0
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Copyright 2017 Google Inc.
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+
17+
This module provides the :func:`expand` and :func:`validate` functions for
18+
interacting with Google-style URL `path templates`_ which are commonly used
19+
in 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+
26+
from __future__ import unicode_literals
27+
28+
import functools
29+
import re
30+
31+
import six
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(r"""
45+
( # Capture the entire variable expression
46+
(?P<positional>\*\*?) # Match & capture * and ** positional variables.
47+
|
48+
# Match & capture named variables {name}
49+
{
50+
(?P<name>[^/]+?)
51+
# Optionally match and capture the named variable's template.
52+
(?:=(?P<template>.+?))?
53+
}
54+
)
55+
""", re.VERBOSE)
56+
57+
# Segment expressions used for validating paths against a template.
58+
_SINGLE_SEGMENT_PATTERN = r'([^/]+)'
59+
_MULTI_SEGMENT_PATTERN = r'(.+)'
60+
61+
62+
def _expand_variable_match(positional_vars, named_vars, match):
63+
"""Expand a matched variable with its value.
64+
65+
Args:
66+
positional_vars (list): A list of positonal variables. This list will
67+
be modified.
68+
named_vars (dict): A dictionary of named variables.
69+
match (re.Match): A regular expression match.
70+
71+
Returns:
72+
str: The expanded variable to replace the match.
73+
74+
Raises:
75+
ValueError: If a positional or named variable is required by the
76+
template but not specified or if an unexpected template expression
77+
is encountered.
78+
"""
79+
positional = match.group('positional')
80+
name = match.group('name')
81+
if name is not None:
82+
try:
83+
return six.text_type(named_vars[name])
84+
except KeyError:
85+
raise ValueError(
86+
'Named variable \'{}\' not specified and needed by template '
87+
'`{}` at position {}'.format(
88+
name, match.string, match.start()))
89+
elif positional is not None:
90+
try:
91+
return six.text_type(positional_vars.pop(0))
92+
except IndexError:
93+
raise ValueError(
94+
'Positional variable not specified and needed by template '
95+
'`{}` at position {}'.format(
96+
match.string, match.start()))
97+
else:
98+
raise ValueError(
99+
'Unknown template expression {}'.format(
100+
match.group(0)))
101+
102+
103+
def expand(tmpl, *args, **kwargs):
104+
"""Expand a path template with the given variables.
105+
106+
..code-block:: python
107+
108+
>>> expand('users/*/messages/*', 'me', '123')
109+
users/me/messages/123
110+
>>> expand('/v1/{name=shelves/*/books/*}', name='shelves/1/books/3')
111+
/v1/shelves/1/books/3
112+
113+
Args:
114+
tmpl (str): The path template.
115+
args: The positional variables for the path.
116+
kwargs: The named variables for the path.
117+
118+
Returns:
119+
str: The expanded path
120+
121+
Raises:
122+
ValueError: If a positional or named variable is required by the
123+
template but not specified or if an unexpected template expression
124+
is encountered.
125+
"""
126+
replacer = functools.partial(_expand_variable_match, list(args), kwargs)
127+
return _VARIABLE_RE.sub(replacer, tmpl)
128+
129+
130+
def _replace_variable_with_pattern(match):
131+
"""Replace a variable match with a pattern that can be used to validate it.
132+
133+
Args:
134+
match (re.Match): A regular expression match
135+
136+
Returns:
137+
str: A regular expression pattern that can be used to validate the
138+
variable in an expanded path.
139+
140+
Raises:
141+
ValueError: If an unexpected template expression is encountered.
142+
"""
143+
positional = match.group('positional')
144+
name = match.group('name')
145+
template = match.group('template')
146+
if name is not None:
147+
if not template:
148+
return _SINGLE_SEGMENT_PATTERN.format(name)
149+
elif template == '**':
150+
return _MULTI_SEGMENT_PATTERN.format(name)
151+
else:
152+
return _generate_pattern_for_template(template)
153+
elif positional == '*':
154+
return _SINGLE_SEGMENT_PATTERN
155+
elif positional == '**':
156+
return _MULTI_SEGMENT_PATTERN
157+
else:
158+
raise ValueError(
159+
'Unknown template expression {}'.format(
160+
match.group(0)))
161+
162+
163+
def _generate_pattern_for_template(tmpl):
164+
"""Generate a pattern that can validate a path template.
165+
166+
Args:
167+
tmpl (str): The path template
168+
169+
Returns:
170+
str: A regular expression pattern that can be used to validate an
171+
expanded path template.
172+
"""
173+
return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl)
174+
175+
176+
def validate(tmpl, path):
177+
"""Validate a path against the path template.
178+
179+
.. code-block:: python
180+
181+
>>> validate('users/*/messages/*', 'users/me/messages/123')
182+
True
183+
>>> validate('users/*/messages/*', 'users/me/drafts/123')
184+
False
185+
>>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/books/3)
186+
True
187+
>>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/tapes/3)
188+
False
189+
190+
Args:
191+
tmpl (str): The path template.
192+
path (str): The expanded path.
193+
194+
Returns:
195+
bool: True if the path matches.
196+
"""
197+
pattern = _generate_pattern_for_template(tmpl) + '$'
198+
return True if re.match(pattern, path) is not None else False
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright 2017 Google Inc.
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+
from __future__ import unicode_literals
16+
17+
import mock
18+
import pytest
19+
20+
from google.api.core import path_template
21+
22+
23+
@pytest.mark.parametrize('tmpl, args, kwargs, expected_result', [
24+
# Basic positional params
25+
['/v1/*', ['a'], {}, '/v1/a'],
26+
['/v1/**', ['a/b'], {}, '/v1/a/b'],
27+
['/v1/*/*', ['a', 'b'], {}, '/v1/a/b'],
28+
['/v1/*/*/**', ['a', 'b', 'c/d'], {}, '/v1/a/b/c/d'],
29+
# Basic named params
30+
['/v1/{name}', [], {'name': 'parent'}, '/v1/parent'],
31+
['/v1/{name=**}', [], {'name': 'parent/child'}, '/v1/parent/child'],
32+
# Named params with a sub-template
33+
['/v1/{name=parent/*}', [], {'name': 'parent/child'}, '/v1/parent/child'],
34+
['/v1/{name=parent/**}', [], {'name': 'parent/child/object'},
35+
'/v1/parent/child/object'],
36+
# Combining positional and named params
37+
['/v1/*/{name}', ['a'], {'name': 'parent'}, '/v1/a/parent'],
38+
['/v1/{name}/*', ['a'], {'name': 'parent'}, '/v1/parent/a'],
39+
['/v1/{parent}/*/{child}/*', ['a', 'b'],
40+
{'parent': 'thor', 'child': 'thorson'}, '/v1/thor/a/thorson/b'],
41+
['/v1/{name}/**', ['a/b'], {'name': 'parent'}, '/v1/parent/a/b'],
42+
# Combining positional and named params with sub-templates.
43+
['/v1/{name=parent/*}/*', ['a'], {'name': 'parent/child'},
44+
'/v1/parent/child/a'],
45+
['/v1/*/{name=parent/**}', ['a'], {'name': 'parent/child/object'},
46+
'/v1/a/parent/child/object'],
47+
])
48+
def test_expand_success(tmpl, args, kwargs, expected_result):
49+
result = path_template.expand(tmpl, *args, **kwargs)
50+
assert result == expected_result
51+
assert path_template.validate(tmpl, result)
52+
53+
54+
@pytest.mark.parametrize('tmpl, args, kwargs, exc_match', [
55+
# Missing positional arg.
56+
['v1/*', [], {}, 'Positional'],
57+
# Missing named arg.
58+
['v1/{name}', [], {}, 'Named'],
59+
])
60+
def test_expanded_failure(tmpl, args, kwargs, exc_match):
61+
with pytest.raises(ValueError, match=exc_match):
62+
path_template.expand(tmpl, *args, **kwargs)
63+
64+
65+
@pytest.mark.parametrize('tmpl, path', [
66+
# Single segment template, but multi segment value
67+
['v1/*', 'v1/a/b'],
68+
['v1/*/*', 'v1/a/b/c'],
69+
# Single segement named template, but multi segment value
70+
['v1/{name}', 'v1/a/b'],
71+
['v1/{name}/{value}', 'v1/a/b/c'],
72+
# Named value with a sub-template but invalid value
73+
['v1/{name=parent/*}', 'v1/grandparent/child'],
74+
])
75+
def test_validate_failure(tmpl, path):
76+
assert not path_template.validate(tmpl, path)
77+
78+
79+
def test__expand_variable_match_unexpected():
80+
match = mock.Mock(spec=['group'])
81+
match.group.return_value = None
82+
with pytest.raises(ValueError, match='Unknown'):
83+
path_template._expand_variable_match([], {}, match)
84+
85+
86+
def test__replace_variable_with_pattern():
87+
match = mock.Mock(spec=['group'])
88+
match.group.return_value = None
89+
with pytest.raises(ValueError, match='Unknown'):
90+
path_template._replace_variable_with_pattern(match)

0 commit comments

Comments
 (0)