Skip to content

Commit 509c733

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

3 files changed

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

15+
import json
16+
1517
import pytest
18+
from google.protobuf import descriptor_pb2
1619

1720
from google.api_core import rest_helpers
21+
from google.api_core.rest_helpers import transcode_request
1822

1923

2024
def test_flatten_simple_value():
@@ -92,3 +96,124 @@ def test_flatten_repeated_list():
9296

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

0 commit comments

Comments
 (0)