Closes the spec-compliance gaps I called out when auditing the earlier invoke-body dispatch against swagger-client's implementation. 1) Multi-content requestBody. Specs that declare more than one media type per operation no longer silently collapse to JSON-first. `preferredContent` now follows swagger-client's convention of first- declared-wins (spec author's preferred order). When the spec declares more than one, the tool's input schema exposes a `contentType` enum so the model can pick at invoke time; the default is still the first-declared. 2) Encoding object (§4.8.15) is now extracted and threaded through invoke. `MediaBinding` carries per-property encoding; the new `OperationRequestBody.contents` preserves every declared media type with its own schema + encoding. 3) Multipart `encoding.contentType` honored. Parts that declare a per-part content type (common case: JSON metadata alongside a binary file) now ship wrapped as a typed `Blob`, so the runtime framer emits the per-part `Content-Type` header. JSON types JSON-stringify the value; other types stringify then tag. 4) Form-urlencoded style/explode/allowReserved honored per-field. `style: form, explode: true` (default) repeats the key for arrays; `explode: false` comma-joins (or pipe / space per `pipeDelimited` / `spaceDelimited`). `style: deepObject` emits `field[subkey]=value` with the brackets URL-encoded — matches swagger-client's wire format, accepted by qs / Rails / most server parsers. Response-side content negotiation kept its JSON-first preference via the new `preferredResponseContent`. Request side is the one that should follow spec-author intent. Tests (`non-json-body.test.ts`): multi-content default + caller override + input-schema surface; multipart JSON-encoded metadata part with typed header; form-urlencoded `explode: false`, `deepObject`, and the `form + explode: true` default. 49/49 passing (37 prior + 12 new).
Unified · Split
| @@ -3,8 +3,9 @@ import { Effect, Option } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { OpenApiExtractionError } from "./errors"; |
| 4 | 4 | import type { ParsedDocument } from "./parse"; |
| 5 | 5 | import { |
| 6 | + declaredContents, | |
| 6 | 7 | DocResolver, |
| 7 | − preferredContent, | |
| 8 | + preferredResponseContent, | |
| 8 | 9 | type OperationObject, |
| 9 | 10 | type ParameterObject, |
| 10 | 11 | type PathItemObject, |
| @@ -12,9 +13,11 @@ import { · 1 unmodified line | ||
| 12 | 13 | type ResponseObject, |
| 13 | 14 | } from "./openapi-utils"; |
| 14 | 15 | import { |
| 16 | + EncodingObject, | |
| 15 | 17 | ExtractedOperation, |
| 16 | 18 | ExtractionResult, |
| 17 | 19 | type HttpMethod, |
| 20 | + MediaBinding, | |
| 18 | 21 | OperationId, |
| 19 | 22 | OperationParameter, |
| 20 | 23 | OperationRequestBody, |
| @@ -83,6 +86,29 @@ const extractParameters = ( · 62 unmodified lines | ||
| 83 | 86 | // Request body extraction |
| 84 | 87 | // --------------------------------------------------------------------------- |
| 85 | 88 | |
| 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 | + | |
| 86 | 112 | const extractRequestBody = ( |
| 87 | 113 | operation: OperationObject, |
| 88 | 114 | r: DocResolver, |
| @@ -92,13 +118,29 @@ const extractRequestBody = ( · 3 unmodified lines | ||
| 92 | 118 | const body = r.resolve<RequestBodyObject>(operation.requestBody); |
| 93 | 119 | if (!body) return undefined; |
| 94 | 120 | |
| 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]!; | |
| 97 | 138 | |
| 98 | 139 | return new OperationRequestBody({ |
| 99 | 140 | 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), | |
| 102 | 144 | }); |
| 103 | 145 | }; |
| 104 | 146 | |
| @@ -118,7 +160,7 @@ const extractOutputSchema = (operation: OperationObject, r: DocResolver): unknow · 13 unmodified lines | ||
| 118 | 160 | for (const [, ref] of preferred) { |
| 119 | 161 | const resp = r.resolve<ResponseObject>(ref); |
| 120 | 162 | if (!resp) continue; |
| 121 | − const content = preferredContent(resp.content); | |
| 163 | + const content = preferredResponseContent(resp.content); | |
| 122 | 164 | if (content?.media.schema) return content.media.schema; |
| 123 | 165 | } |
| 124 | 166 | |
| @@ -144,6 +186,21 @@ const buildInputSchema = ( · 19 unmodified lines | ||
| 144 | 186 | if (requestBody) { |
| 145 | 187 | properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" })); |
| 146 | 188 | 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 | + } | |
| 147 | 204 | } |
| 148 | 205 | |
| 149 | 206 | if (Object.keys(properties).length === 0) return undefined; |
| @@ -52,10 +52,12 @@ export { · 51 unmodified lines | ||
| 52 | 52 | } from "./errors"; |
| 53 | 53 | |
| 54 | 54 | export { |
| 55 | + EncodingObject, | |
| 55 | 56 | ExtractedOperation, |
| 56 | 57 | ExtractionResult, |
| 57 | 58 | InvocationConfig, |
| 58 | 59 | InvocationResult, |
| 60 | + MediaBinding, | |
| 59 | 61 | OAuth2Auth, |
| 60 | 62 | OpenApiOAuthSession, |
| 61 | 63 | OperationBinding, |
| @@ -5,9 +5,11 @@ import type { StorageFailure } from "@executor/sdk"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import { OpenApiInvocationError } from "./errors"; |
| 7 | 7 | import { |
| 8 | + type EncodingObject, | |
| 8 | 9 | type HeaderValue, |
| 9 | 10 | type OperationBinding, |
| 10 | 11 | InvocationResult, |
| 12 | + type MediaBinding, | |
| 11 | 13 | type OperationParameter, |
| 12 | 14 | } from "./types"; |
| 13 | 15 | |
| @@ -207,13 +209,170 @@ const toUint8Array = (value: unknown): Uint8Array | null => { · 193 unmodified lines | ||
| 207 | 209 | type FormDataRecord = Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1]; |
| 208 | 210 | type FormDataCoercible = FormDataRecord[string]; |
| 209 | 211 | |
| 210 | −// Best-effort build of a FormData entry record: most primitives pass through, | |
| 211 | −// plain objects get JSON-stringified so a server receives `{...}` instead of | |
| 212 | −// `[object Object]`. File/Blob are handled natively by bodyFormDataRecord. | |
| 213 | −const coerceFormDataRecord = (value: Record<string, unknown>): FormDataRecord => { | |
| 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 => { | |
| 214 | 350 | const out: Record<string, FormDataCoercible> = {}; |
| 215 | 351 | for (const [key, raw] of Object.entries(value)) { |
| 216 | 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 | + | |
| 217 | 376 | if ( |
| 218 | 377 | typeof raw === "string" || |
| 219 | 378 | typeof raw === "number" || |
| @@ -246,15 +405,6 @@ const coerceFormDataRecord = (value: Record<string, unknown>): FormDataRecord => · 26 unmodified lines | ||
| 246 | 405 | return out; |
| 247 | 406 | }; |
| 248 | 407 | |
| 249 | −// Pull a plain ArrayBuffer out of a Uint8Array — `new Blob([u8])` rejects | |
| 250 | −// views whose `.buffer` is `SharedArrayBuffer | ArrayBuffer` under strict | |
| 251 | −// lib.dom typings. | |
| 252 | −const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { | |
| 253 | − const copy = new ArrayBuffer(bytes.byteLength); | |
| 254 | − new Uint8Array(copy).set(bytes); | |
| 255 | − return copy; | |
| 256 | −}; | |
| 257 | − | |
| 258 | 408 | // --------------------------------------------------------------------------- |
| 259 | 409 | // Request body dispatch |
| 260 | 410 | // |
| @@ -274,6 +424,7 @@ const applyRequestBody = ( · 13 unmodified lines | ||
| 274 | 424 | request: HttpClientRequest.HttpClientRequest, |
| 275 | 425 | contentType: string, |
| 276 | 426 | bodyValue: unknown, |
| 427 | + encoding: Record<string, EncodingObject> | undefined, | |
| 277 | 428 | ): HttpClientRequest.HttpClientRequest => { |
| 278 | 429 | if (isJsonContentType(contentType)) { |
| 279 | 430 | // Pre-serialized JSON strings pass through with the declared media |
| @@ -288,6 +439,16 @@ const applyRequestBody = ( · 8 unmodified lines | ||
| 288 | 439 | if (typeof bodyValue === "string") { |
| 289 | 440 | return HttpClientRequest.bodyText(request, bodyValue, contentType); |
| 290 | 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). | |
| 291 | 452 | return HttpClientRequest.bodyUrlParams( |
| 292 | 453 | request, |
| 293 | 454 | bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1], |
| @@ -301,7 +462,7 @@ const applyRequestBody = ( · 7 unmodified lines | ||
| 301 | 462 | if (typeof bodyValue === "object" && bodyValue !== null) { |
| 302 | 463 | return HttpClientRequest.bodyFormDataRecord( |
| 303 | 464 | request, |
| 304 | − coerceFormDataRecord(bodyValue as Record<string, unknown>), | |
| 465 | + coerceFormDataRecord(bodyValue as Record<string, unknown>, encoding), | |
| 305 | 466 | ); |
| 306 | 467 | } |
| 307 | 468 | // String / primitive under multipart is almost certainly wrong on the |
| @@ -383,7 +544,23 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 75 unmodified lines | ||
| 383 | 544 | const rb = operation.requestBody.value; |
| 384 | 545 | const bodyValue = args.body ?? args.input; |
| 385 | 546 | if (bodyValue !== undefined) { |
| 386 | − request = applyRequestBody(request, rb.contentType, bodyValue); | |
| 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); | |
| 387 | 564 | } |
| 388 | 565 | } |
| 389 | 566 | |
Binary file not shown.
| @@ -90,9 +90,40 @@ export const resolveBaseUrl = (servers: readonly ServerLike[]): string => { · 89 unmodified lines | ||
| 90 | 90 | // Content negotiation |
| 91 | 91 | // --------------------------------------------------------------------------- |
| 92 | 92 | |
| 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 | + */ | |
| 94 | 116 | export const preferredContent = ( |
| 95 | 117 | 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, | |
| 96 | 127 | ): { mediaType: string; media: MediaTypeObject } | undefined => { |
| 97 | 128 | if (!content) return undefined; |
| 98 | 129 | const entries = Object.entries(content); |
| @@ -41,12 +41,45 @@ export class OperationParameter extends Schema.Class<OperationParameter>("Operat · 40 unmodified lines | ||
| 41 | 41 | description: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 42 | 42 | }) {} |
| 43 | 43 | |
| 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 | + | |
| 44 | 69 | export class OperationRequestBody extends Schema.Class<OperationRequestBody>( |
| 45 | 70 | "OperationRequestBody", |
| 46 | 71 | )({ |
| 47 | 72 | 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. */ | |
| 48 | 75 | contentType: Schema.String, |
| 76 | + /** Schema of the default media type. Kept for backward compat with stored | |
| 77 | + * bindings from before `contents` was added. */ | |
| 49 | 78 | 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" }), | |
| 50 | 83 | }) {} |
| 51 | 84 | |
| 52 | 85 | export class ExtractedOperation extends Schema.Class<ExtractedOperation>("ExtractedOperation")({ |