Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b4b7202
feat: support multiple content types in requestBody
claude Apr 26, 2026
0d23963
ci: apply linting and formating
autofix-ci[bot] Apr 26, 2026
51f683a
chore: add changeset for multi-content-type requestBody support
claude Apr 26, 2026
a731ae0
test: update integration snapshots for multi-content-type requestBody
claude Apr 26, 2026
7b67a6f
fix: sanitize content-type parameters and special chars in type suffixes
claude Apr 26, 2026
adcd590
chore: regenerate examples with multi-content-type requestBody support
claude Apr 26, 2026
1be8cb2
fix: forward contentType through vue-query mutations; add multi-schem…
claude Apr 26, 2026
3994cb8
refactor: DRY content-type helpers across plugins
claude Apr 26, 2026
9fe04a4
Merge branch 'main' into claude/multi-content-type-request-kmujk
stijnvanhulle Apr 26, 2026
6fa2ac8
Merge branch 'main' into claude/multi-content-type-request-kmujk
stijnvanhulle Apr 26, 2026
5f9becd
Merge branch 'main' into claude/multi-content-type-request-kmujk
stijnvanhulle Apr 27, 2026
f292ac0
Merge remote-tracking branch 'origin/main' into claude/multi-content-…
Copilot Apr 30, 2026
23dda05
chore: merge main and update test snapshots
Copilot Apr 30, 2026
de445bc
feat: move contentType to RequestConfig; update axios/fetch clients
Copilot Apr 30, 2026
3bff86a
fix: add contentType to src/clients and fix examples typecheck/genera…
Copilot May 1, 2026
0ff1889
Merge remote-tracking branch 'origin/main' into claude/multi-content-…
Copilot May 4, 2026
a15bb52
chore: merge main and update generated examples
Copilot May 4, 2026
22e0d48
docs: research content type approach and install rtk
Copilot May 4, 2026
dc91c37
feat: complete request content type config support
Copilot May 4, 2026
aedb66e
chore(examples): update generated files
Copilot May 4, 2026
a35c1b2
Merge remote-tracking branch 'origin/main' into claude/multi-content-…
Copilot May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
15 changes: 15 additions & 0 deletions .changeset/multi-content-type-request-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@kubb/plugin-client": minor
"@kubb/plugin-react-query": minor
"@kubb/plugin-ts": minor
---

Support multiple content types in `requestBody`.

When an OpenAPI operation declares more than one content type for its `requestBody` (e.g. `application/json` **and** `multipart/form-data`), plugins now generate correct output for all declared types instead of silently ignoring all but the first.

- **plugin-ts**: emits individual types per content type (e.g. `UploadFileJsonData`, `UploadFileFormData`) plus a union alias (`type UploadFileData = UploadFileJsonData | UploadFileFormData`).
- **plugin-client**: adds a `contentType` parameter with a literal union type and a default matching the first declared type; uses a runtime ternary to dispatch between form-data and JSON request paths.
- **plugin-react-query**: `contentType` is forwarded through mutation variables into the client call; `buildFormData` import is now conditional on the operation actually using `multipart/form-data`.

Single-content-type operations are backwards-compatible — generated output is identical to before.
6 changes: 6 additions & 0 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,11 @@ jobs:
with:
node-version: '22'

- name: Install RTK
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot not needed anymore, our setup config has rtk already

run: |
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/rtk" --version

- name: Build
run: pnpm run build
273 changes: 68 additions & 205 deletions docs/plans/multi-content-type-request-body.md
Original file line number Diff line number Diff line change
@@ -1,238 +1,101 @@
# Plan: Support Multiple ContentTypes in requestBody
# Plan: Support Multiple Content Types in requestBody

## Problem

Some OpenAPI specs declare multiple content types for a single operation's `requestBody`
(e.g. `application/json` **and** `multipart/form-data` with different schemas for each).
Today every plugin reads only `content[0]`, so the second content type is silently ignored.
OpenAPI operations can declare multiple `requestBody.content` entries for the same operation. A common example is an upload endpoint that accepts both `application/json` metadata and `multipart/form-data` file uploads.

**Goal:** Generate a single hook/client per operation that supports all declared content types,
with a `contentType` parameter defaulting to the adapter-oas configured type (or the first
type in the spec when no global override is set).
The AST already exposes `requestBody.content` as an array. The plugin layer must preserve every entry instead of reading only `content[0]`.

---
## Research conclusion

## Background / Codebase Context
The current direction makes sense: generate one operation-level API and select the request content type at call time. This keeps Kubb output aligned with OpenAPI, where one operation can accept multiple media types for the same request body.

