Skip to content

Commit d3e29d4

Browse files
BittuBarnwal7479RhysSullivan
authored andcommitted
fix: OpenAPI multipart file field uploads
1 parent eac13e7 commit d3e29d4

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";
@@ -135,7 +136,7 @@ const extractRequestBody = (
135136
const contents = declaredContents(body.content).map(({ mediaType, media }) =>
136137
MediaBinding.make({
137138
contentType: mediaType,
138-
schema: Option.fromNullishOr(media.schema),
139+
schema: Option.fromNullishOr(multipartFileInputSchema(media.schema, mediaType)),
139140
encoding: Option.fromNullishOr(
140141
buildEncodingRecord((media as { encoding?: Record<string, unknown> }).encoding),
141142
),
@@ -184,6 +185,39 @@ const isJsonMediaType = (mediaType: string): boolean => {
184185
const binaryStringSchema = (schema: Record<string, unknown>): boolean =>
185186
stringType(schema) && (schema.format === "binary" || schema.format === "byte");
186187

188+
const isMultipartMediaType = (mediaType: string): boolean =>
189+
normalizedMediaType(mediaType) === "multipart/form-data";
190+
191+
const multipartFileInputSchema = (schema: unknown, mediaType: string): unknown => {
192+
if (!isMultipartMediaType(mediaType)) return schema;
193+
194+
const rewrite = (node: unknown): unknown => {
195+
if (Array.isArray(node)) {
196+
let changed = false;
197+
const out = node.map((item) => {
198+
const next = rewrite(item);
199+
if (next !== item) changed = true;
200+
return next;
201+
});
202+
return changed ? out : node;
203+
}
204+
205+
if (!isRecord(node)) return node;
206+
if (binaryStringSchema(node)) return ToolFileJsonSchema;
207+
208+
let changed = false;
209+
const out: Record<string, unknown> = {};
210+
for (const [key, value] of Object.entries(node)) {
211+
const next = rewrite(value);
212+
if (next !== value) changed = true;
213+
out[key] = next;
214+
}
215+
return changed ? out : node;
216+
};
217+
218+
return rewrite(schema);
219+
};
220+
187221
const base64EncodingFromDescription = (schema: Record<string, unknown>): "base64" | "base64url" =>
188222
typeof schema.description === "string" &&
189223
/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, Exit, Fiber, Layer, Option, Schema, Stream } from "effect";
22
import { HttpClient, HttpClientRequest, HttpClientResponse } 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 { isNdjsonMediaType, NDJSON_MEDIA_TYPES, resolveServerUrl } from "./openapi-utils";
@@ -588,6 +588,21 @@ const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
588588
return copy;
589589
};
590590

591+
const formPartFromToolFile = (
592+
file: ToolFileValue,
593+
contentTypeOverride?: string,
594+
): Blob | File | null => {
595+
const bytes = base64ToUint8Array(file.data);
596+
if (!bytes) return null;
597+
598+
const type = contentTypeOverride ?? file.mimeType;
599+
const body = toArrayBuffer(bytes);
600+
if (typeof File !== "undefined") {
601+
return new File([body], file.name ?? "file", { type });
602+
}
603+
return new Blob([body], { type });
604+
};
605+
591606
// ---------------------------------------------------------------------------
592607
// OpenAPI 3.x encoding — per-property style/explode/allowReserved/contentType
593608
// for multipart/form-data and application/x-www-form-urlencoded bodies.
@@ -709,6 +724,12 @@ const coerceFormDataRecord = (
709724
? Option.getOrUndefined(encoding[key]!.contentType)
710725
: undefined;
711726

727+
if (isToolFile(raw)) {
728+
const filePart = formPartFromToolFile(raw, partType);
729+
out[key] = (filePart ?? JSON.stringify(raw)) as FormDataCoercible;
730+
continue;
731+
}
732+
712733
// Explicit per-part content type: wrap in a typed Blob so the framer
713734
// emits `Content-Type: <partType>` on this part. JSON types get the
714735
// value JSON-stringified first so the blob body is valid JSON.
@@ -738,13 +759,15 @@ const coerceFormDataRecord = (
738759
}
739760
if (Array.isArray(raw)) {
740761
out[key] = raw.map((v) =>
741-
typeof v === "string" ||
742-
typeof v === "number" ||
743-
typeof v === "boolean" ||
744-
v instanceof Blob ||
745-
(typeof File !== "undefined" && v instanceof File)
746-
? (v as FormDataCoercible)
747-
: JSON.stringify(v),
762+
isToolFile(v)
763+
? (formPartFromToolFile(v, partType) ?? JSON.stringify(v))
764+
: typeof v === "string" ||
765+
typeof v === "number" ||
766+
typeof v === "boolean" ||
767+
v instanceof Blob ||
768+
(typeof File !== "undefined" && v instanceof File)
769+
? (v as FormDataCoercible)
770+
: JSON.stringify(v),
748771
) as FormDataCoercible;
749772
continue;
750773
}

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)