Skip to content

Commit 2e4481a

Browse files
committed
feat: centralize REST transcoding into google-api-core (runtime)
1 parent 81482ba commit 2e4481a

4 files changed

Lines changed: 243 additions & 1 deletion

File tree

packages/google-api-core/google/api_core/gapic_v1/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626
__lazy_modules__: Set[str] = {
2727
"google.api_core.gapic_v1.client_info",
2828
"google.api_core.gapic_v1.requests",
29+
"google.api_core.gapic_v1.rest_helpers",
2930
"google.api_core.gapic_v1.routing_header",
3031
}
31-
__all__ = ["client_info", "requests", "routing_header"]
32+
__all__ = ["client_info", "requests", "rest_helpers", "routing_header"]
3233

3334
if _has_grpc:
3435
__lazy_modules__.update(
@@ -43,6 +44,7 @@
4344
from google.api_core.gapic_v1 import ( # noqa: E402
4445
client_info,
4546
requests,
47+
rest_helpers,
4648
routing_header,
4749
)
4850

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import json
16+
from typing import Any, Dict, List, Optional, Tuple
17+
18+
from google.protobuf import json_format
19+
20+
from google.api_core import path_template
21+
22+
23+
def transcode(
24+
http_options: List[Dict[str, str]],
25+
request: Any,
26+
required_fields_default_values: Optional[Dict[str, Any]] = None,
27+
rest_numeric_enums: bool = False,
28+
) -> Tuple[Dict[str, Any], Optional[str], Dict[str, Any]]:
29+
"""Transcodes a request into HTTP method, URI, body, and query parameters.
30+
31+
Args:
32+
http_options (List[Dict[str, str]]): List of HTTP transcoding rules.
33+
request (Any): The protobuf or proto-plus request message.
34+
required_fields_default_values (Optional[Dict[str, Any]]): Dictionary
35+
of required fields default values to merge into query parameters if missing.
36+
rest_numeric_enums (bool): Whether to encode enums as integers.
37+
38+
Returns:
39+
Tuple[Dict[str, Any], Optional[str], Dict[str, Any]]: A tuple containing:
40+
- The raw transcoded request dictionary (containing keys like 'uri', 'method').
41+
- The serialized request body JSON string, or None if no body.
42+
- The query parameters dictionary.
43+
"""
44+
# Convert proto-plus message to its underlying protobuf message if needed
45+
pb_request = getattr(request, "_pb", request)
46+
47+
transcoded_request = path_template.transcode(http_options, pb_request)
48+
49+
body_json = None
50+
if transcoded_request.get("body") is not None:
51+
body_json = json_format.MessageToJson(
52+
transcoded_request["body"],
53+
use_integers_for_enums=rest_numeric_enums,
54+
)
55+
56+
query_params_json = {}
57+
if transcoded_request.get("query_params") is not None:
58+
query_params_json = json.loads(
59+
json_format.MessageToJson(
60+
transcoded_request["query_params"],
61+
use_integers_for_enums=rest_numeric_enums,
62+
)
63+
)
64+
65+
if required_fields_default_values:
66+
for k, v in required_fields_default_values.items():
67+
if k not in query_params_json:
68+
query_params_json[k] = v
69+
70+
if rest_numeric_enums:
71+
query_params_json["$alt"] = "json;enum-encoding=int"
72+
73+
return transcoded_request, body_json, query_params_json
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
from unittest import mock
17+
18+
import pytest
19+
20+
21+
@pytest.fixture(scope="session", autouse=True)
22+
def mock_mtls_env():
23+
"""Autouse session-scoped fixture to isolate unit tests from workstation mTLS environments."""
24+
with mock.patch.dict(
25+
os.environ,
26+
{
27+
"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false",
28+
"CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false",
29+
},
30+
):
31+
yield
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import json
16+
17+
from google.protobuf import descriptor_pb2
18+
19+
from google.api_core.gapic_v1.rest_helpers import transcode
20+
21+
22+
def test_transcode_basic():
23+
# We use FieldDescriptorProto as it has standard primitive fields and nested messages.
24+
http_options = [
25+
{
26+
"method": "get",
27+
"uri": "/v1/test/{name}",
28+
}
29+
]
30+
31+
request = descriptor_pb2.FieldDescriptorProto()
32+
request.name = "my-field"
33+
request.number = 123
34+
35+
transcoded, body, query_params = transcode(http_options, request)
36+
37+
assert transcoded["method"] == "get"
38+
assert transcoded["uri"] == "/v1/test/my-field"
39+
assert body is None
40+
# 'number' should be in query parameters
41+
assert "number" in query_params
42+
assert query_params["number"] == 123
43+
44+
45+
def test_transcode_with_nested_field():
46+
http_options = [
47+
{
48+
"method": "get",
49+
"uri": "/v1/test/{options.deprecated}/{name}",
50+
}
51+
]
52+
53+
request = descriptor_pb2.FieldDescriptorProto()
54+
request.name = "my-field"
55+
request.options.deprecated = True
56+
request.number = 123
57+
58+
transcoded, body, query_params = transcode(http_options, request)
59+
60+
assert transcoded["method"] == "get"
61+
assert transcoded["uri"] == "/v1/test/True/my-field"
62+
assert body is None
63+
assert "number" in query_params
64+
assert query_params["number"] == 123
65+
66+
67+
def test_transcode_with_body():
68+
http_options = [
69+
{
70+
"method": "post",
71+
"uri": "/v1/test/{name}",
72+
"body": "options",
73+
}
74+
]
75+
76+
request = descriptor_pb2.FieldDescriptorProto()
77+
request.name = "my-field"
78+
request.options.deprecated = True
79+
request.number = 123
80+
81+
transcoded, body, query_params = transcode(http_options, request)
82+
83+
assert transcoded["method"] == "post"
84+
assert transcoded["uri"] == "/v1/test/my-field"
85+
assert body is not None
86+
body_data = json.loads(body)
87+
assert body_data["deprecated"] is True
88+
# Query parameters should not contain 'options' (the body)
89+
assert "number" in query_params
90+
assert query_params["number"] == 123
91+
assert "options" not in query_params
92+
93+
94+
def test_transcode_with_required_fields_default_values():
95+
http_options = [
96+
{
97+
"method": "get",
98+
"uri": "/v1/test/{name}",
99+
}
100+
]
101+
102+
request = descriptor_pb2.FieldDescriptorProto()
103+
request.name = "my-field"
104+
105+
required_defaults = {"requiredQueryParam": "default-val"}
106+
107+
transcoded, body, query_params = transcode(
108+
http_options,
109+
request,
110+
required_fields_default_values=required_defaults,
111+
)
112+
113+
assert query_params["requiredQueryParam"] == "default-val"
114+
115+
116+
def test_transcode_with_numeric_enums():
117+
http_options = [
118+
{
119+
"method": "get",
120+
"uri": "/v1/test/{name}",
121+
}
122+
]
123+
124+
request = descriptor_pb2.FieldDescriptorProto()
125+
request.name = "my-field"
126+
request.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING
127+
128+
# Without numeric enums
129+
_, _, query_params = transcode(http_options, request, rest_numeric_enums=False)
130+
assert query_params["type"] == "TYPE_STRING"
131+
132+
# With numeric enums
133+
_, _, query_params = transcode(http_options, request, rest_numeric_enums=True)
134+
# Type number for TYPE_STRING is 9
135+
assert query_params["type"] == 9
136+
assert query_params["$alt"] == "json;enum-encoding=int"

0 commit comments

Comments
 (0)