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

feat: add grpc transcoding + tests #259

Merged
merged 20 commits into from Sep 15, 2021
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
103 changes: 102 additions & 1 deletion google/api_core/path_template.py
Expand Up @@ -25,6 +25,8 @@

from __future__ import unicode_literals

from collections import deque
import copy
import functools
import re

Expand Down Expand Up @@ -64,7 +66,7 @@ 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
positional_vars (list): A list of positional variables. This list will
be modified.
named_vars (dict): A dictionary of named variables.
match (re.Match): A regular expression match.
Expand Down Expand Up @@ -170,6 +172,46 @@ def _generate_pattern_for_template(tmpl):
return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl)


def get_field(request, field):
yihjenku marked this conversation as resolved.
Show resolved Hide resolved
"""Get the value of a field from a given dictionary.

Args:
request (dict): A dictionary object.
field (str): The key to the request in dot notation.

Returns:
The value of the field.
"""
parts = field.split('.')
value = request
for part in parts:
if not isinstance(value, dict):
return
value = value.get(part)
if isinstance(value, dict):
return
return value


def delete_field(request, field):
"""Delete the value of a field from a given dictionary.

Args:
request (dict): A dictionary object.
field (str): The key to the request in dot notation.
"""
parts = deque(field.split('.'))
while len(parts) > 1:
if not isinstance(request, dict):
return
part = parts.popleft()
request = request.get(part)
part = parts.popleft()
if not isinstance(request, dict):
return
request.pop(part, None)


def validate(tmpl, path):
"""Validate a path against the path template.

Expand All @@ -193,3 +235,62 @@ def validate(tmpl, path):
"""
pattern = _generate_pattern_for_template(tmpl) + "$"
return True if re.match(pattern, path) is not None else False

def transcode(http_options, **request_kwargs):
"""Transcodes a grpc request pattern into a proper HTTP request following the rules outlined here,
https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44-L312
yihjenku marked this conversation as resolved.
Show resolved Hide resolved

Args:
http_options (list(dict)): A list of dicts which consist of these keys,
yihjenku marked this conversation as resolved.
Show resolved Hide resolved
'method' (str): The http method
'uri' (str): The path template
'body' (str): The body field name (optional)
(This is a simplified representation of the proto option `google.api.http`)
tseaver marked this conversation as resolved.
Show resolved Hide resolved
request_kwargs (dict) : A dict representing the request object

Returns:
dict: The transcoded request with these keys,
yihjenku marked this conversation as resolved.
Show resolved Hide resolved
'method' (str) : The http method
'uri' (str) : The expanded uri
'body' (dict) : A dict representing the body (optional)
'query_params' (dict) : A dict mapping query parameter variables and values

Raises:
ValueError: If the request does not match the given template.
"""
for http_option in http_options:
yihjenku marked this conversation as resolved.
Show resolved Hide resolved
request = {}

# Assign path
uri_template = http_option['uri']
path_fields = [match.group('name') for match in _VARIABLE_RE.finditer(uri_template)]
path_args = {field: get_field(request_kwargs, field) for field in path_fields}
request['uri'] = expand(uri_template, **path_args)

# Remove fields used in uri path from request
leftovers = copy.deepcopy(request_kwargs)
for path_field in path_fields:
delete_field(leftovers, path_field)

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

# Assign body and query params
body = http_option.get('body')

if body:
if body == '*':
request['body'] = leftovers
request['query_params'] = {}
else:
try:
request['body'] = leftovers.pop(body)
except KeyError:
continue
request['query_params'] = leftovers
else:
request['query_params'] = leftovers
request['method'] = http_option['method']
return request

raise ValueError("Request obj does not match any template")
210 changes: 210 additions & 0 deletions tests/unit/test_path_template.py
Expand Up @@ -84,6 +84,57 @@ def test_expanded_failure(tmpl, args, kwargs, exc_match):
path_template.expand(tmpl, *args, **kwargs)


