Skip to content

feat!: generate usable types and send correct bodies for file uploads - #300

Draft
mfal wants to merge 2 commits into
masterfrom
claude/issue-264-multipart-upload-types
Draft

mfal wants to merge 2 commits into
masterfrom
claude/issue-264-multipart-upload-types

Conversation

@mfal

@mfal mfal commented Sep 4, 2026

Copy link
Copy Markdown
Member

Closes #264

Warning

This is the only PR in the current batch marked breaking. The commit is feat!: with a BREAKING CHANGE: footer, so merging it makes lerna-lite cut 5.0.0 for all packages. If you would rather not spend a major right now, say so and I will re-scope — see "The release decision" at the bottom.

Why the type collapsed

RequestParameters.fromDoc only ever looked at application/json:

const requestBodySchema =
  doc.requestBody && "content" in doc.requestBody && "application/json" in doc.requestBody.content
    ? doc.requestBody.content["application/json"].schema
    : doc.requestBody;          // <-- fallback: the whole RequestBodyObject

For a multipart-only route the fallback handed the entire OpenAPI request body object ({content: {...}, required: true}) to json-schema-to-typescript. That has no type/properties, so it compiled to interface RequestBody { [k: string]: unknown }. Two independent defects: non-JSON media types were never modelled, and format: binary had no TS mapping.

The runtime was broken too — the type was the smaller half

Probed against real axios 1.15.2 and a local HTTP server: passing { emailEml: file } today produces Content-Type: application/json and body {"emailEml":{}}. The file silently vanishes. These routes could not be called at all without hand-rolling a FormData and casting.

Delegating to axios was not an option either — its toFormData uses the form-data package on Node, which rejects Blob ("Blob is not supported. Use a Buffer instead.") and crashes on File (source.on is not a function). So the client builds the FormData itself.

The type

BinaryData = Blob, exported from @mittwald/api-client-commons.

  • Browser File extends Blob — exactly the issue's use case (file input / dropzone).
  • Node exposes global Blob (18+) and File (20+); @types/node declares both, so consumers need no DOM lib.
  • DOM-lib check: root tsconfig.json already sets "lib": ["ES2022", "dom"] and every package extends it, so Blob/File/FormData resolve. BinaryData is not a name in lib.dom.d.ts or @types/node, so no global collision.
  • Deliberately not widened to Buffer/Uint8Array/ReadableStream: WHATWG FormData.append accepts only string | Blob, so allowing them would type-check and then silently mangle the payload. Node users wrap: new Blob([buffer]).

Covers OpenAPI 3.0 (type: string, format: binary) and 3.1 (contentMediaType: application/octet-stream, contentEncoding: binary). format: byte / contentEncoding: base64 stay string.

Applied to request bodies only. Several routes have format: binary responses (/v2/files/{fileId}, container-template assets) which axios returns as strings under the default responseType — typing those Blob would have been a lie.

Runtime changes

New packages/commons/src/core/requestBody.ts: only plain objects are treated as field bags (FormData, URLSearchParams, Blob, Buffer, streams and class instances pass through untouched); multipart builds a real global FormData; no Content-Type is set for multipart so axios owns the boundary; an explicitly supplied Content-Type always wins.

OpenAPIOperation gained an optional requestContentType?: HttpMediaType (additive). The generator emits it only for non-JSON bodies, which is why descriptors.ts gained exactly 3 lines rather than 270.

Verified on the wire:

=== multipart with File
--- content-type: multipart/form-data; boundary=axios-1.15.2-boundary-...
--- body: ...name="emailEml"; filename="mail.eml"\r\nContent-Type: message/rfc822...
=== urlencoded
--- content-type: application/x-www-form-urlencoded
--- body: "grant_type=authorization_code&redirect_uri=...&code=abc"
=== plain json (regression check)
--- content-type: application/json
--- body: "{\"city\":\"Espelkamp\",...}"

What exactly breaks

Three operations, both spec versions: file-create-file, verification-detect-phishing-email (multipart), user-oauth-retrieve-access-token (form-urlencoded).

           export interface RequestBody {
-            [k: string]: unknown;
+            file: BinaryData;
           }
  1. Type narrowing. Excess-property checks now reject extra keys. No permissive index signature was added, because (a) all 270 other request bodies are already strict — the generator compiles with additionalProperties: false — so an index signature here would be an inconsistent special case, and (b) the only code that can break is code that was already sending a broken request.
  2. Wire format. Multipart bodies go from (broken) JSON to real multipart; the OAuth token body goes from JSON to form-urlencoded, which is what its spec declares.

