-
-
Notifications
You must be signed in to change notification settings - Fork 3
feat: support multiple content types in requestBody #91
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
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 0d23963
ci: apply linting and formating
autofix-ci[bot] 51f683a
chore: add changeset for multi-content-type requestBody support
claude a731ae0
test: update integration snapshots for multi-content-type requestBody
claude 7b67a6f
fix: sanitize content-type parameters and special chars in type suffixes
claude adcd590
chore: regenerate examples with multi-content-type requestBody support
claude 1be8cb2
fix: forward contentType through vue-query mutations; add multi-schem…
claude 3994cb8
refactor: DRY content-type helpers across plugins
claude 9fe04a4
Merge branch 'main' into claude/multi-content-type-request-kmujk
stijnvanhulle 6fa2ac8
Merge branch 'main' into claude/multi-content-type-request-kmujk
stijnvanhulle 5f9becd
Merge branch 'main' into claude/multi-content-type-request-kmujk
stijnvanhulle f292ac0
Merge remote-tracking branch 'origin/main' into claude/multi-content-…
Copilot 23dda05
chore: merge main and update test snapshots
Copilot de445bc
feat: move contentType to RequestConfig; update axios/fetch clients
Copilot 3bff86a
fix: add contentType to src/clients and fix examples typecheck/genera…
Copilot 0ff1889
Merge remote-tracking branch 'origin/main' into claude/multi-content-…
Copilot a15bb52
chore: merge main and update generated examples
Copilot 22e0d48
docs: research content type approach and install rtk
Copilot dc91c37
feat: complete request content type config support
Copilot aedb66e
chore(examples): update generated files
Copilot a35c1b2
Merge remote-tracking branch 'origin/main' into claude/multi-content-…
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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