Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[zod-nestjs] Convert OpenAPI 3.1 to OpenAPI 3.0 for consumption by @nestjs/swagger #182

Merged
merged 3 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading