Skip to content

Commit 2c3a593

Browse files
committed
feat(api-core): centralize rest transcoding helpers
1 parent a12aeef commit 2c3a593

3 files changed

Lines changed: 247 additions & 0 deletions

File tree

packages/google-api-core/google/api_core/rest_helpers.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616

1717
import functools
1818
import operator
19+
from typing import Any, Dict, List, Optional, Tuple
20+
21+
from google.protobuf import json_format
22+
23+
from google.api_core import path_template
1924

2025

2126
def flatten_query_params(obj, strict=False):
@@ -107,3 +112,54 @@ def _canonicalize(obj, strict=False):
107112
value = value.lower()
108113
return value
109114
return obj
115+
116+
117+
def transcode_request(
118+
http_options: List[Dict[str, str]],
119+
request: Any,
120+
required_fields_default_values: Optional[Dict[str, Any]] = None,
121+
rest_numeric_enums: bool = False,
122+
) -> Tuple[Dict[str, Any], Optional[str], Dict[str, Any]]:
123+
"""Transcodes a request into HTTP method, URI, body, and query parameters.
124+
125+
Args:
126+
http_options (List[Dict[str, str]]): List of HTTP transcoding rules.
127+
request (Any): The protobuf or proto-plus request message.
128+
required_fields_default_values (Optional[Dict[str, Any]]): Dictionary
129+
of required fields default values to merge into query parameters if missing.
130+
rest_numeric_enums (bool): Whether to encode enums as integers.
131+
132+
Returns:
133+
Tuple[Dict[str, Any], Optional[str], Dict[str, Any]]: A tuple containing:
134+
- The raw transcoded request dictionary (containing keys like 'uri', 'method').
135+
- The serialized request body JSON string, or None if no body.
136+
- The query parameters dictionary.
137+
"""
138+
# Convert proto-plus message to its underlying protobuf message if needed
139+
pb_request = getattr(request, "_pb", request)
140+
141+
transcoded_request = path_template.transcode(http_options, pb_request)
142+
143+
body_json = None
144+
if transcoded_request.get("body") is not None:
145+
body_json = json_format.MessageToJson(
146+
transcoded_request["body"],
147+
use_integers_for_enums=rest_numeric_enums,
148+
)
149+
150+
query_params_json = {}
151+
if transcoded_request.get("query_params") is not None:
152+
query_params_json = json_format.MessageToDict(
153+
transcoded_request["query_params"],
154+
use_integers_for_enums=rest_numeric_enums,
155+
)
156+
157+
if required_fields_default_values:
158+
for k, v in required_fields_default_values.items():
159+
if k not in query_params_json:
160+
query_params_json[k] = v
161+
162+
if rest_numeric_enums:
163+
query_params_json["$alt"] = "json;enum-encoding=int"
164+
165+
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

packages/google-api-core/tests/unit/test_rest_helpers.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import json
16+
from unittest import mock
17+
1518
import pytest
19+
from google.protobuf import descriptor_pb2
1620

1721
from google.api_core import rest_helpers
22+
from google.api_core.rest_helpers import transcode_request
1823

1924

2025
def test_flatten_simple_value():
@@ -92,3 +97,158 @@ def test_flatten_repeated_list():
9297

