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] Recursively convert to OpenAPI 3.0 specification #185

Merged
merged 1 commit into from
Jan 26, 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
37 changes: 37 additions & 0 deletions packages/zod-nestjs/src/lib/create-zod-dto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
121 changes: 77 additions & 44 deletions packages/zod-nestjs/src/lib/create-zod-dto.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -81,50 +84,80 @@ export const createZodDto = <T extends OpenApiZodAny>(
| Record<string, SchemaObject30>
| 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<string, SchemaObject30>;
}

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<string, SchemaObject30>;
}

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