Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/openapi-multipart-file-fields.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 77 additions & 1 deletion packages/plugins/openapi/src/sdk/extract.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown> }).encoding),
),
Expand Down Expand Up @@ -184,6 +185,81 @@ const isJsonMediaType = (mediaType: string): boolean => {
const binaryStringSchema = (schema: Record<string, unknown>): boolean =>
stringType(schema) && (schema.format === "binary" || schema.format === "byte");

const arrayType = (schema: Record<string, unknown>): boolean =>
schema.type === "array" || (Array.isArray(schema.type) && schema.type.includes("array"));

const nullableType = (schema: Record<string, unknown>): 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: [<file>, { type: "null" }]`, since the tool-file schema is
* an object and cannot express null through a type array.
*/
const toolFileSchemaFor = (node: Record<string, unknown>): Record<string, unknown> => {
const annotations: Record<string, unknown> = {};
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<string, unknown>), ...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<string, unknown> = {};
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<string, unknown>): "base64" | "base64url" =>
typeof schema.description === "string" &&
/base64url|base64-url|url[- ]safe/i.test(schema.description)
Expand Down
156 changes: 111 additions & 45 deletions packages/plugins/openapi/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -686,21 +701,39 @@ 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]`).
*/
const coerceFormDataRecord = (
value: Record<string, unknown>,
encoding: Record<string, EncodingObject> | undefined,
): FormDataRecord => {
): FormDataCoercion => {
const out: Record<string, FormDataCoercible> = {};
for (const [key, raw] of Object.entries(value)) {
if (raw === undefined || raw === null) continue;
Expand All @@ -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: <partType>` on this part. JSON types get the
// value JSON-stringified first so the blob body is valid JSON.
Expand All @@ -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;
}
Expand All @@ -755,7 +802,7 @@ const coerceFormDataRecord = (
}
out[key] = JSON.stringify(raw);
}
return out;
return { ok: true, record: out };
};

// ---------------------------------------------------------------------------
Expand All @@ -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<string, EncodingObject> | 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<string, unknown>, 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<typeof HttpClientRequest.bodyUrlParams>[1],
return sent(
HttpClientRequest.bodyUrlParams(
request,
bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[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<string, unknown>, encoding),
);
const coerced = coerceFormDataRecord(bodyValue as Record<string, unknown>, 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));
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
}
}

Expand Down
Loading
Loading