9398
with pytest.raises(ValueError):
9499
rest_helpers.flatten_query_params(obj)
100+
101+
102+
def test_transcode_basic():
103+
# We use FieldDescriptorProto as it has standard primitive fields and nested messages.
104+
http_options = [
105+
{
106+
"method": "get",
107+
"uri": "/v1/test/{name}",
108+
}
109+
]
110+
111+
request = descriptor_pb2.FieldDescriptorProto()
112+
request.name = "my-field"
113+
request.number = 123
114+
115+
transcoded, body, query_params = transcode_request(http_options, request)
116+
117+
assert transcoded["method"] == "get"
118+
assert transcoded["uri"] == "/v1/test/my-field"
119+
assert body is None
120+
# 'number' should be in query parameters
121+
assert "number" in query_params
122+
assert query_params["number"] == 123
123+
124+
125+
def test_transcode_with_nested_field():
126+
http_options = [
127+
{
128+
"method": "get",
129+
"uri": "/v1/test/{options.deprecated}/{name}",
130+
}
131+
]
132+
133+
request = descriptor_pb2.FieldDescriptorProto()
134+
request.name = "my-field"
135+
request.options.deprecated = True
136+
request.number = 123
137+
138+
transcoded, body, query_params = transcode_request(http_options, request)
139+
140+
assert transcoded["method"] == "get"
141+
assert transcoded["uri"] == "/v1/test/True/my-field"
142+
assert body is None
143+
assert "number" in query_params
144+
assert query_params["number"] == 123
145+
146+
147+
def test_transcode_with_body():
148+
http_options = [
149+
{
150+
"method": "post",
151+
"uri": "/v1/test/{name}",
152+
"body": "options",
153+
}
154+
]
155+
156+
request = descriptor_pb2.FieldDescriptorProto()
157+
request.name = "my-field"
158+
request.options.deprecated = True
159+
request.number = 123
160+
161+
transcoded, body, query_params = transcode_request(http_options, request)
162+
163+
assert transcoded["method"] == "post"
164+
assert transcoded["uri"] == "/v1/test/my-field"
165+
assert body is not None
166+
body_data = json.loads(body)
167+
assert body_data["deprecated"] is True
168+
# Query parameters should not contain 'options' (the body)
169+
assert "number" in query_params
170+
assert query_params["number"] == 123
171+
assert "options" not in query_params
172+
173+
174+
def test_transcode_with_required_fields_default_values():
175+
http_options = [
176+
{
177+
"method": "get",
178+
"uri": "/v1/test/{name}",
179+
}
180+
]
181+
182+
request = descriptor_pb2.FieldDescriptorProto()
183+
request.name = "my-field"
184+
185+
required_defaults = {"requiredQueryParam": "default-val"}
186+
187+
transcoded, body, query_params = transcode_request(
188+
http_options,
189+
request,
190+
required_fields_default_values=required_defaults,
191+
)
192+
193+
assert query_params["requiredQueryParam"] == "default-val"
194+
195+
196+
def test_transcode_with_numeric_enums():
197+
http_options = [
198+
{
199+
"method": "get",
200+
"uri": "/v1/test/{name}",
201+
}
202+
]
203+
204+
request = descriptor_pb2.FieldDescriptorProto()
205+
request.name = "my-field"
206+
request.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING
207+
208+
# Without numeric enums
209+
_, _, query_params = transcode_request(
210+
http_options, request, rest_numeric_enums=False
211+
)
212+
assert query_params["type"] == "TYPE_STRING"
213+
214+
# With numeric enums
215+
_, _, query_params = transcode_request(
216+
http_options, request, rest_numeric_enums=True
217+
)
218+
# Type number for TYPE_STRING is 9
219+
assert query_params["type"] == 9
220+
assert query_params["$alt"] == "json;enum-encoding=int"
221+
222+
223+
def test_transcode_no_query_params():
224+
http_options = [{"method": "get", "uri": "/v1/test"}]
225+
request = descriptor_pb2.FieldDescriptorProto()
226+
227+
with mock.patch(
228+
"google.api_core.path_template.transcode",
229+
return_value={"method": "get", "uri": "/v1/test"},
230+
):
231+
transcoded, body, query_params = transcode_request(http_options, request)
232+
assert query_params == {}
233+
234+
235+
def test_transcode_with_required_fields_existing_key():
236+
http_options = [
237+
{
238+
"method": "get",
239+
"uri": "/v1/test",
240+
}
241+
]
242+
243+
request = descriptor_pb2.FieldDescriptorProto()
244+
request.name = "custom-name"
245+
246+
required_defaults = {"name": "default-name"}
247+
248+
transcoded, body, query_params = transcode_request(
249+
http_options,
250+
request,
251+
required_fields_default_values=required_defaults,
252+
)
253+
254+
assert query_params["name"] == "custom-name"

0 commit comments

Comments
 (0)