### Repo layout
Keeping `contentType` on `RequestConfig` also makes sense because the selected media type is transport metadata. The generated operation should still derive a typed literal union from the OpenAPI content entries, use the first declared media type as the default, and pass the selected value into the client config so fetch and axios can set the matching request header.

```
/Users/stijnvanhulle/GitHub/plugins ← this repo (plugins)
/Users/stijnvanhulle/GitHub/kubb ← upstream core (AST, adapter-oas)
```

### Key files

| File | Role |
|---|---|
| `kubb/packages/adapter-oas/src/parser.ts` | Parses OpenAPI → `OperationNode`. Already populates `requestBody.content[]` for every declared content type when no global `contentType` override is set (line 855). |
| `kubb/packages/adapter-oas/src/resolvers.ts` | `getRequestBodyContentTypes()`, `getRequestSchema()` |
| `kubb/packages/ast/src/nodes/operation.ts` | `OperationNode.requestBody.content` is already an **array**. The AST supports multiple entries today. |
| `packages/plugin-ts/src/generators/typeGenerator.tsx` line 151 | Reads `content[0]` only. |
| `packages/plugin-client/src/components/Client.tsx` line 86 | `const contentType = node.requestBody?.content?.[0]?.contentType ?? 'application/json'` |
| `packages/plugin-client/src/generators/clientGenerator.tsx` line 73 | `const isFormData = node.requestBody?.content?.[0]?.contentType === 'multipart/form-data'` |
| `packages/plugin-client/src/utils.ts` line 33 | `buildHeaders(contentType, hasHeaderParams)` |
| `packages/plugin-react-query/src/generators/mutationGenerator.tsx` line 55 | Reads `content[0]` only. |

### How the AST already works

`OasParserContext.contentType` is an optional global override.

```ts
// parser.ts line 855
const allContentTypes = ctx.contentType
? [ctx.contentType] // global override → single entry
: getRequestBodyContentTypes(document, operation) // no override → all entries
```
The approach is incomplete when `contentType` is destructured out of config and not forwarded to the underlying client. TanStack wrappers also need a clear way to choose the content type for each mutation call or, if the choice stays hook-level only, the generated documentation and types must make that limitation explicit.

So `node.requestBody.content` is already a fully-populated array. Plugins just need to
consume it properly.
## Current state

---
| Area | Status | Notes |
|---|---:|---|
| AST input | Done | `requestBody.content` already contains all media types when adapter-oas does not receive a global content type override. |
| `plugin-ts` | Done | Multi-content request bodies emit individual data types plus a union alias such as `UploadFileData = UploadFileJsonData \| UploadFileFormData`. |
| `plugin-client` function client | Done | Generated clients compute a default `contentType`, type it as a literal union, forward it to request config, and switch between `requestData` and `FormData` when multipart is present. |
| `plugin-client` form-data detection | Done | Imports now check all content entries with `.some()` instead of only `content[0]`. |
| Fetch client | Done | `RequestConfig` includes `contentType?: string`, and fetch sets `Content-Type` when the value is not `multipart/form-data`. |
| Axios client | Done | `RequestConfig` includes `contentType?: string`, and axios sets `Content-Type` when the value is not `multipart/form-data`. |
| React Query | Done | Hook-level client config now exposes the same operation-specific `contentType` literal union as the generated client. |
| Vue Query | Done | Vue Query matches React Query for conditional `buildFormData` imports and hook-level content type config. |
| Examples | Done | Examples regenerate and typecheck after adding `contentType` to the published fetch and axios client files. |
| Copilot setup | Done | The setup workflow installs RTK from `rtk-ai/rtk` so future agent sessions can use the `rtk` CLI. |

## Desired generated output
## Implemented follow-up work

### Single content type (no change, backwards-compatible)
### 1. Forward `contentType` to the transport client

```ts
// plugin-ts: unchanged
export type CreatePathData = { name: string }
Generated clients forward the selected `contentType` to the transport client.

// plugin-client: unchanged
export async function createPath({ data }: { data: CreatePathData }, config = {}) {
const requestData = data
const res = await fetch<CreatePathResponse, ResponseErrorConfig<Error>, CreatePathData>({
method: 'POST', url: '/path', data: requestData, ...config,
})
return res.data
}
```ts [generated-client.ts]
const { client: request = fetch, contentType = 'application/json', ...requestConfig } = config

// plugin-react-query: unchanged
export function useCreatePath() {
return useMutation({ mutationFn: ({ data }) => createPath({ data }) })
}
await request({
data: contentType === 'multipart/form-data' ? (formData as FormData) : requestData,
contentType,
...requestConfig,
})
```

### Multiple content types (new behaviour)

```ts
// plugin-ts: union alias added after individual types
export type CreatePathJsonData = { name: string }
export type CreatePathFormData = { file: File }
export type CreatePathData = CreatePathJsonData | CreatePathFormData // ← new