@pytest.mark.parametrize(
'request_obj, field, expected_result',
[
[{'field': 'stringValue'}, 'field', 'stringValue'],
[{'field': 'stringValue'}, 'nosuchfield', None],
[{'field': 'stringValue'}, 'field.subfield', None],
[{'field': {'subfield': 'stringValue'}}, 'field.subfield', 'stringValue'],
[{'field': {'subfield': [1,2,3]}}, 'field.subfield', [1,2,3]],
[{'field': {'subfield': 'stringValue'}}, 'field', None],
[{'field': {'subfield': 'stringValue'}}, 'field.nosuchfield', None],
[
{'field': {'subfield': {'subsubfield': 'stringValue'}}},
'field.subfield.subsubfield',
'stringValue'
],
],
)
def test_get_field(request_obj, field, expected_result):
result = path_template.get_field(request_obj, field)
assert result == expected_result


@pytest.mark.parametrize(
'request_obj, field, expected_result',
[
[{'field': 'stringValue'}, 'field', {}],
[{'field': 'stringValue'}, 'nosuchfield', {'field': 'stringValue'}],
[{'field': 'stringValue'}, 'field.subfield', {'field': 'stringValue'}],
[{'field': {'subfield': 'stringValue'}}, 'field.subfield', {'field': {}}],
[
{'field': {'subfield': 'stringValue', 'q': 'w'}, 'e': 'f'},
'field.subfield',
{'field': {'q': 'w'}, 'e': 'f'}
],
[
{'field': {'subfield': 'stringValue'}},
'field.nosuchfield',
{'field': {'subfield': 'stringValue'}}
],
[
{'field': {'subfield': {'subsubfield': 'stringValue', 'q': 'w'}}},
'field.subfield.subsubfield',
{'field': {'subfield': {'q': 'w'}}}
],
],
)
def test_get_field(request_obj, field, expected_result):
path_template.delete_field(request_obj, field)
assert request_obj == expected_result


