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 3d2d2bb..9eb4961 100644 --- a/packages/zod-nestjs/src/lib/create-zod-dto.spec.ts +++ b/packages/zod-nestjs/src/lib/create-zod-dto.spec.ts @@ -2,6 +2,8 @@ import * as z from 'zod'; import { ZodError } from 'zod'; import { createZodDto } from './create-zod-dto'; +import { SchemaObject as SchemaObject30 } from 'openapi3-ts/oas30'; +import { OpenApiZodAny } from '@anatine/zod-openapi'; describe('zod-nesjs create-zod-dto', () => { const testDtoSchema = z.object({ @@ -31,42 +33,91 @@ describe('zod-nesjs create-zod-dto', () => { }); it('should merge a discriminated union types for class', () => { - enum Kind { A, B }; - const discriminatedSchema = z - .discriminatedUnion('kind', [ - z.object({ - kind: z.literal(Kind.A), - value: z.number() - }), - z.object({ - kind: z.literal(Kind.B), - value: z.string() - }) - ]); + enum Kind { + A, + B, + } + const discriminatedSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal(Kind.A), + value: z.number(), + }), + z.object({ + kind: z.literal(Kind.B), + value: z.string(), + }), + ]); class TestDto extends createZodDto(discriminatedSchema) {} - const result = TestDto.create({kind: Kind.A, value: 1}) + const result = TestDto.create({ kind: Kind.A, value: 1 }); expect(result).toEqual({ kind: Kind.A, value: 1 }); }); it('should merge the union types for class', () => { - enum Kind { A, B }; - const unionSchema = z - .union([ - z.object({ - kind: z.literal(Kind.A), - value: z.number() - }), - z.object({ - kind: z.literal(Kind.B), - value: z.string() - }) - ]); + enum Kind { + A, + B, + } + const unionSchema = z.union([ + z.object({ + kind: z.literal(Kind.A), + value: z.number(), + }), + z.object({ + kind: z.literal(Kind.B), + value: z.string(), + }), + ]); class TestDto extends createZodDto(unionSchema) {} - const result = TestDto.create({kind: Kind.B, value: 'val'}) + const result = TestDto.create({ kind: Kind.B, value: 'val' }); expect(result).toEqual({ kind: Kind.B, value: 'val' }); }); + + it('should output OpenAPI 3.0-style nullable types', () => { + const schema = z.object({ + name: z.string().nullable(), + }); + const metadataFactory = getMetadataFactory(schema); + + const generatedSchema = metadataFactory(); + + expect(generatedSchema).toBeDefined(); + expect(generatedSchema?.name.type).toEqual('string'); + expect(generatedSchema?.name.nullable).toBe(true); + }); + + it('should output OpenAPI 3.0-style exclusive minimum and maximum types', () => { + const schema = z.object({ + inclusive: z.number().min(1).max(10), + exclusive: z.number().gt(1).lt(10), + unlimited: z.number(), + }); + const metadataFactory = getMetadataFactory(schema); + + const generatedSchema = metadataFactory(); + + expect(generatedSchema).toBeDefined(); + expect(generatedSchema?.inclusive.minimum).toBe(1); + expect(generatedSchema?.inclusive.exclusiveMinimum).toBeUndefined(); + expect(generatedSchema?.inclusive.maximum).toBe(10); + expect(generatedSchema?.inclusive.exclusiveMaximum).toBeUndefined(); + expect(generatedSchema?.exclusive.minimum).toBe(1); + expect(generatedSchema?.exclusive.exclusiveMinimum).toBe(true); + expect(generatedSchema?.exclusive.maximum).toBe(10); + expect(generatedSchema?.exclusive.exclusiveMaximum).toBe(true); + expect(generatedSchema?.unlimited.minimum).toBeUndefined(); + expect(generatedSchema?.unlimited.exclusiveMinimum).toBeUndefined(); + expect(generatedSchema?.unlimited.maximum).toBeUndefined(); + expect(generatedSchema?.unlimited.exclusiveMaximum).toBeUndefined(); + }); }); + +function getMetadataFactory(zodRef: OpenApiZodAny) { + const schemaHolderClass = createZodDto(zodRef) as unknown as { + _OPENAPI_METADATA_FACTORY: () => Record | undefined; + }; + return schemaHolderClass._OPENAPI_METADATA_FACTORY; +} diff --git a/packages/zod-nestjs/src/lib/create-zod-dto.ts b/packages/zod-nestjs/src/lib/create-zod-dto.ts index 91402c2..9ef0f89 100644 --- a/packages/zod-nestjs/src/lib/create-zod-dto.ts +++ b/packages/zod-nestjs/src/lib/create-zod-dto.ts @@ -1,4 +1,5 @@ -import type { SchemaObject } from 'openapi3-ts/oas31'; +import type { SchemaObject as SchemaObject30 } from 'openapi3-ts/oas30'; +import type { SchemaObject as SchemaObject31 } from 'openapi3-ts/oas31'; import { generateSchema, OpenApiZodAny } from '@anatine/zod-openapi'; import * as z from 'zod'; @@ -27,12 +28,26 @@ export type CompatibleZodInfer = T['_output']; export type MergeZodSchemaOutput = T extends z.ZodDiscriminatedUnion - ? Merge> - : T extends z.ZodUnion - ? UnionTypes extends z.ZodType[] - ? Merge> - : T['_output'] - : T['_output']; + ? Merge< + object, + TupleToUnion<{ + [X in keyof Options]: Options[X] extends z.ZodType + ? Options[X]['_output'] + : Options[X]; + }> + > + : T extends z.ZodUnion + ? UnionTypes extends z.ZodType[] + ? Merge< + object, + TupleToUnion<{ + [X in keyof UnionTypes]: UnionTypes[X] extends z.ZodType + ? UnionTypes[X]['_output'] + : UnionTypes[X]; + }> + > + : T['_output'] + : T['_output']; export type ZodDtoStatic = { new (): MergeZodSchemaOutput; @@ -41,7 +56,7 @@ export type ZodDtoStatic = { }; // Used for transforming the SchemaObject in _OPENAPI_METADATA_FACTORY -type SchemaObjectForMetadataFactory = Omit & { +type SchemaObjectForMetadataFactory = Omit & { required: boolean | string[]; }; @@ -50,7 +65,7 @@ export const createZodDto = ( ): ZodDtoStatic => { class SchemaHolderClass { public static zodSchema = zodSchema; - schema: SchemaObject | undefined; + schema: SchemaObject31 | undefined; constructor() { this.schema = generateSchema(zodSchema); @@ -63,7 +78,7 @@ export const createZodDto = ( * https://github.com/nestjs/swagger/blob/491b168cbff3003191e55ee96e77e69d8c1deb66/lib/plugin/plugin-constants.ts */ public static _OPENAPI_METADATA_FACTORY(): - | Record + | Record | undefined { const generatedSchema = generateSchema(zodSchema); const properties = generatedSchema.properties ?? {}; @@ -75,15 +90,41 @@ export const createZodDto = ( * This logic takes the SchemaObject, and turns the required field from an * array to a boolean. */ - const schemaObject = properties[key] as SchemaObjectForMetadataFactory; - const schemaObjectWithRequiredField = { + const schemaObject = properties[key]; + if ('$ref' in schemaObject) { + continue; + } + + const convertedSchemaObject = { ...schemaObject, - }; - schemaObjectWithRequiredField.required = !!(generatedSchema.required !== undefined, - generatedSchema.required?.includes(key)); - properties[key] = schemaObjectWithRequiredField as any; // TODO: Fix this + } 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 } - return properties as Record; + return properties as Record; } public static create(input: unknown): CompatibleZodInfer {