Skip to content

Commit 5beabbe

Browse files
sserrataclaude
andauthored
fix(explorer): respect encoding.contentType for multipart/form-data parts (#1369)
* fix(explorer): respect encoding.contentType for multipart/form-data parts OpenAPI encoding objects specify per-part Content-Type for multipart/form-data but these were ignored, causing file parts to always send as application/octet-stream regardless of the spec. - setBody/buildPostmanRequest: thread encoding through and set contentType on sdk.FormParam for each field that declares one, so code snippets reflect the correct per-part Content-Type - makeRequest: wrap file and string parts in a typed Blob when encoding.contentType is present so the actual HTTP request uses it - Request/index.tsx: extract encoding from requestBody for the active content type and pass to both buildPostmanRequest and makeRequest - CodeSnippets: accept requestBody prop and derive encoding from it so generated code snippets stay in sync encoding.contentType may be comma-separated per OAS spec; the first value is used. Closes #1247 * test(explorer): add multipartEncoding.yaml demo for encoding.contentType support Three endpoints covering: - File part with explicit contentType - JSON metadata + binary file (canonical #1247 use-case) - Mixed parts (some with encoding, some without) Servers point to HTTPBin so the actual request parts can be inspected in the response body. Related to #1247 * feat(explorer): per-part Content-Type selector for multipart/form-data encoding When a multipart/form-data field declares a comma-separated encoding.contentType (e.g. "image/png, image/jpeg, application/octet-stream"), a Content-Type dropdown appears next to the field input. Selecting a type updates both the generated code snippet and the actual request in real time. - EncodingSelection/slice: new Redux slice storing per-field content type selections - store/ApiItem: register slice and seed empty initial state - FormBodyItem: parse comma-separated contentType, render FormSelect picker when multiple types are available, dispatch selection to Redux - Body/index: extract encoding from spec and pass fieldEncoding to FormBodyItem - Request/index + CodeSnippets: merge spec encoding with Redux encodingSelection so user picks propagate to both buildPostmanRequest and makeRequest - multipartEncoding.yaml: add /post/multi-content-type demo endpoint with three selectable types to exercise the picker UI Related to #1247 * fix(test): use /anything/* paths for HTTPBin endpoints /post/* paths don't exist on HTTPBin — /anything/{path} accepts any method and echoes the full request. * fix(multipart-encoding): show encoding in snippet before file is uploaded When the body is empty (no file selected), preserve the original placeholder formdata params from the Postman request and apply the selected encoding contentType to them. This ensures the code snippet reflects the encoding selection immediately — before the user uploads a file — since postman-code-generators emits `;type=<ct>` for FormParams that have a contentType set. Also wraps the Content-Type FormSelect in a div to push it onto its own row below the property label. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(multipart-encoding): address code review feedback - Extract duplicate specEncoding+user-selection merge logic into a shared useResolvedEncoding(requestBody) hook; replaces copy-pasted blocks in Request/index.tsx and CodeSnippets/index.tsx - Narrow requestBody prop type in CodeSnippets from `any` to RequestBodyObject - Dispatch clearEncodingSelection when the active content type changes so stale per-field selections don't persist across content-type switches - Add comment explaining the mount-only useEffect in FormBodyItem Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 81bfa7d commit 5beabbe

File tree

13 files changed

+408
-23
lines changed

13 files changed

+408
-23
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Multipart Encoding Demo API
4+
description: |
5+
Demonstrates OAS `encoding.contentType` support for `multipart/form-data` parts.
6+
7+
Per the OAS spec, the `encoding` object lets you declare the Content-Type for
8+
individual form parts. Without this fix, all parts sent `application/octet-stream`
9+
regardless of the declared encoding.
10+
11+
**Cases covered:**
12+
- File part with explicit `contentType` (e.g. `text/plain`, `image/png`)
13+
- String/JSON part with explicit `contentType: application/json`
14+
- Multiple parts with mixed encoding
15+
- Parts without encoding (default browser behaviour)
16+
version: 1.0.0
17+
servers:
18+
- url: https://httpbin.org
19+
description: HTTPBin (inspect actual request parts in the response)
20+
tags:
21+
- name: multipartEncoding
22+
description: Multipart encoding content-type tests
23+
paths:
24+
/anything/file-with-content-type:
25+
post:
26+
tags:
27+
- multipartEncoding
28+
summary: File part with explicit contentType
29+
description: |
30+
Verifies that a binary file part declared with `encoding.contentType: text/plain`
31+
is sent with `Content-Type: text/plain` rather than `application/octet-stream`.
32+
33+
Upload any file — check the generated code snippet and (via HTTPBin) the actual
34+
request to confirm the part Content-Type is `text/plain`.
35+
36+
Schema:
37+
```yaml
38+
encoding:
39+
file:
40+
contentType: text/plain
41+
```
42+
requestBody:
43+
required: true
44+
content:
45+
multipart/form-data:
46+
schema:
47+
type: object
48+
properties:
49+
file:
50+
description: The file to upload.
51+
type: string
52+
format: binary
53+
required:
54+
- file
55+
encoding:
56+
file:
57+
contentType: text/plain
58+
responses:
59+
"200":
60+
description: Request echoed by HTTPBin — inspect `files` for Content-Type
61+
62+
/anything/json-metadata-and-file:
63+
post:
64+
tags:
65+
- multipartEncoding
66+
summary: JSON metadata part + binary file part
67+
description: |
68+
Verifies that:
69+
- The `metadata` string part is sent with `Content-Type: application/json`
70+
- The `file` binary part is sent with `Content-Type: image/png`
71+
72+
This is the canonical use-case from issue #1247.
73+
74+
Schema:
75+
```yaml
76+
encoding:
77+
metadata:
78+
contentType: application/json
79+
file:
80+
contentType: image/png
81+
```
82+
requestBody:
83+
required: true
84+
content:
85+
multipart/form-data:
86+
schema:
87+
type: object
88+
properties:
89+
metadata:
90+
description: JSON metadata for the file.
91+
type: string
92+
file:
93+
description: The file to upload.
94+
type: string
95+
format: binary
96+
required:
97+
- metadata
98+
- file
99+
encoding:
100+
metadata:
101+
contentType: application/json
102+
file:
103+
contentType: image/png
104+
responses:
105+
"200":
106+
description: Request echoed by HTTPBin — inspect part Content-Types
107+
108+
/anything/multi-content-type:
109+
post:
110+
tags:
111+
- multipartEncoding
112+
summary: File part with multiple selectable Content-Types
113+
description: |
114+
Verifies the Content-Type selector UI: when `encoding.contentType` is a
115+
comma-separated list, a dropdown appears next to the file picker letting
116+
the user choose which type to use. The code snippet and actual request
117+
both update to reflect the selection.
118+
119+
Schema:
120+
```yaml
121+
encoding:
122+
attachment:
123+
contentType: image/png, image/jpeg, application/octet-stream
124+
```
125+
requestBody:
126+
required: true
127+
content:
128+
multipart/form-data:
129+
schema:
130+
type: object
131+
properties:
132+
attachment:
133+
description: Upload an image — select its Content-Type from the dropdown.
134+
type: string
135+
format: binary
136+
required:
137+
- attachment
138+
encoding:
139+
attachment:
140+
contentType: "image/png, image/jpeg, application/octet-stream"
141+
responses:
142+
"200":
143+
description: Request echoed by HTTPBin — verify the attachment Content-Type matches your selection
144+
145+
/anything/mixed-encoding:
146+
post:
147+
tags:
148+
- multipartEncoding
149+
summary: Mixed — some parts with encoding, some without
150+
description: |
151+
Verifies that parts without an `encoding` entry use the browser default
152+
while parts with `encoding.contentType` use the declared value.
153+
154+
- `label` — no encoding declared → default (`text/plain`)
155+
- `data` — `contentType: application/json`
156+
- `attachment` — `contentType: application/octet-stream` (explicit)
157+
requestBody:
158+
required: true
159+
content:
160+
multipart/form-data:
161+
schema:
162+
type: object
163+
properties:
164+
label:
165+
description: A plain text label (no encoding declared).
166+
type: string
167+
data:
168+
description: A JSON payload sent as application/json.
169+
type: string
170+
attachment:
171+
description: A file sent as application/octet-stream.
172+
type: string
173+
format: binary
174+
encoding:
175+
data:
176+
contentType: application/json
177+
attachment:
178+
contentType: application/octet-stream
179+
responses:
180+
"200":
181+
description: Request echoed by HTTPBin — inspect part Content-Types

packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Body/FormBodyItem/index.tsx

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { SchemaObject } from "docusaurus-plugin-openapi-docs/src/openapi/ty
1717

1818
import FileArrayFormBodyItem from "../FileArrayFormBodyItem";
1919
import { clearFormBodyKey, setFileFormBody, setStringFormBody } from "../slice";
20+
import { setFieldEncoding } from "../../EncodingSelection/slice";
2021

2122
interface FormBodyItemProps {
2223
schemaObject: SchemaObject;
@@ -25,6 +26,7 @@ interface FormBodyItemProps {
2526
label?: string;
2627
required?: boolean;
2728
exampleValue?: SchemaObject["example"];
29+
fieldEncoding?: string;
2830
}
2931

3032
export default function FormBodyItem({
@@ -34,8 +36,37 @@ export default function FormBodyItem({
3436
label,
3537
required,
3638
exampleValue,
39+
fieldEncoding,
3740
}: FormBodyItemProps): React.JSX.Element {
3841
const dispatch = useTypedDispatch();
42+
43+
// Parse comma-separated encoding contentType into selectable options
44+
const encodingOptions = fieldEncoding
45+
? fieldEncoding
46+
.split(",")
47+
.map((t) => t.trim())
48+
.filter(Boolean)
49+
: [];
50+
const hasMultipleEncodings = encodingOptions.length > 1;
51+
52+
// Initialize with the first declared content type
53+
const [selectedEncoding, setSelectedEncoding] = useState<string>(
54+
encodingOptions[0] ?? ""
55+
);
56+
57+
// Seed Redux with the first declared encoding on mount so the code snippet
58+
// reflects a content type immediately, even before the user interacts.
59+
// The empty dep array is intentional: `fieldEncoding` comes from a static
60+
// spec value that never changes for the lifetime of this component instance,
61+
// and re-seeding on every render would fight user selections.
62+
useEffect(() => {
63+
if (encodingOptions[0]) {
64+
dispatch(
65+
setFieldEncoding({ field: id, contentType: encodingOptions[0] })
66+
);
67+
}
68+
// eslint-disable-next-line react-hooks/exhaustive-deps
69+
}, []);
3970
const [value, setValue] = useState(() => {
4071
let initialValue = exampleValue ?? "";
4172

@@ -71,6 +102,20 @@ export default function FormBodyItem({
71102
return (
72103
<>
73104
{label && <FormLabel label={label} required={required} />}
105+
{hasMultipleEncodings && (
106+
<div style={{ marginTop: "0.5rem" }}>
107+
<FormSelect
108+
label="Content-Type"
109+
options={encodingOptions}
110+
value={selectedEncoding}
111+
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
112+
const ct = e.target.value;
113+
setSelectedEncoding(ct);
114+
dispatch(setFieldEncoding({ field: id, contentType: ct }));
115+
}}
116+
/>
117+
</div>
118+
)}
74119
<FormFileUpload
75120
placeholder={schemaObject.description || id}
76121
onChange={(file: any) => {
@@ -95,16 +140,16 @@ export default function FormBodyItem({
95140

96141
if (schemaObject.type === "object") {
97142
return (
98-
<>
99-
{label && <FormLabel label={label} required={required} />}
100-
<LiveApp
101-
action={(code: string) =>
102-
dispatch(setStringFormBody({ key: id, value: code }))
103-
}
104-
>
105-
{value}
106-
</LiveApp>
107-
</>
143+
<>
144+
{label && <FormLabel label={label} required={required} />}
145+
<LiveApp
146+
action={(code: string) =>
147+
dispatch(setStringFormBody({ key: id, value: code }))
148+
}
149+
>
150+
{value}
151+
</LiveApp>
152+
</>
108153
);
109154
}
110155

packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/Body/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ function Body({
9393
const rawSchema = requestBodyMetadata?.content?.[contentType]?.schema;
9494
const example = requestBodyMetadata?.content?.[contentType]?.example;
9595
const examples = requestBodyMetadata?.content?.[contentType]?.examples;
96+
const encoding: Record<string, { contentType?: string }> | undefined =
97+
requestBodyMetadata?.content?.[contentType]?.encoding;
9698

9799
// Resolve the schema based on user's anyOf/oneOf tab selections
98100
const schema = useMemo(() => {
@@ -339,6 +341,7 @@ function Body({
339341
Array.isArray(schema.required) &&
340342
schema.required.includes(key)
341343
}
344+
fieldEncoding={encoding?.[key]?.contentType}
342345
></FormBodyItem>
343346
</FormItem>
344347
);
@@ -361,6 +364,7 @@ function Body({
361364
Array.isArray(schema.required) &&
362365
schema.required.includes(schemaKey)
363366
}
367+
fieldEncoding={encoding?.[schemaKey]?.contentType}
364368
></FormBodyItem>
365369
</FormItem>
366370
);
@@ -387,6 +391,7 @@ function Body({
387391
Array.isArray(schema.required) &&
388392
schema.required.includes(schemaKey)
389393
}
394+
fieldEncoding={encoding?.[schemaKey]?.contentType}
390395
></FormBodyItem>
391396
</FormItem>
392397
);

packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeSnippets/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
1111
import ApiCodeBlock from "@theme/ApiExplorer/ApiCodeBlock";
1212
import buildPostmanRequest from "@theme/ApiExplorer/buildPostmanRequest";
1313
import CodeTabs from "@theme/ApiExplorer/CodeTabs";
14+
import { useResolvedEncoding } from "@theme/ApiExplorer/EncodingSelection/useResolvedEncoding";
1415
import { useTypedSelector } from "@theme/ApiItem/hooks";
1516
import cloneDeep from "lodash/cloneDeep";
1617
import codegen from "postman-code-generators";
@@ -30,6 +31,7 @@ export interface Props {
3031
postman: sdk.Request;
3132
codeSamples: CodeSample[];
3233
maskCredentials?: boolean;
34+
requestBody?: import("docusaurus-plugin-openapi-docs/src/openapi/types").RequestBodyObject;
3335
}
3436

3537
function CodeTab({ children, hidden, className }: any): React.JSX.Element {
@@ -44,6 +46,7 @@ function CodeSnippets({
4446
postman,
4547
codeSamples,
4648
maskCredentials: propMaskCredentials,
49+
requestBody,
4750
}: Props) {
4851
const { siteConfig } = useDocusaurusContext();
4952

@@ -76,7 +79,9 @@ function CodeSnippets({
7679
const authOptions =
7780
clonedAuth?.options?.[key] ??
7881
clonedAuth?.options?.[comboAuthId];
79-
placeholder = authOptions?.find((opt: any) => opt.key === key)?.name;
82+
placeholder = authOptions?.find(
83+
(opt: any) => opt.key === key
84+
)?.name;
8085
obj[key] = cleanCredentials(obj[key]);
8186
} else {
8287
obj[key] = `<${placeholder ?? key}>`;
@@ -93,6 +98,8 @@ function CodeSnippets({
9398
})()
9499
: auth;
95100

101+
const encoding = useResolvedEncoding(requestBody);
102+
96103
// Create a Postman request object using cleanedAuth or original auth
97104
const cleanedPostmanRequest = buildPostmanRequest(postman, {
98105
queryParams,
@@ -104,6 +111,7 @@ function CodeSnippets({
104111
body,
105112
server,
106113
auth: cleanedAuth,
114+
encoding,
107115
});
108116

109117
// User-defined languages array

packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/ContentType/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import FormSelect from "@theme/ApiExplorer/FormSelect";
1212
import { useTypedDispatch, useTypedSelector } from "@theme/ApiItem/hooks";
1313

1414
import { setContentType } from "./slice";
15+
import { clearEncodingSelection } from "@theme/ApiExplorer/EncodingSelection/slice";
1516

1617
function ContentType() {
1718
const value = useTypedSelector((state: any) => state.contentType.value);
@@ -28,9 +29,10 @@ function ContentType() {
2829
label="Content-Type"
2930
value={value}
3031
options={options}
31-
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
32-
dispatch(setContentType(e.target.value))
33-
}
32+
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
33+
dispatch(setContentType(e.target.value));
34+
dispatch(clearEncodingSelection());
35+
}}
3436
/>
3537
</FormItem>
3638
);

0 commit comments

Comments
 (0)