From 1d880278bf895fe7b6d147ede4aab16e44708164 Mon Sep 17 00:00:00 2001 From: Erk Struwe <9250548+erkstruwe@users.noreply.github.com> Date: Wed, 24 Jan 2024 01:55:38 +0000 Subject: [PATCH] [zod-nestjs] Recursively convert to OpenAPI 3.0 specification --- .../zod-nestjs/src/lib/create-zod-dto.spec.ts | 37 ++++++ packages/zod-nestjs/src/lib/create-zod-dto.ts | 121 +++++++++++------- 2 files changed, 114 insertions(+), 44 deletions(-) diff --git a/packages/zod-nestjs/src/lib/create-zod-dto.spec.ts b/packages/zod-nestjs/src/lib/create-zod-dto.spec.ts index 9eb4961..d6a15b2 100644 --- a/packages/zod-nestjs/src/lib/create-zod-dto.spec.ts +++ b/packages/zod-nestjs/src/lib/create-zod-dto.spec.ts @@ -113,6 +113,43 @@ describe('zod-nesjs create-zod-dto', () => { expect(generatedSchema?.unlimited.maximum).toBeUndefined(); expect(generatedSchema?.unlimited.exclusiveMaximum).toBeUndefined(); }); + + it('should convert to OpenAPI 3.0 in deep objects and arrays', () => { + const schema = z.object({ + person: z.object({ + name: z.string().nullable(), + tags: z.array( + z.object({ id: z.string(), name: z.string().nullable() }) + ), + }), + }); + const metadataFactory = getMetadataFactory(schema); + + const generatedSchema = metadataFactory(); + const personName = generatedSchema?.person.properties?.name as SchemaObject30 + const tags = generatedSchema?.person.properties?.tags as SchemaObject30 + const tagsItems = tags.items as SchemaObject30 + const tagName = tagsItems.properties?.name as SchemaObject30 + + expect(generatedSchema).toBeDefined(); + expect(personName.type).toEqual('string'); + expect(personName.nullable).toBe(true); + expect(tagName.type).toBe('string'); + expect(tagName.nullable).toBe(true); + }); + + it('should convert literal null value to OpenAPI 3.0', () => { + const schema = z.object({ + name: z.null(), + }); + const metadataFactory = getMetadataFactory(schema); + + const generatedSchema = metadataFactory(); + + expect(generatedSchema).toBeDefined(); + expect(generatedSchema?.name.type).toEqual('string'); + expect(generatedSchema?.name.nullable).toBe(true); + }); }); function getMetadataFactory(zodRef: OpenApiZodAny) { diff --git a/packages/zod-nestjs/src/lib/create-zod-dto.ts b/packages/zod-nestjs/src/lib/create-zod-dto.ts index 9ef0f89..760b867 100644 --- a/packages/zod-nestjs/src/lib/create-zod-dto.ts +++ b/packages/zod-nestjs/src/lib/create-zod-dto.ts @@ -1,5 +1,8 @@ import type { SchemaObject as SchemaObject30 } from 'openapi3-ts/oas30'; -import type { SchemaObject as SchemaObject31 } from 'openapi3-ts/oas31'; +import type { + ReferenceObject, + SchemaObject as SchemaObject31, +} from 'openapi3-ts/oas31'; import { generateSchema, OpenApiZodAny } from '@anatine/zod-openapi'; import * as z from 'zod'; @@ -81,50 +84,80 @@ export const createZodDto = ( | Record | undefined { const generatedSchema = generateSchema(zodSchema); - const properties = generatedSchema.properties ?? {}; - for (const key in properties) { - /** For some reason the SchemaObject model has everything except for the - * required field, which is an array. - * The NestJS swagger module requires this to be a boolean representative - * of each property. - * This logic takes the SchemaObject, and turns the required field from an - * array to a boolean. - */ - const schemaObject = properties[key]; - if ('$ref' in schemaObject) { - continue; - } - - const convertedSchemaObject = { - ...schemaObject, - } as SchemaObjectForMetadataFactory; - convertedSchemaObject.required = !!(generatedSchema.required !== - undefined, - generatedSchema.required?.includes(key)); - - // @nestjs/swagger expects OpenAPI 3.0-style schema objects - // Nullable - if (Array.isArray(schemaObject.type)) { - convertedSchemaObject.type = schemaObject.type.find( - (t) => t !== 'null' - ); - convertedSchemaObject.nullable = - schemaObject.type.includes('null') || undefined; - } - // Exclusive minimum and maximum - const { exclusiveMinimum, exclusiveMaximum } = schemaObject; - if (exclusiveMinimum !== undefined) { - convertedSchemaObject.minimum = exclusiveMinimum; - convertedSchemaObject.exclusiveMinimum = true; - } - if (exclusiveMaximum !== undefined) { - convertedSchemaObject.maximum = exclusiveMaximum; - convertedSchemaObject.exclusiveMaximum = true; - } - - properties[key] = convertedSchemaObject as any; // TODO: Fix this + SchemaHolderClass.convertSchemaObject(generatedSchema); + return generatedSchema.properties as Record; + } + + private static convertSchemaObject( + schemaObject: SchemaObject31 | ReferenceObject, + required?: boolean + ): void { + if ('$ref' in schemaObject) { + return; + } + + // Recursively convert all sub-schemas + const subSchemaObjects = [ + ...(schemaObject.allOf ?? []), + ...(schemaObject.oneOf ?? []), + ...(schemaObject.anyOf ?? []), + ...(schemaObject.not ? [schemaObject.not] : []), + ...(schemaObject.items ? [schemaObject.items] : []), + ...(typeof schemaObject.additionalProperties === 'object' + ? [schemaObject.additionalProperties] + : []), + ...(schemaObject.prefixItems ?? []), + ]; + for (const subSchemaObject of subSchemaObjects) { + SchemaHolderClass.convertSchemaObject(subSchemaObject); + } + + for (const [key, subSchemaObject] of Object.entries( + schemaObject.properties ?? {} + )) { + SchemaHolderClass.convertSchemaObject( + subSchemaObject, + schemaObject.required?.includes(key) + ); + } + + /** For some reason the SchemaObject model has everything except for the + * required field, which is an array. + * The NestJS swagger module requires this to be a boolean representative + * of each property. + * This logic takes the SchemaObject, and turns the required field from an + * array to a boolean. + */ + + const convertedSchemaObject = + schemaObject as SchemaObjectForMetadataFactory; + + if (required !== undefined) { + convertedSchemaObject.required = required; + } + + // @nestjs/swagger expects OpenAPI 3.0-style schema objects + // Nullable + if (Array.isArray(convertedSchemaObject.type)) { + convertedSchemaObject.nullable = + convertedSchemaObject.type.includes('null') || undefined; + convertedSchemaObject.type = convertedSchemaObject.type.find( + (item) => item !== 'null' + ); + } else if (convertedSchemaObject.type === 'null') { + convertedSchemaObject.type = 'string'; // There ist no explicit null value in OpenAPI 3.0 + convertedSchemaObject.nullable = true; + } + // Exclusive minimum and maximum + const { exclusiveMinimum, exclusiveMaximum } = schemaObject; + if (exclusiveMinimum !== undefined) { + convertedSchemaObject.minimum = exclusiveMinimum; + convertedSchemaObject.exclusiveMinimum = true; + } + if (exclusiveMaximum !== undefined) { + convertedSchemaObject.maximum = exclusiveMaximum; + convertedSchemaObject.exclusiveMaximum = true; } - return properties as Record; } public static create(input: unknown): CompatibleZodInfer {