Skip to content
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

Merged
merged 6 commits into from
Aug 23, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions core/google/api/core/path_template.py
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:

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

#
# - "*": 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.

This comment was marked as spam.

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.

This comment was marked as spam.



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.

This comment was marked as spam.

This comment was marked as spam.

return True if re.match(pattern, path) is not None else False
90 changes: 90 additions & 0 deletions core/tests/unit/api_core/test_path_template.py
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.

This comment was marked as spam.

])
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.

This comment was marked as spam.

# 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)