Skip to content

Commit d312a1d

Browse files
fix: OpenAPI multipart file field uploads
1 parent fd4fb02 commit d312a1d

3 files changed

Lines changed: 142 additions & 9 deletions

File tree

packages/plugins/openapi/src/sdk/extract.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Effect, Option } from "effect";
2+
import { ToolFileJsonSchema } from "@executor-js/sdk/core";
23

34
import { planToolPaths, type OperationPathInput, type PlannedToolPath } from "./definitions";
45
import { OpenApiExtractionError } from "./errors";
@@ -133,7 +134,7 @@ const extractRequestBody = (
133134
const contents = declaredContents(body.content).map(({ mediaType, media }) =>
134135
MediaBinding.make({
135136
contentType: mediaType,
136-
schema: Option.fromNullishOr(media.schema),
137+
schema: Option.fromNullishOr(multipartFileInputSchema(media.schema, mediaType)),
137138
encoding: Option.fromNullishOr(
138139
buildEncodingRecord((media as { encoding?: Record<string, unknown> }).encoding),
139140
),
@@ -182,6 +183,39 @@ const isJsonMediaType = (mediaType: string): boolean => {
182183
const binaryStringSchema = (schema: Record<string, unknown>): boolean =>
183184
stringType(schema) && (schema.format === "binary" || schema.format === "byte");
184185

186+
const isMultipartMediaType = (mediaType: string): boolean =>
187+
normalizedMediaType(mediaType) === "multipart/form-data";
188+
189+
const multipartFileInputSchema = (schema: unknown, mediaType: string): unknown => {
190+
if (!isMultipartMediaType(mediaType)) return schema;
191+
192+
const rewrite = (node: unknown): unknown => {
193+
if (Array.isArray(node)) {
194+
let changed = false;
195+
const out = node.map((item) => {
196+
const next = rewrite(item);
197+
if (next !== item) changed = true;
198+
return next;
199+
});
200+
return changed ? out : node;
201+
}
202+
203+
if (!isRecord(node)) return node;
204+
if (binaryStringSchema(node)) return ToolFileJsonSchema;
205+
206+
let changed = false;
207+
const out: Record<string, unknown> = {};
208+
for (const [key, value] of Object.entries(node)) {
209+
const next = rewrite(value);
210+
if (next !== value) changed = true;
211+
out[key] = next;
212+
}
213+
return changed ? out : node;
214+
};
215+
216+
return rewrite(schema);
217+
};
218+
185219
const base64EncodingFromDescription = (schema: Record<string, unknown>): "base64" | "base64url" =>
186220
typeof schema.description === "string" &&
187221
/base64url|base64-url|url[- ]safe/i.test(schema.description)

packages/plugins/openapi/src/sdk/invoke.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Effect, Layer, Option } from "effect";
22
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3-
import type { ToolFileValue } from "@executor-js/sdk/core";
3+
import { isToolFile, type ToolFileValue } from "@executor-js/sdk/core";
44

55
import { OpenApiInvocationError } from "./errors";
66
import { resolveServerUrl } from "./openapi-utils";
@@ -379,6 +379,21 @@ const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
379379
return copy;
380380
};
381381

382+
const formPartFromToolFile = (
383+
file: ToolFileValue,
384+
contentTypeOverride?: string,
385+
): Blob | File | null => {
386+
const bytes = base64ToUint8Array(file.data);
387+
if (!bytes) return null;
388+
389+
const type = contentTypeOverride ?? file.mimeType;
390+
const body = toArrayBuffer(bytes);
391+
if (typeof File !== "undefined") {
392+
return new File([body], file.name ?? "file", { type });
393+
}
394+
return new Blob([body], { type });
395+
};
396+
382397
// ---------------------------------------------------------------------------
383398
// OpenAPI 3.x encoding — per-property style/explode/allowReserved/contentType
384399
// for multipart/form-data and application/x-www-form-urlencoded bodies.
@@ -500,6 +515,12 @@ const coerceFormDataRecord = (
500515
? Option.getOrUndefined(encoding[key]!.contentType)
501516
: undefined;
502517

518+
if (isToolFile(raw)) {
519+
const filePart = formPartFromToolFile(raw, partType);
520+
out[key] = (filePart ?? JSON.stringify(raw)) as FormDataCoercible;
521+
continue;
522+
}
523+
503524
// Explicit per-part content type: wrap in a typed Blob so the framer
504525
// emits `Content-Type: <partType>` on this part. JSON types get the
505526
// value JSON-stringified first so the blob body is valid JSON.
@@ -529,13 +550,15 @@ const coerceFormDataRecord = (
529550
}
530551
if (Array.isArray(raw)) {
531552
out[key] = raw.map((v) =>
532-
typeof v === "string" ||
533-
typeof v === "number" ||
534-
typeof v === "boolean" ||
535-
v instanceof Blob ||
536-
(typeof File !== "undefined" && v instanceof File)
537-
? (v as FormDataCoercible)
538-
: JSON.stringify(v),
553+
isToolFile(v)
554+
? (formPartFromToolFile(v, partType) ?? JSON.stringify(v))
555+
: typeof v === "string" ||
556+
typeof v === "number" ||
557+
typeof v === "boolean" ||
558+
v instanceof Blob ||
559+
(typeof File !== "undefined" && v instanceof File)
560+
? (v as FormDataCoercible)
561+
: JSON.stringify(v),
539562
) as FormDataCoercible;
540563
continue;
541564
}

packages/plugins/openapi/src/sdk/non-json-body.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,82 @@ describe("OpenAPI non-JSON request body dispatch", () => {
185185
}),
186186
);
187187

188+
it.effect("multipart/form-data: binary file fields use ToolFile and real file parts", () =>
189+
Effect.gen(function* () {
190+
const { server, captured } = yield* startEchoServer({
191+
name: "upload",
192+
path: "/upload",
193+
payload: ObjectBody.pipe(HttpApiSchema.asMultipart()),
194+
transformSpec: replaceRequestBodyContent(
195+
"/upload",
196+
"post",
197+
{
198+
"multipart/form-data": {
199+
schema: {
200+
type: "object",
201+
properties: {
202+
document: {
203+
type: "string",
204+
format: "binary",
205+
description: "PDF document to upload.",
206+
},
207+
title: { type: "string" },
208+
},
209+
required: ["document"],
210+
},
211+
},
212+
},
213+
{ document: { contentType: "application/pdf" } },
214+
),
215+
});
216+
217+
const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
218+
const conn = yield* addOpenApiTestConnection(executor, server, { slug: "paperless" });
219+
220+
const schema = yield* executor.tools.schema(conn.address("body.upload"));
221+
expect(schema?.inputSchema).toMatchObject({
222+
properties: {
223+
body: {
224+
properties: {
225+
document: {
226+
properties: {
227+
_tag: { enum: ["ToolFile"] },
228+
data: { contentEncoding: "base64" },
229+
},
230+
},
231+
},
232+
},
233+
},
234+
});
235+
236+
const pdfBytes = Buffer.from("%PDF-1.4\nexecutor upload test\n");
237+
yield* executor.execute(conn.address("body.upload"), {
238+
body: {
239+
document: {
240+
_tag: "ToolFile",
241+
name: "invoice.pdf",
242+
mimeType: "application/pdf",
243+
encoding: "base64",
244+
data: pdfBytes.toString("base64"),
245+
byteLength: pdfBytes.byteLength,
246+
},
247+
title: "Invoice",
248+
},
249+
});
250+
251+
expect(captured.contentType).toMatch(/^multipart\/form-data; boundary=/);
252+
const body = captured.body.toString("utf8");
253+
expect(body).toContain('name="document"; filename="invoice.pdf"');
254+
expect(body).toMatch(
255+
/name="document"; filename="invoice\.pdf"[\s\S]*?Content-Type: application\/pdf/,
256+
);
257+
expect(body).toContain("%PDF-1.4");
258+
expect(body).toContain('name="title"');
259+
expect(body).toContain("Invoice");
260+
expect(body).not.toContain("[object Object]");
261+
}),
262+
);
263+
188264
it.effect("application/xml: string body passes through with xml content-type", () =>
189265
Effect.gen(function* () {
190266
const { server, captured } = yield* startEchoServer({

0 commit comments

Comments
 (0)