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 (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
3import { OpenApiExtractionError } from "./errors";3import { OpenApiExtractionError } from "./errors";
4import type { ParsedDocument } from "./parse";4import type { ParsedDocument } from "./parse";
5import {5import {
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";
14import {15import {
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 extraction86// Request body extraction
84// ---------------------------------------------------------------------------87// ---------------------------------------------------------------------------
85 88
89const 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
86const extractRequestBody = (112const 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;
packages/plugins/openapi/src/sdk/index.ts+2−0
@@ -52,10 +52,12 @@ export { · 51 unmodified lines
52} from "./errors";52} from "./errors";
53 53
54export {54export {
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,
packages/plugins/openapi/src/sdk/invoke.ts+343−17
@@ -5,9 +5,11 @@ import type { StorageFailure } from "@executor/sdk"; · 4 unmodified lines
5 5
6import { OpenApiInvocationError } from "./errors";6import { OpenApiInvocationError } from "./errors";
7import {7import {
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
@@ -175,6 +177,330 @@ const isFormUrlEncoded = (ct: string | null | undefined): boolean => · 161 unmodified lines
175const isMultipartFormData = (ct: string | null | undefined): boolean =>177const isMultipartFormData = (ct: string | null | undefined): boolean =>
176 normalizeContentType(ct).startsWith("multipart/form-data");178 normalizeContentType(ct).startsWith("multipart/form-data");
177 179
180const 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
190const isTextContentType = (ct: string | null | undefined): boolean =>
191 normalizeContentType(ct).startsWith("text/");
192
193const isOctetStream = (ct: string | null | undefined): boolean =>
194 normalizeContentType(ct) === "application/octet-stream";
195
196const 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
209type FormDataRecord = Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1];
210type 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.
215const 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
227type StyleExplode = {
228 readonly style: string;
229 readonly explode: boolean;
230 readonly allowReserved: boolean;
231};
232
233const DEFAULT_FORM_STYLE: StyleExplode = {
234 style: "form",
235 explode: true,
236 allowReserved: false,
237};
238
239const 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.
250const RESERVED_UNENCODED_RE = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/;
251
252const 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 */
268const 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 */
346const 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
423const 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
178// ---------------------------------------------------------------------------504// ---------------------------------------------------------------------------
179// Public API — invoke a single operation505// Public API — invoke a single operation
180// ---------------------------------------------------------------------------506// ---------------------------------------------------------------------------
@@ -218,23 +544,23 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 37 unmodified lines
218 const rb = operation.requestBody.value;544 const rb = operation.requestBody.value;
219 const bodyValue = args.body ?? args.input;545 const bodyValue = args.body ?? args.input;
220 if (bodyValue !== undefined) {546 if (bodyValue !== undefined) {
221 if (isJsonContentType(rb.contentType)) {547 // Resolve which declared media type to use. When the spec declares
222 request = HttpClientRequest.bodyUnsafeJson(request, bodyValue);548 // multiple, the caller can override via `args.contentType`; otherwise
223 } else if (typeof bodyValue === "string") {549 // we use the first-declared (spec author's preferred ordering).
224 request = HttpClientRequest.bodyText(request, bodyValue, rb.contentType);550 const contentsOpt = Option.getOrUndefined(rb.contents);
225 } else if (isFormUrlEncoded(rb.contentType)) {551 const requestedCt =
226 request = HttpClientRequest.bodyUrlParams(552 typeof args.contentType === "string" ? args.contentType : undefined;
227 request,553 const selected: MediaBinding | undefined =
228 bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1],554 contentsOpt && requestedCt
229 );555 ? contentsOpt.find((c) => c.contentType === requestedCt)
230 } else if (isMultipartFormData(rb.contentType)) {556 : undefined;
231 request = HttpClientRequest.bodyFormDataRecord(557 const chosenCt = selected?.contentType ?? rb.contentType;
232 request,558 const chosenEncoding = selected
233 bodyValue as Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1],559 ? Option.getOrUndefined(selected.encoding)
234 );560 : contentsOpt && contentsOpt[0]
235 } else {561 ? Option.getOrUndefined(contentsOpt[0].encoding)
236 request = HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), rb.contentType);562 : undefined;
237 }563 request = applyRequestBody(request, chosenCt, bodyValue, chosenEncoding);
238 }564 }
239 }565 }
240 566
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
90// Content negotiation90// 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 */
98export 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 */
94export const preferredContent = (116export 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. */
125export 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);
packages/plugins/openapi/src/sdk/types.ts+33−0
@@ -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 */
53export 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
60export 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
44export class OperationRequestBody extends Schema.Class<OperationRequestBody>(69export 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
52export class ExtractedOperation extends Schema.Class<ExtractedOperation>("ExtractedOperation")({85export class ExtractedOperation extends Schema.Class<ExtractedOperation>("ExtractedOperation")({