History

Merge pull request #366 from RhysSullivan/rs/openapi-non-json-request-bodies

Rhys Sullivan committed 6 months ago

Commit e326dbb2 parents 6294582 70a2aba6 files · +472 −23Browse files

Showing the changes to packages/plugins/openapi/src (6 of 6 files). Show all

Unified · Split

6 files
  1. packages/plugins/openapi/src/sdk/extract.ts +63 −6
  2. packages/plugins/openapi/src/sdk/index.ts +2 −0
  3. packages/plugins/openapi/src/sdk/invoke.ts +343 −17
  4. packages/plugins/openapi/src/sdk/non-json-body.test.ts +0 −0
  5. packages/plugins/openapi/src/sdk/openapi-utils.ts +32 −1
  6. packages/plugins/openapi/src/sdk/types.ts +33 −0
packages/plugins/openapi/src/sdk/extract.ts+63−6
@@ -3,8 +3,9 @@ import { Effect, Option } from "effect"; · 2 unmodified lines
33 import { OpenApiExtractionError } from "./errors";
44 import type { ParsedDocument } from "./parse";
55 import {
6+ declaredContents,
67 DocResolver,
7− preferredContent,
8+ preferredResponseContent,
89 type OperationObject,
910 type ParameterObject,
1011 type PathItemObject,
@@ -12,9 +13,11 @@ import { · 1 unmodified line
1213 type ResponseObject,
1314 } from "./openapi-utils";
1415 import {
16+ EncodingObject,
1517 ExtractedOperation,
1618 ExtractionResult,
1719 type HttpMethod,
20+ MediaBinding,
1821 OperationId,
1922 OperationParameter,
2023 OperationRequestBody,
@@ -83,6 +86,29 @@ const extractParameters = ( · 62 unmodified lines
8386 // Request body extraction
8487 // ---------------------------------------------------------------------------
8588
89+const buildEncodingRecord = (
90+ encoding: Record<string, unknown> | undefined,
91+): Record<string, EncodingObject> | undefined => {
92+ if (!encoding) return undefined;
93+ const out: Record<string, EncodingObject> = {};
94+ for (const [prop, raw] of Object.entries(encoding)) {
95+ if (typeof raw !== "object" || raw === null) continue;
96+ const e = raw as {
97+ contentType?: string;
98+ style?: string;
99+ explode?: boolean;
100+ allowReserved?: boolean;
101+ };
102+ out[prop] = new EncodingObject({
103+ contentType: Option.fromNullable(e.contentType),
104+ style: Option.fromNullable(e.style),
105+ explode: Option.fromNullable(e.explode),
106+ allowReserved: Option.fromNullable(e.allowReserved),
107+ });
108+ }
109+ return Object.keys(out).length > 0 ? out : undefined;
110+};
111+
86112 const extractRequestBody = (
87113 operation: OperationObject,
88114 r: DocResolver,
@@ -92,13 +118,29 @@ const extractRequestBody = ( · 3 unmodified lines
92118 const body = r.resolve<RequestBodyObject>(operation.requestBody);
93119 if (!body) return undefined;
94120
95− const content = preferredContent(body.content);
96− if (!content) return undefined;
121+ const contents = declaredContents(body.content).map(
122+ ({ mediaType, media }) =>
123+ new MediaBinding({
124+ contentType: mediaType,
125+ schema: Option.fromNullable(media.schema),
126+ encoding: Option.fromNullable(
127+ buildEncodingRecord(
128+ (media as { encoding?: Record<string, unknown> }).encoding,
129+ ),
130+ ),
131+ }),
132+ );
133+ if (contents.length === 0) return undefined;
134+
135+ // Default = first declared (spec author's preferred order). Callers can
136+ // override at invoke time with a `contentType` arg.
137+ const defaultContent = contents[0]!;
97138
98139 return new OperationRequestBody({
99140 required: body.required === true,
100− contentType: content.mediaType,
101− schema: Option.fromNullable(content.media.schema),
141+ contentType: defaultContent.contentType,
142+ schema: defaultContent.schema,
143+ contents: Option.some(contents),
102144 });
103145 };
104146
@@ -118,7 +160,7 @@ const extractOutputSchema = (operation: OperationObject, r: DocResolver): unknow · 13 unmodified lines
118160 for (const [, ref] of preferred) {
119161 const resp = r.resolve<ResponseObject>(ref);
120162 if (!resp) continue;
121− const content = preferredContent(resp.content);
163+ const content = preferredResponseContent(resp.content);
122164 if (content?.media.schema) return content.media.schema;
123165 }
124166
@@ -144,6 +186,21 @@ const buildInputSchema = ( · 19 unmodified lines
144186 if (requestBody) {
145187 properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" }));
146188 if (requestBody.required) required.push("body");
189+
190+ // When the spec declares multiple media types for this requestBody,
191+ // expose `contentType` so the model can pick. Default = first declared.
192+ // `body` schema tracks the default; the model is responsible for
193+ // supplying a body shape that matches whichever contentType it picks.
194+ const contents = Option.getOrUndefined(requestBody.contents);
195+ if (contents && contents.length > 1) {
196+ properties.contentType = {
197+ type: "string",
198+ enum: contents.map((c) => c.contentType),
199+ default: requestBody.contentType,
200+ description:
201+ "Content-Type for the request body. Declared media types for this operation, in spec order.",
202+ };
203+ }
147204 }
148205
149206 if (Object.keys(properties).length === 0) return undefined;
packages/plugins/openapi/src/sdk/index.ts+2−0
@@ -52,10 +52,12 @@ export { · 51 unmodified lines
5252 } from "./errors";
5353
5454 export {
55+ EncodingObject,
5556 ExtractedOperation,
5657 ExtractionResult,
5758 InvocationConfig,
5859 InvocationResult,
60+ MediaBinding,
5961 OAuth2Auth,
6062 OpenApiOAuthSession,
6163 OperationBinding,
packages/plugins/openapi/src/sdk/invoke.ts+343−17
@@ -5,9 +5,11 @@ import type { StorageFailure } from "@executor/sdk"; · 4 unmodified lines
55
66 import { OpenApiInvocationError } from "./errors";
77 import {
8+ type EncodingObject,
89 type HeaderValue,
910 type OperationBinding,
1011 InvocationResult,
12+ type MediaBinding,
1113 type OperationParameter,
1214 } from "./types";
1315
@@ -175,6 +177,330 @@ const isFormUrlEncoded = (ct: string | null | undefined): boolean => · 161 unmodified lines
175177 const isMultipartFormData = (ct: string | null | undefined): boolean =>
176178 normalizeContentType(ct).startsWith("multipart/form-data");
177179
180+const isXmlContentType = (ct: string | null | undefined): boolean => {
181+ const normalized = normalizeContentType(ct);
182+ if (!normalized) return false;
183+ return (
184+ normalized === "application/xml" ||
185+ normalized === "text/xml" ||
186+ normalized.endsWith("+xml")
187+ );
188+};
189+
190+const isTextContentType = (ct: string | null | undefined): boolean =>
191+ normalizeContentType(ct).startsWith("text/");
192+
193+const isOctetStream = (ct: string | null | undefined): boolean =>
194+ normalizeContentType(ct) === "application/octet-stream";
195+
196+const toUint8Array = (value: unknown): Uint8Array | null => {
197+ if (value instanceof Uint8Array) return value;
198+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
199+ if (ArrayBuffer.isView(value)) {
200+ const view = value as ArrayBufferView;
201+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
202+ }
203+ if (Array.isArray(value) && value.every((v) => typeof v === "number")) {
204+ return new Uint8Array(value as readonly number[]);
205+ }
206+ return null;
207+};
208+
209+type FormDataRecord = Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1];
210+type FormDataCoercible = FormDataRecord[string];
211+
212+// Pull a plain ArrayBuffer out of a Uint8Array — `new Blob([u8])` rejects
213+// views whose `.buffer` is `SharedArrayBuffer | ArrayBuffer` under strict
214+// lib.dom typings.
215+const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
216+ const copy = new ArrayBuffer(bytes.byteLength);
217+ new Uint8Array(copy).set(bytes);
218+ return copy;
219+};
220+
221+// ---------------------------------------------------------------------------
222+// OpenAPI 3.x encoding — per-property style/explode/allowReserved/contentType
223+// for multipart/form-data and application/x-www-form-urlencoded bodies.
224+// Spec ref: https://spec.openapis.org/oas/v3.1.0#encoding-object
225+// ---------------------------------------------------------------------------
226+
227+type StyleExplode = {
228+ readonly style: string;
229+ readonly explode: boolean;
230+ readonly allowReserved: boolean;
231+};
232+
233+const DEFAULT_FORM_STYLE: StyleExplode = {
234+ style: "form",
235+ explode: true,
236+ allowReserved: false,
237+};
238+
239+const resolveStyleExplode = (e: EncodingObject | undefined): StyleExplode => {
240+ if (!e) return DEFAULT_FORM_STYLE;
241+ return {
242+ style: Option.getOrElse(e.style, () => DEFAULT_FORM_STYLE.style),
243+ explode: Option.getOrElse(e.explode, () => DEFAULT_FORM_STYLE.explode),
244+ allowReserved: Option.getOrElse(e.allowReserved, () => DEFAULT_FORM_STYLE.allowReserved),
245+ };
246+};
247+
248+// RFC 3986 §2.2 reserved chars. `allowReserved: true` leaves these
249+// unencoded; default OAS behavior encodes everything non-unreserved.
250+const RESERVED_UNENCODED_RE = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/;
251+
252+const encodeFormValue = (v: unknown, allowReserved: boolean): string => {
253+ const raw = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
254+ if (!allowReserved) return encodeURIComponent(raw);
255+ // Walk char-by-char so the reserved set passes through as-is.
256+ let out = "";
257+ for (const ch of raw) {
258+ out += RESERVED_UNENCODED_RE.test(ch) ? ch : encodeURIComponent(ch);
259+ }
260+ return out;
261+};
262+
263+/**
264+ * Serialize a record to application/x-www-form-urlencoded with OAS3 style
265+ * rules honored per-field. Supports `form` (default), `deepObject`,
266+ * `pipeDelimited`, `spaceDelimited` styles with `explode` true / false.
267+ */
268+const serializeFormUrlEncoded = (
269+ value: Record<string, unknown>,
270+ encoding: Record<string, EncodingObject> | undefined,
271+): string => {
272+ const parts: string[] = [];
273+ for (const [key, raw] of Object.entries(value)) {
274+ if (raw === undefined || raw === null) continue;
275+ const { style, explode, allowReserved } = resolveStyleExplode(encoding?.[key]);
276+ const encKey = encodeURIComponent(key);
277+
278+ if (Array.isArray(raw)) {
279+ if (explode) {
280+ for (const v of raw) {
281+ parts.push(`${encKey}=${encodeFormValue(v, allowReserved)}`);
282+ }
283+ } else {
284+ const sep =
285+ style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
286+ parts.push(
287+ `${encKey}=${encodeFormValue(
288+ raw.map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(sep),
289+ allowReserved,
290+ )}`,
291+ );
292+ }
293+ continue;
294+ }
295+
296+ if (typeof raw === "object") {
297+ const entries = Object.entries(raw as Record<string, unknown>).filter(
298+ ([, v]) => v !== undefined && v !== null,
299+ );
300+ if (style === "deepObject") {
301+ for (const [subkey, subval] of entries) {
302+ // Encode the whole `key[subkey]` fragment so `[` / `]` become
303+ // `%5B` / `%5D`. Matches swagger-client's behaviour and remains
304+ // accepted by common server-side parsers (qs, Rails, etc.).
305+ parts.push(
306+ `${encodeURIComponent(`${key}[${subkey}]`)}=${encodeFormValue(
307+ subval,
308+ allowReserved,
309+ )}`,
310+ );
311+ }
312+ } else if (explode) {
313+ // form + explode=true on object: sub-keys become top-level fields.
314+ for (const [subkey, subval] of entries) {
315+ parts.push(
316+ `${encodeURIComponent(subkey)}=${encodeFormValue(subval, allowReserved)}`,
317+ );
318+ }
319+ } else {
320+ // form + explode=false on object: flatten to csv key,val,key,val.
321+ const flat = entries.flatMap(([k, v]) => [
322+ k,
323+ typeof v === "object" ? JSON.stringify(v) : String(v),
324+ ]);
325+ parts.push(`${encKey}=${encodeFormValue(flat.join(","), allowReserved)}`);
326+ }
327+ continue;
328+ }
329+
330+ parts.push(`${encKey}=${encodeFormValue(raw, allowReserved)}`);
331+ }
332+ return parts.join("&");
333+};
334+
335+/**
336+ * Best-effort build of a multipart FormData entry record.
337+ *
338+ * If `encoding[key].contentType` is declared (OAS3 §4.8.15), wrap the value
339+ * in a `Blob` with that type so the runtime multipart framer emits the
340+ * per-part `Content-Type` header (e.g. `application/json` for a metadata
341+ * part whose server expects parsed JSON).
342+ *
343+ * Otherwise: primitives pass through, arrays handle their item types, byte
344+ * shapes wrap as Blob, nested objects JSON-stringify (never `[object Object]`).
345+ */
346+const coerceFormDataRecord = (
347+ value: Record<string, unknown>,
348+ encoding: Record<string, EncodingObject> | undefined,
349+): FormDataRecord => {
350+ const out: Record<string, FormDataCoercible> = {};
351+ for (const [key, raw] of Object.entries(value)) {
352+ if (raw === undefined || raw === null) continue;
353+
354+ const partType = encoding?.[key]
355+ ? Option.getOrUndefined(encoding[key]!.contentType)
356+ : undefined;
357+
358+ // Explicit per-part content type: wrap in a typed Blob so the framer
359+ // emits `Content-Type: <partType>` on this part. JSON types get the
360+ // value JSON-stringified first so the blob body is valid JSON.
361+ if (partType) {
362+ const isJson =
363+ partType.startsWith("application/json") || partType.includes("+json");
364+ const serialized =
365+ typeof raw === "string"
366+ ? raw
367+ : isJson
368+ ? JSON.stringify(raw)
369+ : typeof raw === "object"
370+ ? JSON.stringify(raw)
371+ : String(raw);
372+ out[key] = new Blob([serialized], { type: partType });
373+ continue;
374+ }
375+
376+ if (
377+ typeof raw === "string" ||
378+ typeof raw === "number" ||
379+ typeof raw === "boolean" ||
380+ raw instanceof Blob ||
381+ (typeof File !== "undefined" && raw instanceof File)
382+ ) {
383+ out[key] = raw as FormDataCoercible;
384+ continue;
385+ }
386+ if (Array.isArray(raw)) {
387+ out[key] = raw.map((v) =>
388+ typeof v === "string" ||
389+ typeof v === "number" ||
390+ typeof v === "boolean" ||
391+ v instanceof Blob ||
392+ (typeof File !== "undefined" && v instanceof File)
393+ ? (v as FormDataCoercible)
394+ : JSON.stringify(v),
395+ ) as FormDataCoercible;
396+ continue;
397+ }
398+ const bytes = toUint8Array(raw);
399+ if (bytes) {
400+ out[key] = new Blob([toArrayBuffer(bytes)]);
401+ continue;
402+ }
403+ out[key] = JSON.stringify(raw);
404+ }
405+ return out;
406+};
407+
408+// ---------------------------------------------------------------------------
409+// Request body dispatch
410+//
411+// Dispatch is driven by the spec-declared content type first, JS type of
412+// the provided body second. Servers that advertise a specific content type
413+// almost always reject anything else (e.g. a multipart endpoint will hang
414+// waiting for valid framing if it receives `application/json`), so the
415+// content type wins.
416+//
417+// Within each content type we accept both pre-serialized strings (user
418+// already produced the wire format) and structured JS values we can
419+// serialize ourselves. The last-resort fallback is `JSON.stringify(body)`
420+// — never `String(body)` (which produces the useless `[object Object]`).
421+// ---------------------------------------------------------------------------
422+
423+const applyRequestBody = (
424+ request: HttpClientRequest.HttpClientRequest,
425+ contentType: string,
426+ bodyValue: unknown,
427+ encoding: Record<string, EncodingObject> | undefined,
428+): HttpClientRequest.HttpClientRequest => {
429+ if (isJsonContentType(contentType)) {
430+ // Pre-serialized JSON strings pass through with the declared media
431+ // type preserved (important for `application/vnd.foo+json` etc.).
432+ if (typeof bodyValue === "string") {
433+ return HttpClientRequest.bodyText(request, bodyValue, contentType);
434+ }
435+ return HttpClientRequest.bodyUnsafeJson(request, bodyValue);
436+ }
437+
438+ if (isFormUrlEncoded(contentType)) {
439+ if (typeof bodyValue === "string") {
440+ return HttpClientRequest.bodyText(request, bodyValue, contentType);
441+ }
442+ if (typeof bodyValue === "object" && bodyValue !== null && !Array.isArray(bodyValue)) {
443+ // Serialize ourselves so OAS3 encoding (style/explode/deepObject)
444+ // is honored. bodyUrlParams doesn't know about per-field style.
445+ const serialized = serializeFormUrlEncoded(
446+ bodyValue as Record<string, unknown>,
447+ encoding,
448+ );
449+ return HttpClientRequest.bodyText(request, serialized, contentType);
450+ }
451+ // Non-object body — fall back to platform helper (handles URLSearchParams).
452+ return HttpClientRequest.bodyUrlParams(
453+ request,
454+ bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1],
455+ );
456+ }
457+
458+ if (isMultipartFormData(contentType)) {
459+ if (bodyValue instanceof FormData) {
460+ return HttpClientRequest.bodyFormData(request, bodyValue);
461+ }
462+ if (typeof bodyValue === "object" && bodyValue !== null) {
463+ return HttpClientRequest.bodyFormDataRecord(
464+ request,
465+ coerceFormDataRecord(bodyValue as Record<string, unknown>, encoding),
466+ );
467+ }
468+ // String / primitive under multipart is almost certainly wrong on the
469+ // caller's end — send it as text with their declared content type and
470+ // let the server produce a useful error.
471+ return HttpClientRequest.bodyText(request, String(bodyValue), contentType);
472+ }
473+
474+ if (isOctetStream(contentType)) {
475+ const bytes = toUint8Array(bodyValue);
476+ if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType);
477+ if (typeof bodyValue === "string") {
478+ return HttpClientRequest.bodyText(request, bodyValue, contentType);
479+ }
480+ // Unknown shape — serialize as JSON so at least the payload is visible.
481+ return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType);
482+ }
483+
484+ if (isXmlContentType(contentType) || isTextContentType(contentType)) {
485+ if (typeof bodyValue === "string") {
486+ return HttpClientRequest.bodyText(request, bodyValue, contentType);
487+ }
488+ const bytes = toUint8Array(bodyValue);
489+ if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType);
490+ // Object body under text/xml is unusual — stringify so the caller sees
491+ // their own payload instead of `[object Object]`.
492+ return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType);
493+ }
494+
495+ // Unknown content type: respect what the caller supplied.
496+ if (typeof bodyValue === "string") {
497+ return HttpClientRequest.bodyText(request, bodyValue, contentType);
498+ }
499+ const bytes = toUint8Array(bodyValue);
500+ if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType);
501+ return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType);
502+};
503+
178504 // ---------------------------------------------------------------------------
179505 // Public API — invoke a single operation
180506 // ---------------------------------------------------------------------------
@@ -218,23 +544,23 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 37 unmodified lines
218544 const rb = operation.requestBody.value;
219545 const bodyValue = args.body ?? args.input;
220546 if (bodyValue !== undefined) {
221− if (isJsonContentType(rb.contentType)) {
222− request = HttpClientRequest.bodyUnsafeJson(request, bodyValue);
223− } else if (typeof bodyValue === "string") {
224− request = HttpClientRequest.bodyText(request, bodyValue, rb.contentType);
225− } else if (isFormUrlEncoded(rb.contentType)) {
226− request = HttpClientRequest.bodyUrlParams(
227− request,
228− bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1],
229− );
230− } else if (isMultipartFormData(rb.contentType)) {
231− request = HttpClientRequest.bodyFormDataRecord(
232− request,
233− bodyValue as Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1],
234− );
235− } else {
236− request = HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), rb.contentType);
237− }
547+ // Resolve which declared media type to use. When the spec declares
548+ // multiple, the caller can override via `args.contentType`; otherwise
549+ // we use the first-declared (spec author's preferred ordering).
550+ const contentsOpt = Option.getOrUndefined(rb.contents);
551+ const requestedCt =
552+ typeof args.contentType === "string" ? args.contentType : undefined;
553+ const selected: MediaBinding | undefined =
554+ contentsOpt && requestedCt
555+ ? contentsOpt.find((c) => c.contentType === requestedCt)
556+ : undefined;
557+ const chosenCt = selected?.contentType ?? rb.contentType;
558+ const chosenEncoding = selected
559+ ? Option.getOrUndefined(selected.encoding)
560+ : contentsOpt && contentsOpt[0]
561+ ? Option.getOrUndefined(contentsOpt[0].encoding)
562+ : undefined;
563+ request = applyRequestBody(request, chosenCt, bodyValue, chosenEncoding);
238564 }
239565 }
240566
packages/plugins/openapi/src/sdk/non-json-body.test.ts+0−0

