Skip to content

Commit 078b516

Browse files
yuvrajangadsinghxuanyang15
authored andcommitted
feat(tools): add preserve_property_names option to OpenAPIToolset
Merge #4505 Co-authored-by: Xuan Yang <xygoogle@google.com> COPYBARA_INTEGRATE_REVIEW=#4505 from yuvrajangadsingh:fix/openapi-preserve-property-names 8b9debf PiperOrigin-RevId: 881534964
1 parent 4c6096b commit 078b516

4 files changed

Lines changed: 141 additions & 4 deletions

File tree

src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ class OpenApiSpecParser:
7373
3. A callable Python object (a function) that can execute the operation.
7474
"""
7575

76+
def __init__(self, *, preserve_property_names: bool = False):
77+
"""Initializes the OpenApiSpecParser.
78+
79+
Args:
80+
preserve_property_names: If True, preserve the original property names
81+
from the OpenAPI spec instead of converting them to snake_case.
82+
"""
83+
self._preserve_property_names = preserve_property_names
84+
7685
def parse(self, openapi_spec_dict: Dict[str, Any]) -> List[ParsedOperation]:
7786
"""Extracts an OpenAPI spec dict into a list of ParsedOperation objects.
7887
@@ -212,7 +221,10 @@ def _collect_operations(
212221

213222
url = OperationEndpoint(base_url=base_url, path=path, method=method)
214223
operation = Operation.model_validate(operation_dict)
215-
operation_parser = OperationParser(operation)
224+
operation_parser = OperationParser(
225+
operation,
226+
preserve_property_names=self._preserve_property_names,
227+
)
216228

217229
# Check for operation-specific auth scheme
218230
auth_scheme_name = operation_parser.get_auth_scheme_name()

src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_toolset.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def __init__(
7777
header_provider: Optional[
7878
Callable[[ReadonlyContext], Dict[str, str]]
7979
] = None,
80+
preserve_property_names: bool = False,
8081
):
8182
"""Initializes the OpenAPIToolset.
8283
@@ -129,11 +130,17 @@ def __init__(
129130
an argument, allowing dynamic header generation based on the current
130131
context. Useful for adding custom headers like correlation IDs,
131132
authentication tokens, or other request metadata.
133+
preserve_property_names: If True, preserve the original property names
134+
from the OpenAPI spec instead of converting them to snake_case. This
135+
is useful when calling APIs that expect camelCase or other
136+
non-snake_case parameter names in the request. Defaults to False for
137+
backward compatibility.
132138
"""
133139
super().__init__(tool_filter=tool_filter, tool_name_prefix=tool_name_prefix)
134140
self._header_provider = header_provider
135141
self._auth_scheme = auth_scheme
136142
self._auth_credential = auth_credential
143+
self._preserve_property_names = preserve_property_names
137144
# Store auth config as instance variable so ADK can populate
138145
# exchanged_auth_credential in-place before calling get_tools()
139146
self._auth_config: Optional[AuthConfig] = (
@@ -219,7 +226,10 @@ def _load_spec(
219226

220227
def _parse(self, openapi_spec_dict: Dict[str, Any]) -> List[RestApiTool]:
221228
"""Parse OpenAPI spec into a list of RestApiTool."""
222-
operations = OpenApiSpecParser().parse(openapi_spec_dict)
229+
parser = OpenApiSpecParser(
230+
preserve_property_names=self._preserve_property_names
231+
)
232+
operations = parser.parse(openapi_spec_dict)
223233

224234
tools = []
225235
for o in operations:

src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from ..._gemini_schema_util import _to_snake_case
3131
from ..common.common import ApiParameter
3232
from ..common.common import PydocHelper
33+
from ..common.common import rename_python_keywords
3334

3435

3536
class OperationParser:
@@ -42,13 +43,21 @@ class OperationParser:
4243
"""
4344

4445
def __init__(
45-
self, operation: Union[Operation, Dict[str, Any], str], should_parse=True
46+
self,
47+
operation: Union[Operation, Dict[str, Any], str],
48+
should_parse: bool = True,
49+
*,
50+
preserve_property_names: bool = False,
4651
):
4752
"""Initializes the OperationParser with an OpenApiOperation.
4853
4954
Args:
5055
operation: The OpenApiOperation object or a dictionary to process.
5156
should_parse: Whether to parse the operation during initialization.
57+
preserve_property_names: If True, preserve the original property names
58+
from the OpenAPI spec instead of converting them to snake_case.
59+
Useful for APIs that expect camelCase or other non-snake_case
60+
parameter names.
5261
"""
5362
if isinstance(operation, dict):
5463
self._operation = Operation.model_validate(operation)
@@ -57,6 +66,7 @@ def __init__(
5766
else:
5867
self._operation = operation
5968

69+
self._preserve_property_names = preserve_property_names
6070
self._params: List[ApiParameter] = []
6171
self._return_value: Optional[ApiParameter] = None
6272
if should_parse:
@@ -71,12 +81,24 @@ def load(
7181
operation: Union[Operation, Dict[str, Any]],
7282
params: List[ApiParameter],
7383
return_value: Optional[ApiParameter] = None,
84+
*,
85+
preserve_property_names: bool = False,
7486
) -> 'OperationParser':
75-
parser = cls(operation, should_parse=False)
87+
parser = cls(
88+
operation,
89+
should_parse=False,
90+
preserve_property_names=preserve_property_names,
91+
)
7692
parser._params = params
7793
parser._return_value = return_value
7894
return parser
7995

96+
def _get_py_name(self, original_name: str) -> str:
97+
"""Determines the Python parameter name based on preserve_property_names."""
98+
if self._preserve_property_names:
99+
return rename_python_keywords(original_name)
100+
return ''
101+
80102
def _process_operation_parameters(self):
81103
"""Processes parameters from the OpenAPI operation."""
82104
parameters = self._operation.parameters or []
@@ -99,6 +121,7 @@ def _process_operation_parameters(self):
99121
param_schema=schema,
100122
description=description,
101123
required=required,
124+
py_name=self._get_py_name(original_name),
102125
)
103126
)
104127

@@ -126,6 +149,7 @@ def _process_request_body(self):
126149
param_location='body',
127150
param_schema=prop_details,
128151
description=prop_details.description,
152+
py_name=self._get_py_name(prop_name),
129153
)
130154
)
131155

tests/unittests/tools/openapi_tool/openapi_spec_parser/test_openapi_toolset.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,94 @@ def test_openapi_toolset_header_provider_none_by_default(
218218

219219
# Verify all tools have no header_provider
220220
assert all(tool._header_provider is None for tool in toolset._tools)
221+
222+
223+
def test_openapi_toolset_preserve_property_names(openapi_spec: Dict[str, Any]):
224+
"""Test that preserve_property_names keeps original camelCase names."""
225+
toolset = OpenAPIToolset(
226+
spec_dict=openapi_spec,
227+
preserve_property_names=True,
228+
)
229+
tool = toolset.get_tool("calendar_calendars_get")
230+
assert tool is not None
231+
232+
# The calendarId parameter should keep its original camelCase name
233+
params = tool._operation_parser.get_parameters()
234+
param_names = [p.py_name for p in params]
235+
assert "calendarId" in param_names
236+
237+
# The JSON schema should also use the original name
238+
schema = tool._operation_parser.get_json_schema()
239+
assert "calendarId" in schema["properties"]
240+
241+
242+
def test_openapi_toolset_default_snake_case_conversion(
243+
openapi_spec: Dict[str, Any],
244+
):
245+
"""Test that default behavior still converts to snake_case."""
246+
toolset = OpenAPIToolset(spec_dict=openapi_spec)
247+
tool = toolset.get_tool("calendar_calendars_get")
248+
assert tool is not None
249+
250+
# The calendarId parameter should be converted to snake_case by default
251+
params = tool._operation_parser.get_parameters()
252+
param_names = [p.py_name for p in params]
253+
assert "calendar_id" in param_names
254+
assert "calendarId" not in param_names
255+
256+
# The JSON schema should also use snake_case
257+
schema = tool._operation_parser.get_json_schema()
258+
assert "calendar_id" in schema["properties"]
259+
assert "calendarId" not in schema["properties"]
260+
261+
262+
def test_openapi_toolset_preserve_property_names_body_params():
263+
"""Test preserve_property_names with request body properties."""
264+
spec = {
265+
"openapi": "3.0.0",
266+
"info": {"title": "Test API", "version": "1.0"},
267+
"servers": [{"url": "https://api.example.com"}],
268+
"paths": {
269+
"/users": {
270+
"post": {
271+
"operationId": "createUser",
272+
"requestBody": {
273+
"content": {
274+
"application/json": {
275+
"schema": {
276+
"type": "object",
277+
"properties": {
278+
"firstName": {"type": "string"},
279+
"lastName": {"type": "string"},
280+
"emailAddress": {"type": "string"},
281+
},
282+
}
283+
}
284+
}
285+
},
286+
"responses": {"200": {"description": "OK"}},
287+
}
288+
}
289+
},
290+
}
291+
292+
# With preserve_property_names=True
293+
toolset = OpenAPIToolset(
294+
spec_dict=spec,
295+
preserve_property_names=True,
296+
)
297+
tool = toolset.get_tool("create_user")
298+
params = tool._operation_parser.get_parameters()
299+
param_names = [p.py_name for p in params]
300+
assert "firstName" in param_names
301+
assert "lastName" in param_names
302+
assert "emailAddress" in param_names
303+
304+
# Without preserve_property_names (default)
305+
toolset_default = OpenAPIToolset(spec_dict=spec)
306+
tool_default = toolset_default.get_tool("create_user")
307+
params_default = tool_default._operation_parser.get_parameters()
308+
param_names_default = [p.py_name for p in params_default]
309+
assert "first_name" in param_names_default
310+
assert "last_name" in param_names_default
311+
assert "email_address" in param_names_default

0 commit comments

Comments
 (0)