import { z } from 'zod'; import { jsonSchema, openApiDocumentJsonSchema } from './schema'; import { validateBody, validateParams, validateQuery } from './validate'; function toFastifyPath(openApiPath: string): string { return openApiPath.replace(/\{([^}]+)\}/g, ':$1'); } function buildErrorEnvelopeSchema( code: number, dataSchema: z.ZodTypeAny = z.null(), detailsSchema?: z.ZodTypeAny, ): z.ZodTypeAny { const base = z.object({ code: z.literal(code), msg: z.string(), data: dataSchema, request_id: z.string(), }); if (detailsSchema) { return base.extend({ details: detailsSchema.nullable().optional(), }); } return base.extend({ details: z.unknown().optional(), }); } function buildSuccessEnvelopeSchema(successDataSchema: z.ZodTypeAny): z.ZodTypeAny { return z.object({ code: z.literal(0), msg: z.string(), data: successDataSchema, request_id: z.string(), details: z.unknown().optional(), }); } function buildUnifiedResponseSchema( successDataSchema: z.ZodTypeAny, errors: Record, ): Record { const errorEntries = Object.entries(errors) .map(([code, cfg]) => [Number(code), cfg] as const) .sort((a, b) => a[0] - b[0]); if (errorEntries.length === 0) { return openApiDocumentJsonSchema( buildSuccessEnvelopeSchema(successDataSchema), 'output', ); } const variants: Record[] = []; variants.push( openApiDocumentJsonSchema( buildSuccessEnvelopeSchema(successDataSchema), 'output', ), ); for (const [code, cfg] of errorEntries) { variants.push( openApiDocumentJsonSchema( buildErrorEnvelopeSchema(code, cfg.dataSchema, cfg.detailsSchema), 'output', ), ); } return { oneOf: variants }; } type InferZod = T extends z.ZodTypeAny ? z.infer : unknown; export interface DefineRouteOptions< TBody extends z.ZodTypeAny | undefined, TParams extends z.ZodTypeAny | undefined, TQuery extends z.ZodTypeAny | undefined, TSuccessData extends z.ZodTypeAny | undefined, > { method: string; path: string; body?: TBody; params?: TParams; querystring?: TQuery; success?: { data: TSuccessData }; errors?: Record; rawResponse?: Record>; description?: string; summary?: string; tags?: string[]; operationId?: string; consumes?: string[]; } export interface RouteDefinition< TBody extends z.ZodTypeAny | undefined, TParams extends z.ZodTypeAny | undefined, TQuery extends z.ZodTypeAny | undefined, > { method: string; path: string; options: { preHandler: unknown[]; schema: Record; }; handler: ( req: { id: string; body: InferZod; params: InferZod; headers: Record; } & (TQuery extends z.ZodTypeAny ? { query: InferZod } : {}), reply: { send(payload: unknown): unknown }, ) => Promise | void; } export function defineRoute< TBody extends z.ZodTypeAny | undefined, TParams extends z.ZodTypeAny | undefined, TQuery extends z.ZodTypeAny | undefined, TSuccessData extends z.ZodTypeAny | undefined, >( options: DefineRouteOptions, handler: RouteDefinition['handler'], ): RouteDefinition { const preHandler: unknown[] = []; if (options.params) { preHandler.push(validateParams(options.params)); } if (options.body) { preHandler.push(validateBody(options.body)); } if (options.querystring) { preHandler.push(validateQuery(options.querystring)); } const schema: Record = {}; if (options.body) { schema['body'] = jsonSchema(options.body); } if (options.params) { schema['params'] = jsonSchema(options.params); } if (options.querystring) { schema['querystring'] = jsonSchema(options.querystring); } const hasResponse = options.success !== undefined || (options.errors !== undefined && Object.keys(options.errors).length > 0) || options.rawResponse !== undefined; if (hasResponse) { const responses: Record = {}; if (options.success || options.errors) { responses['200'] = buildUnifiedResponseSchema( options.success?.data ?? z.null(), options.errors ?? {}, ); } if (options.rawResponse) { for (const [code, rawSchema] of Object.entries(options.rawResponse)) { responses[String(code)] = rawSchema; } } schema['response'] = responses; } if (options.description) { schema['description'] = options.description; } if (options.summary) { schema['summary'] = options.summary; } if (options.tags) { schema['tags'] = options.tags; } if (options.operationId) { schema['operationId'] = options.operationId; } if (options.consumes) { schema['consumes'] = options.consumes; } return { method: options.method, path: toFastifyPath(options.path), options: { preHandler, schema }, handler, }; }