From 0aa6156d030c88e84d67c6f3420497b35f5c9cbe Mon Sep 17 00:00:00 2001 From: Bram del Canho Date: Tue, 24 Sep 2024 13:56:07 +0200 Subject: [PATCH] feat: added refs (#99) --- README.md | 75 +++++++++++++++++++ package.json | 1 + src/index.ts | 48 +++++++----- src/ref.ts | 70 +++++++++++++++++ src/zod-to-json.ts | 11 +++ .../fastify-swagger.spec.ts.snap | 55 ++++++++++++++ test/fastify-swagger.spec.ts | 60 ++++++++++++++- types/index.test-d.ts | 2 +- 8 files changed, 301 insertions(+), 21 deletions(-) create mode 100644 src/ref.ts create mode 100644 src/zod-to-json.ts diff --git a/README.md b/README.md index 0736a6c..6987056 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ app.register(fastifySwagger, { servers: [], }, transform: jsonSchemaTransform, + // You can also create transform with custom skiplist of endpoints that should not be included in the specification: // // transform: createJsonSchemaTransform({ @@ -107,6 +108,80 @@ async function run() { run(); ``` +## How to create refs to the schemas? +It is possible to create refs to the schemas by using the `createJsonSchemaTransformObject` function. You provide the schemas as an object and fastifySwagger will create a OpenAPI document in which the schemas are referenced. The following example creates a ref to the `User` schema and will include the `User` schema in the OpenAPI document. + +```ts +import fastifySwagger from '@fastify/swagger'; +import fastifySwaggerUI from '@fastify/swagger-ui'; +import fastify from 'fastify'; +import { z } from 'zod'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { + createJsonSchemaTransformObject, + jsonSchemaTransform, + serializerCompiler, + validatorCompiler, +} from 'fastify-type-provider-zod'; + +const USER_SCHEMA = z.object({ + id: z.number().int().positive(), + name: z.string().describe('The name of the user'), +}); + +const app = fastify(); +app.setValidatorCompiler(validatorCompiler); +app.setSerializerCompiler(serializerCompiler); + +app.register(fastifySwagger, { + openapi: { + info: { + title: 'SampleApi', + description: 'Sample backend service', + version: '1.0.0', + }, + servers: [], + }, + transform: jsonSchemaTransform, + transformObject: createJsonSchemaTransformObject({ + schemas: { + User: USER_SCHEMA, + }, + }), +}); + +app.register(fastifySwaggerUI, { + routePrefix: '/documentation', +}); + +app.after(() => { + app.withTypeProvider().route({ + method: 'GET', + url: '/users', + schema: { + response: { + 200: USER_SCHEMA.array(), + }, + }, + handler: (req, res) => { + res.send([]); + }, + }); +}); + +async function run() { + await app.ready(); + + await app.listen({ + port: 4949, + }); + + console.log(`Documentation running at http://localhost:4949/documentation`); +} + +run(); +``` + ## How to create a plugin? ```ts diff --git a/package.json b/package.json index f4dcad4..edcf582 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "fastify": "^4.27.0", "fastify-plugin": "^5.0.1", "oas-validator": "^5.0.8", + "openapi-types": "^12.1.3", "prettier": "^3.2.5", "tsd": "^0.31.0", "typescript": "^5.4.5", diff --git a/src/index.ts b/src/index.ts index 11203b4..e68468b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,12 @@ import type { RawServerDefault, } from 'fastify'; import type { FastifySerializerCompiler } from 'fastify/types/schema'; -import type { ZodAny, ZodTypeAny, z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; +import type { z } from 'zod'; import { ResponseValidationError } from './ResponseValidationError'; +import { resolveRefs } from './ref'; +import { convertZodToJsonSchema } from './zod-to-json'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type FreeformRecord = Record; @@ -28,18 +30,13 @@ const defaultSkipList = [ ]; export interface ZodTypeProvider extends FastifyTypeProvider { - output: this['input'] extends ZodTypeAny ? z.infer : unknown; + output: this['input'] extends z.ZodTypeAny ? z.infer : unknown; } 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) { @@ -63,7 +60,7 @@ export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly str for (const prop in zodSchemas) { const zodSchema = zodSchemas[prop]; if (zodSchema) { - transformed[prop] = zodToJsonSchema(zodSchema, zodToJsonSchemaOptions); + transformed[prop] = convertZodToJsonSchema(zodSchema); } } @@ -75,11 +72,7 @@ export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly str // 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, - ); + const transformedResponse = convertZodToJsonSchema(schema); transformed.response[prop] = transformedResponse; } } @@ -99,7 +92,22 @@ export const jsonSchemaTransform = createJsonSchemaTransform({ skipList: defaultSkipList, }); -export const validatorCompiler: FastifySchemaCompiler = +export const createJsonSchemaTransformObject = + ({ schemas }: { schemas: Record }) => + ( + input: + | { swaggerObject: Partial } + | { openapiObject: Partial }, + ) => { + if ('swaggerObject' in input) { + console.warn('This package currently does not support component references for Swagger 2.0'); + return input.swaggerObject; + } + + return resolveRefs(input.openapiObject, schemas); + }; + +export const validatorCompiler: FastifySchemaCompiler = ({ schema }) => // eslint-disable-next-line @typescript-eslint/no-explicit-any (data): any => { @@ -115,9 +123,9 @@ function hasOwnProperty(obj: T, prop: K): obj is T & R return Object.prototype.hasOwnProperty.call(obj, prop); } -function resolveSchema(maybeSchema: ZodAny | { properties: ZodAny }): Pick { +function resolveSchema(maybeSchema: z.ZodTypeAny | { properties: z.ZodTypeAny }): z.ZodTypeAny { if (hasOwnProperty(maybeSchema, 'safeParse')) { - return maybeSchema; + return maybeSchema as z.ZodTypeAny; } if (hasOwnProperty(maybeSchema, 'properties')) { return maybeSchema.properties; @@ -125,10 +133,12 @@ function resolveSchema(maybeSchema: ZodAny | { properties: ZodAny }): Pick = +export const serializerCompiler: FastifySerializerCompiler< + z.ZodTypeAny | { properties: z.ZodTypeAny } +> = ({ schema: maybeSchema, method, url }) => (data) => { - const schema: Pick = resolveSchema(maybeSchema); + const schema = resolveSchema(maybeSchema); const result = schema.safeParse(data); if (result.success) { diff --git a/src/ref.ts b/src/ref.ts new file mode 100644 index 0000000..57bcfc3 --- /dev/null +++ b/src/ref.ts @@ -0,0 +1,70 @@ +import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; +import type { z } from 'zod'; + +import { convertZodToJsonSchema } from './zod-to-json'; + +const createComponentMap = ( + schemas: Record, +) => { + const map = new Map(); + + Object.entries(schemas).forEach(([key, value]) => map.set(JSON.stringify(value), key)); + + return map; +}; + +const createComponentReplacer = (componentMapVK: Map, schemasObject: object) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function componentReplacer(this: any, key: string, value: any) { + if (typeof value !== 'object') return value; + + // Check if the parent is the schemas object, if so, return the value as is. This is where the schemas are defined. + if (this === schemasObject) return value; + + const stringifiedValue = JSON.stringify(value); + if (componentMapVK.has(stringifiedValue)) + return { $ref: `#/components/schemas/${componentMapVK.get(stringifiedValue)}` }; + + if (value.nullable === true) { + const nonNullableValue = { ...value }; + delete nonNullableValue.nullable; + const stringifiedNonNullableValue = JSON.stringify(nonNullableValue); + if (componentMapVK.has(stringifiedNonNullableValue)) + return { + anyOf: [ + { $ref: `#/components/schemas/${componentMapVK.get(stringifiedNonNullableValue)}` }, + ], + nullable: true, + }; + } + + return value; + }; + +export const resolveRefs = ( + openapiObject: Partial, + zodSchemas: Record, +) => { + const schemas: Record = {}; + for (const key in zodSchemas) { + schemas[key] = convertZodToJsonSchema(zodSchemas[key]); + } + + const document = { + ...openapiObject, + components: { + ...openapiObject.components, + schemas: { + ...openapiObject.components?.schemas, + ...schemas, + }, + }, + }; + + const componentMapVK = createComponentMap(schemas); + const componentReplacer = createComponentReplacer(componentMapVK, document.components.schemas); + + // Using the componentReplacer function we deep check if the document has any schemas that are the same as the zod schemas provided + // When a match is found replace them with a $ref. + return JSON.parse(JSON.stringify(document, componentReplacer)); +}; diff --git a/src/zod-to-json.ts b/src/zod-to-json.ts new file mode 100644 index 0000000..0981593 --- /dev/null +++ b/src/zod-to-json.ts @@ -0,0 +1,11 @@ +import type { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +const zodToJsonSchemaOptions = { + target: 'openApi3', + $refStrategy: 'none', +} as const; + +export const convertZodToJsonSchema = (zodSchema: z.ZodTypeAny) => { + return zodToJsonSchema(zodSchema, zodToJsonSchemaOptions); +}; diff --git a/test/__snapshots__/fastify-swagger.spec.ts.snap b/test/__snapshots__/fastify-swagger.spec.ts.snap index 8f7e031..3486690 100644 --- a/test/__snapshots__/fastify-swagger.spec.ts.snap +++ b/test/__snapshots__/fastify-swagger.spec.ts.snap @@ -123,6 +123,61 @@ exports[`transformer > generates types for fastify-swagger correctly 1`] = ` } `; +exports[`transformer > should generate ref correctly 1`] = ` +{ + "components": { + "schemas": { + "Token": { + "maxLength": 12, + "minLength": 12, + "type": "string", + }, + }, + }, + "info": { + "description": "Sample backend service", + "title": "SampleApi", + "version": "1.0.0", + }, + "openapi": "3.0.3", + "paths": { + "/login": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "access_token": { + "$ref": "#/components/schemas/Token", + }, + "refresh_token": { + "$ref": "#/components/schemas/Token", + }, + }, + "required": [ + "access_token", + "refresh_token", + ], + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "200": { + "description": "Default Response", + }, + }, + }, + }, + }, + "servers": [], +} +`; + exports[`transformer > should not generate ref 1`] = ` { "components": { diff --git a/test/fastify-swagger.spec.ts b/test/fastify-swagger.spec.ts index c158c04..a68cbc0 100644 --- a/test/fastify-swagger.spec.ts +++ b/test/fastify-swagger.spec.ts @@ -5,7 +5,12 @@ import * as validator from 'oas-validator'; import { z } from 'zod'; import type { ZodTypeProvider } from '../src'; -import { jsonSchemaTransform, serializerCompiler, validatorCompiler } from '../src'; +import { + createJsonSchemaTransformObject, + jsonSchemaTransform, + serializerCompiler, + validatorCompiler, +} from '../src'; describe('transformer', () => { it('generates types for fastify-swagger correctly', async () => { @@ -142,4 +147,57 @@ describe('transformer', () => { expect(openApiSpec).toMatchSnapshot(); await validator.validate(openApiSpec, {}); }); + + it('should generate ref correctly', async () => { + const app = Fastify(); + app.setValidatorCompiler(validatorCompiler); + app.setSerializerCompiler(serializerCompiler); + + const TOKEN_SCHEMA = z.string().length(12); + + app.register(fastifySwagger, { + openapi: { + info: { + title: 'SampleApi', + description: 'Sample backend service', + version: '1.0.0', + }, + servers: [], + }, + transform: jsonSchemaTransform, + transformObject: createJsonSchemaTransformObject({ + schemas: { + Token: TOKEN_SCHEMA, + }, + }), + }); + + app.register(fastifySwaggerUI, { + routePrefix: '/documentation', + }); + + app.after(() => { + app.withTypeProvider().route({ + method: 'POST', + url: '/login', + schema: { + body: z.object({ + access_token: TOKEN_SCHEMA, + refresh_token: TOKEN_SCHEMA, + }), + }, + handler: (req, res) => { + res.send('ok'); + }, + }); + }); + + await app.ready(); + + const openApiSpecResponse = await app.inject().get('/documentation/json'); + const openApiSpec = JSON.parse(openApiSpecResponse.body); + + expect(openApiSpec).toMatchSnapshot(); + await validator.validate(openApiSpec, {}); + }); }); diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 75b843b..fe43e5f 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -7,7 +7,7 @@ import type { } from 'fastify'; import Fastify from 'fastify'; import { expectAssignable, expectType } from 'tsd'; -import z from 'zod'; +import { z } from 'zod'; import { serializerCompiler, validatorCompiler } from '../src/index'; import type { ZodTypeProvider } from '../src/index';