Skip to content

Commit 4f21b8b

Browse files
feat(api-core): centralize rest transcoding helpers (#17765)
introduces a generic REST Transcoding helper under `google.api_core.gapic_v1.rest_transcoding`. Scope of Changes: - **`rest_helpers.py`**: A centralized, parameters-driven generic utility `transcode()` that replaces the redundant code generated inside the client REST transport constructors (query parameter JSON parsing, required field default checking, body parsing, and numeric enum serialization). - **`__init__.py`**: Registered lazy-loading for the module. - **`conftest.py`**: Added session-scoped mock fixtures for mTLS variables to prevent local workstation client certificate leakage during python unit tests. - **`test_rest_transcoding.py`**: A comprehensive test suite validating transcoding behavior for query parameters, nested fields, required field default injection, and numeric enums. This is the runtime package companion to the generator PR. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent a623576 commit 4f21b8b

3 files changed

Lines changed: 283 additions & 0 deletions

File tree

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@
1515
"""Helpers for rest transports."""
1616

1717
import functools
18+
import json
1819
import operator
20+
from typing import Any, Dict, List, Optional, Tuple
21+
22+
from google.api_core import path_template
23+
from google.protobuf import json_format
24+
25+
__all__ = ["flatten_query_params", "transcode", "transcode_request"]
1926

2027

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

1720
from google.api_core import rest_helpers
21+
from google.api_core.rest_helpers import transcode_request
22+
from google.protobuf import descriptor_pb2
1823

1924

2025
def test_flatten_simple_value():
@@ -92,3 +97,182 @@ 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"
255+
256+
257+
def test_transcode_alias():
258+
from google.api_core.rest_helpers import transcode as tr_top
259+
260+
assert tr_top is transcode_request
261+
262+
263+
def test_transcode_request_invalid_request():
264+
http_options = [{"method": "get", "uri": "/v1/test"}]
265+
with pytest.raises(TypeError, match="request cannot be None"):
266+
transcode_request(http_options, None)
267+
268+
269+
def test_transcode_request_proto_plus_wrapper():
270+
http_options = [{"method": "get", "uri": "/v1/test/{name}"}]
271+
mock_pb = descriptor_pb2.FieldDescriptorProto()
272+
mock_pb.name = "proto-plus-field"
273+
274+
mock_proto_plus = mock.Mock()
275+
mock_proto_plus._pb = mock_pb
276+
277+
transcoded, _, _ = transcode_request(http_options, mock_proto_plus)
278+
assert transcoded["uri"] == "/v1/test/proto-plus-field"

0 commit comments

Comments
 (0)