Skip to content

fix(openapi): preserve dictionary request bodies - #8038

Open
yang0228 wants to merge 1 commit into
google:mainfrom
yang0228:codex/fix-openapi-map-body
Open

yang0228 wants to merge 1 commit into
google:mainfrom
yang0228:codex/fix-openapi-map-body

Conversation

@yang0228

@yang0228 yang0228 commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

Problem:

An OpenAPI request body with type: object, no named properties, and
additionalProperties: {type: string} generates no body arguments. The sender
then reconstructs {}, silently discarding the supplied dictionary.

Solution:

Represent a pure dictionary body as one body argument and send that argument
whole. A pure dictionary has type: object, absent or empty properties, and
additionalProperties other than false, including the default when omitted.
The parser and sender share this classification.

  • Propagate requestBody.required to the dictionary argument. An omitted optional
    argument sends no payload; an explicitly supplied empty dictionary sends {}.
  • Use the parser's actual parameter name, including body_0 when a query
    parameter is already named body.
  • Preserve the existing flattened arguments for named-property objects, including
    mixed named properties plus additionalProperties. Closed empty objects keep
    their existing zero-argument interface.

No new dependencies or downstream changes are required.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally. The full matrix has 14 baseline failures per version; see below.

Added 14 regression/control cases covering typed and free-form dictionaries,
empty properties, requiredness, name collisions, omitted/empty payloads, and
named/mixed/closed objects. The existing open-object expectation is corrected to
expose body.

New cases on unchanged main:                 9 failed, 5 passed
pytest tests/unittests/tools/openapi_tool:    446 passed (source)
Same suite in a clean locally built wheel:   446 passed
pre-commit on changed files:                 all applicable hooks passed
mypy on the 3 changed source files:          25 existing errors on main and fix;
                                            identical diagnostics, no new errors

Full configured tox matrix, using pristine uv-venv-lock-runner environments
with the original test extra, Python 3.11.15/3.12.13/3.13.15/3.14.7:

Environment Passed Failed Skipped Xfailed Xpassed
py311 23,736 14 115 12 2
py312 23,727 14 116 12 2
py313 23,727 14 116 12 2
py314 23,729 14 114 12 2

The same 14 test IDs fail on unchanged main
(1cb926d5b8d702261ec82a26d3f1c4296e0db7d7) in each matching interpreter,
dependency environment, and host-access context. Each baseline re-run selected
those 14 failed tests; the full main suite was not re-run. No new failing IDs
were introduced in these runs.

  • 13 failures are sensitive to this macOS host's system proxy: 9 existing
    OpenAPI request/SSRF tests, 3 load_web_page tests, and the MCP Google-auth
    async-transport test. Clearing proxy environment variables still leaves the
    system proxy visible in that context. The separate isolated source and clean
    wheel OpenAPI runs above do not detect that proxy and both pass all 446 tests.
  • 1 is test_inclusive_upper_bounds_name_an_installed_release: the generated
    lock selected opentelemetry-api/opentelemetry-sdk 1.45.1, while the declared
    inclusive upper bounds name 1.45.2. This dependency-validation test also fails
    unchanged on main. No dependency ranges or test expectations were changed.

The generated lock was resolved with --exclude-newer=2026-10-24; key test
versions were google-genai 2.33.0, google-cloud-agentplatform/aiplatform 2.6.0,
Pydantic 2.14.1, and pytest 9.1.1. The full test command was equivalent to:

uv lock --exclude-newer=2026-10-24
tox -e py311,py312,py313,py314 \
  --override 'testenv.uv_sync_flags=--exclude-newer=2026-10-24' \
  --override 'testenv.set_env=PYTHONDONTWRITEBYTECODE=1' \
  --override 'testenv.commands=pytest tests/unittests -n2 -p no:cacheprovider'
