diff --git a/packages/google-api-core/google/api_core/rest_helpers.py b/packages/google-api-core/google/api_core/rest_helpers.py index a78822f1572a..b22b16565919 100644 --- a/packages/google-api-core/google/api_core/rest_helpers.py +++ b/packages/google-api-core/google/api_core/rest_helpers.py @@ -16,6 +16,12 @@ import functools import operator +from typing import Any, Dict, List, Optional, Set, Tuple + +from google.api_core import path_template +from google.protobuf import json_format + +__all__ = ["flatten_query_params", "transcode", "transcode_request"] def flatten_query_params(obj, strict=False): @@ -107,3 +113,88 @@ def _canonicalize(obj, strict=False): value = value.lower() return value return obj + + +def transcode_request( + http_options: List[Dict[str, str]], + request: Any, + required_fields_default_values: Optional[Dict[str, Any]] = None, + rest_numeric_enums: bool = False, +) -> Tuple[Dict[str, Any], Optional[str], Dict[str, Any]]: + """Transcodes a request into HTTP method, URI, body, and query parameters. + + Args: + http_options (List[Dict[str, str]]): List of HTTP transcoding rules. + request (Any): The protobuf or proto-plus request message. + required_fields_default_values (Optional[Dict[str, Any]]): Dictionary + of required fields default values to merge into query parameters if missing. + rest_numeric_enums (bool): Whether to encode enums as integers. + + Returns: + Tuple[Dict[str, Any], Optional[str], Dict[str, Any]]: A tuple containing: + - The raw transcoded request dictionary (containing keys like 'uri', 'method'). + - The serialized request body JSON string, or None if no body. + - The query parameters dictionary. + """ + if request is None: + raise TypeError("request cannot be None") + + # Convert proto-plus message to its underlying protobuf message if needed + pb_request = getattr(request, "_pb", request) + + transcoded_request = path_template.transcode(http_options, pb_request) + + body_json = None + if transcoded_request.get("body") is not None: + body_json = json_format.MessageToJson( + transcoded_request["body"], + use_integers_for_enums=rest_numeric_enums, + ) + + query_params_json = {} + if transcoded_request.get("query_params") is not None: + query_params_json = json_format.MessageToDict( + transcoded_request["query_params"], + use_integers_for_enums=rest_numeric_enums, + ) + + if required_fields_default_values: + matched_option = None + for option in http_options: + if ( + option.get("method", "").lower() + == transcoded_request.get("method", "").lower() + ): + if path_template.validate( + option.get("uri", ""), transcoded_request.get("uri", "") + ): + matched_option = option + break + + bound_fields: Optional[Set[str]] = set() + if matched_option: + body_param = matched_option.get("body") + if body_param == "*": + bound_fields = None + else: + assert bound_fields is not None + uri_template = matched_option.get("uri", "") + for m in path_template._VARIABLE_RE.finditer(uri_template): + bound_fields.add(m.group("name").split(".")[0]) + if body_param: + bound_fields.add(body_param.split(".")[0]) + + if bound_fields is not None: + for k, v in required_fields_default_values.items(): + if k in bound_fields: + continue + if k not in query_params_json: + query_params_json[k] = v + + if rest_numeric_enums: + query_params_json["$alt"] = "json;enum-encoding=int" + + return transcoded_request, body_json, query_params_json + + +transcode = transcode_request diff --git a/packages/google-api-core/tests/conftest.py b/packages/google-api-core/tests/conftest.py new file mode 100644 index 000000000000..62a3c999f733 --- /dev/null +++ b/packages/google-api-core/tests/conftest.py @@ -0,0 +1,31 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from unittest import mock + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def mock_mtls_env(): + """Autouse session-scoped fixture to isolate unit tests from workstation mTLS environments.""" + with mock.patch.dict( + os.environ, + { + "GOOGLE_API_USE_CLIENT_CERTIFICATE": "false", + "CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false", + }, + ): + yield diff --git a/packages/google-api-core/tests/unit/test_rest_helpers.py b/packages/google-api-core/tests/unit/test_rest_helpers.py index ff1a43f002aa..d883c56c4d36 100644 --- a/packages/google-api-core/tests/unit/test_rest_helpers.py +++ b/packages/google-api-core/tests/unit/test_rest_helpers.py @@ -12,9 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json +from unittest import mock + import pytest from google.api_core import rest_helpers +from google.api_core.rest_helpers import transcode_request +from google.protobuf import descriptor_pb2 def test_flatten_simple_value(): @@ -92,3 +97,265 @@ def test_flatten_repeated_list(): with pytest.raises(ValueError): rest_helpers.flatten_query_params(obj) + + +def test_transcode_basic(): + # We use FieldDescriptorProto as it has standard primitive fields and nested messages. + http_options = [ + { + "method": "get", + "uri": "/v1/test/{name}", + } + ] + + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-field" + request.number = 123 + + transcoded, body, query_params = transcode_request(http_options, request) + + assert transcoded["method"] == "get" + assert transcoded["uri"] == "/v1/test/my-field" + assert body is None + # 'number' should be in query parameters + assert "number" in query_params + assert query_params["number"] == 123 + + +def test_transcode_with_nested_field(): + http_options = [ + { + "method": "get", + "uri": "/v1/test/{options.deprecated}/{name}", + } + ] + + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-field" + request.options.deprecated = True + request.number = 123 + + transcoded, body, query_params = transcode_request(http_options, request) + + assert transcoded["method"] == "get" + assert transcoded["uri"] == "/v1/test/True/my-field" + assert body is None + assert "number" in query_params + assert query_params["number"] == 123 + + +def test_transcode_with_body(): + http_options = [ + { + "method": "post", + "uri": "/v1/test/{name}", + "body": "options", + } + ] + + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-field" + request.options.deprecated = True + request.number = 123 + + transcoded, body, query_params = transcode_request(http_options, request) + + assert transcoded["method"] == "post" + assert transcoded["uri"] == "/v1/test/my-field" + assert body is not None + body_data = json.loads(body) + assert body_data["deprecated"] is True + # Query parameters should not contain 'options' (the body) + assert "number" in query_params + assert query_params["number"] == 123 + assert "options" not in query_params + + +def test_transcode_with_required_fields_default_values(): + http_options = [ + { + "method": "get", + "uri": "/v1/test/{name}", + } + ] + + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-field" + + required_defaults = {"requiredQueryParam": "default-val"} + + transcoded, body, query_params = transcode_request( + http_options, + request, + required_fields_default_values=required_defaults, + ) + + assert query_params["requiredQueryParam"] == "default-val" + + +def test_transcode_with_numeric_enums(): + http_options = [ + { + "method": "get", + "uri": "/v1/test/{name}", + } + ] + + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-field" + request.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING + + # Without numeric enums + _, _, query_params = transcode_request( + http_options, request, rest_numeric_enums=False + ) + assert query_params["type"] == "TYPE_STRING" + + # With numeric enums + _, _, query_params = transcode_request( + http_options, request, rest_numeric_enums=True + ) + # Type number for TYPE_STRING is 9 + assert query_params["type"] == 9 + assert query_params["$alt"] == "json;enum-encoding=int" + + +def test_transcode_no_query_params(): + http_options = [{"method": "get", "uri": "/v1/test"}] + request = descriptor_pb2.FieldDescriptorProto() + + with mock.patch( + "google.api_core.path_template.transcode", + return_value={"method": "get", "uri": "/v1/test"}, + ): + transcoded, body, query_params = transcode_request(http_options, request) + assert query_params == {} + + +def test_transcode_with_required_fields_existing_key(): + http_options = [ + { + "method": "get", + "uri": "/v1/test", + } + ] + + request = descriptor_pb2.FieldDescriptorProto() + request.name = "custom-name" + + required_defaults = {"name": "default-name"} + + transcoded, body, query_params = transcode_request( + http_options, + request, + required_fields_default_values=required_defaults, + ) + + assert query_params["name"] == "custom-name" + + +def test_transcode_alias(): + from google.api_core.rest_helpers import transcode as tr_top + + assert tr_top is transcode_request + + +def test_transcode_request_invalid_request(): + http_options = [{"method": "get", "uri": "/v1/test"}] + with pytest.raises(TypeError, match="request cannot be None"): + transcode_request(http_options, None) + + +def test_transcode_request_proto_plus_wrapper(): + http_options = [{"method": "get", "uri": "/v1/test/{name}"}] + mock_pb = descriptor_pb2.FieldDescriptorProto() + mock_pb.name = "proto-plus-field" + + mock_proto_plus = mock.Mock() + mock_proto_plus._pb = mock_pb + + transcoded, _, _ = transcode_request(http_options, mock_proto_plus) + assert transcoded["uri"] == "/v1/test/proto-plus-field" + + +def test_transcode_with_required_fields_in_path(): + http_options = [{"method": "get", "uri": "/v1/test/{name}"}] + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-name" + + required_defaults = {"name": "default-name", "filter": "default-filter"} + transcoded, _, query_params = transcode_request( + http_options, request, required_fields_default_values=required_defaults + ) + + assert transcoded["uri"] == "/v1/test/my-name" + assert "name" not in query_params + assert query_params["filter"] == "default-filter" + + +def test_transcode_with_required_fields_in_body(): + http_options = [{"method": "post", "uri": "/v1/test", "body": "*"}] + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-name" + + required_defaults = {"name": "default-name"} + _, body, query_params = transcode_request( + http_options, request, required_fields_default_values=required_defaults + ) + + assert body is not None + assert "name" not in query_params + assert query_params == {} + + +def test_transcode_with_required_fields_in_body_param(): + http_options = [{"method": "post", "uri": "/v1/test/{name}", "body": "options"}] + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-name" + + required_defaults = { + "options": "default-options", + "filter": "default-filter", + } + transcoded, body, query_params = transcode_request( + http_options, request, required_fields_default_values=required_defaults + ) + + assert transcoded["uri"] == "/v1/test/my-name" + assert "options" not in query_params + assert query_params["filter"] == "default-filter" + + +def test_transcode_required_fields_no_matching_option(): + http_options = [{"method": "get", "uri": "/v1/test"}] + request = descriptor_pb2.FieldDescriptorProto() + required_defaults = {"name": "default-name"} + + with mock.patch( + "google.api_core.path_template.transcode", + return_value={"method": "delete", "uri": "/v1/test"}, + ): + _, _, query_params = transcode_request( + http_options, request, required_fields_default_values=required_defaults + ) + assert query_params["name"] == "default-name" + + +def test_transcode_required_fields_uri_mismatch(): + http_options = [ + {"method": "get", "uri": "/v1/wrong_path/{name}"}, + {"method": "get", "uri": "/v1/test/{name}"}, + ] + request = descriptor_pb2.FieldDescriptorProto() + request.name = "my-name" + required_defaults = {"name": "default-name", "filter": "default-filter"} + + with mock.patch( + "google.api_core.path_template.transcode", + return_value={"method": "get", "uri": "/v1/test/my-name"}, + ): + transcoded, _, query_params = transcode_request( + http_options, request, required_fields_default_values=required_defaults + ) + assert "name" not in query_params + assert query_params["filter"] == "default-filter"