Binary file not shown.

packages/plugins/openapi/src/sdk/openapi-utils.ts+32−1
@@ -90,9 +90,40 @@ export const resolveBaseUrl = (servers: readonly ServerLike[]): string => { · 89 unmodified lines
9090 // Content negotiation
9191 // ---------------------------------------------------------------------------
9292
93−/** Pick the preferred media type entry (prefer application/json) */
93+/**
94+ * Return all declared media entries in spec order. `Object.entries` on a
95+ * plain object preserves insertion order in modern engines, which matches
96+ * spec declaration order as the parser produced it.
97+ */
98+export const declaredContents = (
99+ content: Record<string, MediaTypeObject> | undefined,
100+): ReadonlyArray<{ mediaType: string; media: MediaTypeObject }> => {
101+ if (!content) return [];
102+ return Object.entries(content).map(([mediaType, media]) => ({ mediaType, media }));
103+};
104+
105+/**
106+ * Pick the default media type for a requestBody or response. Matches
107+ * swagger-client behaviour: **first declared wins** (not JSON-first). Spec
108+ * authors order content entries to signal intent (upload-heavy endpoints
109+ * declare multipart first, JSON second); respecting that order avoids
110+ * silently downgrading a multipart endpoint to JSON.
111+ *
112+ * For response bodies we still want a JSON preference because the server
113+ * picks the response content type, not the client — the old `application/
114+ * json` preference is preserved via `preferredResponseContent` below.
115+ */
94116 export const preferredContent = (
95117 content: Record<string, MediaTypeObject> | undefined,
118+): { mediaType: string; media: MediaTypeObject } | undefined => {
119+ const first = declaredContents(content)[0];
120+ return first ? first : undefined;
121+};
122+
123+/** Response-side content picker — still JSON-first because the server
124+ * picks the response media type, so we want to advertise a preference. */
125+export const preferredResponseContent = (
126+ content: Record<string, MediaTypeObject> | undefined,
96127 ): { mediaType: string; media: MediaTypeObject } | undefined => {
97128 if (!content) return undefined;
98129 const entries = Object.entries(content);
packages/plugins/openapi/src/sdk/types.ts+33−0
@@ -41,12 +41,45 @@ export class OperationParameter extends Schema.Class<OperationParameter>("Operat · 40 unmodified lines
4141 description: Schema.optionalWith(Schema.String, { as: "Option" }),
4242 }) {}
4343
44+/**
45+ * OpenAPI 3.x `Encoding Object` (§4.8.15). Declared per-property inside a
46+ * multipart/form-data or application/x-www-form-urlencoded request body.
47+ *
48+ * - `contentType` — for multipart, overrides the per-part `Content-Type`
49+ * header (e.g. `application/json` for a JSON-encoded metadata part).
50+ * - `style` / `explode` / `allowReserved` — for form-urlencoded, control
51+ * array / object serialization the same way parameter-level style does.
52+ */
53+export class EncodingObject extends Schema.Class<EncodingObject>("EncodingObject")({
54+ contentType: Schema.optionalWith(Schema.String, { as: "Option" }),
55+ style: Schema.optionalWith(Schema.String, { as: "Option" }),
56+ explode: Schema.optionalWith(Schema.Boolean, { as: "Option" }),
57+ allowReserved: Schema.optionalWith(Schema.Boolean, { as: "Option" }),
58+}) {}
59+
60+export class MediaBinding extends Schema.Class<MediaBinding>("MediaBinding")({
61+ contentType: Schema.String,
62+ schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
63+ encoding: Schema.optionalWith(
64+ Schema.Record({ key: Schema.String, value: EncodingObject }),
65+ { as: "Option" },
66+ ),
67+}) {}
68+
4469 export class OperationRequestBody extends Schema.Class<OperationRequestBody>(
4570 "OperationRequestBody",
4671 )({
4772 required: Schema.Boolean,
73+ /** Default media type — first declared in spec order (not JSON-first).
74+ * Used when the caller does not override via the tool's `contentType` arg. */
4875 contentType: Schema.String,
76+ /** Schema of the default media type. Kept for backward compat with stored
77+ * bindings from before `contents` was added. */
4978 schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
79+ /** All declared media types in spec order. Populated by `extract.ts`
80+ * going forward; older persisted bindings may have this unset and will
81+ * fall back to `{contentType, schema}`. */
82+ contents: Schema.optionalWith(Schema.Array(MediaBinding), { as: "Option" }),
5083 }) {}
5184
5285 export class ExtractedOperation extends Schema.Class<ExtractedOperation>("ExtractedOperation")({