Full-suite failure IDs reproduced on main in all four environments
tests/unittests/test_release_dependencies.py::test_inclusive_upper_bounds_name_an_installed_release
tests/unittests/tools/mcp_tool/test_mcp_session_manager.py::TestMCPSessionManager::test_google_auth_async_transport_handle_request
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_allows_public_url_and_pins_ip_host_and_sni
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_does_not_retry_on_read_timeout
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_overwrites_case_insensitive_host_header
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_pins_idn_hostname_with_punycode_host_header
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_pins_ipv6_and_preserves_userinfo
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_returns_error_on_unresolvable_host
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_tries_next_address_on_connect_failure
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRequestSsrfProtection::test_request_with_client_cert_pins_mtls_googleapis_host
tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py::TestRestApiTool::test_call_serializes_non_string_parameters_and_omits_none
tests/unittests/tools/test_load_web_page.py::test_load_web_page_allows_public_nat64_ip
tests/unittests/tools/test_load_web_page.py::test_load_web_page_fetches_public_urls_by_pinning_the_resolved_ip
tests/unittests/tools/test_load_web_page.py::test_load_web_page_tries_another_resolved_address_after_connect_error

Manual End-to-End (E2E) Tests:

Used the public OpenAPIToolset API with real HTTP connections to an ephemeral
127.0.0.1 server. The source baseline, fixed source, and clean installed wheel
each sent two requests: a pure dictionary and the body query-name collision.

Standalone reviewer reproduction

With the checkout's dependencies installed, save this as e2e_map_body.py and run
PYTHONPATH=src python e2e_map_body.py. Compare baseline
1cb926d5b8d702261ec82a26d3f1c4296e0db7d7 with this branch. To verify packaging,
build with uv build, install that wheel in a clean environment, and run the
same script from outside the checkout without PYTHONPATH.

import asyncio
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading

from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset
import httpx

received = []


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        received.append((self.path, body.decode()))
        response = b'{"ok": true}'
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(response)))
        self.end_headers()
        self.wfile.write(response)

    def log_message(self, *_):
        pass


async def main():
    server = HTTPServer(("127.0.0.1", 0), Handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    payload = {"owner": "alice", "team": "red"}
    try:
        for collision in (False, True):
            operation = {
                "operationId": "set_labels",
                "parameters": ([{"name": "body", "in": "query",
                                 "schema": {"type": "string"}}] if collision else []),
                "requestBody": {"required": True, "content": {
                    "application/json": {"schema": {"type": "object",
                        "additionalProperties": {"type": "string"}}}}},
                "responses": {"200": {"description": "OK"}},
            }
            spec = {
                "openapi": "3.0.3",
                "info": {"title": "Labels", "version": "1"},
                "servers": [{"url": f"http://127.0.0.1:{server.server_port}"}],
                "paths": {"/labels": {"post": operation}},
            }
            toolset = OpenAPIToolset(
                spec_dict=spec,
                httpx_client_factory=lambda: httpx.AsyncClient(trust_env=False),
                allow_private_network_access=True,
            )
            args = ({"body": "filter", "body_0": payload}
                    if collision else {"body": payload})
            result = await toolset.get_tool("set_labels").run_async(
                args=args, tool_context=None
            )
            assert result == {"ok": True}, result
            print("collision" if collision else "plain", received[-1])
    finally:
        server.shutdown()
        server.server_close()
        thread.join()


asyncio.run(main())

Observed server logs:

# Baseline main
plain ('/labels', '{}')
collision ('/labels?body=filter', '{}')

# Fixed source and clean installed wheel
plain ('/labels', '{"owner":"alice","team":"red"}')
collision ('/labels?body=filter', '{"owner":"alice","team":"red"}')

Each call returned {"ok": true}. This verifies construction and HTTP transport
without a mock transport, credentials, an external API, or a model call. Remote
Gemini behavior and enforcement of dictionary value constraints were not tested.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes. New/affected tests pass; the 14 full-suite failures per version also fail on unchanged main.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules. N/A: no dependent changes.

Additional context

Object bodies without named properties currently lose all dictionary
entries. Expose one body argument for open dictionary schemas and send
it whole, while retaining flattened arguments for named-property objects.

Cover typed and free-form maps, empty and omitted bodies, parameter-name
collisions, and named or closed-object compatibility.

Fixes google#8037
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAPIToolset drops additionalProperties dictionary request bodies

2 participants