Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Different Type Providers for validation and serialization #5359

Open
2 tasks done
flodlc opened this issue Mar 13, 2024 · 2 comments
Open
2 tasks done

Different Type Providers for validation and serialization #5359

flodlc opened this issue Mar 13, 2024 · 2 comments

Comments

@flodlc
Copy link
Contributor

flodlc commented Mar 13, 2024

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

@Bram-dc
Copy link

Bram-dc commented Apr 2, 2024

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

}

@flodlc
Copy link
Contributor Author

flodlc commented Apr 5, 2024

Nice, hope it will be merged soon !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants