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 76dda70205d..5f9c4bce1bf 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 @@ -139,9 +139,9 @@ def _process_request_body(self): for _, media_type_object in content.items(): schema = media_type_object.schema_ or {} description = request_body.description or '' + properties = schema.properties if schema else None - if schema and schema.type == 'object': - properties = schema.properties or {} + if schema and schema.type == 'object' and properties: required_properties = set(schema.required or []) for prop_name, prop_details in properties.items(): self._params.append( @@ -166,11 +166,11 @@ def _process_request_body(self): ) else: # Prefer explicit body name to avoid empty keys when schema lacks type - # information (e.g., oneOf/anyOf/allOf) while retaining legacy behavior - # for simple scalar types. + # information (e.g., oneOf/anyOf/allOf) or declares a free-form object, + # while retaining legacy behavior for simple scalar types. if schema and (schema.oneOf or schema.anyOf or schema.allOf): param_name = 'body' - elif not schema or not schema.type: + elif not schema or not schema.type or schema.type == 'object': param_name = 'body' else: param_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 7d067ffa2c1..d2284c50053 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 @@ -422,11 +422,19 @@ def _prepare_request_params( schema = media_type_object.schema_ body_data = None - if schema.type == "object": + # $refs are resolved before a tool is built; narrow so .properties is + # readable. + if isinstance(schema, Schema) and schema.type == "object": body_data = {} for param in parameters: if param.param_location == "body" and param.py_name in kwargs: - body_data[param.original_name] = kwargs[param.py_name] + if schema.properties: + body_data[param.original_name] = kwargs[param.py_name] + else: + # A free-form body has one parameter holding the whole + # payload, so its value is the body rather than a key in it. + body_data = kwargs[param.py_name] + break elif schema.type == "array": for param in parameters: diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py index e90453937ce..61911866fe2 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py @@ -224,7 +224,7 @@ def test_process_request_body_one_of_schema_assigns_name(): def test_process_request_body_empty_object(): - """Test _process_request_body with a schema that is of type object but with no properties.""" + """An object body with no properties becomes one whole-body parameter.""" operation = Operation( requestBody=RequestBody( content={'application/json': MediaType(schema=Schema(type='object'))} @@ -232,7 +232,83 @@ def test_process_request_body_empty_object(): ) parser = OperationParser(operation, should_parse=False) parser._process_request_body() - assert len(parser._params) == 0 + assert len(parser._params) == 1 + assert parser._params[0].original_name == 'body' + assert parser._params[0].py_name == 'body' + assert parser._params[0].param_location == 'body' + assert parser._params[0].param_schema.type == 'object' + + +def test_process_request_body_free_form_object_uses_request_body_description(): + """The whole-body parameter carries the request body description.""" + operation = Operation( + requestBody=RequestBody( + description='Arbitrary JSON document.', + content={'application/json': MediaType(schema=Schema(type='object'))}, + ) + ) + parser = OperationParser(operation, should_parse=False) + parser._process_request_body() + assert len(parser._params) == 1 + assert parser._params[0].description == 'Arbitrary JSON document.' + + +def test_process_request_body_additional_properties_object(): + """An additionalProperties object body becomes one whole-body parameter.""" + operation = Operation( + requestBody=RequestBody( + content={ + 'application/json': MediaType( + schema=Schema(type='object', additionalProperties=True) + ) + } + ) + ) + parser = OperationParser(operation, should_parse=False) + parser._process_request_body() + assert len(parser._params) == 1 + assert parser._params[0].original_name == 'body' + assert parser._params[0].param_location == 'body' + + +def test_free_form_object_body_appears_in_json_schema(): + """The free-form body is an argument that the model can populate.""" + operation = Operation( + operationId='ingest_document', + requestBody=RequestBody( + content={ + 'application/json': MediaType( + schema=Schema(type='object', additionalProperties=True) + ) + } + ), + responses={'200': Response(description='ok')}, + ) + parser = OperationParser(operation) + properties = parser.get_json_schema()['properties'] + assert 'body' in properties + assert '' not in properties + + +def test_process_request_body_object_with_property_named_body(): + """A property called 'body' still expands like any other property.""" + operation = Operation( + requestBody=RequestBody( + content={ + 'application/json': MediaType( + schema=Schema( + type='object', + properties={'body': Schema(type='string')}, + ) + ) + } + ) + ) + parser = OperationParser(operation, should_parse=False) + parser._process_request_body() + assert len(parser._params) == 1 + assert parser._params[0].original_name == 'body' + assert parser._params[0].param_schema.type == 'string' def test_dedupe_param_names(sample_operation): diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py index c17b18d4d6f..8c2a923454f 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py @@ -154,6 +154,26 @@ def sample_auth_credential(): return credential +def free_form_body_operation(mime_type: str = "application/json") -> Operation: + """Builds an operation whose request body is a free-form object.""" + return Operation( + operationId="ingest_document", + requestBody=RequestBody( + content={mime_type: MediaType(schema=OpenAPISchema(type="object"))} + ), + ) + + +def free_form_body_parameter() -> ApiParameter: + """Builds the whole-body parameter that OperationParser emits for it.""" + return ApiParameter( + original_name="body", + py_name="body", + param_location="body", + param_schema=OpenAPISchema(type="object"), + ) + + class TestRestApiToolLegacy: @pytest.fixture(autouse=True) @@ -613,6 +633,102 @@ def test_prepare_request_params_array( assert request_params["json"] == ["item1", "item2"] + def test_prepare_request_params_free_form_object_body( + self, sample_endpoint, sample_auth_scheme, sample_auth_credential + ): + """A free-form object body is sent as the body, not wrapped in a key.""" + tool = RestApiTool( + name="ingest_document", + description="test", + endpoint=sample_endpoint, + operation=free_form_body_operation(), + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + + request_params = tool._prepare_request_params( + [free_form_body_parameter()], {"body": {"a": 1, "nested": {"b": 2}}} + ) + + assert request_params["json"] == {"a": 1, "nested": {"b": 2}} + assert request_params["headers"]["Content-Type"] == "application/json" + + def test_prepare_request_params_free_form_object_body_omitted( + self, sample_endpoint, sample_auth_scheme, sample_auth_credential + ): + """An omitted free-form body argument still sends an empty object.""" + tool = RestApiTool( + name="ingest_document", + description="test", + endpoint=sample_endpoint, + operation=free_form_body_operation(), + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + + request_params = tool._prepare_request_params( + [free_form_body_parameter()], {} + ) + + assert request_params["json"] == {} + + def test_prepare_request_params_object_body_with_property_named_body( + self, sample_endpoint, sample_auth_scheme, sample_auth_credential + ): + """A declared property called 'body' keeps going through the wrapping path.""" + mock_operation = Operation( + operationId="test_op", + requestBody=RequestBody( + content={ + "application/json": MediaType( + schema=OpenAPISchema( + type="object", + properties={"body": OpenAPISchema(type="string")}, + ) + ) + } + ), + ) + tool = RestApiTool( + name="test_tool", + description="test", + endpoint=sample_endpoint, + operation=mock_operation, + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + params = [ + ApiParameter( + original_name="body", + py_name="body", + param_location="body", + param_schema=OpenAPISchema(type="string"), + ) + ] + + request_params = tool._prepare_request_params(params, {"body": "x"}) + + assert request_params["json"] == {"body": "x"} + + def test_prepare_request_params_free_form_object_body_form_urlencoded( + self, sample_endpoint, sample_auth_scheme, sample_auth_credential + ): + """A form-urlencoded free-form body reaches httpx unwrapped too.""" + tool = RestApiTool( + name="ingest_document", + description="test", + endpoint=sample_endpoint, + operation=free_form_body_operation("application/x-www-form-urlencoded"), + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + + request_params = tool._prepare_request_params( + [free_form_body_parameter()], {"body": {"key1": "value1"}} + ) + + assert request_params["data"] == {"key1": "value1"} + def test_prepare_request_params_string( self, sample_endpoint, sample_auth_credential, sample_auth_scheme ): @@ -1554,6 +1670,82 @@ async def test_call_without_httpx_client_factory_uses_default_client( await tool.call(args={}, tool_context=mock_tool_context) assert mock_async_client.called + @pytest.mark.asyncio + async def test_call_puts_free_form_object_body_on_the_wire(self): + """The whole tool path sends the body argument as the request body.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content"] = request.content + captured["content_type"] = request.headers.get("content-type") + return httpx.Response(200, json={"ok": True}) + + tool = RestApiTool( + name="ingest_document", + description="Ingest an arbitrary JSON document.", + endpoint=OperationEndpoint( + base_url="https://example.com", path="/ingest", method="POST" + ), + operation=free_form_body_operation(), + httpx_client_factory=lambda: httpx.AsyncClient( + transport=httpx.MockTransport(handler) + ), + ) + + result = await tool.call(args={"body": {"a": 1}}, tool_context=None) + + assert json.loads(captured["content"]) == {"a": 1} + assert captured["content_type"] == "application/json" + assert result == {"ok": True} + + @pytest.mark.asyncio + async def test_call_free_form_object_body_omitted_sends_empty_object(self): + """An omitted body argument keeps the empty object sent before.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content"] = request.content + return httpx.Response(200, json={"ok": True}) + + tool = RestApiTool( + name="ingest_document", + description="Ingest an arbitrary JSON document.", + endpoint=OperationEndpoint( + base_url="https://example.com", path="/ingest", method="POST" + ), + operation=free_form_body_operation(), + httpx_client_factory=lambda: httpx.AsyncClient( + transport=httpx.MockTransport(handler) + ), + ) + + await tool.call(args={}, tool_context=None) + + assert json.loads(captured["content"]) == {} + + def test_get_declaration_exposes_free_form_object_body(self, sample_endpoint): + """The model-facing declaration advertises the free-form body argument.""" + tool = RestApiTool( + name="ingest_document", + description="Ingest an arbitrary JSON document.", + endpoint=sample_endpoint, + operation=free_form_body_operation(), + ) + + with temporary_feature_override( + FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True + ): + json_schema_declaration = tool._get_declaration() + with temporary_feature_override( + FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, False + ): + gemini_schema_declaration = tool._get_declaration() + + assert ( + "body" in json_schema_declaration.parameters_json_schema["properties"] + ) + assert "body" in gemini_schema_declaration.parameters.properties + def test_prepare_request_params_extracts_embedded_query_params( self, sample_auth_credential, sample_auth_scheme ):