From 37115037a1a3c4efd37e740ce091a7f6c7781c19 Mon Sep 17 00:00:00 2001 From: Roman Claudiu Date: Sat, 7 Nov 2026 21:22:09 +0200 Subject: [PATCH] fix: Let httpx2 choose the multipart/form-data boundary Generated endpoints set Content-Type to multipart/form-data with the fixed boundary +++, so a file containing "\r\n--+++" was split at that point. Leave Content-Type unset for multipart bodies and let httpx2 add it with a random boundary, which also covers endpoints with several body types (#947). Fixes #1509 --- ...et_httpx2_choose_the_multipart_boundary.md | 7 ++ .../test_multipart_bodies.py | 116 ++++++++++++++++++ .../api/tests/post_user_list.py | 2 - .../api/bodies/post_bodies_multiple.py | 2 - .../tests/upload_file_tests_upload_post.py | 2 - .../api/body/post_body_multipart.py | 2 - .../templates/endpoint_module.py.jinja | 9 +- 7 files changed, 126 insertions(+), 14 deletions(-) create mode 100644 .changeset/let_httpx2_choose_the_multipart_boundary.md create mode 100644 end_to_end_tests/functional_tests/generated_code_execution/test_multipart_bodies.py diff --git a/.changeset/let_httpx2_choose_the_multipart_boundary.md b/.changeset/let_httpx2_choose_the_multipart_boundary.md new file mode 100644 index 000000000..75bcfe7f7 --- /dev/null +++ b/.changeset/let_httpx2_choose_the_multipart_boundary.md @@ -0,0 +1,7 @@ +--- +default: patch +--- + +# Let httpx2 choose the boundary for `multipart/form-data` requests + +Since 0.29.0, generated endpoints sent every `multipart/form-data` body with the fixed boundary `+++`, so an uploaded file containing `\r\n--+++` was cut short at that point (#1509). These bodies no longer set `Content-Type`, so httpx2 sets it with a random boundary for each request, as single-body endpoints did before 0.29.0. Endpoints with several body types still get a boundary (#947). diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_multipart_bodies.py b/end_to_end_tests/functional_tests/generated_code_execution/test_multipart_bodies.py new file mode 100644 index 000000000..ee95a8dce --- /dev/null +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_multipart_bodies.py @@ -0,0 +1,116 @@ +import email.parser +import email.policy +import io + +import httpx2 + +from end_to_end_tests.functional_tests.helpers import ( + with_generated_client_fixture, + with_generated_code_imports, +) + +# Content that would end the part early if the request used a fixed "+++" boundary +FILE_CONTENT = b"first line\r\n--+++\r\nstill the same file\r\n" + + +def _send_and_capture(Client, sync_detailed, body) -> httpx2.Request: + sent: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + request.read() + sent.append(request) + return httpx2.Response(200) + + client = Client(base_url="http://test", httpx_args={"transport": httpx2.MockTransport(handler)}) + sync_detailed(client=client, body=body) + assert len(sent) == 1 + return sent[0] + + +def _parse_parts(request: httpx2.Request) -> dict[str, bytes]: + content_type = request.headers["content-type"] + assert content_type.startswith("multipart/form-data; boundary=") + message = email.parser.BytesParser(policy=email.policy.HTTP).parsebytes( + b"Content-Type: " + content_type.encode() + b"\r\n\r\n" + request.content + ) + return { + part.get_param("name", header="content-disposition"): part.get_payload(decode=True) + for part in message.iter_parts() + } + + +@with_generated_client_fixture( + """ +paths: + /upload: + post: + operationId: upload + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: {type: string, format: binary} + responses: + "200": {description: OK} +""" +) +@with_generated_code_imports( + ".api.default.upload.sync_detailed", + ".client.Client", + ".models.UploadBody", + ".types.File", +) +class TestSingleMultipartBody: + def test_file_content_is_not_split_by_boundary(self, sync_detailed, Client, UploadBody, File): + body = UploadBody(file=File(payload=io.BytesIO(FILE_CONTENT), file_name="a.txt")) + + request = _send_and_capture(Client, sync_detailed, body) + + assert _parse_parts(request) == {"file": FILE_CONTENT} + + +@with_generated_client_fixture( + """ +paths: + /upload: + post: + operationId: upload + requestBody: + content: + application/json: + schema: + type: object + properties: + name: {type: string} + multipart/form-data: + schema: + type: object + properties: + file: {type: string, format: binary} + responses: + "200": {description: OK} +""" +) +@with_generated_code_imports( + ".api.default.upload.sync_detailed", + ".client.Client", + ".models.UploadJsonBody", + ".models.UploadFilesBody", + ".types.File", +) +class TestMultipartBodyAmongSeveralBodies: + def test_file_content_is_not_split_by_boundary(self, sync_detailed, Client, UploadJsonBody, UploadFilesBody, File): + body = UploadFilesBody(file=File(payload=io.BytesIO(FILE_CONTENT), file_name="a.txt")) + + request = _send_and_capture(Client, sync_detailed, body) + + assert _parse_parts(request) == {"file": FILE_CONTENT} + + def test_json_body_still_sets_its_content_type(self, sync_detailed, Client, UploadJsonBody, UploadFilesBody, File): + request = _send_and_capture(Client, sync_detailed, UploadJsonBody(name="a")) + + assert request.headers["content-type"] == "application/json" + assert request.content == b'{"name":"a"}' diff --git a/end_to_end_tests/golden-records/my-enum-api-client/my_enum_api_client/api/tests/post_user_list.py b/end_to_end_tests/golden-records/my-enum-api-client/my_enum_api_client/api/tests/post_user_list.py index 3263c1bc3..f1487b831 100644 --- a/end_to_end_tests/golden-records/my-enum-api-client/my_enum_api_client/api/tests/post_user_list.py +++ b/end_to_end_tests/golden-records/my-enum-api-client/my_enum_api_client/api/tests/post_user_list.py @@ -24,8 +24,6 @@ def _get_kwargs( if not isinstance(body, Unset): _kwargs["files"] = body.to_multipart() - headers["Content-Type"] = "multipart/form-data; boundary=+++" - _kwargs["headers"] = headers return _kwargs 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 25348b3f7..b40aafab7 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 @@ -39,8 +39,6 @@ def _get_kwargs( if not isinstance(body, Unset): _kwargs["files"] = body.to_multipart() - headers["Content-Type"] = "multipart/form-data; boundary=+++" - _kwargs["headers"] = headers return _kwargs diff --git a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/upload_file_tests_upload_post.py b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/upload_file_tests_upload_post.py index 406b7c417..cb12c9968 100644 --- a/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/upload_file_tests_upload_post.py +++ b/end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/tests/upload_file_tests_upload_post.py @@ -23,8 +23,6 @@ def _get_kwargs( _kwargs["files"] = body.to_multipart() - headers["Content-Type"] = "multipart/form-data; boundary=+++" - _kwargs["headers"] = headers return _kwargs diff --git a/integration-tests/integration_tests/api/body/post_body_multipart.py b/integration-tests/integration_tests/api/body/post_body_multipart.py index 9e1d19116..803ff4b91 100644 --- a/integration-tests/integration_tests/api/body/post_body_multipart.py +++ b/integration-tests/integration_tests/api/body/post_body_multipart.py @@ -25,8 +25,6 @@ def _get_kwargs( if not isinstance(body, Unset): _kwargs["files"] = body.to_multipart() - headers["Content-Type"] = "multipart/form-data; boundary=+++" - _kwargs["headers"] = headers return _kwargs diff --git a/openapi_python_client/templates/endpoint_module.py.jinja b/openapi_python_client/templates/endpoint_module.py.jinja index 7c6f8894b..48caf194b 100644 --- a/openapi_python_client/templates/endpoint_module.py.jinja +++ b/openapi_python_client/templates/endpoint_module.py.jinja @@ -50,18 +50,15 @@ def _get_kwargs( {% for body in endpoint.bodies %} if isinstance(body, {{body.prop.get_type_string(no_optional=True) | as_unembedded_code }}): {{ body_to_kwarg(body) | indent(8) }} - {%- if body.content_type == "multipart/form-data" %} - headers["Content-Type"] = "multipart/form-data; boundary=+++" - {% else %} + {#- httpx2 sets multipart/form-data with a random boundary itself, a fixed one could appear in the content #} + {%- if body.content_type != "multipart/form-data" %} headers["Content-Type"] = "{{ body.content_type | in_double_quote_literal }}" {% endif %} {% endfor %} {% elif endpoint.bodies | length == 1 %} {% set body = endpoint.bodies[0] %} {{ body_to_kwarg(body) | indent(4) }} - {%- if body.content_type == "multipart/form-data" %} - headers["Content-Type"] = "multipart/form-data; boundary=+++" - {% else %} + {%- if body.content_type != "multipart/form-data" %} headers["Content-Type"] = "{{ body.content_type | in_double_quote_literal }}" {% endif %} {% endif %}