Skip to content

Commit 7fdf94e

Browse files
committed
Merge branch 'feat/gapic-centralization-api-core-request-id' into feat/gapic-generator-centralization-request-id
2 parents 4ff1910 + 77f97c8 commit 7fdf94e

3 files changed

Lines changed: 187 additions & 3 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +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.request",
28+
"google.api_core.gapic_v1.requests",
2929
"google.api_core.gapic_v1.routing_header",
3030
}
31-
__all__ = ["client_info", "request", "routing_header"]
31+
__all__ = ["client_info", "requests", "routing_header"]
3232

3333
if _has_grpc:
3434
__lazy_modules__.update(
@@ -42,7 +42,7 @@
4242

4343
from google.api_core.gapic_v1 import ( # noqa: E402
4444
client_info,
45-
request,
45+
requests,
4646
routing_header,
4747
)
4848

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+
import uuid
25+
from typing import Union
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:
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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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": "already_set"}, True, "already_set"),
73+
({"request_id": ""}, False, "uuid"),
74+
({"request_id": "already_set"}, False, "already_set"),
75+
# None case
76+
(None, True, "none"),
77+
],
78+
ids=[
79+
"proto3_optional_not_in_request",
80+
"proto3_optional_already_in_request",
81+
"non_proto3_optional_empty",
82+
"non_proto3_optional_already_set",
83+
"proto3_optional_not_in_request_proto",
84+
"proto3_optional_already_in_request_proto",
85+
"value_error_fallback",
86+
"dict_proto3_optional_not_in_request",
87+
"dict_proto3_optional_already_in_request",
88+
"dict_non_proto3_optional_empty",
89+
"dict_non_proto3_optional_already_set",
90+
"none_request",
91+
],
92+
)
93+
def test_setup_request_id(request_obj, is_proto3_optional, expected):
94+
# Act
95+
setup_request_id(request_obj, "request_id", is_proto3_optional)
96+
97+
# Assert
98+
if expected == "none":
99+
assert request_obj is None
100+
return
101+
102+
# Extract the resulting value depending on container type
103+
value = (
104+
request_obj["request_id"]
105+
if isinstance(request_obj, dict)
106+
else request_obj.request_id
107+
)
108+
109+
if expected == "uuid":
110+
assert re.match(UUID_REGEX, value)
111+
else:
112+
assert value == expected

0 commit comments

Comments
 (0)