Skip to content

Commit f399974

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

3 files changed

Lines changed: 207 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: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import json
1516
import pytest
17+
from google.protobuf import descriptor_pb2
1618

1719
from google.api_core import rest_helpers
20+
from google.api_core.rest_helpers import transcode_request
1821

1922

2023
def test_flatten_simple_value():
@@ -92,3 +95,120 @@ def test_flatten_repeated_list():
9295

9396
with pytest.raises(ValueError):
9497
rest_helpers.flatten_query_params(obj)
98+
99+
100+
def test_transcode_basic():
101+
# We use FieldDescriptorProto as it has standard primitive fields and nested messages.
102+
http_options = [
103+
{
104+
"method": "get",
105+
"uri": "/v1/test/{name}",
106+
}
107+
]
108+
109+
request = descriptor_pb2.FieldDescriptorProto()
110+
request.name = "my-field"
111+
request.number = 123
112+
113+
transcoded, body, query_params = transcode_request(http_options, request)
114+
115+
assert transcoded["method"] == "get"
116+
assert transcoded["uri"] == "/v1/test/my-field"
117+
assert body is None
118+
# 'number' should be in query parameters
119+
assert "number" in query_params
120+
assert query_params["number"] == 123
121+
122+
123+
def test_transcode_with_nested_field():
124+
http_options = [
125+
{
126+
"method": "get",
127+
"uri": "/v1/test/{options.deprecated}/{name}",
128+
}
129+
]
130+
131+
request = descriptor_pb2.FieldDescriptorProto()
132+
request.name = "my-field"
133+
request.options.deprecated = True
134+
request.number = 123
135+
136+
transcoded, body, query_params = transcode_request(http_options, request)
137+
138+
assert transcoded["method"] == "get"
139+
assert transcoded["uri"] == "/v1/test/True/my-field"
140+
assert body is None
141+
assert "number" in query_params
142+
assert query_params["number"] == 123
143+
144+
145+
def test_transcode_with_body():
146+
http_options = [
147+
{
148+
"method": "post",
149+
"uri": "/v1/test/{name}",
150+
"body": "options",
151+
}
152+
]
153+
154+
request = descriptor_pb2.FieldDescriptorProto()
155+
request.name = "my-field"
156+
request.options.deprecated = True
157+
request.number = 123
158+
159+
transcoded, body, query_params = transcode_request(http_options, request)
160+
161+
assert transcoded["method"] == "post"
162+
assert transcoded["uri"] == "/v1/test/my-field"
163+
assert body is not None
164+
body_data = json.loads(body)
165+
assert body_data["deprecated"] is True
166+
# Query parameters should not contain 'options' (the body)
167+
assert "number" in query_params
168+
assert query_params["number"] == 123
169+
assert "options" not in query_params
170+
171+
172+
def test_transcode_with_required_fields_default_values():
173+
http_options = [
174+
{
175+
"method": "get",
176+
"uri": "/v1/test/{name}",
177+
}
178+
]
179+
180+
request = descriptor_pb2.FieldDescriptorProto()
181+
request.name = "my-field"
182+
183+
required_defaults = {"requiredQueryParam": "default-val"}
184+
185+
transcoded, body, query_params = transcode_request(
186+
http_options,
187+
request,
188+
required_fields_default_values=required_defaults,
189+
)
190+
191+
assert query_params["requiredQueryParam"] == "default-val"
192+
193+
194+
def test_transcode_with_numeric_enums():
195+
http_options = [
196+
{
197+
"method": "get",
198+
"uri": "/v1/test/{name}",
199+
}
200+
]
201+
202+
request = descriptor_pb2.FieldDescriptorProto()
203+
request.name = "my-field"
204+
request.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING
205+
206+
# Without numeric enums
207+
_, _, query_params = transcode_request(http_options, request, rest_numeric_enums=False)
208+
assert query_params["type"] == "TYPE_STRING"
209+
210+
# With numeric enums
211+
_, _, query_params = transcode_request(http_options, request, rest_numeric_enums=True)
212+
# Type number for TYPE_STRING is 9
213+
assert query_params["type"] == 9
214+
assert query_params["$alt"] == "json;enum-encoding=int"

0 commit comments

Comments
 (0)