diff --git a/.changeset/sweet-dolls-remember.md b/.changeset/sweet-dolls-remember.md new file mode 100644 index 000000000..f510ae768 --- /dev/null +++ b/.changeset/sweet-dolls-remember.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix: zod plugin handles recursive schemas diff --git a/packages/openapi-ts/src/compiler/module.ts b/packages/openapi-ts/src/compiler/module.ts index eff3bed0c..4f68703b9 100644 --- a/packages/openapi-ts/src/compiler/module.ts +++ b/packages/openapi-ts/src/compiler/module.ts @@ -123,7 +123,7 @@ export const createConstVariable = ({ expression: ts.Expression; name: string; // TODO: support a more intuitive definition of generics for example - typeName?: string | ts.IndexedAccessTypeNode; + typeName?: string | ts.IndexedAccessTypeNode | ts.TypeNode; }): ts.VariableStatement => { const initializer = assertion ? ts.factory.createAsExpression( diff --git a/packages/openapi-ts/src/plugins/zod/plugin.ts b/packages/openapi-ts/src/plugins/zod/plugin.ts index f11771b6c..75d934e26 100644 --- a/packages/openapi-ts/src/plugins/zod/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/plugin.ts @@ -14,10 +14,16 @@ interface SchemaWithType['type']> type: Extract['type'], T>; } +interface Result { + circularReferenceTracker: Set; + hasCircularReference: boolean; +} + const zodId = 'zod'; // frequently used identifiers const defaultIdentifier = compiler.identifier({ text: 'default' }); +const lazyIdentifier = compiler.identifier({ text: 'lazy' }); const optionalIdentifier = compiler.identifier({ text: 'optional' }); const readonlyIdentifier = compiler.identifier({ text: 'readonly' }); const zIdentifier = compiler.identifier({ text: 'z' }); @@ -27,10 +33,12 @@ const nameTransformer = (name: string) => `z${name}`; const arrayTypeToZodSchema = ({ context, namespace, + result, schema, }: { context: IRContext; namespace: Array; + result: Result; schema: SchemaWithType<'array'>; }): ts.CallExpression => { const functionName = compiler.propertyAccessExpression({ @@ -61,6 +69,7 @@ const arrayTypeToZodSchema = ({ schemaToZodSchema({ context, namespace, + result, schema: item, }), ); @@ -299,11 +308,12 @@ const numberTypeToZodSchema = ({ const objectTypeToZodSchema = ({ context, // namespace, - + result, schema, }: { context: IRContext; namespace: Array; + result: Result; schema: SchemaWithType<'object'>; }) => { const properties: Array = []; @@ -320,6 +330,7 @@ const objectTypeToZodSchema = ({ let propertyExpression = schemaToZodSchema({ context, + result, schema: property, }); @@ -573,14 +584,14 @@ const voidTypeToZodSchema = ({ }; const schemaTypeToZodSchema = ({ - // $ref, context, namespace, + result, schema, }: { - $ref?: string; context: IRContext; namespace: Array; + result: Result; schema: IRSchemaObject; }): ts.Expression => { switch (schema.type as Required['type']) { @@ -588,6 +599,7 @@ const schemaTypeToZodSchema = ({ return arrayTypeToZodSchema({ context, namespace, + result, schema: schema as SchemaWithType<'array'>, }); case 'boolean': @@ -624,6 +636,7 @@ const schemaTypeToZodSchema = ({ return objectTypeToZodSchema({ context, namespace, + result, schema: schema as SchemaWithType<'object'>, }); case 'string': @@ -673,40 +686,92 @@ const schemaToZodSchema = ({ context, // TODO: parser - remove namespace, it's a type plugin construct namespace = [], + result, schema, }: { $ref?: string; context: IRContext; namespace?: Array; + result: Result; schema: IRSchemaObject; }): ts.Expression => { const file = context.file({ id: zodId })!; let expression: ts.Expression | undefined; + let identifier: ReturnType | undefined; + + if ($ref) { + result.circularReferenceTracker.add($ref); + + // emit nodes only if $ref points to a reusable component + if (isRefOpenApiComponent($ref)) { + identifier = file.identifier({ + $ref, + create: true, + nameTransformer, + namespace: 'value', + }); + } + } if (schema.$ref) { + const isCircularReference = result.circularReferenceTracker.has( + schema.$ref, + ); + // if $ref hasn't been processed yet, inline it to avoid the // "Block-scoped variable used before its declaration." error // this could be (maybe?) fixed by reshuffling the generation order - const identifier = file.identifier({ + let identifierRef = file.identifier({ $ref: schema.$ref, nameTransformer, namespace: 'value', }); - if (identifier.name) { - expression = compiler.identifier({ text: identifier.name || '' }); - } else { + + if (!identifierRef.name) { const ref = context.resolveIrRef(schema.$ref); expression = schemaToZodSchema({ context, + result, schema: ref, }); + + identifierRef = file.identifier({ + $ref: schema.$ref, + nameTransformer, + namespace: 'value', + }); + } + + // if `identifierRef.name` is falsy, we already set expression above + if (identifierRef.name) { + const refIdentifier = compiler.identifier({ text: identifierRef.name }); + if (isCircularReference) { + expression = compiler.callExpression({ + functionName: compiler.propertyAccessExpression({ + expression: zIdentifier, + name: lazyIdentifier, + }), + parameters: [ + compiler.arrowFunction({ + statements: [ + compiler.returnStatement({ + expression: refIdentifier, + }), + ], + }), + ], + }); + result.hasCircularReference = true; + } else { + expression = refIdentifier; + } } } else if (schema.type) { expression = schemaTypeToZodSchema({ - $ref, context, namespace, + result, schema, }); } else if (schema.items) { @@ -745,29 +810,34 @@ const schemaToZodSchema = ({ expression = schemaTypeToZodSchema({ context, namespace, + result, schema: { type: 'unknown', }, }); } + if ($ref) { + result.circularReferenceTracker.delete($ref); + } + // emit nodes only if $ref points to a reusable component - if ($ref && isRefOpenApiComponent($ref)) { - const identifier = file.identifier({ - $ref, - create: true, - nameTransformer, - namespace: 'value', - }); + if (identifier?.name) { const statement = compiler.constVariable({ exportConst: true, - expression, - name: identifier.name || '', + expression: expression!, + name: identifier.name, + typeName: result.hasCircularReference + ? (compiler.propertyAccessExpression({ + expression: zIdentifier, + name: 'ZodTypeAny', + }) as unknown as ts.TypeNode) + : undefined, }); file.add(statement); } - return expression; + return expression!; }; export const handler: Plugin.Handler = ({ context, plugin }) => { @@ -790,9 +860,15 @@ export const handler: Plugin.Handler = ({ context, plugin }) => { // }); context.subscribe('schema', ({ $ref, schema }) => { + const result: Result = { + circularReferenceTracker: new Set(), + hasCircularReference: false, + }; + schemaToZodSchema({ $ref, context, + result, schema, }); }); diff --git a/packages/openapi-ts/test/3.1.x.test.ts b/packages/openapi-ts/test/3.1.x.test.ts index a88b5a592..90839db42 100644 --- a/packages/openapi-ts/test/3.1.x.test.ts +++ b/packages/openapi-ts/test/3.1.x.test.ts @@ -461,6 +461,14 @@ describe(`OpenAPI ${VERSION}`, () => { description: 'does not set oneOf composition ref model properties as required', }, + { + config: createConfig({ + input: 'schema-recursive.json', + output: 'schema-recursive', + plugins: ['zod'], + }), + description: 'generates Zod schemas with from recursive schemas', + }, { config: createConfig({ input: 'security-api-key.json', diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts new file mode 100644 index 000000000..b023898a8 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zFoo: z.ZodTypeAny = z.object({ + foo: z.string().optional(), + bar: z.object({ + foo: z.lazy(() => { + return zFoo; + }).optional() + }).optional(), + baz: z.array(z.lazy(() => { + return zFoo; + })).optional() +}); + +export const zBar = z.object({ + foo: zFoo.optional() +}); diff --git a/packages/openapi-ts/test/sample.cjs b/packages/openapi-ts/test/sample.cjs index f62dd69c7..6ca3b983e 100644 --- a/packages/openapi-ts/test/sample.cjs +++ b/packages/openapi-ts/test/sample.cjs @@ -13,7 +13,7 @@ const main = async () => { exclude: '^#/components/schemas/ModelWithCircularReference$', // include: // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', - path: './test/spec/3.0.x/security-api-key.json', + path: './test/spec/3.1.x/schema-recursive.json', // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', // path: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', }, @@ -61,7 +61,7 @@ const main = async () => { // name: '@tanstack/vue-query', }, { - // name: 'zod', + name: 'zod', }, ], // useOptions: false, diff --git a/packages/openapi-ts/test/spec/3.1.x/schema-recursive.json b/packages/openapi-ts/test/spec/3.1.x/schema-recursive.json new file mode 100644 index 000000000..7c3ab3f63 --- /dev/null +++ b/packages/openapi-ts/test/spec/3.1.x/schema-recursive.json @@ -0,0 +1,36 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1.0 schema recursive example", + "version": "1" + }, + "components": { + "schemas": { + "Foo": { + "type": "object", + "properties": { + "foo": { + "type": "string" + }, + "bar": { + "$ref": "#/components/schemas/Bar" + }, + "baz": { + "items": { + "$ref": "#/components/schemas/Foo" + }, + "type": "array" + } + } + }, + "Bar": { + "properties": { + "foo": { + "$ref": "#/components/schemas/Foo" + } + }, + "type": "object" + } + } + } +}