Repository: googleapis/python-api-core
Pull request #259 (2021-08-25 17:19:35)

feat: add grpc transcoding + tests
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-api-core/issues/new/choose) before writing your code!  That way we can discuss the change, evaluate designs, and agree on the general idea
- [ ] Ensure the tests and linter pass
- [ ] Code coverage does not decrease (if any source code was changed)
- [ ] Appropriate docs were updated (if necessary)

Fixes #<issue_number_goes_here> 🦕



Patch:
diff --git a/google/api_core/path_template.py b/google/api_core/path_template.py
index c5969c1..41fbd4f 100644
--- a/google/api_core/path_template.py
+++ b/google/api_core/path_template.py
@@ -25,6 +25,8 @@ in Google APIs for `resource names`_.
 
 from __future__ import unicode_literals
 
+from collections import deque
+import copy
 import functools
 import re
 
@@ -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.
@@ -170,6 +172,46 @@ def _generate_pattern_for_template(tmpl):
     return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl)
 
 
+def get_field(request, field):
+    """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.
 
@@ -193,3 +235,66 @@ 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
+
+        Args:
+            http_options (list(dict)): A list of dicts which consist of these keys,
+                '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`)
+
+            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
+
+        Raises:
+            ValueError: If the request does not match the given template.
+    """
+    for http_option in http_options:
+        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")


Test patch:
diff --git a/tests/unit/test_path_template.py b/tests/unit/test_path_template.py
index 4c8a7c5..2c5216e 100644
--- a/tests/unit/test_path_template.py
+++ b/tests/unit/test_path_template.py
@@ -84,6 +84,61 @@ 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", 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",
+        ],
+        ["string", "field", None],
+    ],
+)
+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"}}},
+        ],
+        ["string", "field", "string"],
+        ["string", "field.subfield", "string"],
+    ],
+)
+def test_delete_field(request_obj, field, expected_result):
+    path_template.delete_field(request_obj, field)
+    assert request_obj == expected_result
+
+
 @pytest.mark.parametrize(
     "tmpl, path",
     [
@@ -113,3 +168,222 @@ 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
+
+
+@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)