Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/let_httpx2_choose_the_multipart_boundary.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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"}'
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ def _get_kwargs(

_kwargs["files"] = body.to_multipart()

headers["Content-Type"] = "multipart/form-data; boundary=+++"

_kwargs["headers"] = headers
return _kwargs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 3 additions & 6 deletions openapi_python_client/templates/endpoint_module.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
Loading