fastify-type-provider-zod
Version:
Zod Type Provider for Fastify@4
139 lines (113 loc) • 4.16 kB
text/typescript
import type { FastifySchema, FastifySchemaCompiler, FastifyTypeProvider } from 'fastify';
import type { FastifySerializerCompiler } from 'fastify/types/schema';
import type { z, ZodAny, ZodTypeAny } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FreeformRecord = Record<string, any>;
const defaultSkipList = [
'/documentation/',
'/documentation/initOAuth',
'/documentation/json',
'/documentation/uiConfig',
'/documentation/yaml',
'/documentation/*',
'/documentation/static/*',
];
export interface ZodTypeProvider extends FastifyTypeProvider {
output: this['input'] extends ZodTypeAny ? z.infer<this['input']> : never;
}
interface Schema extends FastifySchema {
hide?: boolean;
}
const zodToJsonSchemaOptions = {
target: 'openApi3',
$refStrategy: 'none',
} as const;
export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly string[] }) => {
return ({ schema, url }: { schema: Schema; url: string }) => {
if (!schema) {
return {
schema,
url,
};
}
const { response, headers, querystring, body, params, hide, ...rest } = schema;
const transformed: FreeformRecord = {};
if (skipList.includes(url) || hide) {
transformed.hide = true;
return { schema: transformed, url };
}
const zodSchemas: FreeformRecord = { headers, querystring, body, params };
for (const prop in zodSchemas) {
const zodSchema = zodSchemas[prop];
if (zodSchema) {
transformed[prop] = zodToJsonSchema(zodSchema, zodToJsonSchemaOptions);
}
}
if (response) {
transformed.response = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const prop in response as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema = resolveSchema((response as any)[prop]);
const transformedResponse = zodToJsonSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema as any,
zodToJsonSchemaOptions,
);
transformed.response[prop] = transformedResponse;
}
}
for (const prop in rest) {
const meta = rest[prop as keyof typeof rest];
if (meta) {
transformed[prop] = meta;
}
}
return { schema: transformed, url };
};
};
export const jsonSchemaTransform = createJsonSchemaTransform({
skipList: defaultSkipList,
});
export const validatorCompiler: FastifySchemaCompiler<ZodAny> =
({ schema }) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data): any => {
try {
return { value: schema.parse(data) };
} catch (error) {
return { error };
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hasOwnProperty<T, K extends PropertyKey>(obj: T, prop: K): obj is T & Record<K, any> {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
function resolveSchema(maybeSchema: ZodAny | { properties: ZodAny }): Pick<ZodAny, 'safeParse'> {
if (hasOwnProperty(maybeSchema, 'safeParse')) {
return maybeSchema;
}
if (hasOwnProperty(maybeSchema, 'properties')) {
return maybeSchema.properties;
}
throw new Error(`Invalid schema passed: ${JSON.stringify(maybeSchema)}`);
}
export class ResponseValidationError extends Error {
public details: FreeformRecord;
constructor(validationResult: FreeformRecord) {
super("Response doesn't match the schema");
this.name = 'ResponseValidationError';
this.details = validationResult.error;
}
}
export const serializerCompiler: FastifySerializerCompiler<ZodAny | { properties: ZodAny }> =
({ schema: maybeSchema }) =>
(data) => {
const schema: Pick<ZodAny, 'safeParse'> = resolveSchema(maybeSchema);
const result = schema.safeParse(data);
if (result.success) {
return JSON.stringify(result.data);
}
throw new ResponseValidationError(result);
};