diff --git a/spec/lib/helpers.ts b/spec/lib/helpers.ts index 9d0bb00..5e7e683 100644 --- a/spec/lib/helpers.ts +++ b/spec/lib/helpers.ts @@ -18,6 +18,7 @@ import { OpenApiGeneratorV31 } from '../../src/v3.1/openapi-generator'; import { OpenApiGeneratorOptions, OpenApiVersion, + SchemaRefValue, } from '../../src/openapi-generator'; export function createSchemas( @@ -36,10 +37,18 @@ export function createSchemas( const OpenApiGenerator = openApiVersion === '3.1.0' ? OpenApiGeneratorV31 : OpenApiGeneratorV3; - const { components } = new OpenApiGenerator( - definitions, - options - ).generateComponents(); + const generator = new OpenApiGenerator(definitions, options); + + const { components } = generator.generateComponents(); + + const schemaRefs: Record = (generator as any) + .generator.schemaRefs; + const schemaValues = Object.values(schemaRefs); + + // 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'); return components; } diff --git a/spec/types/lazy.spec.ts b/spec/types/lazy.spec.ts new file mode 100644 index 0000000..8378802 --- /dev/null +++ b/spec/types/lazy.spec.ts @@ -0,0 +1,681 @@ +import { z } from 'zod'; +import { expectSchema } from '../lib/helpers'; +import { SchemaObject } from 'src/types'; + +// Based on the "Any Type" section of https://swagger.io/docs/specification/data-models/data-types/ + +describe('lazy', () => { + describe('basic functionality', () => { + it('supports not registered lazy schemas', () => { + const schema = z + .object({ key: z.lazy(() => z.string()) }) + .openapi('Test'); + + expectSchema([schema], { + Test: { + type: 'object', + properties: { + key: { + type: 'string', + }, + }, + required: ['key'], + }, + }); + }); + + it('supports registered non-recursive lazy schemas', () => { + const lazySchema = z.lazy(() => z.string()).openapi('LazyString'); + + expectSchema([lazySchema], { + LazyString: { + type: 'string', + }, + }); + }); + + it('supports registered recursive lazy schemas', () => { + const baseCategorySchema = z.object({ + name: z.string(), + }); + + type Category = z.infer & { + subcategory: Category; + }; + + const categorySchema: z.ZodType = baseCategorySchema + .extend({ + subcategory: z.lazy(() => categorySchema), + }) + .openapi('Category'); + + expectSchema([categorySchema], { + Category: { + type: 'object', + properties: { + name: { type: 'string' }, + subcategory: { + $ref: '#/components/schemas/Category', + }, + }, + required: ['name', 'subcategory'], + }, + }); + }); + }); + + describe('complex nested structures', () => { + it('supports arrays of lazy schemas', () => { + const itemSchema = z + .lazy(() => + z.object({ + id: z.string(), + value: z.number(), + }) + ) + .openapi('LazyItem'); + + const arraySchema = z.array(itemSchema).openapi('ItemArray'); + + expectSchema([itemSchema, arraySchema], { + LazyItem: { + type: 'object', + properties: { + id: { type: 'string' }, + value: { type: 'number' }, + }, + required: ['id', 'value'], + }, + ItemArray: { + type: 'array', + items: { + $ref: '#/components/schemas/LazyItem', + }, + }, + }); + }); + + it('supports lazy schemas in unions', () => { + const stringSchema = z.lazy(() => z.string()).openapi('LazyString'); + const numberSchema = z.lazy(() => z.number()).openapi('LazyNumber'); + + const unionSchema = z + .union([stringSchema, numberSchema]) + .openapi('LazyUnion'); + + expectSchema([stringSchema, numberSchema, unionSchema], { + LazyString: { type: 'string' }, + LazyNumber: { type: 'number' }, + LazyUnion: { + anyOf: [ + { $ref: '#/components/schemas/LazyString' }, + { $ref: '#/components/schemas/LazyNumber' }, + ], + }, + }); + }); + + it('supports lazy schemas in intersections', () => { + const baseSchema = z + .lazy(() => + z.object({ + id: z.string(), + }) + ) + .openapi('LazyBase'); + + const metaSchema = z + .lazy(() => + z.object({ + metadata: z.record(z.string(), z.string()), + }) + ) + .openapi('LazyMeta'); + + const intersectionSchema = z + .intersection(baseSchema, metaSchema) + .openapi('LazyIntersection'); + + expectSchema([baseSchema, metaSchema, intersectionSchema], { + LazyBase: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + LazyMeta: { + type: 'object', + properties: { + metadata: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + required: ['metadata'], + }, + LazyIntersection: { + allOf: [ + { $ref: '#/components/schemas/LazyBase' }, + { $ref: '#/components/schemas/LazyMeta' }, + ], + }, + }); + }); + + it('supports multiple levels of lazy nesting', () => { + const leafSchema = z.lazy(() => z.string()).openapi('LazyLeaf'); + const branchSchema = z + .lazy(() => + z.object({ + leaf: leafSchema, + value: z.number(), + }) + ) + .openapi('LazyBranch'); + const treeSchema = z + .lazy(() => + z.object({ + branch: branchSchema, + name: z.string(), + }) + ) + .openapi('LazyTree'); + + expectSchema([leafSchema, branchSchema, treeSchema], { + LazyLeaf: { type: 'string' }, + LazyBranch: { + type: 'object', + properties: { + leaf: { $ref: '#/components/schemas/LazyLeaf' }, + value: { type: 'number' }, + }, + required: ['leaf', 'value'], + }, + LazyTree: { + type: 'object', + properties: { + branch: { $ref: '#/components/schemas/LazyBranch' }, + name: { type: 'string' }, + }, + required: ['branch', 'name'], + }, + }); + }); + }); + + describe('metadata and modifiers', () => { + it('supports lazy schemas with OpenAPI metadata', () => { + const lazySchema = z + .lazy(() => z.string()) + .openapi('LazyWithMeta', { + description: 'A lazy string schema', + example: 'lazy example', + minLength: 5, + maxLength: 100, + }); + + expectSchema([lazySchema], { + LazyWithMeta: { + type: 'string', + description: 'A lazy string schema', + example: 'lazy example', + minLength: 5, + maxLength: 100, + }, + }); + }); + + it('supports optional lazy schemas', () => { + const optionalLazySchema = z + .object({ + requiredField: z.string(), + optionalLazy: z.lazy(() => z.number()).optional(), + }) + .openapi('OptionalLazy'); + + expectSchema([optionalLazySchema], { + OptionalLazy: { + type: 'object', + properties: { + requiredField: { type: 'string' }, + optionalLazy: { type: 'number' }, + }, + required: ['requiredField'], + }, + }); + }); + + it('supports nullable lazy schemas', () => { + const nullableLazySchema = z + .object({ + field: z.lazy(() => z.string()).nullable(), + }) + .openapi('NullableLazy'); + + expectSchema([nullableLazySchema], { + NullableLazy: { + type: 'object', + properties: { + field: { + type: 'string', + nullable: true, + }, + }, + required: ['field'], + }, + }); + }); + + it('supports lazy schemas with defaults', () => { + const defaultLazySchema = z + .object({ + field: z.lazy(() => z.string()).default('default value'), + }) + .openapi('DefaultLazy'); + + expectSchema([defaultLazySchema], { + DefaultLazy: { + type: 'object', + properties: { + field: { + type: 'string', + default: 'default value', + }, + }, + }, + }); + }); + + it('supports lazy schemas with refinements', () => { + const refinedLazySchema = z + .lazy(() => + z.string().refine(val => val.length > 0, 'String must not be empty') + ) + .openapi('RefinedLazy'); + + expectSchema([refinedLazySchema], { + RefinedLazy: { + type: 'string', + // Note: refinements don't typically show up in OpenAPI schemas + // but the schema should still generate correctly + }, + }); + }); + }); + + describe('complex recursive scenarios', () => { + it('supports mutual recursion between schemas', () => { + const personSchema: z.ZodType = z + .lazy(() => + z.object({ + name: z.string(), + company: companySchema.optional(), + }) + ) + .openapi('Person'); + + const companySchema: z.ZodType = z + .lazy(() => + z.object({ + name: z.string(), + employees: z.array(personSchema), + }) + ) + .openapi('Company'); + + expectSchema([personSchema, companySchema], { + Person: { + type: 'object', + properties: { + name: { type: 'string' }, + company: { $ref: '#/components/schemas/Company' }, + }, + required: ['name'], + }, + Company: { + type: 'object', + properties: { + name: { type: 'string' }, + employees: { + type: 'array', + items: { $ref: '#/components/schemas/Person' }, + }, + }, + required: ['name', 'employees'], + }, + }); + }); + + it('supports deeply nested recursive tree structures', () => { + type TreeNode = { + id: string; + value: number; + children: TreeNode[]; + parent?: TreeNode; + }; + + const treeNodeSchema: z.ZodType = z + .lazy(() => + z.object({ + id: z.string(), + value: z.number(), + children: z.array(treeNodeSchema), + parent: treeNodeSchema.optional(), + }) + ) + .openapi('TreeNode'); + + expectSchema([treeNodeSchema], { + TreeNode: { + type: 'object', + properties: { + id: { type: 'string' }, + value: { type: 'number' }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/TreeNode' }, + }, + parent: { $ref: '#/components/schemas/TreeNode' }, + }, + required: ['id', 'value', 'children'], + }, + }); + }); + + it('supports recursive schemas with discriminated unions', () => { + const nodeSchema: z.ZodType = z + .lazy(() => + z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('leaf'), + value: z.string(), + }) + .openapi('Leaf'), + z + .object({ + type: z.literal('branch'), + children: z.array(nodeSchema), + }) + .openapi('Branch'), + ]) + ) + .openapi('RecursiveNode'); + + expectSchema([nodeSchema], { + RecursiveNode: { + discriminator: { + propertyName: 'type', + mapping: { + leaf: '#/components/schemas/Leaf', + branch: '#/components/schemas/Branch', + }, + }, + oneOf: [ + { $ref: '#/components/schemas/Leaf' }, + { $ref: '#/components/schemas/Branch' }, + ], + }, + Leaf: { + type: 'object', + properties: { + type: { type: 'string', enum: ['leaf'] }, + value: { type: 'string' }, + }, + required: ['type', 'value'], + }, + Branch: { + type: 'object', + properties: { + type: { type: 'string', enum: ['branch'] }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/RecursiveNode' }, + }, + }, + required: ['type', 'children'], + }, + }); + }); + }); + + describe('lazy schemas with complex types', () => { + it('supports lazy record schemas', () => { + const lazyRecordSchema = z + .lazy(() => z.record(z.string(), z.number())) + .openapi('LazyRecord'); + + expectSchema([lazyRecordSchema], { + LazyRecord: { + type: 'object', + additionalProperties: { type: 'number' }, + }, + }); + }); + + it('supports lazy tuple schemas', () => { + const lazyTupleSchema = z + .lazy(() => z.tuple([z.string(), z.number(), z.boolean()])) + .openapi('LazyTuple'); + + expectSchema([lazyTupleSchema], { + LazyTuple: { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + ], + }, + minItems: 3, + maxItems: 3, + }, + }); + }); + + it('supports lazy enum schemas', () => { + const lazyEnumSchema = z + .lazy(() => z.enum(['option1', 'option2', 'option3'])) + .openapi('LazyEnum'); + + expectSchema([lazyEnumSchema], { + LazyEnum: { + type: 'string', + enum: ['option1', 'option2', 'option3'], + }, + }); + }); + + it('supports lazy date schemas', () => { + const lazyDateSchema = z.lazy(() => z.date()).openapi('LazyDate'); + + expectSchema([lazyDateSchema], { + LazyDate: { + type: 'string', + format: 'date', + }, + }); + }); + }); + + describe('edge cases and error scenarios', () => { + it('handles lazy schema returning different types based on conditions', () => { + let useString = true; + const conditionalLazySchema = z + .lazy(() => (useString ? z.string() : z.number())) + .openapi('ConditionalLazy'); + + // This test verifies that the schema is evaluated at generation time + expectSchema([conditionalLazySchema], { + ConditionalLazy: { + type: 'string', // Should be string since useString is true when evaluated + }, + }); + }); + + it('supports lazy schema with preprocessing', () => { + const preprocessedLazySchema = z + .lazy(() => z.preprocess(val => String(val).trim(), z.string().min(1))) + .openapi('PreprocessedLazy'); + + expectSchema([preprocessedLazySchema], { + PreprocessedLazy: { + type: 'string', + minLength: 1, + nullable: true, + }, + }); + }); + + it('supports lazy schema with transforms', () => { + const transformedLazySchema = z + .lazy(() => z.string().transform(val => val.toUpperCase())) + .openapi('TransformedLazy'); + + expectSchema([transformedLazySchema], { + TransformedLazy: { + type: 'string', + }, + }); + }); + + it('supports lazy schemas in nested arrays and objects', () => { + const nestedLazySchema = z + .object({ + data: z.array( + z.object({ + items: z.array(z.lazy(() => z.string().uuid())), + }) + ), + }) + .openapi('NestedLazy'); + + expectSchema([nestedLazySchema], { + NestedLazy: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'string', + format: 'uuid', + }, + }, + }, + required: ['items'], + }, + }, + }, + required: ['data'], + }, + }); + }); + }); + + describe('OpenAPI version compatibility', () => { + it('supports lazy schemas with OpenAPI 3.1', () => { + const lazySchema = z + .lazy(() => z.string().nullable()) + .openapi('LazyNullable'); + + expectSchema( + [lazySchema], + { + LazyNullable: { + type: ['string', 'null'], + }, + }, + { version: '3.1.0' } + ); + }); + + it('supports lazy schemas with OpenAPI 3.0', () => { + const lazySchema = z + .lazy(() => z.string().nullable()) + .openapi('LazyNullable'); + + expectSchema( + [lazySchema], + { + LazyNullable: { + type: 'string', + nullable: true, + }, + }, + { version: '3.0.0' } + ); + }); + }); + + describe('performance and stability', () => { + it('handles deep recursive nesting without stack overflow', () => { + const levels = 9; + + // Create a deeply nested structure + let currentSchema: z.ZodType = z.string(); + for (let i = 0; i <= levels; i++) { + const nextSchema = currentSchema; + currentSchema = z.lazy(() => + z.object({ + level: z.number().default(i), + nested: nextSchema.optional(), + }) + ); + } + + const deepSchema = currentSchema.openapi('DeepNesting'); + + function getExpectedData(level: number): SchemaObject { + if (level < 0) return { type: 'string' }; + + return { + type: 'object', + properties: { + level: { type: 'number', default: level }, + nested: getExpectedData(level - 1), + }, + }; + } + + // This should not throw and should generate a valid schema + expectSchema([deepSchema], { + DeepNesting: getExpectedData(levels), + }); + }); + + it('handles multiple independent lazy schemas efficiently', () => { + const schemas = Array.from({ length: 50 }, (_, i) => + z + .lazy(() => + z.object({ + id: z.string(), + value: z.number().default(i), + }) + ) + .openapi(`LazySchema${i}`) + ); + + const expectedSchemas = Object.fromEntries( + Array.from({ length: 50 }, (_, i) => [ + `LazySchema${i}`, + { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + value: { type: 'number' as const, default: i }, + }, + required: ['id'], + }, + ]) + ) as Record; + + // This should complete in reasonable time without memory issues + expectSchema(schemas, expectedSchemas); + }); + }); +}); diff --git a/spec/types/recursive-schemas.spec.ts b/spec/types/recursive-schemas.spec.ts new file mode 100644 index 0000000..7348920 --- /dev/null +++ b/spec/types/recursive-schemas.spec.ts @@ -0,0 +1,572 @@ +import { z } from 'zod'; +import { expectSchema } from '../lib/helpers'; +import { SchemaObject } from 'src/types'; + +// Tests for Zod's new recursive schema approach using getters +// Based on https://zod.dev/api?id=recursive-objects + +describe('recursive schemas (new getter approach)', () => { + describe('basic recursive functionality', () => { + it('supports basic recursive lazy schemas with getters', () => { + const categorySchema = z + .object({ + name: z.string(), + get subcategory() { + return categorySchema; + }, + }) + .openapi('Category'); + + expectSchema([categorySchema], { + Category: { + type: 'object', + properties: { + name: { type: 'string' }, + subcategory: { + $ref: '#/components/schemas/Category', + }, + }, + required: ['name', 'subcategory'], + }, + }); + }); + + it('supports optional recursive properties with getters', () => { + const nodeSchema = z + .object({ + id: z.string(), + value: z.number(), + get child() { + return nodeSchema.optional(); + }, + }) + .openapi('Node'); + + expectSchema([nodeSchema], { + Node: { + type: 'object', + properties: { + id: { type: 'string' }, + value: { type: 'number' }, + child: { $ref: '#/components/schemas/Node' }, + }, + required: ['id', 'value'], + }, + }); + }); + }); + + describe('complex recursive scenarios with getters', () => { + it('supports mutual recursion between schemas using getters', () => { + const personSchema = z + .object({ + name: z.string(), + get company() { + return companySchema.optional(); + }, + }) + .openapi('Person'); + + const companySchema = z + .object({ + name: z.string(), + get employees() { + return z.array(personSchema); + }, + }) + .openapi('Company'); + + expectSchema([personSchema, companySchema], { + Person: { + type: 'object', + properties: { + name: { type: 'string' }, + company: { $ref: '#/components/schemas/Company' }, + }, + required: ['name'], + }, + Company: { + type: 'object', + properties: { + name: { type: 'string' }, + employees: { + type: 'array', + items: { $ref: '#/components/schemas/Person' }, + }, + }, + required: ['name', 'employees'], + }, + }); + }); + + it('supports deeply nested recursive tree structures with getters', () => { + const treeNodeSchema = z + .object({ + id: z.string(), + value: z.number(), + get children() { + return z.array(treeNodeSchema); + }, + get parent() { + return treeNodeSchema.optional(); + }, + }) + .openapi('TreeNode'); + + expectSchema([treeNodeSchema], { + TreeNode: { + type: 'object', + properties: { + id: { type: 'string' }, + value: { type: 'number' }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/TreeNode' }, + }, + parent: { $ref: '#/components/schemas/TreeNode' }, + }, + required: ['id', 'value', 'children'], + }, + }); + }); + + it('supports recursive schemas with unions using getters', () => { + const recursiveNodeSchema = z + .object({ + id: z.string(), + type: z.enum(['simple', 'complex']), + get children() { + return z.array(recursiveNodeSchema).optional(); + }, + }) + .openapi('RecursiveNode'); + + const stringSchema = z.string().openapi('SimpleString'); + + const unionSchema = z + .union([recursiveNodeSchema, stringSchema]) + .openapi('RecursiveUnion'); + + // TODO: We have the same problem here - if we have already registered + // a schema we get to register it once again and this leads to a + // Maximum call stack error. + expectSchema([recursiveNodeSchema, stringSchema, unionSchema], { + RecursiveNode: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string', enum: ['simple', 'complex'] }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/RecursiveNode' }, + }, + }, + required: ['id', 'type'], + }, + SimpleString: { type: 'string' }, + RecursiveUnion: { + anyOf: [ + { $ref: '#/components/schemas/RecursiveNode' }, + { $ref: '#/components/schemas/SimpleString' }, + ], + }, + }); + }); + }); + + describe('recursive schemas with complex types and getters', () => { + it('supports recursive schemas in arrays with getters', () => { + const itemSchema = z + .object({ + id: z.string(), + value: z.number(), + get children() { + return z.array(itemSchema); + }, + }) + .openapi('RecursiveItem'); + + const arraySchema = z.array(itemSchema).openapi('ItemArray'); + + expectSchema([itemSchema, arraySchema], { + RecursiveItem: { + type: 'object', + properties: { + id: { type: 'string' }, + value: { type: 'number' }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/RecursiveItem' }, + }, + }, + required: ['id', 'value', 'children'], + }, + ItemArray: { + type: 'array', + items: { + $ref: '#/components/schemas/RecursiveItem', + }, + }, + }); + }); + + it('supports recursive schemas in unions with getters', () => { + const recursiveSchema = z + .object({ + id: z.string(), + get nested() { + return recursiveSchema.optional(); + }, + }) + .openapi('RecursiveType'); + + const stringSchema = z.string().openapi('SimpleString'); + + const unionSchema = z + .union([recursiveSchema, stringSchema]) + .openapi('RecursiveUnion'); + + expectSchema([recursiveSchema, stringSchema, unionSchema], { + RecursiveType: { + type: 'object', + properties: { + id: { type: 'string' }, + nested: { $ref: '#/components/schemas/RecursiveType' }, + }, + required: ['id'], + }, + SimpleString: { type: 'string' }, + RecursiveUnion: { + anyOf: [ + { $ref: '#/components/schemas/RecursiveType' }, + { $ref: '#/components/schemas/SimpleString' }, + ], + }, + }); + }); + + it('supports recursive schemas in intersections with getters', () => { + const recursiveBaseSchema = z + .object({ + id: z.string(), + get child() { + return recursiveBaseSchema.optional(); + }, + }) + .openapi('RecursiveBase'); + + const metaSchema = z.object({ + metadata: z.record(z.string(), z.string()), + }); + + const intersectionSchema = z + .intersection(recursiveBaseSchema, metaSchema) + .openapi('RecursiveIntersection'); + + expectSchema([recursiveBaseSchema, intersectionSchema], { + RecursiveBase: { + type: 'object', + properties: { + id: { type: 'string' }, + child: { $ref: '#/components/schemas/RecursiveBase' }, + }, + required: ['id'], + }, + RecursiveIntersection: { + allOf: [ + { $ref: '#/components/schemas/RecursiveBase' }, + { + type: 'object', + properties: { + metadata: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + required: ['metadata'], + }, + ], + }, + }); + }); + }); + + describe('metadata and modifiers with recursive getters', () => { + it('supports recursive schemas with OpenAPI metadata using getters', () => { + const recursiveSchema = z + .object({ + name: z.string(), + get child() { + return recursiveSchema.optional(); + }, + }) + .openapi('RecursiveWithMeta', { + description: 'A recursive schema with metadata', + example: { name: 'root', child: { name: 'child' } }, + }); + + expectSchema([recursiveSchema], { + RecursiveWithMeta: { + type: 'object', + description: 'A recursive schema with metadata', + example: { name: 'root', child: { name: 'child' } }, + properties: { + name: { type: 'string' }, + child: { $ref: '#/components/schemas/RecursiveWithMeta' }, + }, + required: ['name'], + }, + }); + }); + + it('supports nullable recursive schemas with getters', () => { + const recursiveSchema = z + .object({ + value: z.string(), + get next() { + return recursiveSchema.nullable().optional(); + }, + }) + .openapi('NullableRecursive'); + + expectSchema([recursiveSchema], { + NullableRecursive: { + type: 'object', + properties: { + value: { type: 'string' }, + next: { + $ref: '#/components/schemas/NullableRecursive', + nullable: true, + }, + }, + required: ['value'], + }, + }); + }); + + it('supports recursive schemas with defaults using getters', () => { + const recursiveSchema = z + .object({ + name: z.string(), + count: z.number().default(0), + get children() { + return z.array(recursiveSchema).default([]); + }, + }) + .openapi('RecursiveWithDefaults'); + + expectSchema([recursiveSchema], { + RecursiveWithDefaults: { + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'number', default: 0 }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/RecursiveWithDefaults' }, + default: [], + }, + }, + required: ['name'], + }, + }); + }); + }); + + describe('performance and stability with recursive getters', () => { + it('handles deep recursive nesting without stack overflow using getters', () => { + const levels = 9; + + // Create a deeply nested structure using getters + function createRecursiveSchema(level: number): z.ZodType { + return z.object({ + level: z.number().default(level), + ...(level === 0 + ? { nested: z.string().optional() } + : { + get nested() { + return createRecursiveSchema(level - 1).optional(); + }, + }), + }); + } + + const deepSchema = createRecursiveSchema(levels).openapi('DeepNesting'); + + function getExpectedData(level: number): SchemaObject { + if (level < 0) return { type: 'string' }; + + return { + type: 'object', + properties: { + level: { type: 'number', default: level }, + nested: getExpectedData(level - 1), + }, + }; + } + + // This should not throw and should generate a valid schema + expectSchema([deepSchema], { + DeepNesting: getExpectedData(levels), + }); + }); + + it('handles multiple independent recursive schemas efficiently using getters', () => { + const schemas = Array.from({ length: 10 }, (_, i) => { + const recursiveSchema = z + .object({ + id: z.string(), + value: z.number().default(i), + get children() { + return z.array(recursiveSchema); + }, + }) + .openapi(`RecursiveSchema${i}`); + + return recursiveSchema; + }); + + const expectedSchemas = Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [ + `RecursiveSchema${i}`, + { + type: 'object' as const, + properties: { + id: { type: 'string' as const }, + value: { type: 'number' as const, default: i }, + children: { + type: 'array' as const, + items: { $ref: `#/components/schemas/RecursiveSchema${i}` }, + }, + }, + required: ['id', 'children'], + }, + ]) + ) as Record; + + // This should complete in reasonable time without memory issues + expectSchema(schemas, expectedSchemas); + }); + }); + + describe('OpenAPI version compatibility with recursive getters', () => { + it('supports recursive schemas with OpenAPI 3.1 using getters', () => { + const recursiveSchema = z + .object({ + value: z.string(), + get child() { + return recursiveSchema.nullable().optional(); + }, + }) + .openapi('RecursiveNullable'); + + expectSchema( + [recursiveSchema], + { + RecursiveNullable: { + type: 'object', + properties: { + value: { type: 'string' }, + child: { + anyOf: [ + { $ref: '#/components/schemas/RecursiveNullable' }, + { type: 'null' }, + ], + }, + }, + required: ['value'], + }, + }, + { version: '3.1.0' } + ); + }); + + it('supports recursive schemas with OpenAPI 3.0 using getters', () => { + const recursiveSchema = z + .object({ + value: z.string(), + get child() { + return recursiveSchema.nullable().optional(); + }, + }) + .openapi('RecursiveNullable'); + + expectSchema( + [recursiveSchema], + { + RecursiveNullable: { + type: 'object', + properties: { + value: { type: 'string' }, + child: { + $ref: '#/components/schemas/RecursiveNullable', + nullable: true, + }, + }, + required: ['value'], + }, + }, + { version: '3.0.0' } + ); + }); + }); + + describe('edge cases with recursive getters', () => { + it('handles simple recursive schema using getters', () => { + const recursiveSchema = z + .object({ + name: z.string(), + get child() { + return recursiveSchema.optional(); + }, + }) + .openapi('SimpleRecursive'); + + expectSchema([recursiveSchema], { + SimpleRecursive: { + type: 'object', + properties: { + name: { type: 'string' }, + child: { $ref: '#/components/schemas/SimpleRecursive' }, + }, + required: ['name'], + }, + }); + }); + + it('supports recursive schemas in nested arrays and objects using getters', () => { + const recursiveSchema = z + .object({ + id: z.string(), + get nested() { + return z.object({ + get items() { + return z.array(recursiveSchema); + }, + }); + }, + }) + .openapi('NestedRecursive'); + + expectSchema([recursiveSchema], { + NestedRecursive: { + type: 'object', + properties: { + id: { type: 'string' }, + nested: { + type: 'object', + properties: { + items: { + type: 'array', + items: { $ref: '#/components/schemas/NestedRecursive' }, + }, + }, + required: ['items'], + }, + }, + required: ['id', 'nested'], + }, + }); + }); + }); +}); diff --git a/src/lib/zod-is-type.ts b/src/lib/zod-is-type.ts index ad17272..2e36325 100644 --- a/src/lib/zod-is-type.ts +++ b/src/lib/zod-is-type.ts @@ -9,6 +9,7 @@ export type ZodTypes = { ZodTransform: z.ZodTransform; ZodEnum: z.ZodEnum; ZodIntersection: z.ZodIntersection; + ZodLazy: z.ZodLazy; ZodLiteral: z.ZodLiteral; ZodNever: z.ZodNever; ZodNull: z.ZodNull; @@ -39,6 +40,7 @@ const ZodTypeKeys: Record = { ZodTransform: 'transform', ZodEnum: 'enum', ZodIntersection: 'intersection', + ZodLazy: 'lazy', ZodLiteral: 'literal', ZodNever: 'never', ZodNull: 'null', diff --git a/src/openapi-generator.ts b/src/openapi-generator.ts index ec6a376..e824513 100644 --- a/src/openapi-generator.ts +++ b/src/openapi-generator.ts @@ -81,8 +81,10 @@ export interface OpenApiGeneratorOptions { sortComponents?: 'alphabetically'; } +export type SchemaRefValue = SchemaObject | ReferenceObject | 'pending'; + export class OpenAPIGenerator { - private schemaRefs: Record = {}; + private schemaRefs: Record = {}; private paramRefs: Record = {}; private pathRefs: Record = {}; private rawComponents: { @@ -128,7 +130,7 @@ export class OpenAPIGenerator { const allSchemas = { ...(rawComponents.schemas ?? {}), - ...this.schemaRefs, + ...this.filteredSchemaRefs, }; const schemas = @@ -149,7 +151,18 @@ export class OpenAPIGenerator { return { ...rawComponents, schemas, parameters }; } - private sortObjectKeys(object: Record) {} + private isNotPendingRefEntry( + entry: [string, SchemaRefValue] + ): entry is [string, SchemaObject | ReferenceObject] { + return entry[1] !== 'pending'; + } + + private get filteredSchemaRefs() { + const filtered = Object.entries(this.schemaRefs).filter( + this.isNotPendingRefEntry + ); + return Object.fromEntries(filtered); + } private sortDefinitions() { const generationOrder: OpenAPIDefinitions['type'][] = [ @@ -398,6 +411,25 @@ export class OpenAPIGenerator { const innerSchema = Metadata.unwrapChained(zodSchema); const metadata = Metadata.getOpenApiMetadata(zodSchema); const defaultValue = Metadata.getDefaultValue(zodSchema); + const refId = Metadata.getRefId(zodSchema); + + // TODO: Do I need a similar implementation as bellow inside constructReferencedOpenAPISchema + if (refId && typeof this.schemaRefs[refId] === 'object') { + 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') { + 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'; + } const result = metadata?.type ? { type: metadata.type } @@ -420,7 +452,6 @@ export class OpenAPIGenerator { ): SchemaObject | ReferenceObject { const metadata = Metadata.getOpenApiMetadata(zodSchema); const innerSchema = Metadata.unwrapChained(zodSchema); - const defaultValue = Metadata.getDefaultValue(zodSchema); const isNullable = isNullableSchema(zodSchema); @@ -428,6 +459,40 @@ export class OpenAPIGenerator { return this.versionSpecifics.mapNullableType(metadata.type, isNullable); } + 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 ('$ref' in this.schemaRefs[refId]) { + return this.schemaRefs[refId]; + } + + if (this.schemaRefs[refId].type) { + return { + ...this.schemaRefs[refId], + ...this.versionSpecifics.mapNullableType( + this.schemaRefs[refId].type, + isNullable + ), + }; + } + + 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') { + 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'; + } + return this.toOpenAPISchema(innerSchema, isNullable, defaultValue); } @@ -450,6 +515,11 @@ export class OpenAPIGenerator { $ref: this.generateSchemaRef(refId), }; + // We are currently calculating this schema or there is nothing + if (this.schemaRefs[refId] === 'pending') { + return referenceObject; + } + // Metadata provided from .openapi() that is new to what we had already registered const newMetadata = omitBy( Metadata.buildSchemaMetadata(metadata ?? {}), @@ -494,15 +564,13 @@ export class OpenAPIGenerator { private generateSchemaWithRef(zodSchema: ZodType) { const refId = Metadata.getRefId(zodSchema); - const result = this.generateSimpleSchema(zodSchema); - - if (refId && this.schemaRefs[refId] === undefined) { - this.schemaRefs[refId] = result; + if (refId && !this.schemaRefs[refId]) { + this.schemaRefs[refId] = this.generateSimpleSchema(zodSchema); return { $ref: this.generateSchemaRef(refId) }; } - return result; + return this.generateSimpleSchema(zodSchema); } private generateSchemaRef(refId: string) { diff --git a/src/transformers/index.ts b/src/transformers/index.ts index 3c32e3b..26a7863 100644 --- a/src/transformers/index.ts +++ b/src/transformers/index.ts @@ -20,6 +20,7 @@ import { OpenApiVersionSpecifics, } from '../openapi-generator'; import { DateTransformer } from './date'; +import { LazyTransformer } from './lazy'; export class OpenApiTransformer { private objectTransformer = new ObjectTransformer(); @@ -27,6 +28,7 @@ export class OpenApiTransformer { private numberTransformer = new NumberTransformer(); private bigIntTransformer = new BigIntTransformer(); private dateTransformer = new DateTransformer(); + private lazyTransformer = new LazyTransformer(); private literalTransformer = new LiteralTransformer(); private enumTransformer = new EnumTransformer(); private arrayTransformer = new ArrayTransformer(); @@ -112,6 +114,12 @@ export class OpenApiTransformer { return this.versionSpecifics.mapNullableType('boolean', isNullable); } + if (isZodType(zodSchema, 'ZodLazy')) { + return this.lazyTransformer.transform(zodSchema, mapItem, schema => + this.versionSpecifics.mapNullableType(schema, isNullable) + ); + } + if (isZodType(zodSchema, 'ZodLiteral')) { return this.literalTransformer.transform(zodSchema, schema => this.versionSpecifics.mapNullableType(schema, isNullable) diff --git a/src/transformers/lazy.ts b/src/transformers/lazy.ts new file mode 100644 index 0000000..6c1d7d9 --- /dev/null +++ b/src/transformers/lazy.ts @@ -0,0 +1,28 @@ +import { Metadata } from '../metadata'; +import { + MapNullableType, + MapSubSchema, + ReferenceObject, + SchemaObject, +} from '../types'; +import { ZodLazy, ZodType } from 'zod'; + +export class LazyTransformer { + transform( + zodSchema: ZodLazy, + mapItem: MapSubSchema, + mapNullableType: MapNullableType + ): SchemaObject | ReferenceObject { + const result = mapItem(zodSchema._zod.def.getter() as ZodType); + + if ('$ref' in result) { + return result; + } + + if (result.type) { + return { ...result, ...mapNullableType(result.type) }; + } + + return result; + } +} diff --git a/src/v3.1/specifics.ts b/src/v3.1/specifics.ts index 2be3d66..936aa15 100644 --- a/src/v3.1/specifics.ts +++ b/src/v3.1/specifics.ts @@ -1,7 +1,12 @@ -import type { ReferenceObject, SchemaObject } from 'openapi3-ts/oas31'; +import type { + ReferenceObject, + SchemaObject, + SchemaObjectType, +} from 'openapi3-ts/oas31'; import type { $ZodCheckGreaterThan, $ZodCheckLessThan } from 'zod/core'; import { OpenApiVersionSpecifics } from '../openapi-generator'; import { ZodNumericCheck, SchemaObject as CommonSchemaObject } from '../types'; +import { uniq } from '../lib/lodash'; export class OpenApiGeneratorV31Specifics implements OpenApiVersionSpecifics { get nullType() { @@ -29,14 +34,16 @@ export class OpenApiGeneratorV31Specifics implements OpenApiVersionSpecifics { // Open API 3.1.0 made the `nullable` key invalid and instead you use type arrays if (isNullable) { - return { - type: Array.isArray(type) ? [...type, 'null'] : [type, 'null'], - }; + const typeArray = Array.isArray(type) ? type : [type]; + + // If the type already contained null we do not want to have it twice. + // this is possible for example in z.null usages or z.lazy recursive usages + const nullableType = uniq([...typeArray, 'null']); + + return { type: nullableType }; } - return { - type, - }; + return { type }; } mapTupleItems(schemas: (CommonSchemaObject | ReferenceObject)[]) {