diff --git a/google/api_core/path_template.py b/google/api_core/path_template.py index a99a4c81..2639459a 100644 --- a/google/api_core/path_template.py +++ b/google/api_core/path_template.py @@ -176,7 +176,7 @@ def get_field(request, field): """Get the value of a field from a given dictionary. Args: - request (dict): A dictionary object. + request (dict | Message): A dictionary or a Message object. field (str): The key to the request in dot notation. Returns: @@ -184,10 +184,12 @@ def get_field(request, field): """ parts = field.split(".") value = request + for part in parts: if not isinstance(value, dict): - return - value = value.get(part) + value = getattr(value, part, None) + else: + value = value.get(part) if isinstance(value, dict): return return value @@ -197,19 +199,27 @@ def delete_field(request, field): """Delete the value of a field from a given dictionary. Args: - request (dict): A dictionary object. + request (dict | Message): A dictionary object or a Message. 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) + if not isinstance(request, dict): + if hasattr(request, part): + request = getattr(request, part, None) + else: + return + else: + request = request.get(part) part = parts.popleft() if not isinstance(request, dict): - return - request.pop(part, None) + if hasattr(request, part): + request.ClearField(part) + else: + return + else: + request.pop(part, None) def validate(tmpl, path): @@ -237,7 +247,7 @@ def validate(tmpl, path): return True if re.match(pattern, path) is not None else False -def transcode(http_options, **request_kwargs): +def transcode(http_options, message=None, **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 @@ -248,18 +258,20 @@ def transcode(http_options, **request_kwargs): 'body' (str): The body field name (optional) (This is a simplified representation of the proto option `google.api.http`) + message (Message) : A request object (optional) request_kwargs (dict) : A dict representing the request object Returns: dict: The transcoded request with these keys, '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 + 'body' (dict | Message) : A dict or a Message representing the body (optional) + 'query_params' (dict | Message) : A dict or Message mapping query parameter variables and values Raises: ValueError: If the request does not match the given template. """ + transcoded_value = message or request_kwargs for http_option in http_options: request = {} @@ -268,27 +280,35 @@ def transcode(http_options, **request_kwargs): 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} + path_args = {field: get_field(transcoded_value, 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 + # Remove fields used in uri path from request + leftovers = copy.deepcopy(transcoded_value) + for path_field in path_fields: + delete_field(leftovers, path_field) + # Assign body and query params body = http_option.get("body") if body: if body == "*": request["body"] = leftovers - request["query_params"] = {} + if message: + request["query_params"] = message.__class__() + else: + request["query_params"] = {} else: try: - request["body"] = leftovers.pop(body) - except KeyError: + if message: + request["body"] = getattr(leftovers, body) + delete_field(leftovers, body) + else: + request["body"] = leftovers.pop(body) + except (KeyError, AttributeError): continue request["query_params"] = leftovers else: diff --git a/tests/unit/test_path_template.py b/tests/unit/test_path_template.py index c12b35fc..73d351c0 100644 --- a/tests/unit/test_path_template.py +++ b/tests/unit/test_path_template.py @@ -17,6 +17,7 @@ import mock import pytest +from google.api import auth_pb2 from google.api_core import path_template @@ -171,113 +172,264 @@ def test__replace_variable_with_pattern(): @pytest.mark.parametrize( - "http_options, request_kwargs, expected_result", + "http_options, message, request_kwargs, expected_result", [ [ [["get", "/v1/no/template", ""]], + None, {"foo": "bar"}, ["get", "/v1/no/template", {}, {"foo": "bar"}], ], + [ + [["get", "/v1/no/template", ""]], + auth_pb2.AuthenticationRule(selector="bar"), + {}, + [ + "get", + "/v1/no/template", + None, + auth_pb2.AuthenticationRule(selector="bar"), + ], + ], # Single templates [ [["get", "/v1/{field}", ""]], + None, {"field": "parent"}, ["get", "/v1/parent", {}, {}], ], + [ + [["get", "/v1/{selector}", ""]], + auth_pb2.AuthenticationRule(selector="parent"), + {}, + ["get", "/v1/parent", None, auth_pb2.AuthenticationRule()], + ], [ [["get", "/v1/{field.sub}", ""]], + None, {"field": {"sub": "parent"}, "foo": "bar"}, ["get", "/v1/parent", {}, {"field": {}, "foo": "bar"}], ], + [ + [["get", "/v1/{oauth.canonical_scopes}", ""]], + auth_pb2.AuthenticationRule( + selector="bar", + oauth=auth_pb2.OAuthRequirements(canonical_scopes="parent"), + ), + {}, + [ + "get", + "/v1/parent", + None, + auth_pb2.AuthenticationRule( + selector="bar", oauth=auth_pb2.OAuthRequirements() + ), + ], + ], ], ) -def test_transcode_base_case(http_options, request_kwargs, expected_result): +def test_transcode_base_case(http_options, message, request_kwargs, expected_result): http_options, expected_result = helper_test_transcode(http_options, expected_result) - result = path_template.transcode(http_options, **request_kwargs) + result = path_template.transcode(http_options, message, **request_kwargs) assert result == expected_result @pytest.mark.parametrize( - "http_options, request_kwargs, expected_result", + "http_options, message, request_kwargs, expected_result", [ [ [["get", "/v1/{field.subfield}", ""]], + None, {"field": {"subfield": "parent"}, "foo": "bar"}, ["get", "/v1/parent", {}, {"field": {}, "foo": "bar"}], ], + [ + [["get", "/v1/{oauth.canonical_scopes}", ""]], + auth_pb2.AuthenticationRule( + selector="bar", + oauth=auth_pb2.OAuthRequirements(canonical_scopes="parent"), + ), + {}, + [ + "get", + "/v1/parent", + None, + auth_pb2.AuthenticationRule( + selector="bar", oauth=auth_pb2.OAuthRequirements() + ), + ], + ], [ [["get", "/v1/{field.subfield.subsubfield}", ""]], + None, {"field": {"subfield": {"subsubfield": "parent"}}, "foo": "bar"}, ["get", "/v1/parent", {}, {"field": {"subfield": {}}, "foo": "bar"}], ], [ [["get", "/v1/{field.subfield1}/{field.subfield2}", ""]], + None, {"field": {"subfield1": "parent", "subfield2": "child"}, "foo": "bar"}, ["get", "/v1/parent/child", {}, {"field": {}, "foo": "bar"}], ], + [ + [["get", "/v1/{selector}/{oauth.canonical_scopes}", ""]], + auth_pb2.AuthenticationRule( + selector="parent", + oauth=auth_pb2.OAuthRequirements(canonical_scopes="child"), + ), + {"field": {"subfield1": "parent", "subfield2": "child"}, "foo": "bar"}, + [ + "get", + "/v1/parent/child", + None, + auth_pb2.AuthenticationRule(oauth=auth_pb2.OAuthRequirements()), + ], + ], ], ) -def test_transcode_subfields(http_options, request_kwargs, expected_result): +def test_transcode_subfields(http_options, message, request_kwargs, expected_result): http_options, expected_result = helper_test_transcode(http_options, expected_result) - result = path_template.transcode(http_options, **request_kwargs) + result = path_template.transcode(http_options, message, **request_kwargs) assert result == expected_result @pytest.mark.parametrize( - "http_options, request_kwargs, expected_result", + "http_options, message, request_kwargs, expected_result", [ # Single segment wildcard [ [["get", "/v1/{field=*}", ""]], + None, {"field": "parent"}, ["get", "/v1/parent", {}, {}], ], + [ + [["get", "/v1/{selector=*}", ""]], + auth_pb2.AuthenticationRule(selector="parent"), + {}, + ["get", "/v1/parent", None, auth_pb2.AuthenticationRule()], + ], [ [["get", "/v1/{field=a/*/b/*}", ""]], + None, {"field": "a/parent/b/child", "foo": "bar"}, ["get", "/v1/a/parent/b/child", {}, {"foo": "bar"}], ], + [ + [["get", "/v1/{selector=a/*/b/*}", ""]], + auth_pb2.AuthenticationRule( + selector="a/parent/b/child", allow_without_credential=True + ), + {}, + [ + "get", + "/v1/a/parent/b/child", + None, + auth_pb2.AuthenticationRule(allow_without_credential=True), + ], + ], # Double segment wildcard [ [["get", "/v1/{field=**}", ""]], + None, {"field": "parent/p1"}, ["get", "/v1/parent/p1", {}, {}], ], + [ + [["get", "/v1/{selector=**}", ""]], + auth_pb2.AuthenticationRule(selector="parent/p1"), + {}, + ["get", "/v1/parent/p1", None, auth_pb2.AuthenticationRule()], + ], [ [["get", "/v1/{field=a/**/b/**}", ""]], + None, {"field": "a/parent/p1/b/child/c1", "foo": "bar"}, ["get", "/v1/a/parent/p1/b/child/c1", {}, {"foo": "bar"}], ], + [ + [["get", "/v1/{selector=a/**/b/**}", ""]], + auth_pb2.AuthenticationRule( + selector="a/parent/p1/b/child/c1", allow_without_credential=True + ), + {}, + [ + "get", + "/v1/a/parent/p1/b/child/c1", + None, + auth_pb2.AuthenticationRule(allow_without_credential=True), + ], + ], # Combined single and double segment wildcard [ [["get", "/v1/{field=a/*/b/**}", ""]], + None, {"field": "a/parent/b/child/c1"}, ["get", "/v1/a/parent/b/child/c1", {}, {}], ], + [ + [["get", "/v1/{selector=a/*/b/**}", ""]], + auth_pb2.AuthenticationRule(selector="a/parent/b/child/c1"), + {}, + ["get", "/v1/a/parent/b/child/c1", None, auth_pb2.AuthenticationRule()], + ], [ [["get", "/v1/{field=a/**/b/*}/v2/{name}", ""]], + None, {"field": "a/parent/p1/b/child", "name": "first", "foo": "bar"}, ["get", "/v1/a/parent/p1/b/child/v2/first", {}, {"foo": "bar"}], ], + [ + [["get", "/v1/{selector=a/**/b/*}/v2/{oauth.canonical_scopes}", ""]], + auth_pb2.AuthenticationRule( + selector="a/parent/p1/b/child", + oauth=auth_pb2.OAuthRequirements(canonical_scopes="first"), + ), + {"field": "a/parent/p1/b/child", "name": "first", "foo": "bar"}, + [ + "get", + "/v1/a/parent/p1/b/child/v2/first", + None, + auth_pb2.AuthenticationRule(oauth=auth_pb2.OAuthRequirements()), + ], + ], ], ) -def test_transcode_with_wildcard(http_options, request_kwargs, expected_result): +def test_transcode_with_wildcard( + http_options, message, request_kwargs, expected_result +): http_options, expected_result = helper_test_transcode(http_options, expected_result) - result = path_template.transcode(http_options, **request_kwargs) + result = path_template.transcode(http_options, message, **request_kwargs) assert result == expected_result @pytest.mark.parametrize( - "http_options, request_kwargs, expected_result", + "http_options, message, request_kwargs, expected_result", [ # Single field body [ [["post", "/v1/no/template", "data"]], + None, {"data": {"id": 1, "info": "some info"}, "foo": "bar"}, ["post", "/v1/no/template", {"id": 1, "info": "some info"}, {"foo": "bar"}], ], + [ + [["post", "/v1/no/template", "oauth"]], + auth_pb2.AuthenticationRule( + selector="bar", + oauth=auth_pb2.OAuthRequirements(canonical_scopes="child"), + ), + {}, + [ + "post", + "/v1/no/template", + auth_pb2.OAuthRequirements(canonical_scopes="child"), + auth_pb2.AuthenticationRule(selector="bar"), + ], + ], [ [["post", "/v1/{field=a/*}/b/{name=**}", "data"]], + None, { "field": "a/parent", "name": "first/last", @@ -291,9 +443,29 @@ def test_transcode_with_wildcard(http_options, request_kwargs, expected_result): {"foo": "bar"}, ], ], + [ + [["post", "/v1/{selector=a/*}/b/{oauth.canonical_scopes=**}", "oauth"]], + auth_pb2.AuthenticationRule( + selector="a/parent", + allow_without_credential=True, + requirements=[auth_pb2.AuthRequirement(provider_id="p")], + oauth=auth_pb2.OAuthRequirements(canonical_scopes="first/last"), + ), + {}, + [ + "post", + "/v1/a/parent/b/first/last", + auth_pb2.OAuthRequirements(), + auth_pb2.AuthenticationRule( + requirements=[auth_pb2.AuthRequirement(provider_id="p")], + allow_without_credential=True, + ), + ], + ], # Wildcard body [ [["post", "/v1/{field=a/*}/b/{name=**}", "*"]], + None, { "field": "a/parent", "name": "first/last", @@ -307,16 +479,38 @@ def test_transcode_with_wildcard(http_options, request_kwargs, expected_result): {}, ], ], + [ + [["post", "/v1/{selector=a/*}/b/{oauth.canonical_scopes=**}", "*"]], + auth_pb2.AuthenticationRule( + selector="a/parent", + allow_without_credential=True, + oauth=auth_pb2.OAuthRequirements(canonical_scopes="first/last"), + ), + { + "field": "a/parent", + "name": "first/last", + "data": {"id": 1, "info": "some info"}, + "foo": "bar", + }, + [ + "post", + "/v1/a/parent/b/first/last", + auth_pb2.AuthenticationRule( + allow_without_credential=True, oauth=auth_pb2.OAuthRequirements() + ), + auth_pb2.AuthenticationRule(), + ], + ], ], ) -def test_transcode_with_body(http_options, request_kwargs, expected_result): +def test_transcode_with_body(http_options, message, request_kwargs, expected_result): http_options, expected_result = helper_test_transcode(http_options, expected_result) - result = path_template.transcode(http_options, **request_kwargs) + result = path_template.transcode(http_options, message, **request_kwargs) assert result == expected_result @pytest.mark.parametrize( - "http_options, request_kwargs, expected_result", + "http_options, message, request_kwargs, expected_result", [ # Additional bindings [ @@ -324,6 +518,7 @@ def test_transcode_with_body(http_options, request_kwargs, expected_result): ["post", "/v1/{field=a/*}/b/{name=**}", "extra_data"], ["post", "/v1/{field=a/*}/b/{name=**}", "*"], ], + None, { "field": "a/parent", "name": "first/last", @@ -337,38 +532,105 @@ def test_transcode_with_body(http_options, request_kwargs, expected_result): {}, ], ], + [ + [ + [ + "post", + "/v1/{selector=a/*}/b/{oauth.canonical_scopes=**}", + "extra_data", + ], + ["post", "/v1/{selector=a/*}/b/{oauth.canonical_scopes=**}", "*"], + ], + auth_pb2.AuthenticationRule( + selector="a/parent", + allow_without_credential=True, + oauth=auth_pb2.OAuthRequirements(canonical_scopes="first/last"), + ), + {}, + [ + "post", + "/v1/a/parent/b/first/last", + auth_pb2.AuthenticationRule( + allow_without_credential=True, oauth=auth_pb2.OAuthRequirements() + ), + auth_pb2.AuthenticationRule(), + ], + ], [ [ ["get", "/v1/{field=a/*}/b/{name=**}", ""], ["get", "/v1/{field=a/*}/b/first/last", ""], ], + None, {"field": "a/parent", "foo": "bar"}, ["get", "/v1/a/parent/b/first/last", {}, {"foo": "bar"}], ], + [ + [ + ["get", "/v1/{selector=a/*}/b/{oauth.allow_without_credential=**}", ""], + ["get", "/v1/{selector=a/*}/b/first/last", ""], + ], + auth_pb2.AuthenticationRule( + selector="a/parent", + allow_without_credential=True, + oauth=auth_pb2.OAuthRequirements(), + ), + {}, + [ + "get", + "/v1/a/parent/b/first/last", + None, + auth_pb2.AuthenticationRule( + allow_without_credential=True, oauth=auth_pb2.OAuthRequirements() + ), + ], + ], ], ) def test_transcode_with_additional_bindings( - http_options, request_kwargs, expected_result + http_options, message, request_kwargs, expected_result ): http_options, expected_result = helper_test_transcode(http_options, expected_result) - result = path_template.transcode(http_options, **request_kwargs) + result = path_template.transcode(http_options, message, **request_kwargs) assert result == expected_result @pytest.mark.parametrize( - "http_options, request_kwargs", + "http_options, message, 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"}], - [[["post", "/v1/{first_name}", "data"]], {"last_name": "last"}], + [[["get", "/v1/{name}", ""]], None, {"foo": "bar"}], + [[["get", "/v1/{selector}", ""]], auth_pb2.AuthenticationRule(), {}], + [[["get", "/v1/{name}", ""]], auth_pb2.AuthenticationRule(), {}], + [[["get", "/v1/{name}", ""]], None, {"name": "first/last"}], + [ + [["get", "/v1/{selector}", ""]], + auth_pb2.AuthenticationRule(selector="first/last"), + {}, + ], + [[["get", "/v1/{name=mr/*/*}", ""]], None, {"name": "first/last"}], + [ + [["get", "/v1/{selector=mr/*/*}", ""]], + auth_pb2.AuthenticationRule(selector="first/last"), + {}, + ], + [[["post", "/v1/{name}", "data"]], None, {"name": "first/last"}], + [ + [["post", "/v1/{selector}", "data"]], + auth_pb2.AuthenticationRule(selector="first"), + {}, + ], + [[["post", "/v1/{first_name}", "data"]], None, {"last_name": "last"}], + [ + [["post", "/v1/{first_name}", ""]], + auth_pb2.AuthenticationRule(selector="first"), + {}, + ], ], ) -def test_transcode_fails(http_options, request_kwargs): +def test_transcode_fails(http_options, message, request_kwargs): http_options, _ = helper_test_transcode(http_options, range(4)) with pytest.raises(ValueError): - path_template.transcode(http_options, **request_kwargs) + path_template.transcode(http_options, message, **request_kwargs) def helper_test_transcode(http_options_list, expected_result_list):