diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index 48a27ec21d24..78937c032670 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,9 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", + "google.api_core.gapic_v1.requests", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "routing_header"] +__all__ = ["client_info", "requests", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -41,6 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, + requests, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/requests.py b/packages/google-api-core/google/api_core/gapic_v1/requests.py new file mode 100644 index 000000000000..f440ac69126c --- /dev/null +++ b/packages/google-api-core/google/api_core/gapic_v1/requests.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# 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. +# + +"""Helpers for preparing and structuring API requests. + +This module provides utilities to preprocess request parameters and objects +before invoking API methods, such as automatically generating request IDs +if they are not already set. +""" + +from typing import Union +import uuid + +import google.protobuf.message + + +def setup_request_id( + request: Union[google.protobuf.message.Message, dict, None], + field_name: str, + is_proto3_optional: bool, +) -> None: + """Populate a UUID4 field in the request if it is not already set. + + This helper is used to ensure request idempotency by automatically + generating a unique identifier (such as `request_id`) for requests + that support it. If a request is retried, the same identifier can be + sent on subsequent retries, allowing the server to recognize the retried + request and prevent duplicate processing (e.g., creating duplicate + resources). + + Args: + request (Union[google.protobuf.message.Message, dict]): The + request object. + field_name (str): The name of the field to populate. + is_proto3_optional (bool): Whether the field is proto3 optional. + """ + if request is None: + return + + if isinstance(request, dict): + if is_proto3_optional: + if field_name not in request or request[field_name] is None: + request[field_name] = str(uuid.uuid4()) + elif not request.get(field_name): + request[field_name] = str(uuid.uuid4()) + return + + if is_proto3_optional: + try: + # Pure protobuf messages + if not request.HasField(field_name): + setattr(request, field_name, str(uuid.uuid4())) + except (AttributeError, ValueError): + # Proto-plus messages or other objects + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) + else: + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) diff --git a/packages/google-api-core/tests/unit/gapic/test_requests.py b/packages/google-api-core/tests/unit/gapic/test_requests.py new file mode 100644 index 000000000000..1e921955d043 --- /dev/null +++ b/packages/google-api-core/tests/unit/gapic/test_requests.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# 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 re + +import pytest + +from google.api_core.gapic_v1.requests import setup_request_id + + +# --- Mock Request Helper Classes --- + + +class MockRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __contains__(self, key): + return hasattr(self, key) + + +class MockProtoRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def HasField(self, key): + return hasattr(self, key) + + +class MockValueErrorRequest: + def HasField(self, key): + raise ValueError("Mismatched field") + + def __contains__(self, key): + return hasattr(self, key) + + +# --- Parameterized Test --- + +UUID_REGEX = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" + + +@pytest.mark.parametrize( + "request_obj, is_proto3_optional, expected", + [ + # MockRequest cases + (MockRequest(), True, "uuid"), + (MockRequest(request_id="already_set"), True, "already_set"), + (MockRequest(request_id=""), False, "uuid"), + (MockRequest(request_id="already_set"), False, "already_set"), + # MockProtoRequest cases + (MockProtoRequest(), True, "uuid"), + (MockProtoRequest(request_id="already_set"), True, "already_set"), + # ValueError case + (MockValueErrorRequest(), True, "uuid"), + # Dict cases + ({}, True, "uuid"), + ({"request_id": None}, True, "uuid"), + ({"request_id": "already_set"}, True, "already_set"), + ({"request_id": ""}, False, "uuid"), + ({"request_id": None}, False, "uuid"), + ({"request_id": "already_set"}, False, "already_set"), + # None case + (None, True, "none"), + ], + ids=[ + "proto3_optional_not_in_request", + "proto3_optional_already_in_request", + "non_proto3_optional_empty", + "non_proto3_optional_already_set", + "proto3_optional_not_in_request_proto", + "proto3_optional_already_in_request_proto", + "value_error_fallback", + "dict_proto3_optional_not_in_request", + "dict_proto3_optional_value_none", + "dict_proto3_optional_already_in_request", + "dict_non_proto3_optional_empty", + "dict_non_proto3_optional_value_none", + "dict_non_proto3_optional_already_set", + "none_request", + ], +) +def test_setup_request_id(request_obj, is_proto3_optional, expected): + # Act + setup_request_id(request_obj, "request_id", is_proto3_optional) + + # Assert + if expected == "none": + assert request_obj is None + return + + # Extract the resulting value depending on container type + value = ( + request_obj["request_id"] + if isinstance(request_obj, dict) + else request_obj.request_id + ) + + if expected == "uuid": + assert re.match(UUID_REGEX, value) + else: + assert value == expected