fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1416 from hey-api/fix/zod-plugin-circular-ref

fix: zod plugin handles recursive schemas

authored by

Lubos and committed by
GitHub
322eccb8 318e06e7

+165 -21
+5
.changeset/sweet-dolls-remember.md
··· 1 + --- 2 + '@hey-api/openapi-ts': patch 3 + --- 4 + 5 + fix: zod plugin handles recursive schemas
+1 -1
packages/openapi-ts/src/compiler/module.ts
··· 123 123 expression: ts.Expression; 124 124 name: string; 125 125 // TODO: support a more intuitive definition of generics for example 126 - typeName?: string | ts.IndexedAccessTypeNode; 126 + typeName?: string | ts.IndexedAccessTypeNode | ts.TypeNode; 127 127 }): ts.VariableStatement => { 128 128 const initializer = assertion 129 129 ? ts.factory.createAsExpression(
+94 -18
packages/openapi-ts/src/plugins/zod/plugin.ts
··· 14 14 type: Extract<Required<IRSchemaObject>['type'], T>; 15 15 } 16 16 17 + interface Result { 18 + circularReferenceTracker: Set<string>; 19 + hasCircularReference: boolean; 20 + } 21 + 17 22 const zodId = 'zod'; 18 23 19 24 // frequently used identifiers 20 25 const defaultIdentifier = compiler.identifier({ text: 'default' }); 26 + const lazyIdentifier = compiler.identifier({ text: 'lazy' }); 21 27 const optionalIdentifier = compiler.identifier({ text: 'optional' }); 22 28 const readonlyIdentifier = compiler.identifier({ text: 'readonly' }); 23 29 const zIdentifier = compiler.identifier({ text: 'z' }); ··· 27 33 const arrayTypeToZodSchema = ({ 28 34 context, 29 35 namespace, 36 + result, 30 37 schema, 31 38 }: { 32 39 context: IRContext; 33 40 namespace: Array<ts.Statement>; 41 + result: Result; 34 42 schema: SchemaWithType<'array'>; 35 43 }): ts.CallExpression => { 36 44 const functionName = compiler.propertyAccessExpression({ ··· 61 69 schemaToZodSchema({ 62 70 context, 63 71 namespace, 72 + result, 64 73 schema: item, 65 74 }), 66 75 ); ··· 299 308 const objectTypeToZodSchema = ({ 300 309 context, 301 310 // namespace, 302 - 311 + result, 303 312 schema, 304 313 }: { 305 314 context: IRContext; 306 315 namespace: Array<ts.Statement>; 316 + result: Result; 307 317 schema: SchemaWithType<'object'>; 308 318 }) => { 309 319 const properties: Array<ts.PropertyAssignment> = []; ··· 320 330 321 331 let propertyExpression = schemaToZodSchema({ 322 332 context, 333 + result, 323 334 schema: property, 324 335 }); 325 336 ··· 573 584 }; 574 585 575 586 const schemaTypeToZodSchema = ({ 576 - // $ref, 577 587 context, 578 588 namespace, 589 + result, 579 590 schema, 580 591 }: { 581 - $ref?: string; 582 592 context: IRContext; 583 593 namespace: Array<ts.Statement>; 594 + result: Result; 584 595 schema: IRSchemaObject; 585 596 }): ts.Expression => { 586 597 switch (schema.type as Required<IRSchemaObject>['type']) { ··· 588 599 return arrayTypeToZodSchema({ 589 600 context, 590 601 namespace, 602 + result, 591 603 schema: schema as SchemaWithType<'array'>, 592 604 }); 593 605 case 'boolean': ··· 624 636 return objectTypeToZodSchema({ 625 637 context, 626 638 namespace, 639 + result, 627 640 schema: schema as SchemaWithType<'object'>, 628 641 }); 629 642 case 'string': ··· 673 686 context, 674 687 // TODO: parser - remove namespace, it's a type plugin construct 675 688 namespace = [], 689 + result, 676 690 schema, 677 691 }: { 678 692 $ref?: string; 679 693 context: IRContext; 680 694 namespace?: Array<ts.Statement>; 695 + result: Result; 681 696 schema: IRSchemaObject; 682 697 }): ts.Expression => { 683 698 const file = context.file({ id: zodId })!; 684 699 685 700 let expression: ts.Expression | undefined; 701 + let identifier: ReturnType<typeof file.identifier> | undefined; 702 + 703 + if ($ref) { 704 + result.circularReferenceTracker.add($ref); 705 + 706 + // emit nodes only if $ref points to a reusable component 707 + if (isRefOpenApiComponent($ref)) { 708 + identifier = file.identifier({ 709 + $ref, 710 + create: true, 711 + nameTransformer, 712 + namespace: 'value', 713 + }); 714 + } 715 + } 686 716 687 717 if (schema.$ref) { 718 + const isCircularReference = result.circularReferenceTracker.has( 719 + schema.$ref, 720 + ); 721 + 688 722 // if $ref hasn't been processed yet, inline it to avoid the 689 723 // "Block-scoped variable used before its declaration." error 690 724 // this could be (maybe?) fixed by reshuffling the generation order 691 - const identifier = file.identifier({ 725 + let identifierRef = file.identifier({ 692 726 $ref: schema.$ref, 693 727 nameTransformer, 694 728 namespace: 'value', 695 729 }); 696 - if (identifier.name) { 697 - expression = compiler.identifier({ text: identifier.name || '' }); 698 - } else { 730 + 731 + if (!identifierRef.name) { 699 732 const ref = context.resolveIrRef<IRSchemaObject>(schema.$ref); 700 733 expression = schemaToZodSchema({ 701 734 context, 735 + result, 702 736 schema: ref, 703 737 }); 738 + 739 + identifierRef = file.identifier({ 740 + $ref: schema.$ref, 741 + nameTransformer, 742 + namespace: 'value', 743 + }); 744 + } 745 + 746 + // if `identifierRef.name` is falsy, we already set expression above 747 + if (identifierRef.name) { 748 + const refIdentifier = compiler.identifier({ text: identifierRef.name }); 749 + if (isCircularReference) { 750 + expression = compiler.callExpression({ 751 + functionName: compiler.propertyAccessExpression({ 752 + expression: zIdentifier, 753 + name: lazyIdentifier, 754 + }), 755 + parameters: [ 756 + compiler.arrowFunction({ 757 + statements: [ 758 + compiler.returnStatement({ 759 + expression: refIdentifier, 760 + }), 761 + ], 762 + }), 763 + ], 764 + }); 765 + result.hasCircularReference = true; 766 + } else { 767 + expression = refIdentifier; 768 + } 704 769 } 705 770 } else if (schema.type) { 706 771 expression = schemaTypeToZodSchema({ 707 - $ref, 708 772 context, 709 773 namespace, 774 + result, 710 775 schema, 711 776 }); 712 777 } else if (schema.items) { ··· 745 810 expression = schemaTypeToZodSchema({ 746 811 context, 747 812 namespace, 813 + result, 748 814 schema: { 749 815 type: 'unknown', 750 816 }, 751 817 }); 752 818 } 753 819 820 + if ($ref) { 821 + result.circularReferenceTracker.delete($ref); 822 + } 823 + 754 824 // emit nodes only if $ref points to a reusable component 755 - if ($ref && isRefOpenApiComponent($ref)) { 756 - const identifier = file.identifier({ 757 - $ref, 758 - create: true, 759 - nameTransformer, 760 - namespace: 'value', 761 - }); 825 + if (identifier?.name) { 762 826 const statement = compiler.constVariable({ 763 827 exportConst: true, 764 - expression, 765 - name: identifier.name || '', 828 + expression: expression!, 829 + name: identifier.name, 830 + typeName: result.hasCircularReference 831 + ? (compiler.propertyAccessExpression({ 832 + expression: zIdentifier, 833 + name: 'ZodTypeAny', 834 + }) as unknown as ts.TypeNode) 835 + : undefined, 766 836 }); 767 837 file.add(statement); 768 838 } 769 839 770 - return expression; 840 + return expression!; 771 841 }; 772 842 773 843 export const handler: Plugin.Handler<Config> = ({ context, plugin }) => { ··· 790 860 // }); 791 861 792 862 context.subscribe('schema', ({ $ref, schema }) => { 863 + const result: Result = { 864 + circularReferenceTracker: new Set(), 865 + hasCircularReference: false, 866 + }; 867 + 793 868 schemaToZodSchema({ 794 869 $ref, 795 870 context, 871 + result, 796 872 schema, 797 873 }); 798 874 });
+8
packages/openapi-ts/test/3.1.x.test.ts
··· 463 463 }, 464 464 { 465 465 config: createConfig({ 466 + input: 'schema-recursive.json', 467 + output: 'schema-recursive', 468 + plugins: ['zod'], 469 + }), 470 + description: 'generates Zod schemas with from recursive schemas', 471 + }, 472 + { 473 + config: createConfig({ 466 474 input: 'security-api-key.json', 467 475 output: 'security-api-key', 468 476 plugins: [
+19
packages/openapi-ts/test/__snapshots__/3.1.x/schema-recursive/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod'; 4 + 5 + export const zFoo: z.ZodTypeAny = z.object({ 6 + foo: z.string().optional(), 7 + bar: z.object({ 8 + foo: z.lazy(() => { 9 + return zFoo; 10 + }).optional() 11 + }).optional(), 12 + baz: z.array(z.lazy(() => { 13 + return zFoo; 14 + })).optional() 15 + }); 16 + 17 + export const zBar = z.object({ 18 + foo: zFoo.optional() 19 + });
+2 -2
packages/openapi-ts/test/sample.cjs
··· 13 13 exclude: '^#/components/schemas/ModelWithCircularReference$', 14 14 // include: 15 15 // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', 16 - path: './test/spec/3.0.x/security-api-key.json', 16 + path: './test/spec/3.1.x/schema-recursive.json', 17 17 // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', 18 18 // path: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', 19 19 }, ··· 61 61 // name: '@tanstack/vue-query', 62 62 }, 63 63 { 64 - // name: 'zod', 64 + name: 'zod', 65 65 }, 66 66 ], 67 67 // useOptions: false,
+36
packages/openapi-ts/test/spec/3.1.x/schema-recursive.json
··· 1 + { 2 + "openapi": "3.1.0", 3 + "info": { 4 + "title": "OpenAPI 3.1.0 schema recursive example", 5 + "version": "1" 6 + }, 7 + "components": { 8 + "schemas": { 9 + "Foo": { 10 + "type": "object", 11 + "properties": { 12 + "foo": { 13 + "type": "string" 14 + }, 15 + "bar": { 16 + "$ref": "#/components/schemas/Bar" 17 + }, 18 + "baz": { 19 + "items": { 20 + "$ref": "#/components/schemas/Foo" 21 + }, 22 + "type": "array" 23 + } 24 + } 25 + }, 26 + "Bar": { 27 + "properties": { 28 + "foo": { 29 + "$ref": "#/components/schemas/Foo" 30 + } 31 + }, 32 + "type": "object" 33 + } 34 + } 35 + } 36 + }