Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 = ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,15 +224,91 @@ 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'))}
)
)
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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -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
):
Expand Down
Loading