Different Type Providers for validation and serialization
flodlc opened this issue Β· comments
Prerequisites
- I have written a descriptive issue title
- I have searched existing issues to ensure the feature has not already been requested
π Feature Proposal
I am currently using Zod for validation and serialization with fastify-type-provider-zod
.
With Zod, you can infer the schema type using z.infer or z.input. In many cases, these types are identical, but they can differ if you utilize the default
feature.
For the schema below, it would be beneficial if Fastify could accept { name: undefined }
as a response. It could be achieved using a Type Provider based on z.input<typeof zodSchema>
.
{
response: {
200: { name: z.string().default("Default name") },
},
}
Basically in the zod type provider we could implement something like the following for serialization purposes.
export interface ZodTypeProvider extends FastifyTypeProvider {
output: this["input"] extends ZodTypeAny ? z.input<this["input"]> : never;
}
It it something that could be discussed ?
Motivation
No response
Example
No response
Check out my PR: #5315
I am currently using my own fork to overcome this issue: https://github.com/Bram-dc/fastify/tree/separated-typeprovider
With this type-provider:
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { FastifyBaseLogger, FastifyInstance, FastifySchema, FastifySchemaCompiler, FastifySeparatedTypeProvider, FastifyTypeProvider, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault } from 'fastify'
import type { FastifySerializerCompiler } from 'fastify/types/schema'
import { z } from 'zod'
import { BadRequest } from './errors'
import zodToJsonSchema from 'zod-to-json-schema'
import { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
interface ZodValidatorTypeProvider extends FastifyTypeProvider {
output: this['input'] extends z.ZodTypeAny ? z.output<this['input']> : this['input'] extends z.ZodRawShape ? z.output<z.ZodObject<this['input']>> : unknown
}
interface ZodSerializerTypeProvider extends FastifyTypeProvider {
output: this['input'] extends z.ZodTypeAny ? z.input<this['input']> : this['input'] extends z.ZodRawShape ? z.input<z.ZodObject<this['input']>> : unknown
}
export type ZodFastifyInstance = FastifyInstance<RawServerDefault, RawRequestDefaultExpression<RawServerDefault>, RawReplyDefaultExpression<RawServerDefault>, FastifyBaseLogger, ZodTypeProvider>
export interface ZodTypeProvider extends FastifySeparatedTypeProvider {
validator: ZodValidatorTypeProvider,
serializer: ZodSerializerTypeProvider,
}
type Schema = z.ZodTypeAny | z.ZodRawShape | { type: 'object', properties: z.ZodTypeAny } | { type: 'object', properties: z.ZodRawShape }
const zodSchemaCache = new Map<z.ZodRawShape, z.ZodTypeAny>()
const convertToZodSchema = (shape: z.ZodRawShape) => {
if (zodSchemaCache.has(shape))
return zodSchemaCache.get(shape)!
const zodSchema = z.object(shape)
zodSchemaCache.set(shape, zodSchema)
return zodSchema
}
const getZodSchema = (schema: Schema): z.ZodTypeAny => {
if (schema instanceof z.ZodType)
return schema
if (schema.type === 'object')
return getZodSchema(schema.properties)
return convertToZodSchema(schema as z.ZodRawShape)
}
export const validatorCompiler: FastifySchemaCompiler<Schema> = ({ schema }) => data => {
const result = getZodSchema(schema).safeParse(data)
if (result.success)
return { value: result.data }
const map: Record<string, string> = result.error.errors.reduce((map, error) => ({ ...map, [error.path.join('.')]: error.message }), {})
const message = 'Sommige velden zijn niet correct ingevuld: ' + Object.entries(map).map(([path, message]) => `\`${path}\` ${message.toLowerCase()}`).join(', ')
return { error: new BadRequest(message, map) }
}
export const serializerCompiler: FastifySerializerCompiler<Schema> = ({ schema }) => data => {
const result = getZodSchema(schema).safeParse(data)
if (result.success)
return JSON.stringify(result.data)
throw new Error(`Failed to create view: ${result.error.message}`)
}
const componentSymbol = Symbol.for('reference-component')
declare module 'zod' {
interface ZodType {
[componentSymbol]?: string
}
}
const zodSchemaToJsonSchema = (zodSchema: z.ZodTypeAny) => {
return zodToJsonSchema(zodSchema, {
target: 'openApi3',
$refStrategy: 'none',
})
}
const componentCacheVK = new Map<string, string>()
const checkZodSchemaForComponent = (zodSchema: z.ZodTypeAny) => {
if (zodSchema[componentSymbol])
componentCacheVK.set(JSON.stringify(zodSchemaToJsonSchema(zodSchema)), zodSchema[componentSymbol])
if (zodSchema instanceof z.ZodNullable)
checkZodSchemaForComponent(zodSchema.unwrap())
if (zodSchema instanceof z.ZodOptional)
checkZodSchemaForComponent(zodSchema.unwrap())
if (zodSchema instanceof z.ZodArray)
checkZodSchemaForComponent(zodSchema._def.type)
if (zodSchema instanceof z.ZodObject)
for (const key in zodSchema.shape)
checkZodSchemaForComponent(zodSchema.shape[key])
if (zodSchema instanceof z.ZodEffects)
checkZodSchemaForComponent(zodSchema._def.schema)
}
const transformSchema = (schema: Schema) => {
const zodSchema = getZodSchema(schema)
checkZodSchemaForComponent(zodSchema)
return zodSchemaToJsonSchema(zodSchema)
}
export const jsonSchemaTransform = ({ schema: { response, headers, querystring, body, params, ...rest }, url }: { schema: FastifySchema, url: string }) => {
const transformed: Record<string, any> = {}
const schemas: Record<string, any> = { headers, querystring, body, params }
for (const prop in schemas)
if (schemas[prop])
transformed[prop] = transformSchema(schemas[prop])
if (response) {
transformed.response = {}
for (const prop in response)
transformed.response[prop] = transformSchema(response[prop as keyof typeof response])
}
for (const prop in rest) {
const meta = rest[prop as keyof typeof rest]
if (meta)
transformed[prop] = meta
}
return { schema: transformed, url }
}
const RANDOM_COMPONENT_KEY_PREFIX = Math.random().toString(36).substring(2)
const componentReplacer = (key: string, value: any) => {
if (typeof value !== 'object')
return value
if (key.startsWith(RANDOM_COMPONENT_KEY_PREFIX))
return value
const stringifiedValue = JSON.stringify(value)
if (componentCacheVK.has(stringifiedValue))
return { $ref: `#/components/schemas/${componentCacheVK.get(stringifiedValue)}` }
if (value.nullable === true) {
const nonNullableValue = { ...value }
delete nonNullableValue.nullable
const stringifiedNonNullableValue = JSON.stringify(nonNullableValue)
if (componentCacheVK.has(stringifiedNonNullableValue))
return { allOf: [{ $ref: `#/components/schemas/${componentCacheVK.get(stringifiedNonNullableValue)}` }], nullable: true }
}
return value
}
export const jsonSchemaTransformObject = (input: { swaggerObject: Partial<OpenAPIV2.Document> } | { openapiObject: Partial<OpenAPIV3.Document | OpenAPIV3_1.Document> }) => {
if ('swaggerObject' in input)
throw new Error('This package does not support component references for OpenAPIV2')
const document = {
...input.openapiObject,
components: {
...input.openapiObject.components,
schemas: {
...input.openapiObject.components?.schemas,
...Object.fromEntries([...componentCacheVK].map(([key, value]) => [RANDOM_COMPONENT_KEY_PREFIX + value, JSON.parse(key)])),
},
},
}
const stringified = JSON.stringify(document, componentReplacer).replaceAll(RANDOM_COMPONENT_KEY_PREFIX, '')
const parsed = JSON.parse(stringified)
return parsed
}
Nice, hope it will be merged soon !