From 67a6e4f271b1d1deefc8308069582f25bff756ab Mon Sep 17 00:00:00 2001 From: Yauheni Date: Fri, 10 Nov 2023 16:48:47 +0100 Subject: [PATCH] feat: use references in lazy type --- .../__snapshots__/zod-to-openapi.test.ts.snap | 121 +++++++++++++++++ src/openapi/zod-to-openapi.test.ts | 128 +++++++++++++----- src/openapi/zod-to-openapi.ts | 87 +++++++++--- 3 files changed, 280 insertions(+), 56 deletions(-) diff --git a/src/openapi/__snapshots__/zod-to-openapi.test.ts.snap b/src/openapi/__snapshots__/zod-to-openapi.test.ts.snap index a94b986..88dda59 100644 --- a/src/openapi/__snapshots__/zod-to-openapi.test.ts.snap +++ b/src/openapi/__snapshots__/zod-to-openapi.test.ts.snap @@ -273,3 +273,124 @@ Object { "type": "object", } `; + +exports[`should serialize nested recursive schema 1`] = ` +Object { + "oneOf": Array [ + Object { + "properties": Object { + "children": Object { + "items": Object { + "$ref": "#/components/schemas/ZodLazy1", + }, + "type": "array", + }, + "root": Object { + "$ref": "#/components/schemas/ZodLazy0", + }, + "text": Object { + "type": "string", + }, + }, + "required": Array [ + "text", + "root", + "children", + ], + "type": "object", + }, + Object { + "type": "string", + }, + ], +} +`; + +exports[`should serialize nested recursive schema 2`] = ` +Object { + "ZodLazy0": Object { + "oneOf": Array [ + Object { + "properties": Object { + "children": Object { + "items": Object { + "$ref": "#/components/schemas/ZodLazy0", + }, + "type": "array", + }, + "text": Object { + "type": "string", + }, + }, + "required": Array [ + "text", + "children", + ], + "type": "object", + }, + Object { + "type": "string", + }, + ], + }, + "ZodLazy1": Object { + "oneOf": Array [ + Object { + "properties": Object { + "children": Object { + "items": Object { + "$ref": "#/components/schemas/ZodLazy1", + }, + "type": "array", + }, + "root": Object { + "$ref": "#/components/schemas/ZodLazy0", + }, + "text": Object { + "type": "string", + }, + }, + "required": Array [ + "text", + "root", + "children", + ], + "type": "object", + }, + Object { + "type": "string", + }, + ], + }, +} +`; + +exports[`should serialize root recursive schema 1`] = ` +Object { + "ZodLazy6d7efd287e": Object { + "oneOf": Array [ + Object { + "properties": Object { + "children": Object { + "items": Object { + "$ref": "#/components/schemas/ZodLazy6d7efd287e", + }, + "type": "array", + }, + "text": Object { + "type": "string", + }, + }, + "required": Array [ + "text", + "children", + ], + "type": "object", + }, + Object { + "type": "string", + }, + ], + }, +} +`; diff --git a/src/openapi/zod-to-openapi.test.ts b/src/openapi/zod-to-openapi.test.ts index b582fa2..9c58752 100644 --- a/src/openapi/zod-to-openapi.test.ts +++ b/src/openapi/zod-to-openapi.test.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto' +import { createMock } from '@golevelup/ts-jest' import { z, ZodTypeAny } from '../z' import { zodToOpenAPI } from './zod-to-openapi' @@ -73,38 +75,57 @@ const transformedSchema = z const lazySchema = z.lazy(() => z.string()) +const rootRecursiveSchema = z.lazy(() => z.union([rootNodeScheme, z.string()])) + +const rootNodeScheme: z.ZodType<{ text: string }> = z.object({ + text: z.string(), + children: z.array(rootRecursiveSchema), +}) + +const nestedNodeScheme: z.ZodType<{ text: string }> = z.object({ + text: z.string(), + root: rootRecursiveSchema, + children: z.array(z.lazy(() => nestedRecursiveSchema)), +}) + +const nestedRecursiveSchema = z.union([nestedNodeScheme, z.string()]) + it('should serialize a complex schema', () => { - const openApiObject = zodToOpenAPI(complexTestSchema) + const { refs, schema } = zodToOpenAPI(complexTestSchema) - expect(openApiObject).toMatchSnapshot() + expect(schema).toMatchSnapshot() + expect(refs).toEqual({}) }) it('should serialize an intersection of objects', () => { - const openApiObject = zodToOpenAPI(intersectedObjectsSchema) + const { refs, schema } = zodToOpenAPI(intersectedObjectsSchema) - expect(openApiObject).toMatchSnapshot() + expect(schema).toMatchSnapshot() + expect(refs).toEqual({}) }) it('should serialize an intersection of unions', () => { - const openApiObject = zodToOpenAPI(intersectedUnionsSchema) + const { refs, schema } = zodToOpenAPI(intersectedUnionsSchema) - expect(openApiObject).toMatchSnapshot() + expect(schema).toMatchSnapshot() + expect(refs).toEqual({}) }) it('should serialize an intersection with overrided fields', () => { - const openApiObject = zodToOpenAPI(overrideIntersectionSchema) + const { refs, schema } = zodToOpenAPI(overrideIntersectionSchema) - expect(openApiObject).toMatchSnapshot() + expect(schema).toMatchSnapshot() + expect(refs).toEqual({}) }) it('should serialize objects', () => { - const schema = z.object({ + const dto = z.object({ prop1: z.string(), prop2: z.string().optional(), }) - const openApiObject = zodToOpenAPI(schema) + const { refs, schema } = zodToOpenAPI(dto) - expect(openApiObject).toEqual({ + expect(schema).toEqual({ type: 'object', required: ['prop1'], properties: { @@ -116,18 +137,19 @@ it('should serialize objects', () => { }, }, }) + expect(refs).toEqual({}) }) it('should serialize partial objects', () => { - const schema = z + const dto = z .object({ prop1: z.string(), prop2: z.string(), }) .partial() - const openApiObject = zodToOpenAPI(schema) + const { refs, schema } = zodToOpenAPI(dto) - expect(openApiObject).toEqual({ + expect(schema).toEqual({ type: 'object', properties: { prop1: { @@ -138,37 +160,42 @@ it('should serialize partial objects', () => { }, }, }) + expect(refs).toEqual({}) }) it('should serialize nullable types', () => { - const schema = z.string().nullable() - const openApiObject = zodToOpenAPI(schema) + const dto = z.string().nullable() + const { refs, schema } = zodToOpenAPI(dto) - expect(openApiObject).toEqual({ type: 'string', nullable: true }) + expect(schema).toEqual({ type: 'string', nullable: true }) + expect(refs).toEqual({}) }) it('should serialize optional types', () => { - const schema = z.string().optional() - const openApiObject = zodToOpenAPI(schema) + const dto = z.string().optional() + const { refs, schema } = zodToOpenAPI(dto) - expect(openApiObject).toEqual({ type: 'string' }) + expect(schema).toEqual({ type: 'string' }) + expect(refs).toEqual({}) }) it('should serialize types with default value', () => { - const schema = z.string().default('abitia') - const openApiObject = zodToOpenAPI(schema) + const dto = z.string().default('abitia') + const { refs, schema } = zodToOpenAPI(dto) - expect(openApiObject).toEqual({ type: 'string', default: 'abitia' }) + expect(schema).toEqual({ type: 'string', default: 'abitia' }) + expect(refs).toEqual({}) }) it('should serialize enums', () => { - const schema = z.enum(['adama', 'kota']) - const openApiObject = zodToOpenAPI(schema) + const dto = z.enum(['adama', 'kota']) + const { refs, schema } = zodToOpenAPI(dto) - expect(openApiObject).toEqual({ + expect(schema).toEqual({ type: 'string', enum: ['adama', 'kota'], }) + expect(refs).toEqual({}) }) it('should serialize native enums', () => { @@ -177,14 +204,15 @@ it('should serialize native enums', () => { KOTA = 'kota', } - const schema = z.nativeEnum(NativeEnum) - const openApiObject = zodToOpenAPI(schema) + const dto = z.nativeEnum(NativeEnum) + const { refs, schema } = zodToOpenAPI(dto) - expect(openApiObject).toEqual({ + expect(schema).toEqual({ 'type': 'string', 'enum': ['adama', 'kota'], 'x-enumNames': ['ADAMA', 'KOTA'], }) + expect(refs).toEqual({}) }) describe('scalar types', () => { @@ -201,20 +229,21 @@ describe('scalar types', () => { for (const [zodType, expectedType, expectedFormat] of testCases) { // eslint-disable-next-line no-loop-func it(expectedType, () => { - const openApiObject = zodToOpenAPI(zodType) + const { refs, schema } = zodToOpenAPI(zodType) - expect(openApiObject).toEqual({ + expect(schema).toEqual({ type: expectedType, format: expectedFormat ?? undefined, }) + expect(refs).toEqual({}) }) } }) it('should serialize transformed schema', () => { - const openApiObject = zodToOpenAPI(transformedSchema) + const { refs, schema } = zodToOpenAPI(transformedSchema) - expect(openApiObject).toEqual({ + expect(schema).toEqual({ type: 'object', required: ['seconds'], properties: { @@ -226,9 +255,38 @@ it('should serialize transformed schema', () => { }) it('should serialize lazy schema', () => { - const openApiObject = zodToOpenAPI(lazySchema) + const { refs, schema } = zodToOpenAPI(lazySchema) - expect(openApiObject).toEqual({ + expect(schema).toEqual({ type: 'string', }) + expect(refs).toEqual({}) +}) + +it('should serialize root recursive schema', () => { + const id = '6d7efd287e' + + jest + .spyOn(crypto, 'randomBytes') + .mockImplementation(() => ({ toString: () => id })) + + const { refs, schema } = zodToOpenAPI(rootRecursiveSchema) + + expect(schema).toEqual({ + $ref: `#/components/schemas/ZodLazy${id}`, + }) + expect(refs).toMatchSnapshot() +}) + +it('should serialize nested recursive schema', () => { + let i = 0 + + jest + .spyOn(crypto, 'randomBytes') + .mockImplementation(() => ({ toString: () => i++ })) + + const { refs, schema } = zodToOpenAPI(nestedRecursiveSchema) + + expect(schema).toMatchSnapshot() + expect(refs).toMatchSnapshot() }) diff --git a/src/openapi/zod-to-openapi.ts b/src/openapi/zod-to-openapi.ts index 8d0f32c..c5c51ee 100644 --- a/src/openapi/zod-to-openapi.ts +++ b/src/openapi/zod-to-openapi.ts @@ -1,5 +1,9 @@ +import crypto from 'crypto' import { Type } from '@nestjs/common' -import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' +import { + ReferenceObject, + SchemaObject, +} from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' import mergeDeep from 'merge-deep' import { z } from '../z' @@ -18,9 +22,13 @@ export function is>( export function zodToOpenAPI( zodType: z.ZodTypeAny, - visited: Set = new Set() + visited: Map< + () => z.ZodTypeAny, + ExtendedSchemaObject | ReferenceObject + > = new Map(), + refs: Partial> = {} ) { - const object: ExtendedSchemaObject = {} + const object: ExtendedSchemaObject | ReferenceObject = {} if (zodType.description) { object.description = zodType.description @@ -111,13 +119,15 @@ export function zodToOpenAPI( object.type = 'array' if (minLength) object.minItems = minLength.value if (maxLength) object.maxItems = maxLength.value - object.items = zodToOpenAPI(type, visited) + object.items = zodToOpenAPI(type, visited, refs).schema } if (is(zodType, z.ZodTuple)) { const { items } = zodType._def object.type = 'array' - object.items = { oneOf: items.map((item) => zodToOpenAPI(item, visited)) } + object.items = { + oneOf: items.map((item) => zodToOpenAPI(item, visited, refs).schema), + } } if (is(zodType, z.ZodSet)) { @@ -125,20 +135,22 @@ export function zodToOpenAPI( object.type = 'array' if (minSize) object.minItems = minSize.value if (maxSize) object.maxItems = maxSize.value - object.items = zodToOpenAPI(valueType, visited) + object.items = zodToOpenAPI(valueType, visited, refs).schema object.uniqueItems = true } if (is(zodType, z.ZodUnion)) { const { options } = zodType._def - object.oneOf = options.map((option) => zodToOpenAPI(option, visited)) + object.oneOf = options.map( + (option) => zodToOpenAPI(option, visited, refs).schema + ) } if (is(zodType, z.ZodDiscriminatedUnion)) { const { options } = zodType._def object.oneOf = [] for (const schema of options.values()) { - object.oneOf.push(zodToOpenAPI(schema, visited)) + object.oneOf.push(zodToOpenAPI(schema, visited, refs).schema) } } @@ -178,23 +190,23 @@ export function zodToOpenAPI( if (is(zodType, z.ZodTransformer)) { const { schema } = zodType._def - Object.assign(object, zodToOpenAPI(schema, visited)) + Object.assign(object, zodToOpenAPI(schema, visited, refs).schema) } if (is(zodType, z.ZodNullable)) { const { innerType } = zodType._def - Object.assign(object, zodToOpenAPI(innerType, visited)) + Object.assign(object, zodToOpenAPI(innerType, visited, refs).schema) object.nullable = true } if (is(zodType, z.ZodOptional)) { const { innerType } = zodType._def - Object.assign(object, zodToOpenAPI(innerType, visited)) + Object.assign(object, zodToOpenAPI(innerType, visited, refs).schema) } if (is(zodType, z.ZodDefault)) { const { defaultValue, innerType } = zodType._def - Object.assign(object, zodToOpenAPI(innerType, visited)) + Object.assign(object, zodToOpenAPI(innerType, visited, refs).schema) object.default = defaultValue() } @@ -206,7 +218,7 @@ export function zodToOpenAPI( object.required = [] for (const [key, schema] of Object.entries(shape())) { - object.properties[key] = zodToOpenAPI(schema, visited) + object.properties[key] = zodToOpenAPI(schema, visited, refs).schema const optionalTypes = [z.ZodOptional.name, z.ZodDefault.name] const isOptional = optionalTypes.includes(schema.constructor.name) if (!isOptional) object.required.push(key) @@ -220,30 +232,63 @@ export function zodToOpenAPI( if (is(zodType, z.ZodRecord)) { const { valueType } = zodType._def object.type = 'object' - object.additionalProperties = zodToOpenAPI(valueType, visited) + object.additionalProperties = zodToOpenAPI(valueType, visited, refs).schema } if (is(zodType, z.ZodIntersection)) { const { left, right } = zodType._def const merged = mergeDeep( - zodToOpenAPI(left, visited), - zodToOpenAPI(right, visited) + zodToOpenAPI(left, visited, refs).schema, + zodToOpenAPI(right, visited, refs).schema ) Object.assign(object, merged) } if (is(zodType, z.ZodEffects)) { const { schema } = zodType._def - Object.assign(object, zodToOpenAPI(schema, visited)) + Object.assign(object, zodToOpenAPI(schema, visited, refs).schema) } if (is(zodType, z.ZodLazy)) { const { getter } = zodType._def - if (visited.has(getter)) return object - visited.add(getter) - Object.assign(object, zodToOpenAPI(getter(), visited)) + if (visited.has(getter)) { + const visitedObject = visited.get(getter)! + + if ('$ref' in visitedObject) { + return { refs, schema: { $ref: visitedObject.$ref } } + } + + const refName = `${zodType._def.typeName}${crypto + .randomBytes(5) + .toString('hex')}` + + Object.assign(visitedObject, { $ref: `#/components/schemas/${refName}` }) + + return { refs, schema: visitedObject } + } + + visited.set(getter, object) + const mergedObject = Object.assign( + object, + zodToOpenAPI(getter(), visited, refs).schema + ) + + if ('$ref' in mergedObject) { + const ref: ExtendedSchemaObject = {} + refs[mergedObject.$ref.replace('#/components/schemas/', '')] = ref + + for (const key in mergedObject) { + const objectKey = key as keyof typeof mergedObject + + if (objectKey === '$ref' || !Object.hasOwn(mergedObject, objectKey)) + continue + + ref[objectKey] = mergedObject[objectKey] + delete mergedObject[objectKey] + } + } } - return object + return { refs, schema: object } }