Skip to content

Commit 68e1313

Browse files
authored
feat(api_core): add request-id auto-population logic to gapic_v1 public helpers (#17738)
Introduces `requests` module containing `setup_request_id` helper. Exposes the module as public in `gapic_v1`. The unit tests in [test_method_helpers.py](https://github.com/googleapis/google-cloud-python/blob/feat/gapic-centralization-api-core-request-id/packages/google-api-core/tests/unit/gapic/test_method_helpers.py#L20-L119) are now identical to the generator's original test template [test_service.py.j2](https://github.com/googleapis/google-cloud-python/blob/main/packages/gapic-generator/gapic/templates/tests/unit/gapic/%25name_%25version/%25sub/test_%25service.py.j2#L437-L514) (with the exception of our added None request test case).
1 parent 95da740 commit 68e1313

3 files changed

Lines changed: 191 additions & 1 deletion

File tree

packages/google-api-core/google/api_core/gapic_v1/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
2525
# Older Python versions safely ignore this variable.
2626
__lazy_modules__: Set[str] = {
2727
"google.api_core.gapic_v1.client_info",
28+
"google.api_core.gapic_v1.requests",
2829
"google.api_core.gapic_v1.routing_header",
2930
}
30-
__all__ = ["client_info", "routing_header"]
31+
__all__ = ["client_info", "requests", "routing_header"]
3132

3233
if _has_grpc:
3334
__lazy_modules__.update(
@@ -41,6 +42,7 @@
4142

4243
from google.api_core.gapic_v1 import ( # noqa: E402
4344
client_info,
45+
requests,
4446
routing_header,
4547
)
4648

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""Helpers for preparing and structuring API requests.
18+
19+
This module provides utilities to preprocess request parameters and objects
20+
before invoking API methods, such as automatically generating request IDs
21+
if they are not already set.
22+
"""
23+
24+
from typing import Union
25+
import uuid
26+
27+
import google.protobuf.message
28+
29+
30+
def setup_request_id(
31+
request: Union[google.protobuf.message.Message, dict, None],
32+
field_name: str,
33+
is_proto3_optional: bool,
34+
) -> None:
35+
"""Populate a UUID4 field in the request if it is not already set.
36+
37+
This helper is used to ensure request idempotency by automatically
38+
generating a unique identifier (such as `request_id`) for requests
39+
that support it. If a request is retried, the same identifier can be
40+
sent on subsequent retries, allowing the server to recognize the retried
41+
request and prevent duplicate processing (e.g., creating duplicate
42+
resources).
43+
44+
Args:
45+
request (Union[google.protobuf.message.Message, dict]): The
46+
request object.
47+
field_name (str): The name of the field to populate.
48+
is_proto3_optional (bool): Whether the field is proto3 optional.
49+
"""
50+
if request is None:
51+
return
52+
53+
if isinstance(request, dict):
54+
if is_proto3_optional:
55+
if field_name not in request or request[field_name] is None:
56+
request[field_name] = str(uuid.uuid4())
57+
elif not request.get(field_name):
58+
request[field_name] = str(uuid.uuid4())
59+
return
60+
61+
if is_proto3_optional:
62+
try:
63+
# Pure protobuf messages
64+
if not request.HasField(field_name):
65+
setattr(request, field_name, str(uuid.uuid4()))
66+
except (AttributeError, ValueError):
67+
# Proto-plus messages or other objects
68+
if not getattr(request, field_name, None):
69+
setattr(request, field_name, str(uuid.uuid4()))
70+
else:
71+
if not getattr(request, field_name, None):
72+
setattr(request, field_name, str(uuid.uuid4()))
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import re
17+
18+
import pytest
19+
20+
from google.api_core.gapic_v1.requests import setup_request_id
21+
22+
23+
# --- Mock Request Helper Classes ---
24+
25+
26+
class MockRequest:
27+
def __init__(self, **kwargs):
28+
for k, v in kwargs.items():
29+
setattr(self, k, v)
30+
31+
def __contains__(self, key):
32+
return hasattr(self, key)
33+
34+
35+
class MockProtoRequest:
36+
def __init__(self, **kwargs):
37+
for k, v in kwargs.items():
38+
setattr(self, k, v)
39+
40+
def HasField(self, key):
41+
return hasattr(self, key)
42+
43+
44+
class MockValueErrorRequest:
45+
def HasField(self, key):
46+
raise ValueError("Mismatched field")
47+
48+
def __contains__(self, key):
49+
return hasattr(self, key)
50+
51+
52+
# --- Parameterized Test ---
53+
54+
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}"
55+
56+
57+
@pytest.mark.parametrize(
58+
"request_obj, is_proto3_optional, expected",
59+
[
60+
# MockRequest cases
61+
(MockRequest(), True, "uuid"),
62+
(MockRequest(request_id="already_set"), True, "already_set"),
63+
(MockRequest(request_id=""), False, "uuid"),
64+
(MockRequest(request_id="already_set"), False, "already_set"),
65+
# MockProtoRequest cases
66+
(MockProtoRequest(), True, "uuid"),
67+
(MockProtoRequest(request_id="already_set"), True, "already_set"),
68+
# ValueError case
69+
(MockValueErrorRequest(), True, "uuid"),
70+
# Dict cases
71+
({}, True, "uuid"),
72+
({"request_id": None}, True, "uuid"),
73+
({"request_id": "already_set"}, True, "already_set"),
74+
({"request_id": ""}, False, "uuid"),
75+
({"request_id": None}, False, "uuid"),
76+
({"request_id": "already_set"}, False, "already_set"),
77+
# None case
78+
(None, True, "none"),
79+
],
80+
ids=[
81+
"proto3_optional_not_in_request",
82+
"proto3_optional_already_in_request",
83+
"non_proto3_optional_empty",
84+
"non_proto3_optional_already_set",
85+
"proto3_optional_not_in_request_proto",
86+
"proto3_optional_already_in_request_proto",
87+
"value_error_fallback",
88+
"dict_proto3_optional_not_in_request",
89+
"dict_proto3_optional_value_none",
90+
"dict_proto3_optional_already_in_request",
91+
"dict_non_proto3_optional_empty",
92+
"dict_non_proto3_optional_value_none",
93+
"dict_non_proto3_optional_already_set",
94+
"none_request",
95+
],
96+
)
97+
def test_setup_request_id(request_obj, is_proto3_optional, expected):
98+
# Act
99+
setup_request_id(request_obj, "request_id", is_proto3_optional)
100+
101+
# Assert
102+
if expected == "none":
103+
assert request_obj is None
104+
return
105+
106+
# Extract the resulting value depending on container type
107+
value = (
108+
request_obj["request_id"]
109+
if isinstance(request_obj, dict)
110+
else request_obj.request_id
111+
)
112+
113+
if expected == "uuid":
114+
assert re.match(UUID_REGEX, value)
115+
else:
116+
assert value == expected

0 commit comments

Comments
 (0)