fastify / fastify

Fast and low overhead web framework, for Node.js

Home Page:https://www.fastify.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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 !