Skip to content

Fix multipart body arrays #938

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from 16 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
5 changes: 5 additions & 0 deletions .changeset/raise_minimum_httpx_version_to_023.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: major
---

# Raise minimum httpx version to 0.23
15 changes: 15 additions & 0 deletions .changeset/removed_ability_to_set_an_array_as_a_multipart_body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
default: major
---

# Removed ability to set an array as a multipart body

Previously, when defining a request's body as `multipart/form-data`, the generator would attempt to generate code
for both `object` schemas and `array` schemas. However, most arrays could not generate valid multipart bodies, as
there would be no field names (required to set the `Content-Disposition` headers).

The code to generate any body for `multipart/form-data` where the schema is `array` has been removed, and any such
bodies will be skipped. This is not _expected_ to be a breaking change in practice, since the code generated would
probably never work.

If you have a use-case for `multipart/form-data` with an `array` schema, please [open a new discussion](https://github.com/openapi-generators/openapi-python-client/discussions) with an example schema and the desired functional Python code.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
default: major
---

# Change default multipart array serialization

Previously, any arrays of values in a `multipart/form-data` body would be serialized as an `application/json` part.
This matches the default behavior specified by OpenAPI and supports arrays of files (`binary` format strings).
However, because this generator doesn't yet support specifying `encoding` per property, this may result in
now-incorrect code when the encoding _was_ explicitly set to `application/json` for arrays of scalar values.

PR #938 fixes #692. Thanks @micha91 for the fix, @ratgen and @FabianSchurig for testing, and @davidlizeng for the original report... many years ago 😅.
30 changes: 7 additions & 23 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,12 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
httpx_version:
- "0.20.0"
- ""
lockfile:
- "pdm.lock"
- "pdm.minimal.lock"
services:
openapi-test-server:
image: ghcr.io/openapi-generators/openapi-test-server:0.0.1
image: ghcr.io/openapi-generators/openapi-test-server:0.2.1
ports:
- "3000:3000"
steps:
Expand All @@ -170,34 +170,18 @@ jobs:
- name: Get Python Version
id: get_python_version
run: echo "python_version=$(python --version)" >> $GITHUB_OUTPUT
- name: Cache dependencies
uses: actions/cache@v4
with:
path: .venv
key: ${{ runner.os }}-${{ steps.get_python_version.outputs.python_version }}-dependencies-${{ hashFiles('**/pdm.lock') }}
restore-keys: |
${{ runner.os }}-${{ steps.get_python_version.outputs.python_version }}-dependencies
- name: Install dependencies
run: |
pip install pdm
python -m venv .venv
pdm install
- name: Cache Generated Client Dependencies
uses: actions/cache@v4
with:
path: integration-tests/.venv
key: ${{ runner.os }}-${{ steps.get_python_version.outputs.python_version }}-integration-dependencies-${{ hashFiles('**/pdm.lock') }}
key: ${{ runner.os }}-${{ steps.get_python_version.outputs.python_version }}-integration-dependencies-${{ hashFiles('integration-tests/pdm*.lock') }}
restore-keys: |
${{ runner.os }}-${{ steps.get_python_version.outputs.python_version }}-integration-dependencies
- name: Set httpx version
if: matrix.httpx_version != ''
run: |
cd integration-tests
pdm add httpx==${{ matrix.httpx_version }}
- name: Install Integration Dependencies
run: |
cd integration-tests
pdm install
pip install pdm
pdm install -L ${{ matrix.lockfile }}
- name: Run Tests
run: |
cd integration-tests
Expand Down
45 changes: 0 additions & 45 deletions end_to_end_tests/baseline_openapi_3.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -431,51 +431,6 @@
}
}
},
"/tests/upload/multiple": {
"post": {
"tags": [
"tests"
],
"summary": "Upload multiple files",
"description": "Upload several files in the same request",
"operationId": "upload_multiple_files_tests_upload_post",
"parameters": [],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "array",
"items": {
"type": "string",
"format": "binary"
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/tests/json_body": {
"post": {
"tags": [
Expand Down
45 changes: 0 additions & 45 deletions end_to_end_tests/baseline_openapi_3.1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -418,51 +418,6 @@ info:
}
}
},
"/tests/upload/multiple": {
"post": {
"tags": [
"tests"
],
"summary": "Upload multiple files",
"description": "Upload several files in the same request",
"operationId": "upload_multiple_files_tests_upload_post",
"parameters": [ ],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "array",
"items": {
"type": "string",
"format": "binary"
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/tests/json_body": {
"post": {
"tags": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
token_with_cookie_auth_token_with_cookie_get,
unsupported_content_tests_unsupported_content_get,
upload_file_tests_upload_post,
upload_multiple_files_tests_upload_post,
)


Expand Down Expand Up @@ -82,13 +81,6 @@ def upload_file_tests_upload_post(cls) -> types.ModuleType:
"""
return upload_file_tests_upload_post

@classmethod
def upload_multiple_files_tests_upload_post(cls) -> types.ModuleType:
"""
Upload several files in the same request
"""
return upload_multiple_files_tests_upload_post

@classmethod
def json_body_tests_json_body_post(cls) -> types.ModuleType:
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Contains some shared types for properties"""

from collections.abc import MutableMapping
from collections.abc import Mapping, MutableMapping
from http import HTTPStatus
from typing import BinaryIO, Generic, Literal, Optional, TypeVar
from typing import IO, BinaryIO, Generic, Literal, Optional, TypeVar, Union

from attrs import define

Expand All @@ -14,7 +14,15 @@ def __bool__(self) -> Literal[False]:

UNSET: Unset = Unset()

FileJsonType = tuple[Optional[str], BinaryIO, Optional[str]]
# The types that `httpx.Client(files=)` can accept, copied from that library.
FileContent = Union[IO[bytes], bytes, str]
FileTypes = Union[
# (filename, file (or bytes), content_type)
tuple[Optional[str], FileContent, Optional[str]],
# (filename, file (or bytes), content_type, headers)
tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
]
RequestFiles = list[tuple[str, FileTypes]]


@define
Expand All @@ -25,7 +33,7 @@ class File:
file_name: Optional[str] = None
mime_type: Optional[str] = None

def to_tuple(self) -> FileJsonType:
def to_tuple(self) -> FileTypes:
"""Return a tuple representation that httpx will accept for multipart/form-data"""
return self.file_name, self.payload, self.mime_type

Expand All @@ -43,4 +51,4 @@ class Response(Generic[T]):
parsed: Optional[T]


__all__ = ["UNSET", "File", "FileJsonType", "Response", "Unset"]
__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"]
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ include = ["CHANGELOG.md", "my_test_api_client/py.typed"]

[tool.poetry.dependencies]
python = "^3.9"
httpx = ">=0.20.0,<0.29.0"
httpx = ">=0.23.0,<0.29.0"
attrs = ">=22.2.0"
python-dateutil = "^2.8.0"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ def _get_kwargs(
"url": "/bodies/json-like",
}

_body = body.to_dict()
_kwargs["json"] = body.to_dict()

_kwargs["json"] = _body
headers["Content-Type"] = "application/vnd+json"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,20 @@ def _get_kwargs(
}

if isinstance(body, PostBodiesMultipleJsonBody):
_json_body = body.to_dict()
_kwargs["json"] = body.to_dict()

_kwargs["json"] = _json_body
headers["Content-Type"] = "application/json"
if isinstance(body, File):
_content_body = body.payload
_kwargs["content"] = body.payload

_kwargs["content"] = _content_body
headers["Content-Type"] = "application/octet-stream"
if isinstance(body, PostBodiesMultipleDataBody):
_data_body = body.to_dict()
_kwargs["data"] = body.to_dict()

_kwargs["data"] = _data_body
headers["Content-Type"] = "application/x-www-form-urlencoded"
if isinstance(body, PostBodiesMultipleFilesBody):
_files_body = body.to_multipart()
_kwargs["files"] = body.to_multipart()

_kwargs["files"] = _files_body
headers["Content-Type"] = "multipart/form-data"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ def _get_kwargs(
"url": "/bodies/refs",
}

_body = body.to_dict()
_kwargs["json"] = body.to_dict()

_kwargs["json"] = _body
headers["Content-Type"] = "application/json"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ def _get_kwargs(
"url": "/config/content-type-override",
}

_body = body
_kwargs["json"] = body

_kwargs["json"] = _body
headers["Content-Type"] = "openapi/python/client"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ def _get_kwargs(
"url": "/naming/property-conflict-with-import",
}

_body = body.to_dict()
_kwargs["json"] = body.to_dict()

_kwargs["json"] = _body
headers["Content-Type"] = "application/json"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ def _get_kwargs(
"url": "/tests/callback",
}

_body = body.to_dict()
_kwargs["json"] = body.to_dict()

_kwargs["json"] = _body
headers["Content-Type"] = "application/json"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ def _get_kwargs(
"url": "/tests/json_body",
}

_body = body.to_dict()
_kwargs["json"] = body.to_dict()

_kwargs["json"] = _body
headers["Content-Type"] = "application/json"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ def _get_kwargs(
"url": "/tests/octet_stream",
}

_body = body.payload
_kwargs["content"] = body.payload

_kwargs["content"] = _body
headers["Content-Type"] = "application/octet-stream"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ def _get_kwargs(
"url": "/tests/post_form_data",
}

_body = body.to_dict()
_kwargs["data"] = body.to_dict()

_kwargs["data"] = _body
headers["Content-Type"] = "application/x-www-form-urlencoded"

_kwargs["headers"] = headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ def _get_kwargs(
"url": "/tests/post_form_data_inline",
}

_body = body.to_dict()
_kwargs["data"] = body.to_dict()

_kwargs["data"] = _body
headers["Content-Type"] = "application/x-www-form-urlencoded"

_kwargs["headers"] = headers
Expand Down
Loading
Loading