Skip to content
Open
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
11 changes: 7 additions & 4 deletions spec/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { OpenApiGeneratorV31 } from '../../src/v3.1/openapi-generator';
import {
OpenApiGeneratorOptions,
OpenApiVersion,
SchemaRefValue,
SchemaRefs,
} from '../../src/openapi-generator';

export function createSchemas(
Expand All @@ -41,14 +41,17 @@ export function createSchemas(

const { components } = generator.generateComponents();

const schemaRefs: Record<string, SchemaRefValue> = (generator as any)
.generator.schemaRefs;
const schemaRefs: SchemaRefs = (generator as any).generator.schemaRefs;
const schemaValues = Object.values(schemaRefs);

const pendingSchemas = schemaValues.filter(
value => Array.isArray(value) && value[0] === 'pending'
);

// At no point should we have pending as leftover in the specs.
// They are filtered when generating the final document but
// in general we should never have a schema left in pending state
expect(schemaValues).not.toContain('pending');
expect(pendingSchemas).toEqual([]);

return components;
}
Expand Down
71 changes: 61 additions & 10 deletions spec/types/recursive-schemas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ describe('recursive schemas (new getter approach)', () => {
.object({
name: z.string(),
get child() {
return recursiveSchema.optional();
return recursiveSchema.optional().openapi({
deprecated: true,
});
},
})
.openapi('RecursiveWithMeta', {
Expand All @@ -312,7 +314,12 @@ describe('recursive schemas (new getter approach)', () => {
example: { name: 'root', child: { name: 'child' } },
properties: {
name: { type: 'string' },
child: { $ref: '#/components/schemas/RecursiveWithMeta' },
child: {
allOf: [
{ $ref: '#/components/schemas/RecursiveWithMeta' },
{ deprecated: true },
],
},
},
required: ['name'],
},
Expand All @@ -324,22 +331,64 @@ describe('recursive schemas (new getter approach)', () => {
.object({
value: z.string(),
get next() {
return recursiveSchema.nullable().optional();
return recursiveSchema
.nullable()
.openapi({ description: 'This can be null' });
},
})
.openapi('NullableRecursive');
.openapi('NullableRecursive', { deprecated: true });

expectSchema([recursiveSchema], {
NullableRecursive: {
type: 'object',
properties: {
value: { type: 'string' },
next: {
$ref: '#/components/schemas/NullableRecursive',
nullable: true,
allOf: [
{
oneOf: [
{ $ref: '#/components/schemas/NullableRecursive' },
{ nullable: true },
],
},
{ description: 'This can be null' },
],
},
},
required: ['value'],
deprecated: true,

required: ['value', 'next'],
},
});
});

it('supports recursive schemas with manual type passed as metadata', () => {
const recursiveSchema = z
.object({
value: z.string(),
get next() {
return recursiveSchema.openapi({
type: 'object',
});
},
})
.openapi('RecursiveWithMetadata', { example: 3 });

expectSchema([recursiveSchema], {
RecursiveWithMetadata: {
type: 'object',
properties: {
value: { type: 'string' },
next: {
allOf: [
{ $ref: '#/components/schemas/RecursiveWithMetadata' },
{ type: 'object' },
],
},
},
example: 3,

required: ['value', 'next'],
},
});
});
Expand Down Expand Up @@ -468,7 +517,7 @@ describe('recursive schemas (new getter approach)', () => {
properties: {
value: { type: 'string' },
child: {
anyOf: [
oneOf: [
{ $ref: '#/components/schemas/RecursiveNullable' },
{ type: 'null' },
],
Expand Down Expand Up @@ -499,8 +548,10 @@ describe('recursive schemas (new getter approach)', () => {
properties: {
value: { type: 'string' },
child: {
$ref: '#/components/schemas/RecursiveNullable',
nullable: true,
oneOf: [
{ $ref: '#/components/schemas/RecursiveNullable' },
{ nullable: true },
],
},
},
required: ['value'],
Expand Down
95 changes: 83 additions & 12 deletions src/openapi-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,12 @@ export interface OpenApiGeneratorOptions {
sortComponents?: 'alphabetically';
}

export type SchemaRefValue = SchemaObject | ReferenceObject | 'pending';
type SchemaRefValue = SchemaObject | ReferenceObject | ['pending', ZodType];

export type SchemaRefs = Record<string, SchemaRefValue>;

export class OpenAPIGenerator {
private schemaRefs: Record<string, SchemaRefValue> = {};
private schemaRefs: SchemaRefs = {};
private paramRefs: Record<string, ParameterObject> = {};
private pathRefs: Record<string, PathItemObject> = {};
private rawComponents: {
Expand Down Expand Up @@ -154,7 +156,8 @@ export class OpenAPIGenerator {
private isNotPendingRefEntry(
entry: [string, SchemaRefValue]
): entry is [string, SchemaObject | ReferenceObject] {
return entry[1] !== 'pending';
const value = entry[1];
return !Array.isArray(value) || value[0] !== 'pending';
}

private get filteredSchemaRefs() {
Expand Down Expand Up @@ -414,21 +417,25 @@ export class OpenAPIGenerator {
const refId = Metadata.getRefId(zodSchema);

// TODO: Do I need a similar implementation as bellow inside constructReferencedOpenAPISchema
if (refId && typeof this.schemaRefs[refId] === 'object') {
if (
refId &&
this.schemaRefs[refId] &&
!Array.isArray(this.schemaRefs[refId])
) {
return this.schemaRefs[refId];
}

// If there is already a pending generation with this name
// reference it directly. This means that it is recursive
if (refId && this.schemaRefs[refId] === 'pending') {
if (refId && Array.isArray(this.schemaRefs[refId])) {
return { $ref: this.generateSchemaRef(refId) };
}

// We start the generation by setting the ref to pending for
// any future recursive definition. It would get set to a proper
// value within `generateSchemaWithRef`
if (refId && !this.schemaRefs[refId]) {
this.schemaRefs[refId] = 'pending';
this.schemaRefs[refId] = ['pending', zodSchema];
}

const result = metadata?.type
Expand Down Expand Up @@ -462,7 +469,11 @@ export class OpenAPIGenerator {
const refId = Metadata.getRefId(zodSchema);

// TODO: Extract this in a Recursive transformer and reuse here and within LazyTransformer
if (refId && typeof this.schemaRefs[refId] === 'object') {
if (
refId &&
this.schemaRefs[refId] &&
!Array.isArray(this.schemaRefs[refId])
) {
if ('$ref' in this.schemaRefs[refId]) {
return this.schemaRefs[refId];
}
Expand All @@ -482,15 +493,15 @@ export class OpenAPIGenerator {

// If there is already a pending generation with this name
// reference it directly. This means that it is recursive
if (refId && this.schemaRefs[refId] === 'pending') {
if (refId && Array.isArray(this.schemaRefs[refId])) {
return { $ref: this.generateSchemaRef(refId) };
}

// We start the generation by setting the ref to pending for
// any future recursive definition. It would get set to a proper
// value within `generateSchemaWithRef`
if (refId && !this.schemaRefs[refId]) {
this.schemaRefs[refId] = 'pending';
this.schemaRefs[refId] = ['pending', zodSchema];
}

return this.toOpenAPISchema(innerSchema, isNullable, defaultValue);
Expand All @@ -510,16 +521,76 @@ export class OpenAPIGenerator {
return this.generateSchemaWithMetadata(zodSchema);
}

const schemaRef = this.schemaRefs[refId] as SchemaObject;
const referenceObject: ReferenceObject = {
$ref: this.generateSchemaRef(refId),
};

// We are currently calculating this schema or there is nothing
if (this.schemaRefs[refId] === 'pending') {
return referenceObject;
if (Array.isArray(this.schemaRefs[refId])) {
const schemaAtDefinition = this.schemaRefs[refId][1];

const metadataAtDefinition =
Metadata.getOpenApiMetadata(schemaAtDefinition);

const schemaRef = metadataAtDefinition as SchemaObject;
// Metadata provided from .openapi() that is new to what we had already registered
const newMetadata = omitBy(
Metadata.buildSchemaMetadata(metadata ?? {}),
(value, key) =>
value === undefined || objectEquals(value, schemaRef[key])
);

// Do not calculate schema metadata overrides if type is provided in .openapi
// https://github.com/asteasolutions/zod-to-openapi/pull/52/files/8ff707fe06e222bc573ed46cf654af8ee0b0786d#r996430801
if (newMetadata.type) {
return {
allOf: [referenceObject, newMetadata],
};
}

// TODO: Not sure if we need to bring this back

// New metadata from zodSchema properties.
// const newSchemaMetadata = omitBy(
// this.openApiTransformer.toNullableType(
// zodSchema,
// isNullableSchema(zodSchema)
// ),
// (value, key) =>
// value === undefined ||
// objectEquals(
// value,
// this.openApiTransformer.toNullableType(
// schemaAtDefinition,
// isNullableSchema(schemaAtDefinition)
// )[key]
// )
// );

let typeSchema: SchemaObject | ReferenceObject = referenceObject;
if (
isNullableSchema(zodSchema) &&
!isNullableSchema(schemaAtDefinition)
) {
typeSchema = {
oneOf: this.versionSpecifics.mapNullableOfArray(
[referenceObject],
true
),
};
}

if (Object.keys(newMetadata).length > 0) {
return {
allOf: [typeSchema, newMetadata],
};
}

return typeSchema;
}

const schemaRef = this.schemaRefs[refId] as SchemaObject;

// Metadata provided from .openapi() that is new to what we had already registered
const newMetadata = omitBy(
Metadata.buildSchemaMetadata(metadata ?? {}),
Expand Down
6 changes: 5 additions & 1 deletion src/transformers/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { MapNullableType, MapSubSchema } from '../types';
import { $ZodCheckMinLength, $ZodCheckMaxLength } from 'zod/core';
import { isAnyZodType } from '../lib/zod-is-type';
export class ArrayTransformer {
get openApiType() {
return 'array' as const;
}

transform(
zodSchema: ZodArray,
mapNullableType: MapNullableType,
Expand All @@ -21,7 +25,7 @@ export class ArrayTransformer {
)?._zod.def.maximum;

return {
...mapNullableType('array'),
...mapNullableType(this.openApiType),
items: isAnyZodType(itemType) ? mapItems(itemType) : {},

minItems,
Expand Down
6 changes: 5 additions & 1 deletion src/transformers/big-int.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { MapNullableType } from '../types';

export class BigIntTransformer {
get openApiType() {
return 'string' as const;
}

transform(mapNullableType: MapNullableType) {
return {
...mapNullableType('string'),
...mapNullableType(this.openApiType),
pattern: `^\d+$`,
};
}
Expand Down
6 changes: 5 additions & 1 deletion src/transformers/date.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { MapNullableType } from '../types';

export class DateTransformer {
get openApiType() {
return 'string' as const;
}

transform(mapNullableType: MapNullableType) {
return {
...mapNullableType('string'),
...mapNullableType(this.openApiType),
format: 'date',
};
}
Expand Down
13 changes: 13 additions & 0 deletions src/transformers/discriminated-union.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@ import {
DiscriminatorObject,
MapNullableOfArrayWithNullable,
MapSubSchema,
SchemaObject,
} from '../types';
import { isString } from '../lib/lodash';
import { isZodType } from '../lib/zod-is-type';
import { Metadata } from '../metadata';

export class DiscriminatedUnionTransformer {
openApiType(zodSchema: ZodDiscriminatedUnion, mapToType: MapSubSchema) {
const options = [...zodSchema.def.options] as ZodObject[];

const optionSchema = options.map(mapToType);

const oneOfSchema: SchemaObject = {
oneOf: optionSchema,
};

return oneOfSchema;
}

transform(
zodSchema: ZodDiscriminatedUnion,
isNullable: boolean,
Expand Down
Loading