// plugin-client: contentType param added, ternary replaces static isFormData
export async function createPath(
{ data, contentType = 'application/json' }: {
data: CreatePathData
contentType?: 'application/json' | 'multipart/form-data'
},
config = {},
) {
const formData = buildFormData(data) // buildFormData creates the FormData instance; `as FormData` is a type assertion only
const res = await fetch<CreatePathResponse, ResponseErrorConfig<Error>, CreatePathData>({
method: 'POST',
url: '/path',
data: contentType === 'multipart/form-data' ? formData as FormData : data,
...config,
})
return res.data
}

// plugin-react-query: contentType forwarded through mutation variables
export function useCreatePath() {
return useMutation({
mutationFn: ({ data, contentType }: { data: CreatePathData; contentType?: 'application/json' | 'multipart/form-data' }) =>
createPath({ data, contentType }),
})
}
```

---

## Implementation steps

All changes must be **backwards-compatible**: when `content.length === 1`, generated output
is identical to today.

### Step 1: `plugin-ts` typeGenerator

**File:** `packages/plugin-ts/src/generators/typeGenerator.tsx`

When `node.requestBody.content.length > 1`:
- The individual schema types are already generated per content entry (this works today when
no global contentType override is set. Verify this assumption by running tests).
- Add a **union alias** after the individual types:
```ts
export type CreatePathData = CreatePathJsonData | CreatePathFormData
```
- The union alias name must match what `tsResolver.resolveDataName(node)` returns (the same
name used today for the single-type case), so downstream consumers (plugin-client,
plugin-react-query) continue to reference `CreatePathData` without changes.
- The individual type names should follow the existing naming convention plus a content-type
suffix. Determine the suffix from the content type string:
- `application/json` → `Json`
- `multipart/form-data` → `FormData`
- `application/x-www-form-urlencoded` → `FormUrlEncoded`
- anything else → capitalised last segment of the MIME type
The generated request config type should narrow `contentType` to the OpenAPI literal union for that operation, for example:

### Step 2: `plugin-client` Client.tsx

**File:** `packages/plugin-client/src/components/Client.tsx`

Key change area: lines 86–88 (contentType / isFormData derivation) and lines 237–239
(formData / requestData usage).

When `node.requestBody?.content?.length > 1`:

1. **Derive the literal union type** for the `contentType` param:
```ts
const contentTypeUnion = node.requestBody.content
.map(e => JSON.stringify(e.contentType))
.join(' | ')
// e.g. "'application/json' | 'multipart/form-data'"
```

2. **Derive the default**: use `content[0].contentType` (this is the adapter-oas configured
type when a global override is set, or the first type in the spec otherwise).

3. **Add `contentType` to the function params** (via `extraParams` in `getParams()` or
directly in the rendered `<Function>` params string). It should be part of the destructured
data/params object to match the existing param style.

4. **Replace the static `isFormData` check** with runtime usage:
- Keep `const formData = buildFormData(data)` unconditionally when any content type is
`multipart/form-data`.
- Change `data: isFormData ? 'formData as FormData' : 'requestData'` to
`data: contentType === 'multipart/form-data' ? formData as FormData : requestData`.

5. When `content.length === 1`: no change (existing static `isFormData` path unchanged).

**Also update** `clientGenerator.tsx` line 73:
```ts
// before
const isFormData = node.requestBody?.content?.[0]?.contentType === 'multipart/form-data'

// after: only used to determine whether buildFormData import is needed
const hasFormData = node.requestBody?.content?.some(e => e.contentType === 'multipart/form-data') ?? false
```ts [generated-client.ts]
config: Partial<RequestConfig<UploadFileData>> & {
client?: Client
contentType?: 'application/json' | 'multipart/form-data'
} = {}
```

`classClientGenerator.tsx` line 162 and `staticClassClientGenerator.tsx` line 163 already use
`hasFormData` with `.some()`, but still narrow to `content?.[0]?...`. Update to check all
entries:
```ts
// before (already uses hasFormData but wrong: checks only content[0])
const hasFormData = ops.some((op) => op.node.requestBody?.content?.[0]?.contentType === 'multipart/form-data')
### 2. Update axios to honor `config.contentType`

// after: checks all declared content types per operation
const hasFormData = ops.some((op) => op.node.requestBody?.content?.some(e => e.contentType === 'multipart/form-data'))
```
The axios client sets `Content-Type` from `config.contentType` in the same way the fetch client does. It skips an explicit header for `multipart/form-data`, so axios or the browser can attach the boundary.

### Step 3: `plugin-react-query` mutationGenerator
### 3. Finish TanStack Query behavior

