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 | import { OpenApiExtractionError } from "./errors"; | 3 | import { OpenApiExtractionError } from "./errors"; |
| 4 | import type { ParsedDocument } from "./parse"; | 4 | import type { ParsedDocument } from "./parse"; |
| 5 | import { | 5 | import { |
| 6 | declaredContents, | ||
| 6 | DocResolver, | 7 | DocResolver, |
| 7 | preferredContent, | 8 | preferredResponseContent, |
| 8 | type OperationObject, | 9 | type OperationObject, |
| 9 | type ParameterObject, | 10 | type ParameterObject, |
| 10 | type PathItemObject, | 11 | type PathItemObject, |
| @@ -12,9 +13,11 @@ import { · 1 unmodified line | |||
| 12 | type ResponseObject, | 13 | type ResponseObject, |
| 13 | } from "./openapi-utils"; | 14 | } from "./openapi-utils"; |
| 14 | import { | 15 | import { |
| 16 | EncodingObject, | ||
| 15 | ExtractedOperation, | 17 | ExtractedOperation, |
| 16 | ExtractionResult, | 18 | ExtractionResult, |
| 17 | type HttpMethod, | 19 | type HttpMethod, |
| 20 | MediaBinding, | ||
| 18 | OperationId, | 21 | OperationId, |
| 19 | OperationParameter, | 22 | OperationParameter, |
| 20 | OperationRequestBody, | 23 | OperationRequestBody, |
| @@ -83,6 +86,29 @@ const extractParameters = ( · 62 unmodified lines | |||
| 83 | // Request body extraction | 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 | const extractRequestBody = ( | 112 | const extractRequestBody = ( |
| 87 | operation: OperationObject, | 113 | operation: OperationObject, |
| 88 | r: DocResolver, | 114 | r: DocResolver, |
| @@ -92,13 +118,29 @@ const extractRequestBody = ( · 3 unmodified lines | |||
| 92 | const body = r.resolve<RequestBodyObject>(operation.requestBody); | 118 | const body = r.resolve<RequestBodyObject>(operation.requestBody); |
| 93 | if (!body) return undefined; | 119 | if (!body) return undefined; |
| 94 | 120 | ||
| 95 | const content = preferredContent(body.content); | 121 | const contents = declaredContents(body.content).map( |
| 96 | if (!content) return undefined; | 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 | return new OperationRequestBody({ | 139 | return new OperationRequestBody({ |
| 99 | required: body.required === true, | 140 | required: body.required === true, |
| 100 | contentType: content.mediaType, | 141 | contentType: defaultContent.contentType, |
| 101 | schema: Option.fromNullable(content.media.schema), | 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 | for (const [, ref] of preferred) { | 160 | for (const [, ref] of preferred) { |
| 119 | const resp = r.resolve<ResponseObject>(ref); | 161 | const resp = r.resolve<ResponseObject>(ref); |
| 120 | if (!resp) continue; | 162 | if (!resp) continue; |
| 121 | const content = preferredContent(resp.content); | 163 | const content = preferredResponseContent(resp.content); |
| 122 | if (content?.media.schema) return content.media.schema; | 164 | if (content?.media.schema) return content.media.schema; |
| 123 | } | 165 | } |
| 124 | 166 | ||
| @@ -144,6 +186,21 @@ const buildInputSchema = ( · 19 unmodified lines | |||
| 144 | if (requestBody) { | 186 | if (requestBody) { |
| 145 | properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" })); | 187 | properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" })); |
| 146 | if (requestBody.required) required.push("body"); | 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 | if (Object.keys(properties).length === 0) return undefined; | 206 | if (Object.keys(properties).length === 0) return undefined; |
| @@ -52,10 +52,12 @@ export { · 51 unmodified lines | |||
| 52 | } from "./errors"; | 52 | } from "./errors"; |
| 53 | 53 | ||
| 54 | export { | 54 | export { |
| 55 | EncodingObject, | ||
| 55 | ExtractedOperation, | 56 | ExtractedOperation, |
| 56 | ExtractionResult, | 57 | ExtractionResult, |
| 57 | InvocationConfig, | 58 | InvocationConfig, |
| 58 | InvocationResult, | 59 | InvocationResult, |
| 60 | MediaBinding, | ||
| 59 | OAuth2Auth, | 61 | OAuth2Auth, |
| 60 | OpenApiOAuthSession, | 62 | OpenApiOAuthSession, |
| 61 | OperationBinding, | 63 | OperationBinding, |
| @@ -5,9 +5,11 @@ import type { StorageFailure } from "@executor/sdk"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { OpenApiInvocationError } from "./errors"; | 6 | import { OpenApiInvocationError } from "./errors"; |
| 7 | import { | 7 | import { |
| 8 | type EncodingObject, | ||
| 8 | type HeaderValue, | 9 | type HeaderValue, |
| 9 | type OperationBinding, | 10 | type OperationBinding, |
| 10 | InvocationResult, | 11 | InvocationResult, |
| 12 | type MediaBinding, | ||
| 11 | type OperationParameter, | 13 | type OperationParameter, |
| 12 | } from "./types"; | 14 | } from "./types"; |
| 13 | 15 | ||
| @@ -207,13 +209,170 @@ const toUint8Array = (value: unknown): Uint8Array | null => { · 193 unmodified lines | |||
| 207 | type FormDataRecord = Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1]; | 209 | type FormDataRecord = Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1]; |
| 208 | type FormDataCoercible = FormDataRecord[string]; | 210 | type FormDataCoercible = FormDataRecord[string]; |
| 209 | 211 | ||
| 210 | // Best-effort build of a FormData entry record: most primitives pass through, | 212 | // Pull a plain ArrayBuffer out of a Uint8Array — `new Blob([u8])` rejects |
| 211 | // plain objects get JSON-stringified so a server receives `{...}` instead of | 213 | // views whose `.buffer` is `SharedArrayBuffer | ArrayBuffer` under strict |
| 212 | // `[object Object]`. File/Blob are handled natively by bodyFormDataRecord. | 214 | // lib.dom typings. |
| 213 | const coerceFormDataRecord = (value: Record<string, unknown>): FormDataRecord => { | 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 | const out: Record<string, FormDataCoercible> = {}; | 350 | const out: Record<string, FormDataCoercible> = {}; |
| 215 | for (const [key, raw] of Object.entries(value)) { | 351 | for (const [key, raw] of Object.entries(value)) { |
| 216 | if (raw === undefined || raw === null) continue; | 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 | if ( | 376 | if ( |
| 218 | typeof raw === "string" || | 377 | typeof raw === "string" || |
| 219 | typeof raw === "number" || | 378 | typeof raw === "number" || |
| @@ -246,15 +405,6 @@ const coerceFormDataRecord = (value: Record<string, unknown>): FormDataRecord => · 26 unmodified lines | |||
| 246 | return out; | 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 | // Request body dispatch | 409 | // Request body dispatch |
| 260 | // | 410 | // |
| @@ -274,6 +424,7 @@ const applyRequestBody = ( · 13 unmodified lines | |||
| 274 | request: HttpClientRequest.HttpClientRequest, | 424 | request: HttpClientRequest.HttpClientRequest, |
| 275 | contentType: string, | 425 | contentType: string, |
| 276 | bodyValue: unknown, | 426 | bodyValue: unknown, |
| 427 | encoding: Record<string, EncodingObject> | undefined, | ||
| 277 | ): HttpClientRequest.HttpClientRequest => { | 428 | ): HttpClientRequest.HttpClientRequest => { |
| 278 | if (isJsonContentType(contentType)) { | 429 | if (isJsonContentType(contentType)) { |
| 279 | // Pre-serialized JSON strings pass through with the declared media | 430 | // Pre-serialized JSON strings pass through with the declared media |
| @@ -288,6 +439,16 @@ const applyRequestBody = ( · 8 unmodified lines | |||
| 288 | if (typeof bodyValue === "string") { | 439 | if (typeof bodyValue === "string") { |
| 289 | return HttpClientRequest.bodyText(request, bodyValue, contentType); | 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 | return HttpClientRequest.bodyUrlParams( | 452 | return HttpClientRequest.bodyUrlParams( |
| 292 | request, | 453 | request, |
| 293 | bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1], | 454 | bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1], |
| @@ -301,7 +462,7 @@ const applyRequestBody = ( · 7 unmodified lines | |||
| 301 | if (typeof bodyValue === "object" && bodyValue !== null) { | 462 | if (typeof bodyValue === "object" && bodyValue !== null) { |
| 302 | return HttpClientRequest.bodyFormDataRecord( | 463 | return HttpClientRequest.bodyFormDataRecord( |
| 303 | request, | 464 | request, |
| 304 | coerceFormDataRecord(bodyValue as Record<string, unknown>), | 465 | coerceFormDataRecord(bodyValue as Record<string, unknown>, encoding), |
| 305 | ); | 466 | ); |
| 306 | } | 467 | } |
| 307 | // String / primitive under multipart is almost certainly wrong on the | 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 | const rb = operation.requestBody.value; | 544 | const rb = operation.requestBody.value; |
| 384 | const bodyValue = args.body ?? args.input; | 545 | const bodyValue = args.body ?? args.input; |
| 385 | if (bodyValue !== undefined) { | 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 | // Content negotiation | 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 | export const preferredContent = ( | 116 | export const preferredContent = ( |
| 95 | content: Record<string, MediaTypeObject> | undefined, | 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 | ): { mediaType: string; media: MediaTypeObject } | undefined => { | 127 | ): { mediaType: string; media: MediaTypeObject } | undefined => { |
| 97 | if (!content) return undefined; | 128 | if (!content) return undefined; |
| 98 | const entries = Object.entries(content); | 129 | const entries = Object.entries(content); |
| @@ -41,12 +41,45 @@ export class OperationParameter extends Schema.Class<OperationParameter>("Operat · 40 unmodified lines | |||
| 41 | description: Schema.optionalWith(Schema.String, { as: "Option" }), | 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 | export class OperationRequestBody extends Schema.Class<OperationRequestBody>( | 69 | export class OperationRequestBody extends Schema.Class<OperationRequestBody>( |
| 45 | "OperationRequestBody", | 70 | "OperationRequestBody", |
| 46 | )({ | 71 | )({ |
| 47 | required: Schema.Boolean, | 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 | contentType: Schema.String, | 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 | schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }), | 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 | export class ExtractedOperation extends Schema.Class<ExtractedOperation>("ExtractedOperation")({ | 85 | export class ExtractedOperation extends Schema.Class<ExtractedOperation>("ExtractedOperation")({ |