Skip to content

Commit

Permalink
Merge pull request #182 from erkstruwe/main
Browse files Browse the repository at this point in the history
[zod-nestjs] Convert OpenAPI 3.1 to OpenAPI 3.0 for consumption by @nestjs/swagger
  • Loading branch information
Brian-McBride authored Jan 23, 2024
2 parents 4d53953 + 5a6611b commit 879096a
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 43 deletions.
103 changes: 77 additions & 26 deletions packages/zod-nestjs/src/lib/create-zod-dto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<string, SchemaObject30> | undefined;
};
return schemaHolderClass._OPENAPI_METADATA_FACTORY;
}
75 changes: 58 additions & 17 deletions packages/zod-nestjs/src/lib/create-zod-dto.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -27,12 +28,26 @@ export type CompatibleZodInfer<T extends CompatibleZodType> = T['_output'];

export type MergeZodSchemaOutput<T extends CompatibleZodType> =
T extends z.ZodDiscriminatedUnion<string, infer Options>
? Merge<object, TupleToUnion<{[X in keyof Options]: Options[X] extends z.ZodType ? Options[X]['_output'] : Options[X]}>>
: T extends z.ZodUnion<infer UnionTypes>
? 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'];
? Merge<
object,
TupleToUnion<{
[X in keyof Options]: Options[X] extends z.ZodType
? Options[X]['_output']
: Options[X];
}>
>
: T extends z.ZodUnion<infer UnionTypes>
? 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<T extends CompatibleZodType = CompatibleZodType> = {
new (): MergeZodSchemaOutput<T>;
Expand All @@ -41,7 +56,7 @@ export type ZodDtoStatic<T extends CompatibleZodType = CompatibleZodType> = {
};

// Used for transforming the SchemaObject in _OPENAPI_METADATA_FACTORY
type SchemaObjectForMetadataFactory = Omit<SchemaObject, 'required'> & {
type SchemaObjectForMetadataFactory = Omit<SchemaObject30, 'required'> & {
required: boolean | string[];
};

Expand All @@ -50,7 +65,7 @@ export const createZodDto = <T extends OpenApiZodAny>(
): ZodDtoStatic<T> => {
class SchemaHolderClass {
public static zodSchema = zodSchema;
schema: SchemaObject | undefined;
schema: SchemaObject31 | undefined;

constructor() {
this.schema = generateSchema(zodSchema);
Expand All @@ -63,7 +78,7 @@ export const createZodDto = <T extends OpenApiZodAny>(
* https://github.com/nestjs/swagger/blob/491b168cbff3003191e55ee96e77e69d8c1deb66/lib/plugin/plugin-constants.ts
*/
public static _OPENAPI_METADATA_FACTORY():
| Record<string, SchemaObject>
| Record<string, SchemaObject30>
| undefined {
const generatedSchema = generateSchema(zodSchema);
const properties = generatedSchema.properties ?? {};
Expand All @@ -75,15 +90,41 @@ export const createZodDto = <T extends OpenApiZodAny>(
* 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<string, SchemaObject>;
return properties as Record<string, SchemaObject30>;
}

public static create(input: unknown): CompatibleZodInfer<T> {
Expand Down

0 comments on commit 879096a

Please sign in to comment.