diff --git a/package.json b/package.json index 087050e..26ccaca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dokploy/trpc-openapi", - "version": "0.0.17", + "version": "0.0.18", "description": "tRPC OpenAPI", "author": "dokploy", "private": false, @@ -76,6 +76,7 @@ "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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 542daa3..311090d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: zod: specifier: ^4.0.0 version: 4.0.14 + zod-form-data: + specifier: ^2.0.8 + version: 2.0.8(zod@4.0.14) zod-openapi: specifier: 5.4.2 version: 5.4.2(zod@4.0.14) @@ -1761,6 +1764,9 @@ packages: cpu: [x64] os: [win32] + '@rvf/set-get@7.0.1': + resolution: {integrity: sha512-GkTSn9K1GrTYoTUqlUs36k6nJnzjQaFBTTEIqUYmzBcsGsoJM8xG7EAx2WLHWAA4QzFjcwWUSHQ3vM3Fbw50Tg==} + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -6158,6 +6164,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-form-data@2.0.8: + resolution: {integrity: sha512-X31GkEc8uk5/L387L4TVI1z7obBbN/0MRHBHfHW3uMOWkVJeSsa+grvkTvY9qyFbNshKEnqK+jLJNlY+d7jpLw==} + peerDependencies: + zod: '>= 3.11.0' + zod-openapi@5.4.2: resolution: {integrity: sha512-F2s/1E/8Ahd43JhrkqE/dc13qjaGF/VTdUVq7rtRKvyDWyuYYryyzdxHnzH1ff1zLpd4KkvNvIpeBnVQShlxYg==} engines: {node: '>=20'} @@ -7597,6 +7608,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.4': optional: true + '@rvf/set-get@7.0.1': {} + '@scarf/scarf@1.4.0': {} '@sec-ant/readable-stream@0.4.1': {} @@ -12960,6 +12973,11 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-form-data@2.0.8(zod@4.0.14): + dependencies: + '@rvf/set-get': 7.0.1 + zod: 4.0.14 + zod-openapi@5.4.2(zod@4.0.14): dependencies: zod: 4.0.14 diff --git a/src/adapters/node-http/core.ts b/src/adapters/node-http/core.ts index a277454..0e8f450 100644 --- a/src/adapters/node-http/core.ts +++ b/src/adapters/node-http/core.ts @@ -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< @@ -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 @@ -117,8 +118,16 @@ 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, @@ -126,7 +135,7 @@ export const createOpenApiNodeHttpHandler = < } // 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; diff --git a/src/adapters/node-http/input.ts b/src/adapters/node-http/input.ts index 8cfb353..4faf420 100644 --- a/src/adapters/node-http/input.ts +++ b/src/adapters/node-http/input.ts @@ -1,3 +1,4 @@ +import { Readable } from 'stream'; import { TRPCError } from '@trpc/server'; import parse from 'co-body'; import { NodeHTTPRequest } from '../../types'; @@ -73,3 +74,20 @@ export const getBody = async (req: NodeHTTPRequest, maxBodySize = BODY_100_KB): return req.body; }; + +export const getMultipartBody = async (req: NodeHTTPRequest): Promise => { + 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(); +}; diff --git a/src/generator/paths.ts b/src/generator/paths.ts index c742008..6bd058b 100644 --- a/src/generator/paths.ts +++ b/src/generator/paths.ts @@ -16,6 +16,7 @@ import { instanceofZodType, instanceofZodTypeLikeVoid, instanceofZodTypeObject, + isSchemaOptional, unwrapZodType, } from '../utils'; import { getParameterObjects, getRequestBodyObject, getResponsesObject, hasInputs } from './schema'; @@ -106,8 +107,8 @@ export const getOpenApiPathsObject = >( // 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({ diff --git a/src/generator/schema.ts b/src/generator/schema.ts index bc6f8bf..92b38d3 100644 --- a/src/generator/schema.ts +++ b/src/generator/schema.ts @@ -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.ZodObject => { + const shape = zodSchema.shape; + const newShape: Record = {}; + + 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, required: boolean, @@ -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 { diff --git a/src/types.ts b/src/types.ts index 87885c7..66d2957 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,7 @@ type TRPCMeta = Record; export type OpenApiContentType = | 'application/json' | 'application/x-www-form-urlencoded' + | 'multipart/form-data' // eslint-disable-next-line @typescript-eslint/ban-types | (string & {}); diff --git a/src/utils/zod.ts b/src/utils/zod.ts index 1ec8f13..d9a4d2b 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -111,3 +111,64 @@ export const coerceSchema = (schema: ZodObject) => { 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)); + }); +}; diff --git a/test/generator.test.ts b/test/generator.test.ts index a35157f..e8c309a 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -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'); + }); + }); });