diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py index a2c0bec080..614baafc4b 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.py @@ -73,6 +73,15 @@ class OpenApiSpecParser: 3. A callable Python object (a function) that can execute the operation. """ + def __init__(self, preserve_property_names: bool = False): + """Initializes the OpenApiSpecParser. + + Args: + preserve_property_names: If True, preserve the original property names + from the OpenAPI spec instead of converting them to snake_case. + """ + self._preserve_property_names = preserve_property_names + def parse(self, openapi_spec_dict: Dict[str, Any]) -> List[ParsedOperation]: """Extracts an OpenAPI spec dict into a list of ParsedOperation objects. @@ -212,7 +221,10 @@ def _collect_operations( url = OperationEndpoint(base_url=base_url, path=path, method=method) operation = Operation.model_validate(operation_dict) - operation_parser = OperationParser(operation) + operation_parser = OperationParser( + operation, + preserve_property_names=self._preserve_property_names, + ) # Check for operation-specific auth scheme auth_scheme_name = operation_parser.get_auth_scheme_name() diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_toolset.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_toolset.py index 9e5dd8dda4..da3332e9da 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_toolset.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_toolset.py @@ -77,6 +77,7 @@ def __init__( header_provider: Optional[ Callable[[ReadonlyContext], Dict[str, str]] ] = None, + preserve_property_names: bool = False, ): """Initializes the OpenAPIToolset. @@ -129,11 +130,17 @@ def __init__( an argument, allowing dynamic header generation based on the current context. Useful for adding custom headers like correlation IDs, authentication tokens, or other request metadata. + preserve_property_names: If True, preserve the original property names + from the OpenAPI spec instead of converting them to snake_case. This + is useful when calling APIs that expect camelCase or other + non-snake_case parameter names in the request. Defaults to False for + backward compatibility. """ super().__init__(tool_filter=tool_filter, tool_name_prefix=tool_name_prefix) self._header_provider = header_provider self._auth_scheme = auth_scheme self._auth_credential = auth_credential + self._preserve_property_names = preserve_property_names # Store auth config as instance variable so ADK can populate # exchanged_auth_credential in-place before calling get_tools() self._auth_config: Optional[AuthConfig] = ( @@ -219,7 +226,10 @@ def _load_spec( def _parse(self, openapi_spec_dict: Dict[str, Any]) -> List[RestApiTool]: """Parse OpenAPI spec into a list of RestApiTool.""" - operations = OpenApiSpecParser().parse(openapi_spec_dict) + parser = OpenApiSpecParser( + preserve_property_names=self._preserve_property_names + ) + operations = parser.parse(openapi_spec_dict) tools = [] for o in operations: @@ -227,6 +237,7 @@ def _parse(self, openapi_spec_dict: Dict[str, Any]) -> List[RestApiTool]: o, ssl_verify=self._ssl_verify, header_provider=self._header_provider, + preserve_property_names=self._preserve_property_names, ) logger.info("Parsed tool: %s", tool.name) tools.append(tool) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py index 1475446f94..45f81b46fd 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py @@ -30,6 +30,7 @@ from ..._gemini_schema_util import _to_snake_case from ..common.common import ApiParameter from ..common.common import PydocHelper +from ..common.common import rename_python_keywords class OperationParser: @@ -42,13 +43,21 @@ class OperationParser: """ def __init__( - self, operation: Union[Operation, Dict[str, Any], str], should_parse=True + self, + operation: Union[Operation, Dict[str, Any], str], + should_parse: bool = True, + *, + preserve_property_names: bool = False, ): """Initializes the OperationParser with an OpenApiOperation. Args: operation: The OpenApiOperation object or a dictionary to process. should_parse: Whether to parse the operation during initialization. + preserve_property_names: If True, preserve the original property names + from the OpenAPI spec instead of converting them to snake_case. + Useful for APIs that expect camelCase or other non-snake_case + parameter names. """ if isinstance(operation, dict): self._operation = Operation.model_validate(operation) @@ -57,6 +66,7 @@ def __init__( else: self._operation = operation + self._preserve_property_names = preserve_property_names self._params: List[ApiParameter] = [] self._return_value: Optional[ApiParameter] = None if should_parse: @@ -71,12 +81,24 @@ def load( operation: Union[Operation, Dict[str, Any]], params: List[ApiParameter], return_value: Optional[ApiParameter] = None, + *, + preserve_property_names: bool = False, ) -> 'OperationParser': - parser = cls(operation, should_parse=False) + parser = cls( + operation, + should_parse=False, + preserve_property_names=preserve_property_names, + ) parser._params = params parser._return_value = return_value return parser + def _get_py_name(self, original_name: str) -> str: + """Determines the Python parameter name based on preserve_property_names.""" + if self._preserve_property_names: + return rename_python_keywords(original_name) + return '' + def _process_operation_parameters(self): """Processes parameters from the OpenAPI operation.""" parameters = self._operation.parameters or [] @@ -99,6 +121,7 @@ def _process_operation_parameters(self): param_schema=schema, description=description, required=required, + py_name=self._get_py_name(original_name), ) ) @@ -126,6 +149,7 @@ def _process_request_body(self): param_location='body', param_schema=prop_details, description=prop_details.description, + py_name=self._get_py_name(prop_name), ) ) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index 300c47e1e5..57a0645ab2 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -178,6 +178,7 @@ def from_parsed_operation( header_provider: Optional[ Callable[[ReadonlyContext], Dict[str, str]] ] = None, + preserve_property_names: bool = False, ) -> "RestApiTool": """Initializes the RestApiTool from a ParsedOperation object. @@ -189,12 +190,17 @@ def from_parsed_operation( an argument, allowing dynamic header generation based on the current context. Useful for adding custom headers like correlation IDs, authentication tokens, or other request metadata. + preserve_property_names: If True, preserve original property names + from the OpenAPI spec instead of converting to snake_case. Returns: A RestApiTool object. """ operation_parser = OperationParser.load( - parsed.operation, parsed.parameters, parsed.return_value + parsed.operation, + parsed.parameters, + parsed.return_value, + preserve_property_names=preserve_property_names, ) tool_name = _to_snake_case(operation_parser.get_function_name()) diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_openapi_toolset.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_openapi_toolset.py index e4c71b2420..77608b5595 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_openapi_toolset.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_openapi_toolset.py @@ -218,3 +218,92 @@ def test_openapi_toolset_header_provider_none_by_default( # Verify all tools have no header_provider assert all(tool._header_provider is None for tool in toolset._tools) + + +def test_openapi_toolset_preserve_property_names(openapi_spec: Dict): + """Test that preserve_property_names keeps original camelCase names.""" + toolset = OpenAPIToolset( + spec_dict=openapi_spec, + preserve_property_names=True, + ) + tool = toolset.get_tool("calendar_calendars_get") + assert tool is not None + + # The calendarId parameter should keep its original camelCase name + params = tool._operation_parser.get_parameters() + param_names = [p.py_name for p in params] + assert "calendarId" in param_names + + # The JSON schema should also use the original name + schema = tool._operation_parser.get_json_schema() + assert "calendarId" in schema["properties"] + + +def test_openapi_toolset_default_snake_case_conversion(openapi_spec: Dict): + """Test that default behavior still converts to snake_case.""" + toolset = OpenAPIToolset(spec_dict=openapi_spec) + tool = toolset.get_tool("calendar_calendars_get") + assert tool is not None + + # The calendarId parameter should be converted to snake_case by default + params = tool._operation_parser.get_parameters() + param_names = [p.py_name for p in params] + assert "calendar_id" in param_names + assert "calendarId" not in param_names + + # The JSON schema should also use snake_case + schema = tool._operation_parser.get_json_schema() + assert "calendar_id" in schema["properties"] + assert "calendarId" not in schema["properties"] + + +def test_openapi_toolset_preserve_property_names_body_params(): + """Test preserve_property_names with request body properties.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "firstName": {"type": "string"}, + "lastName": {"type": "string"}, + "emailAddress": {"type": "string"}, + }, + } + } + } + }, + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + + # With preserve_property_names=True + toolset = OpenAPIToolset( + spec_dict=spec, + preserve_property_names=True, + ) + tool = toolset.get_tool("create_user") + params = tool._operation_parser.get_parameters() + param_names = [p.py_name for p in params] + assert "firstName" in param_names + assert "lastName" in param_names + assert "emailAddress" in param_names + + # Without preserve_property_names (default) + toolset_default = OpenAPIToolset(spec_dict=spec) + tool_default = toolset_default.get_tool("create_user") + params_default = tool_default._operation_parser.get_parameters() + param_names_default = [p.py_name for p in params_default] + assert "first_name" in param_names_default + assert "last_name" in param_names_default + assert "email_address" in param_names_default