**File:** `packages/plugin-react-query/src/generators/mutationGenerator.tsx`

**Also fix unconditional `buildFormData` import** (line 126): currently `buildFormData` is always
imported when `!shouldUseClientPlugin`, regardless of whether the operation has any
`multipart/form-data` content. Make it conditional:
```ts
// before
{!shouldUseClientPlugin && <File.Import name={['buildFormData']} ... />}

// after: only import when actually needed
{!shouldUseClientPlugin && node.requestBody?.content?.some(e => e.contentType === 'multipart/form-data') && (
<File.Import name={['buildFormData']} ... />
)}
```
React Query and Vue Query share the same hook-level policy through `internals/tanstack-query`.

When `node.requestBody?.content?.length > 1`:
Current direction:

1. Add `contentType` to the mutation variable type:
```ts
mutationFn: ({ data, contentType }: { data: CreatePathData; contentType?: '...' }) =>
createPath({ data, contentType })
```
1. Keep `contentType` as `RequestConfig` metadata, not as a separate top-level generated function argument.
2. Allow hook-level configuration through the existing `client` option.
3. Keep React Query and Vue Query snapshots aligned.

2. The literal union string for `contentType` can be derived the same way as in Step 2.
### 4. Update class and static clients

When `content.length === 1`: no change.
Class-based clients use the same multi-content logic as function clients:

---
- Detect multipart content with `.some()` across every content entry.
- Use the runtime `contentType` selector when both JSON and multipart bodies exist.
- Pass the selected `contentType` into the request config.

## Testing checklist
### 5. Add coverage for edge cases

- [ ] Existing snapshot tests still pass (single content type = no output change)
- [ ] Add a test operation node with two content entries to `clientGenerator.test.tsx`
- [ ] Add same to `mutationGenerator.test.tsx`
- [ ] Add same to `typeGenerator.test.tsx` (verify union alias is emitted)
- [ ] Run `pnpm test` in `packages/plugin-ts`, `packages/plugin-client`,
`packages/plugin-react-query`
- [ ] Verify `examples/` still generate cleanly with `pnpm generate` (or equivalent)
Snapshots and unit tests now cover these cases:

---
- A single content type remains byte-for-byte compatible.
- `application/json` + `multipart/form-data` switches payloads correctly.
- Non-form multi-content operations still expose the typed literal `contentType` union.
- Fetch sets `Content-Type` for JSON and other non-multipart content types.
- Axios sets `Content-Type` for JSON and other non-multipart content types.
- React Query and Vue Query expose the same content type override behavior.
- Class and static clients match the function client behavior.

## Out of scope (follow-up)
## Validation checklist

- `plugin-vue-query`, `plugin-swr`: same pattern as plugin-react-query, can follow after
- `plugin-mcp`: same pattern
- Response content type union (only requestBody is in scope here)
- [x] Merge `main` into the branch.
- [x] Regenerate examples.
- [x] Typecheck examples.
- [x] Run the existing test suite.
- [x] Forward `contentType` to fetch and axios at runtime.
- [x] Add literal union typing to generated request config.
- [x] Finish React Query and Vue Query parity.
- [x] Update class and static client generation.
- [x] Add snapshots for the remaining edge cases.
10 changes: 10 additions & 0 deletions examples/advanced/petStore.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,16 @@ paths:
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- url
properties:
url:
type: string
format: uri
description: URL of the image to upload
multipart/form-data:
schema:
$ref: '#/components/schemas/Pet'
Expand Down
16 changes: 13 additions & 3 deletions examples/advanced/src/axios-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type RequestConfig<TData = unknown> = {
responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'
signal?: AbortSignal
headers?: AxiosRequestConfig['headers']
contentType?: string
}
/**
* Subset of AxiosResponse
Expand All @@ -37,9 +38,18 @@ export const axiosInstance = axios.create({
export type Client = <TData, _TError = unknown, TVariables = unknown>(config: RequestConfig<TVariables>) => Promise<ResponseConfig<TData>>

export const client = async <TData, TError = unknown, TVariables = unknown>(config: RequestConfig<TVariables>): Promise<ResponseConfig<TData>> => {
const promise = axiosInstance.request<TVariables, ResponseConfig<TData>>(config).catch((e: AxiosError<TError>) => {
throw e
})
const { contentType, headers, ...axiosConfig } = config
const promise = axiosInstance
.request<TVariables, ResponseConfig<TData>>({
...axiosConfig,
headers: {
...(contentType && contentType !== 'multipart/form-data' ? { 'Content-Type': contentType } : {}),
...headers,
},
})
.catch((e: AxiosError<TError>) => {
throw e
})

return promise
}
Expand Down
Loading
Loading