@pytest.mark.parametrize(
"tmpl, path",
[
Expand Down Expand Up @@ -113,3 +164,162 @@ def test__replace_variable_with_pattern():
match.group.return_value = None
with pytest.raises(ValueError, match="Unknown"):
path_template._replace_variable_with_pattern(match)


@pytest.mark.parametrize(
'http_options, request_kwargs, expected_result',
[
[[['get','/v1/no/template','']],
{'foo':'bar'},
['get','/v1/no/template',{},{'foo':'bar'}]],

# Single templates
[[['get','/v1/{field}','']],
{'field':'parent'},
['get','/v1/parent',{},{}]],

[[['get','/v1/{field.sub}','']],
{'field': {'sub': 'parent'}, 'foo':'bar'},
['get','/v1/parent',{},{'field': {}, 'foo':'bar'}]],
]
)
def test_transcode_base_case(http_options, request_kwargs, expected_result):
http_options, expected_result = helper_test_transcode(http_options, expected_result)
result = path_template.transcode(http_options, **request_kwargs)
assert result == expected_result


@pytest.mark.parametrize(
'http_options, request_kwargs, expected_result',
[
[[['get','/v1/{field.subfield}','']],
{'field': {'subfield': 'parent'}, 'foo':'bar'},
['get','/v1/parent',{},{'field': {}, 'foo':'bar'}]],

[[['get','/v1/{field.subfield.subsubfield}','']],
{'field': {'subfield': {'subsubfield': 'parent'}}, 'foo':'bar'},
['get','/v1/parent',{},{'field': {'subfield': {}}, 'foo':'bar'}]],

[[['get','/v1/{field.subfield1}/{field.subfield2}','']],
{'field': {'subfield1': 'parent', 'subfield2': 'child'}, 'foo':'bar'},
['get','/v1/parent/child',{},{'field': {}, 'foo':'bar'}]],
]
)
def test_transcode_subfields(http_options, request_kwargs, expected_result):
http_options, expected_result = helper_test_transcode(http_options, expected_result)
result = path_template.transcode(http_options, **request_kwargs)
assert result == expected_result


@pytest.mark.parametrize(
'http_options, request_kwargs, expected_result',
[
# Single segment wildcard
[[['get','/v1/{field=*}','']],
{'field':'parent'},
['get','/v1/parent',{},{}]],

[[['get','/v1/{field=a/*/b/*}','']],
{'field':'a/parent/b/child','foo':'bar'},
['get','/v1/a/parent/b/child',{},{'foo':'bar'}]],

# Double segment wildcard
[[['get','/v1/{field=**}','']],
{'field':'parent/p1'},
['get','/v1/parent/p1',{},{}]],

[[['get','/v1/{field=a/**/b/**}','']],
{'field':'a/parent/p1/b/child/c1', 'foo':'bar'},
['get','/v1/a/parent/p1/b/child/c1',{},{'foo':'bar'}]],

# Combined single and double segment wildcard
[[['get','/v1/{field=a/*/b/**}','']],
{'field':'a/parent/b/child/c1'},
['get','/v1/a/parent/b/child/c1',{},{}]],

[[['get','/v1/{field=a/**/b/*}/v2/{name}','']],
{'field':'a/parent/p1/b/child', 'name':'first', 'foo':'bar'},
['get','/v1/a/parent/p1/b/child/v2/first',{},{'foo':'bar'}]],
]
)
def test_transcode_with_wildcard(http_options, request_kwargs, expected_result):
http_options, expected_result = helper_test_transcode(http_options, expected_result)
result = path_template.transcode(http_options, **request_kwargs)
assert result == expected_result


@pytest.mark.parametrize(
'http_options, request_kwargs, expected_result',
[
# Single field body
[[['post','/v1/no/template','data']],
{'data':{'id':1, 'info':'some info'},'foo':'bar'},
['post','/v1/no/template',{'id':1, 'info':'some info'},{'foo':'bar'}]],

[[['post','/v1/{field=a/*}/b/{name=**}','data']],
{'field':'a/parent','name':'first/last','data':{'id':1, 'info':'some info'},'foo':'bar'},
['post','/v1/a/parent/b/first/last',{'id':1, 'info':'some info'},{'foo':'bar'}]],

# Wildcard body
[[['post','/v1/{field=a/*}/b/{name=**}','*']],
{'field':'a/parent','name':'first/last','data':{'id':1, 'info':'some info'},'foo':'bar'},
['post','/v1/a/parent/b/first/last',{'data':{'id':1, 'info':'some info'},'foo':'bar'},{}]],
]
)
def test_transcode_with_body(http_options, request_kwargs, expected_result):
http_options, expected_result = helper_test_transcode(http_options, expected_result)
result = path_template.transcode(http_options, **request_kwargs)
assert result == expected_result
yihjenku marked this conversation as resolved.
Show resolved Hide resolved


@pytest.mark.parametrize(
'http_options, request_kwargs, expected_result',
[
# Additional bindings
[[['post','/v1/{field=a/*}/b/{name=**}','extra_data'], ['post','/v1/{field=a/*}/b/{name=**}','*']],
{'field':'a/parent','name':'first/last','data':{'id':1, 'info':'some info'},'foo':'bar'},
['post','/v1/a/parent/b/first/last',{'data':{'id':1, 'info':'some info'},'foo':'bar'},{}]],

[[['get','/v1/{field=a/*}/b/{name=**}',''],['get','/v1/{field=a/*}/b/first/last','']],
{'field':'a/parent','foo':'bar'},
['get','/v1/a/parent/b/first/last',{},{'foo':'bar'}]],
]
)
def test_transcode_with_additional_bindings(http_options, request_kwargs, expected_result):
http_options, expected_result = helper_test_transcode(http_options, expected_result)
result = path_template.transcode(http_options, **request_kwargs)
assert result == expected_result


@pytest.mark.parametrize(
'http_options, request_kwargs',
[
[[['get','/v1/{name}','']], {'foo':'bar'}],
[[['get','/v1/{name}','']], {'name':'first/last'}],
[[['get','/v1/{name=mr/*/*}','']], {'name':'first/last'}],
[[['post','/v1/{name}','data']], {'name':'first/last'}],
]
)
def test_transcode_fails(http_options, request_kwargs):
http_options, _ = helper_test_transcode(http_options, range(4))
with pytest.raises(ValueError):
path_template.transcode(http_options, **request_kwargs)


def helper_test_transcode(http_options_list, expected_result_list):
http_options = []
for opt_list in http_options_list:
http_option = {'method':opt_list[0], 'uri':opt_list[1]}
if opt_list[2]:
http_option['body'] = opt_list[2]
http_options.append(http_option)

expected_result = {
'method':expected_result_list[0],
'uri':expected_result_list[1],
'query_params':expected_result_list[3]
}
if (expected_result_list[2]):
expected_result['body'] = expected_result_list[2]

return(http_options, expected_result)