diff --git a/.changeset/openapi-multipart-file-fields.md b/.changeset/openapi-multipart-file-fields.md new file mode 100644 index 0000000000..815277fad4 --- /dev/null +++ b/.changeset/openapi-multipart-file-fields.md @@ -0,0 +1,12 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Multipart file fields in an OpenAPI spec now accept and send real files. A `multipart/form-data` property typed as a binary or byte string is rewritten into the SDK's tool-file schema when the tool is extracted, so an agent supplies a file the same way it does everywhere else. On invocation those values are decoded back into `File`/`Blob` parts — as bare properties and inside arrays, with a per-property `encoding.contentType` applied to each file part — instead of being JSON-stringified into the form body, which is what upstreams were previously rejecting. A file whose base64 payload does not decode now fails the invocation and names the field, rather than sending the file envelope as JSON. + +The rewrite advertises only the shapes the request encoder can deliver. Two are deliberately left alone: + +- A binary field nested inside an object property. Only top-level multipart properties and direct items of a top-level array property become form parts. +- A multipart body schema, or one of its properties, behind a `$ref`. Component schemas are carried through unresolved by design — the streaming compile path never materializes `components.schemas` — so a `$ref`'d file field keeps its declared binary string type. + +The rewrite reads the request schema's own `properties` map rather than walking every object key, so a `default`, `example`, or vendor extension that happens to look like a binary string schema is untouched. Descriptions, titles, and nullability on the replaced field are carried onto the file schema. diff --git a/packages/plugins/openapi/src/sdk/extract.ts b/packages/plugins/openapi/src/sdk/extract.ts index 77a2202607..c242160a18 100644 --- a/packages/plugins/openapi/src/sdk/extract.ts +++ b/packages/plugins/openapi/src/sdk/extract.ts @@ -1,4 +1,5 @@ import { Effect, Option } from "effect"; +import { ToolFileJsonSchema } from "@executor-js/sdk/core"; import { planToolPaths, type OperationPathInput, type PlannedToolPath } from "./definitions"; import { OpenApiExtractionError } from "./errors"; @@ -135,7 +136,7 @@ const extractRequestBody = ( const contents = declaredContents(body.content).map(({ mediaType, media }) => MediaBinding.make({ contentType: mediaType, - schema: Option.fromNullishOr(media.schema), + schema: Option.fromNullishOr(multipartFileInputSchema(media.schema, mediaType)), encoding: Option.fromNullishOr( buildEncodingRecord((media as { encoding?: Record }).encoding), ), @@ -184,6 +185,81 @@ const isJsonMediaType = (mediaType: string): boolean => { const binaryStringSchema = (schema: Record): boolean => stringType(schema) && (schema.format === "binary" || schema.format === "byte"); +const arrayType = (schema: Record): boolean => + schema.type === "array" || (Array.isArray(schema.type) && schema.type.includes("array")); + +const nullableType = (schema: Record): boolean => + Array.isArray(schema.type) && schema.type.includes("null"); + +const isMultipartMediaType = (mediaType: string): boolean => + normalizedMediaType(mediaType) === "multipart/form-data"; + +/** + * Replace one binary/byte string node with the tool-file schema, carrying the + * spec author's own annotations across. A `type: ["string", "null"]` node stays + * nullable as `anyOf: [, { type: "null" }]`, since the tool-file schema is + * an object and cannot express null through a type array. + */ +const toolFileSchemaFor = (node: Record): Record => { + const annotations: Record = {}; + if (typeof node.title === "string") annotations.title = node.title; + if (typeof node.description === "string") annotations.description = node.description; + + return nullableType(node) + ? { anyOf: [ToolFileJsonSchema, { type: "null" }], ...annotations } + : { ...(ToolFileJsonSchema as Record), ...annotations }; +}; + +/** + * Rewrite one multipart property. Deliberately limited to the two shapes the + * invoke-side form encoder can actually deliver: a property that IS a binary + * string, and an array property whose direct items are binary strings. + */ +const multipartFileProperty = (property: unknown): unknown => { + if (!isRecord(property)) return property; + if (binaryStringSchema(property)) return toolFileSchemaFor(property); + + const items = property.items; + if (arrayType(property) && isRecord(items) && binaryStringSchema(items)) { + return { ...property, items: toolFileSchemaFor(items) }; + } + + return property; +}; + +/** + * Advertise `multipart/form-data` binary fields as tool files. + * + * The rewrite is scoped to the request schema's own `properties` map — never a + * blind walk of every object key — so `default`, `example`, and vendor + * extensions that happen to look like a binary string schema are left alone. + * + * Two shapes are NOT rewritten, because the invoke-side encoder cannot honor + * them and advertising an input it would silently JSON-stringify is worse than + * not advertising it at all: + * - binary fields nested inside an object property (only top-level properties + * and direct array items become form parts); + * - a body schema, or a property, behind a `$ref`. Component schemas are + * carried through unresolved by design — the streaming compile path never + * materializes `components.schemas` — so there is nothing to inspect here. + */ +const multipartFileInputSchema = (schema: unknown, mediaType: string): unknown => { + if (!isMultipartMediaType(mediaType) || !isRecord(schema)) return schema; + + const properties = schema.properties; + if (!isRecord(properties)) return schema; + + let changed = false; + const out: Record = {}; + for (const [name, property] of Object.entries(properties)) { + const next = multipartFileProperty(property); + if (next !== property) changed = true; + out[name] = next; + } + + return changed ? { ...schema, properties: out } : schema; +}; + const base64EncodingFromDescription = (schema: Record): "base64" | "base64url" => typeof schema.description === "string" && /base64url|base64-url|url[- ]safe/i.test(schema.description) diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 57e21b1f30..148b4a052a 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -1,6 +1,6 @@ import { Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import type { ToolFileValue } from "@executor-js/sdk/core"; +import { isToolFile, type ToolFileValue } from "@executor-js/sdk/core"; import { OpenApiInvocationError } from "./errors"; import { isNdjsonMediaType, NDJSON_MEDIA_TYPES, resolveServerUrl } from "./openapi-utils"; @@ -588,6 +588,21 @@ const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { return copy; }; +const formPartFromToolFile = ( + file: ToolFileValue, + contentTypeOverride?: string, +): Blob | File | null => { + const bytes = base64ToUint8Array(file.data); + if (!bytes) return null; + + const type = contentTypeOverride ?? file.mimeType; + const body = toArrayBuffer(bytes); + if (typeof File !== "undefined") { + return new File([body], file.name ?? "file", { type }); + } + return new Blob([body], { type }); +}; + // --------------------------------------------------------------------------- // OpenAPI 3.x encoding — per-property style/explode/allowReserved/contentType // for multipart/form-data and application/x-www-form-urlencoded bodies. @@ -686,13 +701,31 @@ const serializeFormUrlEncoded = ( return parts.join("&"); }; +const isFormDataPrimitive = (value: unknown): boolean => + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value instanceof Blob || + (typeof File !== "undefined" && value instanceof File); + +type FormDataCoercion = + | { readonly ok: true; readonly record: FormDataRecord } + // The named field carried a tool file whose base64 payload does not decode. + | { readonly ok: false; readonly field: string }; + /** * Best-effort build of a multipart FormData entry record. * - * If `encoding[key].contentType` is declared (OAS3 §4.8.15), wrap the value - * in a `Blob` with that type so the runtime multipart framer emits the - * per-part `Content-Type` header (e.g. `application/json` for a metadata - * part whose server expects parsed JSON). + * Tool files come first: a file value — bare, or as an item of an array + * property — becomes a real file part, with `encoding[key].contentType` + * applied as the per-part content type override. A file whose base64 payload + * does not decode fails the whole coercion rather than silently degrading to + * a JSON string the upstream cannot use. + * + * If `encoding[key].contentType` is declared (OAS3 §4.8.15) for a non-file + * value, wrap it in a `Blob` with that type so the runtime multipart framer + * emits the per-part `Content-Type` header (e.g. `application/json` for a + * metadata part whose server expects parsed JSON). * * Otherwise: primitives pass through, arrays handle their item types, byte * shapes wrap as Blob, nested objects JSON-stringify (never `[object Object]`). @@ -700,7 +733,7 @@ const serializeFormUrlEncoded = ( const coerceFormDataRecord = ( value: Record, encoding: Record | undefined, -): FormDataRecord => { +): FormDataCoercion => { const out: Record = {}; for (const [key, raw] of Object.entries(value)) { if (raw === undefined || raw === null) continue; @@ -709,6 +742,31 @@ const coerceFormDataRecord = ( ? Option.getOrUndefined(encoding[key]!.contentType) : undefined; + if (isToolFile(raw)) { + const filePart = formPartFromToolFile(raw, partType); + if (!filePart) return { ok: false, field: key }; + out[key] = filePart; + continue; + } + + // Files inside an array are matched BEFORE the per-part content type + // branch below: for a file array, `encoding[key].contentType` describes + // each file part, not a JSON serialization of the whole array. + if (Array.isArray(raw) && raw.some(isToolFile)) { + const parts: FormDataCoercible[] = []; + for (const item of raw) { + if (isToolFile(item)) { + const filePart = formPartFromToolFile(item, partType); + if (!filePart) return { ok: false, field: key }; + parts.push(filePart); + continue; + } + parts.push(isFormDataPrimitive(item) ? (item as FormDataCoercible) : JSON.stringify(item)); + } + out[key] = parts as FormDataCoercible; + continue; + } + // Explicit per-part content type: wrap in a typed Blob so the framer // emits `Content-Type: ` on this part. JSON types get the // value JSON-stringified first so the blob body is valid JSON. @@ -726,25 +784,14 @@ const coerceFormDataRecord = ( continue; } - if ( - typeof raw === "string" || - typeof raw === "number" || - typeof raw === "boolean" || - raw instanceof Blob || - (typeof File !== "undefined" && raw instanceof File) - ) { + if (isFormDataPrimitive(raw)) { out[key] = raw as FormDataCoercible; continue; } if (Array.isArray(raw)) { + // No tool files here — that array shape returned above. out[key] = raw.map((v) => - typeof v === "string" || - typeof v === "number" || - typeof v === "boolean" || - v instanceof Blob || - (typeof File !== "undefined" && v instanceof File) - ? (v as FormDataCoercible) - : JSON.stringify(v), + isFormDataPrimitive(v) ? (v as FormDataCoercible) : JSON.stringify(v), ) as FormDataCoercible; continue; } @@ -755,7 +802,7 @@ const coerceFormDataRecord = ( } out[key] = JSON.stringify(raw); } - return out; + return { ok: true, record: out }; }; // --------------------------------------------------------------------------- @@ -773,82 +820,94 @@ const coerceFormDataRecord = ( // — never `String(body)` (which produces the useless `[object Object]`). // --------------------------------------------------------------------------- +type AppliedRequestBody = + | { readonly ok: true; readonly request: HttpClientRequest.HttpClientRequest } + // Only the multipart branch can reject a body: a tool file whose base64 + // payload does not decode names the offending field here. + | { readonly ok: false; readonly invalidFileField: string }; + const applyRequestBody = ( request: HttpClientRequest.HttpClientRequest, contentType: string, bodyValue: unknown, encoding: Record | undefined, -): HttpClientRequest.HttpClientRequest => { +): AppliedRequestBody => { + const sent = (req: HttpClientRequest.HttpClientRequest): AppliedRequestBody => ({ + ok: true, + request: req, + }); + if (isJsonContentType(contentType)) { // Pre-serialized JSON strings pass through with the declared media // type preserved (important for `application/vnd.foo+json` etc.). if (typeof bodyValue === "string") { - return HttpClientRequest.bodyText(request, bodyValue, contentType); + return sent(HttpClientRequest.bodyText(request, bodyValue, contentType)); } - return HttpClientRequest.bodyJsonUnsafe(request, bodyValue); + return sent(HttpClientRequest.bodyJsonUnsafe(request, bodyValue)); } if (isFormUrlEncoded(contentType)) { if (typeof bodyValue === "string") { - return HttpClientRequest.bodyText(request, bodyValue, contentType); + return sent(HttpClientRequest.bodyText(request, bodyValue, contentType)); } if (typeof bodyValue === "object" && bodyValue !== null && !Array.isArray(bodyValue)) { // Serialize ourselves so OAS3 encoding (style/explode/deepObject) // is honored. bodyUrlParams doesn't know about per-field style. const serialized = serializeFormUrlEncoded(bodyValue as Record, encoding); - return HttpClientRequest.bodyText(request, serialized, contentType); + return sent(HttpClientRequest.bodyText(request, serialized, contentType)); } // Non-object body — fall back to platform helper (handles URLSearchParams). - return HttpClientRequest.bodyUrlParams( - request, - bodyValue as Parameters[1], + return sent( + HttpClientRequest.bodyUrlParams( + request, + bodyValue as Parameters[1], + ), ); } if (isMultipartFormData(contentType)) { if (bodyValue instanceof FormData) { - return HttpClientRequest.bodyFormData(request, bodyValue); + return sent(HttpClientRequest.bodyFormData(request, bodyValue)); } if (typeof bodyValue === "object" && bodyValue !== null) { - return HttpClientRequest.bodyFormDataRecord( - request, - coerceFormDataRecord(bodyValue as Record, encoding), - ); + const coerced = coerceFormDataRecord(bodyValue as Record, encoding); + if (!coerced.ok) return { ok: false, invalidFileField: coerced.field }; + return sent(HttpClientRequest.bodyFormDataRecord(request, coerced.record)); } // String / primitive under multipart is almost certainly wrong on the // caller's end — send it as text with their declared content type and // let the server produce a useful error. - return HttpClientRequest.bodyText(request, String(bodyValue), contentType); + return sent(HttpClientRequest.bodyText(request, String(bodyValue), contentType)); } if (isOctetStream(contentType)) { const bytes = toUint8Array(bodyValue); - if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType); + if (bytes) return sent(HttpClientRequest.bodyUint8Array(request, bytes, contentType)); if (typeof bodyValue === "string") { - return HttpClientRequest.bodyText(request, bodyValue, contentType); + return sent(HttpClientRequest.bodyText(request, bodyValue, contentType)); } // Unknown shape — serialize as JSON so at least the payload is visible. - return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType); + return sent(HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType)); } if (isXmlContentType(contentType) || isTextContentType(contentType)) { if (typeof bodyValue === "string") { - return HttpClientRequest.bodyText(request, bodyValue, contentType); + return sent(HttpClientRequest.bodyText(request, bodyValue, contentType)); } const bytes = toUint8Array(bodyValue); - if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType); + if (bytes) return sent(HttpClientRequest.bodyUint8Array(request, bytes, contentType)); // Object body under text/xml is unusual — stringify so the caller sees // their own payload instead of `[object Object]`. - return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType); + return sent(HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType)); } // Unknown content type: respect what the caller supplied. if (typeof bodyValue === "string") { - return HttpClientRequest.bodyText(request, bodyValue, contentType); + return sent(HttpClientRequest.bodyText(request, bodyValue, contentType)); } const bytes = toUint8Array(bodyValue); - if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType); - return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType); + if (bytes) return sent(HttpClientRequest.bodyUint8Array(request, bytes, contentType)); + return sent(HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType)); }; // --------------------------------------------------------------------------- @@ -1068,7 +1127,14 @@ export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* ( : contentsOpt && contentsOpt[0] ? Option.getOrUndefined(contentsOpt[0].encoding) : undefined; - request = applyRequestBody(request, chosenCt, bodyValue, chosenEncoding); + const applied = applyRequestBody(request, chosenCt, bodyValue, chosenEncoding); + if (!applied.ok) { + return yield* new OpenApiInvocationError({ + message: `Request body field \`${applied.invalidFileField}\` is not a valid file: \`data\` is not valid base64`, + statusCode: Option.none(), + }); + } + request = applied.request; } } diff --git a/packages/plugins/openapi/src/sdk/non-json-body.test.ts b/packages/plugins/openapi/src/sdk/non-json-body.test.ts index 3d85b787b7..87deeaba98 100644 --- a/packages/plugins/openapi/src/sdk/non-json-body.test.ts +++ b/packages/plugins/openapi/src/sdk/non-json-body.test.ts @@ -14,7 +14,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Schema } from "effect"; +import { Cause, Effect, Exit, Schema } from "effect"; import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpApi, @@ -40,6 +40,14 @@ const JsonNameBody = Schema.fromJsonString( ); const decodeJsonNameBody = Schema.decodeUnknownSync(JsonNameBody); +const JsonAttachmentBody = Schema.fromJsonString( + Schema.Struct({ + attachment: Schema.String, + name: Schema.String, + }), +); +const decodeJsonAttachmentBody = Schema.decodeUnknownSync(JsonAttachmentBody); + const testPlugins = () => [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const; @@ -185,6 +193,361 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); + it.effect("multipart/form-data: binary file fields use ToolFile and real file parts", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + name: "upload", + path: "/upload", + payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), + transformSpec: replaceRequestBodyContent( + "/upload", + "post", + { + "multipart/form-data": { + schema: { + type: "object", + properties: { + document: { + type: "string", + format: "binary", + description: "PDF document to upload.", + }, + title: { type: "string" }, + }, + required: ["document"], + }, + }, + }, + { document: { contentType: "application/pdf" } }, + ), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" }); + + const schema = yield* executor.tools.schema(conn.address("body.upload")); + expect(schema?.inputSchema).toMatchObject({ + properties: { + body: { + properties: { + document: { + properties: { + _tag: { enum: ["ToolFile"] }, + data: { contentEncoding: "base64" }, + }, + }, + }, + }, + }, + }); + + const pdfBytes = Buffer.from("%PDF-1.4\nexecutor upload test\n"); + yield* executor.execute(conn.address("body.upload"), { + body: { + document: { + _tag: "ToolFile", + name: "invoice.pdf", + mimeType: "application/pdf", + encoding: "base64", + data: pdfBytes.toString("base64"), + byteLength: pdfBytes.byteLength, + }, + title: "Invoice", + }, + }); + + expect(captured.contentType).toMatch(/^multipart\/form-data; boundary=/); + const body = captured.body.toString("utf8"); + expect(body).toContain('name="document"; filename="invoice.pdf"'); + expect(body).toMatch( + /name="document"; filename="invoice\.pdf"[\s\S]*?Content-Type: application\/pdf/, + ); + expect(body).toContain("%PDF-1.4"); + expect(body).toContain('name="title"'); + expect(body).toContain("Invoice"); + expect(body).not.toContain("[object Object]"); + }), + ); + + it.effect("multipart/form-data: file arrays become file parts with the encoding type", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + name: "uploadPages", + path: "/upload-pages", + payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), + transformSpec: replaceRequestBodyContent( + "/upload-pages", + "post", + { + "multipart/form-data": { + schema: { + type: "object", + properties: { + pages: { + type: "array", + items: { type: "string", format: "binary" }, + }, + }, + required: ["pages"], + }, + }, + }, + // A per-part content type on a file array describes each file part, + // not a JSON serialization of the array. + { pages: { contentType: "image/png" } }, + ), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "pages" }); + + const schema = yield* executor.tools.schema(conn.address("body.uploadPages")); + expect(schema?.inputSchema).toMatchObject({ + properties: { + body: { + properties: { + pages: { + type: "array", + items: { + properties: { + _tag: { enum: ["ToolFile"] }, + data: { contentEncoding: "base64" }, + }, + }, + }, + }, + }, + }, + }); + + const first = Buffer.from("page-one-bytes"); + const second = Buffer.from("page-two-bytes"); + yield* executor.execute(conn.address("body.uploadPages"), { + body: { + pages: [ + { + _tag: "ToolFile", + name: "one.png", + mimeType: "application/octet-stream", + encoding: "base64", + data: first.toString("base64"), + byteLength: first.byteLength, + }, + { + _tag: "ToolFile", + name: "two.png", + mimeType: "application/octet-stream", + encoding: "base64", + data: second.toString("base64"), + byteLength: second.byteLength, + }, + ], + }, + }); + + expect(captured.contentType).toMatch(/^multipart\/form-data; boundary=/); + const body = captured.body.toString("utf8"); + expect(body).toContain('name="pages"; filename="one.png"'); + expect(body).toContain('name="pages"; filename="two.png"'); + // The encoding contentType overrides each file's own mime type. + expect(body.match(/Content-Type: image\/png/g)).toHaveLength(2); + expect(body).toContain("page-one-bytes"); + expect(body).toContain("page-two-bytes"); + // Regression guard for the branch-ordering bug: the array must not be + // JSON-stringified into a single part. + expect(body).not.toContain("_tag"); + expect(body).not.toContain(first.toString("base64")); + expect(body).not.toContain("[object Object]"); + }), + ); + + it.effect("multipart/form-data: a file whose base64 does not decode fails before dispatch", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + name: "uploadBroken", + path: "/upload-broken", + payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), + transformSpec: replaceRequestBodyContent("/upload-broken", "post", { + "multipart/form-data": { + schema: { + type: "object", + properties: { document: { type: "string", format: "binary" } }, + required: ["document"], + }, + }, + }), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "broken" }); + + const exit = yield* executor + .execute(conn.address("body.uploadBroken"), { + body: { + document: { + _tag: "ToolFile", + name: "broken.pdf", + mimeType: "application/pdf", + encoding: "base64", + data: "@@@@not base64@@@@", + byteLength: 12, + }, + }, + }) + .pipe(Effect.exit); + + // Never silently JSON.stringify the file envelope into the form body. + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : ""; + expect(failure).toContain("`document`"); + expect(captured.contentType).toBe(""); + expect(captured.body.length).toBe(0); + }), + ); + + it.effect("application/json: binary string properties are left untouched", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + name: "createNote", + path: "/notes", + payload: JsonNameObject, + transformSpec: replaceRequestBodyContent("/notes", "post", { + "application/json": { + schema: { + type: "object", + properties: { + attachment: { type: "string", format: "byte" }, + name: { type: "string" }, + }, + }, + }, + }), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes" }); + + // The rewrite is scoped to multipart bodies: a JSON body keeps its + // declared base64 string field. + const schema = yield* executor.tools.schema(conn.address("body.createNote")); + expect(schema?.inputSchema).toMatchObject({ + properties: { body: { properties: { attachment: { type: "string", format: "byte" } } } }, + }); + expect(JSON.stringify(schema?.inputSchema)).not.toContain("ToolFile"); + + yield* executor.execute(conn.address("body.createNote"), { + body: { attachment: "aGVsbG8=", name: "Acme" }, + }); + + expect(captured.contentType).toBe("application/json"); + expect(decodeJsonAttachmentBody(captured.body.toString("utf8"))).toEqual({ + attachment: "aGVsbG8=", + name: "Acme", + }); + }), + ); + + it.effect("multipart/form-data: only encoder-supported file shapes are advertised", () => + Effect.gen(function* () { + const { server } = yield* startEchoServer({ + name: "uploadMixed", + path: "/upload-mixed", + payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), + transformSpec: replaceRequestBodyContent("/upload-mixed", "post", { + "multipart/form-data": { + schema: { + type: "object", + properties: { + document: { + type: "string", + format: "binary", + title: "Document", + description: "PDF document to upload.", + }, + optionalDocument: { type: ["string", "null"], format: "binary" }, + // Nested files are a documented limitation: the form encoder + // only builds parts from top-level properties and direct + // array items, so this stays a plain binary string. + metadata: { + type: "object", + properties: { thumbnail: { type: "string", format: "binary" } }, + }, + // Scoping guard: a non-schema keyword that happens to look + // like a binary string schema is never rewritten. + title: { type: "string", default: { type: "string", format: "binary" } }, + }, + }, + }, + }), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mixed" }); + + const schema = yield* executor.tools.schema(conn.address("body.uploadMixed")); + expect(schema?.inputSchema).toMatchObject({ + properties: { + body: { + properties: { + // Annotations survive the rewrite. + document: { + title: "Document", + description: "PDF document to upload.", + properties: { _tag: { enum: ["ToolFile"] } }, + }, + // Nullability survives as an anyOf branch, since the file + // schema is an object and cannot carry a "null" type entry. + optionalDocument: { + anyOf: [{ properties: { _tag: { enum: ["ToolFile"] } } }, { type: "null" }], + }, + // Untouched: nested and non-schema positions stay verbatim. + metadata: { + type: "object", + properties: { thumbnail: { type: "string", format: "binary" } }, + }, + title: { type: "string", default: { type: "string", format: "binary" } }, + }, + }, + }, + }); + }), + ); + + it.effect("multipart/form-data: a $ref'd body schema is left unrewritten", () => + Effect.gen(function* () { + const { server } = yield* startEchoServer({ + name: "uploadRef", + path: "/upload-ref", + payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), + transformSpec: (spec) => { + const components = { ...((spec.components as Record) ?? {}) }; + components.schemas = { + ...((components.schemas as Record) ?? {}), + UploadForm: { + type: "object", + properties: { document: { type: "string", format: "binary" } }, + }, + }; + return replaceRequestBodyContent("/upload-ref", "post", { + "multipart/form-data": { schema: { $ref: "#/components/schemas/UploadForm" } }, + })({ ...spec, components }); + }, + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "reffed" }); + + // Documented limitation: component schemas are carried through + // unresolved, so a $ref'd multipart body reaches the tool schema as the + // reference itself and is never rewritten to a file input. + const schema = yield* executor.tools.schema(conn.address("body.uploadRef")); + expect(schema?.inputSchema).toMatchObject({ + properties: { body: { $ref: "#/$defs/UploadForm" } }, + }); + expect(JSON.stringify(schema?.inputSchema)).not.toContain("ToolFile"); + }), + ); + it.effect("application/xml: string body passes through with xml content-type", () => Effect.gen(function* () { const { server, captured } = yield* startEchoServer({