From 3f1bfb3e1ab2f3b717848dd59d8f9f261f618474 Mon Sep 17 00:00:00 2001 From: Dima Date: Tue, 15 Sep 2026 17:45:52 +0100 Subject: [PATCH 1/2] fix: preserve omitted and null JSON request bodies An optional nullable primitive request body rendered an orphan `else:` in the generated endpoint, so the client did not compile. The JSON body macro now uses the normal property transform, drops the `json` kwarg when the serialised value is UNSET, and sends explicit None as raw `content=b"null"` because HTTPX treats `json=None` as no body. Signature requiredness, nested serialisation and declared Content-Type are unchanged. Add generated-client regressions for OpenAPI 3.0 and 3.1 nullable bodies that assert request bytes through HTTPX for omitted, UNSET, None and concrete values, and regenerate the affected endpoint golden records. Fixes #1425 Co-Authored-By: Claude Fable 5.1 --- .../test_nullable_request_bodies.py | 401 ++++++++++++++++++ .../api/default/misc_metadata_escapes.py | 4 + .../api/default/non_string_example.py | 4 + .../api/default/property_escapes.py | 4 + .../api/printtag_escape/with_braces_path.py | 4 + .../api/bodies/json_like.py | 7 + .../api/bodies/optional_body.py | 7 + .../api/bodies/post_bodies_multiple.py | 7 + .../my_test_api_client/api/bodies/refs.py | 7 + .../api/config/content_type_override.py | 9 +- .../post_types_unions_duplicate_types.py | 11 +- ...st_naming_property_conflict_with_import.py | 7 + .../api/tests/callback_test.py | 4 + .../tests/json_body_tests_json_body_post.py | 4 + .../api/tests/post_tests_json_body_string.py | 4 + .../api/tests/test_inline_objects.py | 4 + .../api/const/post_const_path.py | 4 + .../api/prefix_items/post_prefix_items.py | 4 + .../templates/endpoint_macros.py.jinja | 15 +- 19 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py b/end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py new file mode 100644 index 000000000..1cf016282 --- /dev/null +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py @@ -0,0 +1,401 @@ +import asyncio +import datetime +import inspect +import subprocess +import sys +from typing import Any, get_args + +import httpx +import pytest + +from end_to_end_tests.functional_tests.helpers import with_generated_client_fixture + + +def _sync_request( + endpoint: Any, + Client: Any, + *, + body: Any = inspect.Parameter.empty, +) -> httpx.Request: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(204) + + with httpx.Client(base_url="https://example.test", transport=httpx.MockTransport(handler)) as httpx_client: + client = Client(base_url="https://example.test").set_httpx_client(httpx_client) + if body is inspect.Parameter.empty: + endpoint.sync_detailed(client=client) + else: + endpoint.sync_detailed(client=client, body=body) + + assert len(requests) == 1 + return requests[0] + + +def _async_request( + endpoint: Any, + Client: Any, + *, + body: Any = inspect.Parameter.empty, +) -> httpx.Request: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(204) + + async def send() -> None: + async with httpx.AsyncClient( + base_url="https://example.test", transport=httpx.MockTransport(handler) + ) as httpx_client: + client = Client(base_url="https://example.test").set_async_httpx_client(httpx_client) + if body is inspect.Parameter.empty: + await endpoint.asyncio_detailed(client=client) + else: + await endpoint.asyncio_detailed(client=client, body=body) + + asyncio.run(send()) + assert len(requests) == 1 + return requests[0] + + +OPENAPI_30_SPEC = """ +openapi: 3.0.3 +info: {title: testapi, version: 1.0.0} +paths: + /optional-number: + post: + operationId: optional_number + parameters: + - name: json_body + in: query + schema: {type: string} + requestBody: + content: + application/json: + schema: {type: number, nullable: true} + responses: + "204": {description: No content} + /optional-integer: + post: + operationId: optional_integer + requestBody: + required: false + content: + application/json: + schema: {type: integer, nullable: true} + responses: + "204": {description: No content} + /optional-boolean: + post: + operationId: optional_boolean + requestBody: + content: + application/json: + schema: {type: boolean, nullable: true} + responses: + "204": {description: No content} + /optional-string: + post: + operationId: optional_string + requestBody: + content: + application/json: + schema: {type: string, nullable: true} + responses: + "204": {description: No content} +""" + + +@with_generated_client_fixture(OPENAPI_30_SPEC) +class TestOpenAPI30NullableRequestBodies: + def test_generated_package_type_checks_with_json_body_parameter(self, generated_client: Any) -> None: + result = subprocess.run( + [sys.executable, "-m", "mypy", str(generated_client.output_path), "--strict"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + + @pytest.mark.parametrize( + ("operation", "ordinary", "ordinary_json", "falsy", "falsy_json"), + [ + ("optional_number", 1.5, b"1.5", 0.0, b"0.0"), + ("optional_integer", 7, b"7", 0, b"0"), + ("optional_boolean", True, b"true", False, b"false"), + ("optional_string", "present", b'"present"', "", b'""'), + ], + ) + def test_omitted_null_and_values_have_distinct_wire_representations( + self, + generated_client: Any, + operation: str, + ordinary: Any, + ordinary_json: bytes, + falsy: Any, + falsy_json: bytes, + ) -> None: + endpoint = generated_client.import_module(f".api.default.{operation}") + Client = generated_client.import_symbol(".client", "Client") + UNSET = generated_client.import_symbol(".types", "UNSET") + + assert _sync_request(endpoint, Client).content == b"" + assert _sync_request(endpoint, Client, body=UNSET).content == b"" + assert _sync_request(endpoint, Client, body=None).content == b"null" + assert _sync_request(endpoint, Client, body=ordinary).content == ordinary_json + assert _sync_request(endpoint, Client, body=falsy).content == falsy_json + + +OPENAPI_31_SPEC = """ +openapi: 3.1.0 +info: {title: testapi, version: 1.0.0} +paths: + /type-list: + post: + operationId: type_list + requestBody: + content: + application/json: + schema: {type: [number, "null"]} + responses: + "204": {description: No content} + /any-of: + post: + operationId: any_of_nullable + requestBody: + content: + application/json: + schema: + anyOf: [{type: integer}, {type: "null"}] + responses: + "204": {description: No content} + /one-of: + post: + operationId: one_of_nullable + requestBody: + content: + application/json: + schema: + oneOf: [{type: string}, {type: "null"}] + responses: + "204": {description: No content} + /required-nullable: + post: + operationId: required_nullable + requestBody: + required: true + content: + application/problem+json: + schema: {type: [boolean, "null"]} + responses: + "204": {description: No content} + /optional-non-nullable: + post: + operationId: optional_non_nullable + requestBody: + required: false + content: + application/json: + schema: {type: string} + responses: + "204": {description: No content} + /required-non-nullable: + post: + operationId: required_non_nullable + requestBody: + required: true + content: + application/json: + schema: {type: integer} + responses: + "204": {description: No content} + /pass-through-union: + post: + operationId: pass_through_union + requestBody: + content: + application/json: + schema: + anyOf: [{type: integer}, {type: string}] + responses: + "204": {description: No content} + /date-time: + post: + operationId: nullable_date_time + requestBody: + content: + application/json: + schema: + oneOf: + - {type: string, format: date-time} + - {type: "null"} + responses: + "204": {description: No content} + /model: + post: + operationId: nullable_model + requestBody: + content: + application/json: + schema: + oneOf: + - {$ref: "#/components/schemas/Payload"} + - {type: "null"} + responses: + "204": {description: No content} + /array: + post: + operationId: nullable_array + requestBody: + content: + application/json: + schema: + type: [array, "null"] + items: {type: integer} + responses: + "204": {description: No content} + /nested: + post: + operationId: nested_nullable_fields + requestBody: + required: true + content: + application/json: + schema: {$ref: "#/components/schemas/NestedPayload"} + responses: + "204": {description: No content} +components: + schemas: + Payload: + type: object + properties: + name: {type: string} + required: [name] + NestedPayload: + type: object + properties: + optionalNullable: {type: [string, "null"]} + optionalDateTime: + type: [string, "null"] + format: date-time +""" + + +@with_generated_client_fixture(OPENAPI_31_SPEC) +class TestOpenAPI31NullableRequestBodies: + def _endpoint(self, generated_client: Any, operation: str) -> Any: + return generated_client.import_module(f".api.default.{operation}") + + def test_type_list_and_composed_unions_preserve_presence(self, generated_client: Any) -> None: + Client = generated_client.import_symbol(".client", "Client") + UNSET = generated_client.import_symbol(".types", "UNSET") + + for operation, value, encoded in [ + ("type_list", 2.5, b"2.5"), + ("any_of_nullable", 0, b"0"), + ("one_of_nullable", "", b'""'), + ]: + endpoint = self._endpoint(generated_client, operation) + assert _sync_request(endpoint, Client).content == b"" + assert _sync_request(endpoint, Client, body=UNSET).content == b"" + assert _sync_request(endpoint, Client, body=None).content == b"null" + assert _sync_request(endpoint, Client, body=value).content == encoded + + def test_requiredness_and_nullability_are_independent_in_signatures(self, generated_client: Any) -> None: + UNSET = generated_client.import_symbol(".types", "UNSET") + + def body_parameter(operation: str) -> inspect.Parameter: + endpoint = self._endpoint(generated_client, operation) + return inspect.signature(endpoint.sync_detailed).parameters["body"] + + required_nullable = body_parameter("required_nullable") + optional_nullable = body_parameter("type_list") + optional_non_nullable = body_parameter("optional_non_nullable") + required_non_nullable = body_parameter("required_non_nullable") + + assert required_nullable.default is inspect.Parameter.empty + assert type(None) in get_args(required_nullable.annotation) + assert optional_nullable.default is UNSET + assert type(None) in get_args(optional_nullable.annotation) + assert optional_non_nullable.default is UNSET + assert type(None) not in get_args(optional_non_nullable.annotation) + assert required_non_nullable.default is inspect.Parameter.empty + assert type(None) not in get_args(required_non_nullable.annotation) + + def test_non_nullable_requiredness_controls(self, generated_client: Any) -> None: + Client = generated_client.import_symbol(".client", "Client") + UNSET = generated_client.import_symbol(".types", "UNSET") + + optional = self._endpoint(generated_client, "optional_non_nullable") + assert _sync_request(optional, Client).content == b"" + assert _sync_request(optional, Client, body=UNSET).content == b"" + assert _sync_request(optional, Client, body="").content == b'""' + + required = self._endpoint(generated_client, "required_non_nullable") + assert _sync_request(required, Client, body=0).content == b"0" + + def test_required_nullable_uses_declared_json_media_type(self, generated_client: Any) -> None: + Client = generated_client.import_symbol(".client", "Client") + endpoint = self._endpoint(generated_client, "required_nullable") + + request = _sync_request(endpoint, Client, body=None) + + assert request.content == b"null" + assert request.headers["Content-Type"] == "application/problem+json" + assert _sync_request(endpoint, Client, body=False).content == b"false" + + def test_pass_through_and_transforming_union_members(self, generated_client: Any) -> None: + Client = generated_client.import_symbol(".client", "Client") + + pass_through = self._endpoint(generated_client, "pass_through_union") + assert _sync_request(pass_through, Client).content == b"" + assert _sync_request(pass_through, Client, body=0).content == b"0" + assert _sync_request(pass_through, Client, body="value").content == b'"value"' + + date_time = self._endpoint(generated_client, "nullable_date_time") + value = datetime.datetime(2026, 9, 15, 12, 30, tzinfo=datetime.UTC) + assert _sync_request(date_time, Client).content == b"" + assert _sync_request(date_time, Client, body=None).content == b"null" + assert _sync_request(date_time, Client, body=value).content == b'"2026-09-15T12:30:00+00:00"' + + def test_nullable_model_array_and_nested_fields(self, generated_client: Any) -> None: + Client = generated_client.import_symbol(".client", "Client") + Payload = generated_client.import_symbol(".models", "Payload") + NestedPayload = generated_client.import_symbol(".models", "NestedPayload") + + model_endpoint = self._endpoint(generated_client, "nullable_model") + assert _sync_request(model_endpoint, Client).content == b"" + assert _sync_request(model_endpoint, Client, body=None).content == b"null" + assert _sync_request(model_endpoint, Client, body=Payload(name="Ada")).content == b'{"name":"Ada"}' + + array_endpoint = self._endpoint(generated_client, "nullable_array") + assert _sync_request(array_endpoint, Client).content == b"" + assert _sync_request(array_endpoint, Client, body=None).content == b"null" + assert _sync_request(array_endpoint, Client, body=[]).content == b"[]" + assert _sync_request(array_endpoint, Client, body=[0, 2]).content == b"[0,2]" + + nested_endpoint = self._endpoint(generated_client, "nested_nullable_fields") + assert _sync_request(nested_endpoint, Client, body=NestedPayload()).content == b"{}" + assert ( + _sync_request(nested_endpoint, Client, body=NestedPayload(optional_nullable=None)).content + == b'{"optionalNullable":null}' + ) + date_time = datetime.datetime(2026, 9, 15, 12, 30, tzinfo=datetime.UTC) + assert ( + _sync_request(nested_endpoint, Client, body=NestedPayload(optional_date_time=date_time)).content + == b'{"optionalDateTime":"2026-09-15T12:30:00+00:00"}' + ) + + def test_async_transport_preserves_presence(self, generated_client: Any) -> None: + Client = generated_client.import_symbol(".client", "Client") + UNSET = generated_client.import_symbol(".types", "UNSET") + endpoint = self._endpoint(generated_client, "any_of_nullable") + + assert _async_request(endpoint, Client).content == b"" + assert _async_request(endpoint, Client, body=UNSET).content == b"" + assert _async_request(endpoint, Client, body=None).content == b"null" + assert _async_request(endpoint, Client, body=0).content == b"0" diff --git a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/misc_metadata_escapes.py b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/misc_metadata_escapes.py index adabb42d1..22082ae31 100644 --- a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/misc_metadata_escapes.py +++ b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/misc_metadata_escapes.py @@ -21,6 +21,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/non_string_example.py b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/non_string_example.py index 7090834e2..5691675a8 100644 --- a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/non_string_example.py +++ b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/non_string_example.py @@ -21,6 +21,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/property_escapes.py b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/property_escapes.py index 8a62c1764..a25e8916b 100644 --- a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/property_escapes.py +++ b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/default/property_escapes.py @@ -21,6 +21,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/printtag_escape/with_braces_path.py b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/printtag_escape/with_braces_path.py index cbbbc3541..101f78405 100644 --- a/end_to_end_tests/golden-records/escapes-client/escapes_client/api/printtag_escape/with_braces_path.py +++ b/end_to_end_tests/golden-records/escapes-client/escapes_client/api/printtag_escape/with_braces_path.py @@ -33,6 +33,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = 'application/json; profile="https://example.com/escape" + print("uh oh") + "' diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/json_like.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/json_like.py index 1a4fc2fd9..0935377a3 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/json_like.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/json_like.py @@ -20,8 +20,15 @@ def _get_kwargs( "url": "/bodies/json-like", } + _kwargs["json"] = UNSET if not isinstance(body, Unset): _kwargs["json"] = body.to_dict() + if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] + elif _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/vnd+json" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/optional_body.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/optional_body.py index 8402cf086..7090a4359 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/optional_body.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/optional_body.py @@ -20,8 +20,15 @@ def _get_kwargs( "url": "/bodies/optional", } + _kwargs["json"] = UNSET if not isinstance(body, Unset): _kwargs["json"] = body.to_dict() + if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] + elif _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/post_bodies_multiple.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/post_bodies_multiple.py index 9361e3f56..d104424cf 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/post_bodies_multiple.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/post_bodies_multiple.py @@ -23,8 +23,15 @@ def _get_kwargs( } if isinstance(body, PostBodiesMultipleJsonBody): + _kwargs["json"] = UNSET if not isinstance(body, Unset): _kwargs["json"] = body.to_dict() + if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] + elif _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" if isinstance(body, File): diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/refs.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/refs.py index 2e224bc8c..af5072d26 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/refs.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/bodies/refs.py @@ -20,8 +20,15 @@ def _get_kwargs( "url": "/bodies/refs", } + _kwargs["json"] = UNSET if not isinstance(body, Unset): _kwargs["json"] = body.to_dict() + if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] + elif _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/config/content_type_override.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/config/content_type_override.py index be06459be..a5ca89c86 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/config/content_type_override.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/config/content_type_override.py @@ -19,8 +19,13 @@ def _get_kwargs( "url": "/config/content-type-override", } - if not isinstance(body, Unset): - _kwargs["json"] = body + _kwargs["json"] = body + if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] + elif _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "openapi/python/client" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/default/post_types_unions_duplicate_types.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/default/post_types_unions_duplicate_types.py index f6eabc4a7..46d2c0a9f 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/default/post_types_unions_duplicate_types.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/default/post_types_unions_duplicate_types.py @@ -20,9 +20,18 @@ def _get_kwargs( "url": "/types/unions/duplicate-types", } - if isinstance(body, AModel): + if isinstance(body, Unset): + _kwargs["json"] = UNSET + else: _kwargs["json"] = body.to_dict() + if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] + elif _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] + headers["Content-Type"] = "application/json" _kwargs["headers"] = headers diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py index 9e848f7af..a132896a5 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py @@ -23,8 +23,15 @@ def _get_kwargs( "url": "/naming/property-conflict-with-import", } + _kwargs["json"] = UNSET if not isinstance(body, Unset): _kwargs["json"] = body.to_dict() + if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] + elif _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/callback_test.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/callback_test.py index e806fe60c..e600dc215 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/callback_test.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/callback_test.py @@ -22,6 +22,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/json_body_tests_json_body_post.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/json_body_tests_json_body_post.py index 5be950c77..8a7771c80 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/json_body_tests_json_body_post.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/json_body_tests_json_body_post.py @@ -22,6 +22,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/post_tests_json_body_string.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/post_tests_json_body_string.py index 498d0572e..fcdf58108 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/post_tests_json_body_string.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/post_tests_json_body_string.py @@ -21,6 +21,10 @@ def _get_kwargs( } _kwargs["json"] = body + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/test_inline_objects.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/test_inline_objects.py index ea2e45c94..e5d21b3df 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/test_inline_objects.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/test_inline_objects.py @@ -22,6 +22,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/const/post_const_path.py b/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/const/post_const_path.py index bf3472121..e5d657c06 100644 --- a/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/const/post_const_path.py +++ b/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/const/post_const_path.py @@ -36,6 +36,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/prefix_items/post_prefix_items.py b/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/prefix_items/post_prefix_items.py index 5b114873e..5f9b90149 100644 --- a/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/prefix_items/post_prefix_items.py +++ b/end_to_end_tests/golden-records/test-3-1-features-client/test_3_1_features_client/api/prefix_items/post_prefix_items.py @@ -21,6 +21,10 @@ def _get_kwargs( } _kwargs["json"] = body.to_dict() + if _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] headers["Content-Type"] = "application/json" diff --git a/openapi_python_client/templates/endpoint_macros.py.jinja b/openapi_python_client/templates/endpoint_macros.py.jinja index 493c3551c..839ec149e 100644 --- a/openapi_python_client/templates/endpoint_macros.py.jinja +++ b/openapi_python_client/templates/endpoint_macros.py.jinja @@ -84,13 +84,18 @@ if not isinstance(body, Unset): {% set property = body.prop %} {% import "property_templates/" + property.template as prop_template %} {% if prop_template.transform %} -{{ prop_template.transform(property, property.python_name, "_kwargs[\"json\"]", skip_unset=True, declare_type=False) }} -{% elif property.required %} -_kwargs["json"] = {{ property.python_name }} +{{ prop_template.transform(property, property.python_name, "_kwargs[\"json\"]", declare_type=False) }} {% else %} -if not isinstance({{property.python_name}}, Unset): - _kwargs["json"] = {{ property.python_name }} +_kwargs["json"] = {{ property.python_name }} +{% endif %} +{% if not property.required %} +if isinstance(_kwargs["json"], Unset): + del _kwargs["json"] {% endif %} +{% if property.required %}if{% else %}elif{% endif %} _kwargs["json"] is None: + # HTTPX treats json=None as no body; send JSON null explicitly. + _kwargs["content"] = b"null" + del _kwargs["json"] {% endmacro %} {% macro multipart_body(body) %} From b49258862a179c4e23d7e610405e12d33ea4ab64 Mon Sep 17 00:00:00 2001 From: Dima Date: Tue, 15 Sep 2026 19:42:06 +0100 Subject: [PATCH 2/2] test: compare structured request bodies as decoded JSON --- .../test_nullable_request_bodies.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py b/end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py index 1cf016282..269a5f41a 100644 --- a/end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_nullable_request_bodies.py @@ -1,6 +1,7 @@ import asyncio import datetime import inspect +import json import subprocess import sys from typing import Any, get_args @@ -370,25 +371,23 @@ def test_nullable_model_array_and_nested_fields(self, generated_client: Any) -> model_endpoint = self._endpoint(generated_client, "nullable_model") assert _sync_request(model_endpoint, Client).content == b"" assert _sync_request(model_endpoint, Client, body=None).content == b"null" - assert _sync_request(model_endpoint, Client, body=Payload(name="Ada")).content == b'{"name":"Ada"}' + assert json.loads(_sync_request(model_endpoint, Client, body=Payload(name="Ada")).content) == {"name": "Ada"} array_endpoint = self._endpoint(generated_client, "nullable_array") assert _sync_request(array_endpoint, Client).content == b"" assert _sync_request(array_endpoint, Client, body=None).content == b"null" - assert _sync_request(array_endpoint, Client, body=[]).content == b"[]" - assert _sync_request(array_endpoint, Client, body=[0, 2]).content == b"[0,2]" + assert json.loads(_sync_request(array_endpoint, Client, body=[]).content) == [] + assert json.loads(_sync_request(array_endpoint, Client, body=[0, 2]).content) == [0, 2] nested_endpoint = self._endpoint(generated_client, "nested_nullable_fields") - assert _sync_request(nested_endpoint, Client, body=NestedPayload()).content == b"{}" - assert ( + assert json.loads(_sync_request(nested_endpoint, Client, body=NestedPayload()).content) == {} + assert json.loads( _sync_request(nested_endpoint, Client, body=NestedPayload(optional_nullable=None)).content - == b'{"optionalNullable":null}' - ) + ) == {"optionalNullable": None} date_time = datetime.datetime(2026, 9, 15, 12, 30, tzinfo=datetime.UTC) - assert ( + assert json.loads( _sync_request(nested_endpoint, Client, body=NestedPayload(optional_date_time=date_time)).content - == b'{"optionalDateTime":"2026-09-15T12:30:00+00:00"}' - ) + ) == {"optionalDateTime": "2026-09-15T12:30:00+00:00"} def test_async_transport_preserves_presence(self, generated_client: Any) -> None: Client = generated_client.import_symbol(".client", "Client")