Generated diff, regenerated in-PR: 60 insertions / 6 deletions across 4 files. Baseline regeneration was confirmed byte-identical to master first, and the post-change generation is idempotent.

Tests

binarySchemasToCustomTypes.test.ts (3.0/3.1 forms, base64 negatives, nesting, non-mutation) · RequestParameters.test.ts (fixture spec with a binary field plus a normal string field; asserts file: BinaryData, description?: string, no index signature, the import, the descriptor's requestContentType, JSON-preferred-when-present, urlencoded) · requestBody.test.ts (12 serialization cases) · packages/mittwald/src/v2/fileUpload.test-types.ts — a consumer-facing regression guard for this issue: File/Blob assignable without a cast, @ts-expect-error on a string and on {}.

All green: nx run-many -t test --skip-nx-cache (4 projects, 9 dependent tasks), generator test:unit (9 suites/39), commons test (4 suites/33), test:compile (3), yarn lint (4).

The release decision

The practical break is close to zero — the affected code paths were non-functional. If you would rather not cut 5.0.0 for this, the alternatives are: hold the PR until the next intended major, or split it so the runtime fix (feat:) lands now and the type narrowing waits. I marked it breaking because it genuinely is by the letter of the contract, and silently shipping a narrowed public type as a minor is the worse failure mode.

🤖 Generated with Claude Code

Request bodies were only ever read from the `application/json` content of an
operation. For operations without it, the whole OpenAPI request body object was
handed to json-schema-to-typescript, which produced `{ [p: string]: unknown }`.
Consumers of `multipart/form-data` routes such as
`verification-detect-phishing-email` therefore had to cast their `File` to
`unknown` (#264).

The generator now picks `application/json` when the operation offers it and
falls back to the first declared media type otherwise, and maps binary schemas
(`type: string, format: binary` in OpenAPI 3.0, `contentMediaType:
application/octet-stream` / `contentEncoding: binary` in 3.1) to the new
`BinaryData` type exported by `@mittwald/api-client-commons`. `BinaryData` is
`Blob`, which covers browser `File`s from file inputs and drop zones as well as
Node's global `Blob`/`File`.

A nice type alone would not have helped: axios JSON-serialized multipart
payloads into `{"emailEml":{}}`, and its own multipart conversion falls back to
the `form-data` package on Node, which cannot handle `Blob`s. Operations whose
request body is not JSON now carry their media type in the generated descriptor
(`requestContentType`), and the runtime builds a real `FormData` for multipart
bodies and lets axios encode everything else once it knows the content type.
Payloads that are not plain objects (an already built `FormData`, a
`URLSearchParams`, a `Blob`, a stream) are passed through untouched, and an
explicitly set `Content-Type` request header always wins.

Affected operations in the generated client: `file-create-file`,
`verification-detect-phishing-email` (both multipart) and
`user-oauth-retrieve-access-token` (form-urlencoded).

Closes #264

BREAKING CHANGE: The request body types of the three non-JSON operations
(`file-create-file`, `verification-detect-phishing-email` and
`user-oauth-retrieve-access-token`) narrow from `{ [p: string]: unknown }` to
their declared shape, so code passing arbitrary extra keys no longer compiles.
Their bodies are now sent as `multipart/form-data` respectively
`application/x-www-form-urlencoded` instead of JSON.

Both behaviours were broken before: a multipart payload was JSON-serialized to
`{"emailEml":{}}` and the file was silently dropped, so these routes could not
be called at all without hand-rolling a `FormData` and casting it to `unknown`.
Callers who did that must remove the workaround and pass the `File`/`Blob`
directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Master's date-time PR (#299) rewrote the same three spots in Request.ts:

- The request body is now taken from the date-serialized request object, so
  a `Date` in a multipart body is appended as a bare ISO string part rather
  than a JSON-quoted one. Covered by a new test.
- `makeAxiosHeaders` keeps this branch's optional-headers/content-type
  handling and applies master's `serializeDates` to the header values.

`serializeDates` rebuilds plain objects, so a JSON body is no longer the
caller's exact object instance. Three assertions in requestBody.test.ts that
checked referential identity now compare structurally; the non-plain payloads
(FormData, Blob, URLSearchParams) are still passed through by reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Generated type for file uploads

1 participant