-
Notifications
You must be signed in to change notification settings - Fork 1.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add google.api.core.path_template #3851
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
# Copyright 2017 Google Inc. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
"""Expand and validate URL path templates. | ||
|
||
This module provides the :func:`expand` and :func:`validate` functions for | ||
interacting with Google-style URL `path templates`_ which are commonly used | ||
in Google APIs for `resource names`_. | ||
|
||
.. _path templates: https://github.com/googleapis/googleapis/blob | ||
/57e2d376ac7ef48681554204a3ba78a414f2c533/google/api/http.proto#L212 | ||
.. _resource names: https://cloud.google.com/apis/design/resource_names | ||
""" | ||
|
||
from __future__ import unicode_literals | ||
|
||
import functools | ||
import re | ||
|
||
import six | ||
|
||
# Regular expression for extracting variable parts from a path template. | ||
# The variables can be expressed as: | ||
# | ||
# - "*": a single-segment positional variable, for example: "books/*" | ||
# - "**": a multi-segment positional variable, for example: "shelf/**/book/*" | ||
# - "{name}": a single-segment wildcard named variable, for example | ||
# "books/{name}" | ||
# - "{name=*}: same as above. | ||
# - "{name=**}": a multi-segment wildcard named variable, for example | ||
# "shelf/{name=**}" | ||
# - "{name=/path/*/**}": a multi-segment named variable with a sub-template. | ||
_VARIABLE_RE = re.compile(r""" | ||
( # Capture the entire variable expression | ||
(?P<positional>\*\*?) # Match & capture * and ** positional variables. | ||
| | ||
# Match & capture named variables {name} | ||
{ | ||
(?P<name>[^/]+?) | ||
# Optionally match and capture the named variable's template. | ||
(?:=(?P<template>.+?))? | ||
} | ||
) | ||
""", re.VERBOSE) | ||
|
||
# Segment expressions used for validating paths against a template. | ||
_SINGLE_SEGMENT_PATTERN = r'([^/]+)' | ||
_MULTI_SEGMENT_PATTERN = r'(.+)' | ||
|
||
|
||
def _expand_variable_match(positional_vars, named_vars, match): | ||
"""Expand a matched variable with its value. | ||
|
||
Args: | ||
positional_vars (list): A list of positonal variables. This list will | ||
be modified. | ||
named_vars (dict): A dictionary of named variables. | ||
match (re.Match): A regular expression match. | ||
|
||
Returns: | ||
str: The expanded variable to replace the match. | ||
|
||
Raises: | ||
ValueError: If a positional or named variable is required by the | ||
template but not specified or if an unexpected template expression | ||
is encountered. | ||
""" | ||
positional = match.group('positional') | ||
name = match.group('name') | ||
if name is not None: | ||
try: | ||
return six.text_type(named_vars[name]) | ||
except KeyError: | ||
raise ValueError( | ||
'Named variable \'{}\' not specified and needed by template ' | ||
'`{}` at position {}'.format( | ||
name, match.string, match.start())) | ||
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong. |
||
elif positional is not None: | ||
try: | ||
return six.text_type(positional_vars.pop(0)) | ||
except IndexError: | ||
raise ValueError( | ||
'Positional variable not specified and needed by template ' | ||
'`{}` at position {}'.format( | ||
match.string, match.start())) | ||
else: | ||
raise ValueError( | ||
'Unknown template expression {}'.format( | ||
match.group(0))) | ||
|
||
|
||
def expand(tmpl, *args, **kwargs): | ||
"""Expand a path template with the given variables. | ||
|
||
..code-block:: python | ||
|
||
>>> expand('users/*/messages/*', 'me', '123') | ||
users/me/messages/123 | ||
>>> expand('/v1/{name=shelves/*/books/*}', name='shelves/1/books/3') | ||
/v1/shelves/1/books/3 | ||
|
||
Args: | ||
tmpl (str): The path template. | ||
args: The positional variables for the path. | ||
kwargs: The named variables for the path. | ||
|
||
Returns: | ||
str: The expanded path | ||
|
||
Raises: | ||
ValueError: If a positional or named variable is required by the | ||
template but not specified or if an unexpected template expression | ||
is encountered. | ||
""" | ||
replacer = functools.partial(_expand_variable_match, list(args), kwargs) | ||
return _VARIABLE_RE.sub(replacer, tmpl) | ||
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong. |
||
|
||
|
||
def _replace_variable_with_pattern(match): | ||
"""Replace a variable match with a pattern that can be used to validate it. | ||
|
||
Args: | ||
match (re.Match): A regular expression match | ||
|
||
Returns: | ||
str: A regular expression pattern that can be used to validate the | ||
variable in an expanded path. | ||
|
||
Raises: | ||
ValueError: If an unexpected template expression is encountered. | ||
""" | ||
positional = match.group('positional') | ||
name = match.group('name') | ||
template = match.group('template') | ||
if name is not None: | ||
if not template: | ||
return _SINGLE_SEGMENT_PATTERN.format(name) | ||
elif template == '**': | ||
return _MULTI_SEGMENT_PATTERN.format(name) | ||
else: | ||
return _generate_pattern_for_template(template) | ||
elif positional == '*': | ||
return _SINGLE_SEGMENT_PATTERN | ||
elif positional == '**': | ||
return _MULTI_SEGMENT_PATTERN | ||
else: | ||
raise ValueError( | ||
'Unknown template expression {}'.format( | ||
match.group(0))) | ||
|
||
|
||
def _generate_pattern_for_template(tmpl): | ||
"""Generate a pattern that can validate a path template. | ||
|
||
Args: | ||
tmpl (str): The path template | ||
|
||
Returns: | ||
str: A regular expression pattern that can be used to validate an | ||
expanded path template. | ||
""" | ||
return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl) | ||
|
||
|
||
def validate(tmpl, path): | ||
"""Validate a path against the path template. | ||
|
||
.. code-block:: python | ||
|
||
>>> validate('users/*/messages/*', 'users/me/messages/123') | ||
True | ||
>>> validate('users/*/messages/*', 'users/me/drafts/123') | ||
False | ||
>>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/books/3) | ||
True | ||
>>> validate('/v1/{name=shelves/*/books/*}', /v1/shelves/1/tapes/3) | ||
False | ||
|
||
Args: | ||
tmpl (str): The path template. | ||
path (str): The expanded path. | ||
|
||
Returns: | ||
bool: True if the path matches. | ||
""" | ||
pattern = _generate_pattern_for_template(tmpl) + '$' | ||
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong. |
||
return True if re.match(pattern, path) is not None else False |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
# Copyright 2017 Google Inc. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
from __future__ import unicode_literals | ||
|
||
import mock | ||
import pytest | ||
|
||
from google.api.core import path_template | ||
|
||
|
||
@pytest.mark.parametrize('tmpl, args, kwargs, expected_result', [ | ||
# Basic positional params | ||
['/v1/*', ['a'], {}, '/v1/a'], | ||
['/v1/**', ['a/b'], {}, '/v1/a/b'], | ||
['/v1/*/*', ['a', 'b'], {}, '/v1/a/b'], | ||
['/v1/*/*/**', ['a', 'b', 'c/d'], {}, '/v1/a/b/c/d'], | ||
# Basic named params | ||
['/v1/{name}', [], {'name': 'parent'}, '/v1/parent'], | ||
['/v1/{name=**}', [], {'name': 'parent/child'}, '/v1/parent/child'], | ||
# Named params with a sub-template | ||
['/v1/{name=parent/*}', [], {'name': 'parent/child'}, '/v1/parent/child'], | ||
['/v1/{name=parent/**}', [], {'name': 'parent/child/object'}, | ||
'/v1/parent/child/object'], | ||
# Combining positional and named params | ||
['/v1/*/{name}', ['a'], {'name': 'parent'}, '/v1/a/parent'], | ||
['/v1/{name}/*', ['a'], {'name': 'parent'}, '/v1/parent/a'], | ||
['/v1/{parent}/*/{child}/*', ['a', 'b'], | ||
{'parent': 'thor', 'child': 'thorson'}, '/v1/thor/a/thorson/b'], | ||
['/v1/{name}/**', ['a/b'], {'name': 'parent'}, '/v1/parent/a/b'], | ||
# Combining positional and named params with sub-templates. | ||
['/v1/{name=parent/*}/*', ['a'], {'name': 'parent/child'}, | ||
'/v1/parent/child/a'], | ||
['/v1/*/{name=parent/**}', ['a'], {'name': 'parent/child/object'}, | ||
'/v1/a/parent/child/object'], | ||
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong. |
||
]) | ||
def test_expand_success(tmpl, args, kwargs, expected_result): | ||
result = path_template.expand(tmpl, *args, **kwargs) | ||
assert result == expected_result | ||
assert path_template.validate(tmpl, result) | ||
|
||
|
||
@pytest.mark.parametrize('tmpl, args, kwargs, exc_match', [ | ||
# Missing positional arg. | ||
['v1/*', [], {}, 'Positional'], | ||
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong. |
||
# Missing named arg. | ||
['v1/{name}', [], {}, 'Named'], | ||
]) | ||
def test_expanded_failure(tmpl, args, kwargs, exc_match): | ||
with pytest.raises(ValueError, match=exc_match): | ||
path_template.expand(tmpl, *args, **kwargs) | ||
|
||
|
||
@pytest.mark.parametrize('tmpl, path', [ | ||
# Single segment template, but multi segment value | ||
['v1/*', 'v1/a/b'], | ||
['v1/*/*', 'v1/a/b/c'], | ||
# Single segement named template, but multi segment value | ||
['v1/{name}', 'v1/a/b'], | ||
['v1/{name}/{value}', 'v1/a/b/c'], | ||
# Named value with a sub-template but invalid value | ||
['v1/{name=parent/*}', 'v1/grandparent/child'], | ||
]) | ||
def test_validate_failure(tmpl, path): | ||
assert not path_template.validate(tmpl, path) | ||
|
||
|
||
def test__expand_variable_match_unexpected(): | ||
match = mock.Mock(spec=['group']) | ||
match.group.return_value = None | ||
with pytest.raises(ValueError, match='Unknown'): | ||
path_template._expand_variable_match([], {}, match) | ||
|
||
|
||
def test__replace_variable_with_pattern(): | ||
match = mock.Mock(spec=['group']) | ||
match.group.return_value = None | ||
with pytest.raises(ValueError, match='Unknown'): | ||
path_template._replace_variable_with_pattern(match) |
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong.
This comment was marked as spam.
Sorry, something went wrong.