Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,11 @@
"ts-node": "^10.9.1",
"typescript": "5.8.3",
"zod": "^4.0.0",
"zod-form-data": "^2.0.8",
"zod-openapi": "5.4.2"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"packageManager": "pnpm@10.18.1"
}
}
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions src/adapters/node-http/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
getRequestSignal,
} from '../../utils';
import { TRPC_ERROR_CODE_HTTP_STATUS, getErrorFromUnknown } from './errors';
import { getBody, getQuery } from './input';
import { getBody, getMultipartBody, getQuery } from './input';
import { createProcedureCache } from './procedures';

export type CreateOpenApiNodeHttpHandlerOptions<
Expand Down Expand Up @@ -104,8 +104,9 @@ export const createOpenApiNodeHttpHandler = <

const contentType = getContentType(req);
const useBody = acceptsRequestBody(method);
const isMultipart = contentType?.startsWith('multipart/form-data');

if (useBody && !contentType?.startsWith('application/json')) {
if (useBody && !isMultipart && !contentType?.startsWith('application/json')) {
throw new TRPCError({
code: 'UNSUPPORTED_MEDIA_TYPE',
message: contentType
Expand All @@ -117,16 +118,24 @@ export const createOpenApiNodeHttpHandler = <
const { inputParser } = getInputOutputParsers(procedure.procedure);
const unwrappedSchema = unwrapZodType(inputParser, true);

// input should stay undefined if z.void()
if (!instanceofZodTypeLikeVoid(unwrappedSchema)) {
if (isMultipart) {
const formData = await getMultipartBody(req);
if (pathInput) {
for (const [key, value] of Object.entries(pathInput)) {
formData.append(key, value as string);
}
}
input = formData;
} else if (!instanceofZodTypeLikeVoid(unwrappedSchema)) {
// input should stay undefined if z.void()
input = {
...(useBody ? await getBody(req, maxBodySize) : getQuery(req, url)),
...pathInput,
};
}

// if supported, coerce all string values to correct types
if (zodSupportsCoerce && instanceofZodTypeObject(unwrappedSchema)) {
if (!isMultipart && zodSupportsCoerce && instanceofZodTypeObject(unwrappedSchema)) {
if (!useBody) {
for (const [key, shape] of Object.entries(unwrappedSchema.shape)) {
let isArray = false;
Expand Down
18 changes: 18 additions & 0 deletions src/adapters/node-http/input.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Readable } from 'stream';
import { TRPCError } from '@trpc/server';
import parse from 'co-body';
import { NodeHTTPRequest } from '../../types';
Expand Down Expand Up @@ -73,3 +74,20 @@ export const getBody = async (req: NodeHTTPRequest, maxBodySize = BODY_100_KB):

return req.body;
};

export const getMultipartBody = async (req: NodeHTTPRequest): Promise<FormData> => {
const contentType = req.headers['content-type'];
if (!contentType) {
throw new TRPCError({
message: 'Missing content-type header',
code: 'BAD_REQUEST',
});
}

const readable = Readable.toWeb(req as unknown as Readable) as ReadableStream;
const response = new Response(readable, {
headers: { 'content-type': contentType },
});

return response.formData();
};
5 changes: 3 additions & 2 deletions src/generator/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
instanceofZodType,
instanceofZodTypeLikeVoid,
instanceofZodTypeObject,
isSchemaOptional,
unwrapZodType,
} from '../utils';
import { getParameterObjects, getRequestBodyObject, getResponsesObject, hasInputs } from './schema';
Expand Down Expand Up @@ -106,8 +107,8 @@ export const getOpenApiPathsObject = <TMeta = Record<string, unknown>>(
// When no output parser is defined, use empty object schema (procedure still included in OpenAPI doc)
const responseSchema = instanceofZodType(outputParser) ? outputParser : z.object({});

// Request body is required only when the input schema does not accept undefined (e.g. not .optional())
const isInputRequired = !(inputParser as z.ZodTypeAny).safeParse(undefined).success;
// Use safe optionality check to avoid triggering zfd preprocessing
const isInputRequired = !isSchemaOptional(inputParser);

const o = inputParser.meta();
const inputSchema = unwrapZodType(inputParser, true).meta({
Expand Down
39 changes: 37 additions & 2 deletions src/generator/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,37 @@ import {
instanceofZodTypeLikeString,
instanceofZodTypeLikeVoid,
instanceofZodTypeOptional,
instanceofZodFormDataFile,
isSchemaOptional,
schemaContainsFileField,
unwrapZodType,
zodSupportsCoerce,
} from '../utils';
import { HttpMethods } from './paths';

/**
* Generate a zod schema for form-data, replacing file fields with string+binary for OpenAPI.
*/
const generateFormDataSchema = (
zodSchema: z.ZodObject<z.ZodRawShape>,
): z.ZodObject<z.ZodRawShape> => {
const shape = zodSchema.shape;
const newShape: Record<string, z.ZodTypeAny> = {};

for (const [key, fieldSchema] of Object.entries(shape)) {
const field = fieldSchema as z.ZodTypeAny;
if (instanceofZodFormDataFile(field) || instanceofZodFormDataFile(unwrapZodType(field, false))) {
const isOpt = isSchemaOptional(field);
const fileSchema = z.string().meta({ format: 'binary' });
newShape[key] = isOpt ? fileSchema.optional() : fileSchema;
} else {
newShape[key] = field;
}
}

return z.object(newShape);
};

export const getParameterObjects = (
schema: z.ZodObject<z.ZodRawShape>,
required: boolean,
Expand Down Expand Up @@ -159,10 +185,19 @@ export const getRequestBodyObject = (
return undefined;
}

// Auto-detect multipart/form-data when file fields are present
const hasFileFields = schemaContainsFileField(dedupedSchema);
const effectiveContentTypes = hasFileFields
? (['multipart/form-data'] as OpenApiContentType[])
: contentTypes;
const effectiveSchema = hasFileFields
? generateFormDataSchema(dedupedSchema)
: dedupedSchema;

const content: ZodOpenApiContentObject = {};
for (const contentType of contentTypes) {
for (const contentType of effectiveContentTypes) {
content[contentType] = {
schema: dedupedSchema,
schema: effectiveSchema,
};
}
return {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type TRPCMeta = Record<string, unknown>;
export type OpenApiContentType =
| 'application/json'
| 'application/x-www-form-urlencoded'
| 'multipart/form-data'
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {});

Expand Down
61 changes: 61 additions & 0 deletions src/utils/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,64 @@ export const coerceSchema = (schema: ZodObject<ZodRawShape>) => {
else if (instanceofZodTypeObject(unwrappedShapeSchema)) coerceSchema(unwrappedShapeSchema);
});
};

/**
* Safely check if a schema is optional without triggering parse/preprocessing.
* Important for zod-form-data schemas where isOptional()/safeParse() would trigger form parsing.
*/
export const isSchemaOptional = (schema: $ZodType): boolean => {
if (instanceofZodTypeKind(schema, 'optional')) return true;
if (instanceofZodTypeKind(schema, 'nullable')) return true;
if (instanceofZodTypeKind(schema, 'default')) return true;
if (instanceofZodTypeKind(schema, 'pipe')) {
return isSchemaOptional((schema as z.ZodPipe<$ZodTypes>).def.out);
}
// Zod v3 compat: check ZodEffects inner schema
const def = (schema as any)?._def;
if (def?.typeName === 'ZodEffects') {
return isSchemaOptional(def.schema);
}
return false;
};

/**
* Detect if a schema is a zod-form-data file field (zfd.file()).
* In Zod v4, zfd.file() creates: pipe(transform → custom) where custom validates instanceof File/Blob.
*/
export const instanceofZodFormDataFile = (_type: $ZodType): boolean => {
const type = unwrapZodType(_type, false);

// Zod v4: pipe(transform → custom) pattern from zfd.file()
if (instanceofZodTypeKind(type, 'pipe')) {
const out = (type as z.ZodPipe<$ZodTypes>).def.out;
if (instanceofZodTypeKind(out, 'custom')) return true;
if (instanceofZodTypeKind(out, 'any')) return true;
return instanceofZodFormDataFile(out);
}

// Zod v3 compat: ZodEffects(preprocess) -> ZodEffects(refinement) -> ZodAny
const def = (type as any)?._def;
if (def?.typeName === 'ZodEffects' && def.effect?.type === 'preprocess') {
const inner = def.schema;
if (inner?._def?.typeName === 'ZodEffects' && inner._def.effect?.type === 'refinement') {
if (inner._def.schema?._def?.typeName === 'ZodAny') return true;
}
if (inner?._def?.typeName === 'ZodAny') return true;
if (inner?._def?.typeName === 'ZodUnion') {
return inner._def.options.some((opt: any) => instanceofZodFormDataFile(opt));
}
}

return false;
};

/** Check if an object schema contains any file fields */
export const schemaContainsFileField = (type: $ZodType): boolean => {
const unwrapped = unwrapZodType(type, true);
if (!instanceofZodTypeObject(unwrapped)) return false;

return Object.values(unwrapped.shape).some((fieldSchema) => {
const field = fieldSchema as $ZodType;
return instanceofZodFormDataFile(field) || instanceofZodFormDataFile(unwrapZodType(field, false));
});
};
100 changes: 100 additions & 0 deletions test/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3561,4 +3561,104 @@ describe('generator', () => {

expect(openApiDocument.paths!['/metadata/all']!.get!.operationId).toBe('getAllMetadataAboutMe');
});

describe('multipart/form-data support', () => {
test('with zod-form-data file schema - auto-detects multipart', async () => {
const { zfd } = await import('zod-form-data');

const appRouter = t.router({
uploadFile: t.procedure
.meta({ openapi: { method: 'POST', path: '/upload', override: true } })
.input(
zfd.formData({
name: z.string(),
file: zfd.file(),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(() => ({ success: true })),
});

const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts);

const requestBody = openApiDocument.paths!['/upload']?.post?.requestBody as any;
expect(requestBody).toBeDefined();
expect(Object.keys(requestBody.content)).toEqual(['multipart/form-data']);
expect(requestBody.content['multipart/form-data'].schema.properties.file).toEqual({
type: 'string',
format: 'binary',
});
expect(requestBody.content['multipart/form-data'].schema.properties.name.type).toBe('string');
});

test('with multiple file fields', async () => {
const { zfd } = await import('zod-form-data');

const appRouter = t.router({
uploadMultiple: t.procedure
.meta({ openapi: { method: 'POST', path: '/upload-multiple', override: true } })
.input(
zfd.formData({
document: zfd.file(),
thumbnail: zfd.file(),
title: z.string(),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(() => ({ success: true })),
});

const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts);

const schema = (openApiDocument.paths!['/upload-multiple']?.post?.requestBody as any)?.content[
'multipart/form-data'
]?.schema;

expect(schema.properties.document).toEqual({ type: 'string', format: 'binary' });
expect(schema.properties.thumbnail).toEqual({ type: 'string', format: 'binary' });
expect(schema.properties.title.type).toBe('string');
});

test('backward compatibility - non-file schemas use application/json', () => {
const appRouter = t.router({
createUser: t.procedure
.meta({ openapi: { method: 'POST', path: '/users', override: true } })
.input(z.object({ name: z.string() }))
.output(z.object({ id: z.string() }))
.mutation(() => ({ id: '123' })),
});

const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts);

expect(
Object.keys((openApiDocument.paths!['/users']?.post?.requestBody as any)?.content),
).toEqual(['application/json']);
});

test('file fields are marked as required correctly', async () => {
const { zfd } = await import('zod-form-data');

const appRouter = t.router({
upload: t.procedure
.meta({ openapi: { method: 'POST', path: '/upload', override: true } })
.input(
zfd.formData({
requiredFile: zfd.file(),
optionalName: z.string().optional(),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(() => ({ success: true })),
});

const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts);

const schema = (openApiDocument.paths!['/upload']?.post?.requestBody as any)?.content[
'multipart/form-data'
]?.schema;

expect(schema.required).toContain('requiredFile');
expect(schema.required).not.toContain('optionalName');
});
});
});