Skip to content

Commit

Permalink
Merge pull request #1416 from hey-api/fix/zod-plugin-circular-ref
Browse files Browse the repository at this point in the history
fix: zod plugin handles recursive schemas
  • Loading branch information
mrlubos authored Dec 11, 2024
2 parents 318e06e + 2a605b7 commit 322eccb
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/sweet-dolls-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/openapi-ts': patch
---

fix: zod plugin handles recursive schemas
2 changes: 1 addition & 1 deletion packages/openapi-ts/src/compiler/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
112 changes: 94 additions & 18 deletions packages/openapi-ts/src/plugins/zod/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ interface SchemaWithType<T extends Required<IRSchemaObject>['type']>
type: Extract<Required<IRSchemaObject>['type'], T>;
}

interface Result {
circularReferenceTracker: Set<string>;
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' });
Expand All @@ -27,10 +33,12 @@ const nameTransformer = (name: string) => `z${name}`;
const arrayTypeToZodSchema = ({
context,
namespace,
result,
schema,
}: {
context: IRContext;
namespace: Array<ts.Statement>;
result: Result;
schema: SchemaWithType<'array'>;
}): ts.CallExpression => {
const functionName = compiler.propertyAccessExpression({
Expand Down Expand Up @@ -61,6 +69,7 @@ const arrayTypeToZodSchema = ({
schemaToZodSchema({
context,
namespace,
result,
schema: item,
}),
);
Expand Down Expand Up @@ -299,11 +308,12 @@ const numberTypeToZodSchema = ({
const objectTypeToZodSchema = ({
context,
// namespace,

result,
schema,
}: {
context: IRContext;
namespace: Array<ts.Statement>;
result: Result;
schema: SchemaWithType<'object'>;
}) => {
const properties: Array<ts.PropertyAssignment> = [];
Expand All @@ -320,6 +330,7 @@ const objectTypeToZodSchema = ({

let propertyExpression = schemaToZodSchema({
context,
result,
schema: property,
});

Expand Down Expand Up @@ -573,21 +584,22 @@ const voidTypeToZodSchema = ({
};

const schemaTypeToZodSchema = ({
// $ref,
context,
namespace,
result,
schema,
}: {
$ref?: string;
context: IRContext;
namespace: Array<ts.Statement>;
result: Result;
schema: IRSchemaObject;
}): ts.Expression => {
switch (schema.type as Required<IRSchemaObject>['type']) {
case 'array':
return arrayTypeToZodSchema({
context,
namespace,
result,
schema: schema as SchemaWithType<'array'>,
});
case 'boolean':
Expand Down Expand Up @@ -624,6 +636,7 @@ const schemaTypeToZodSchema = ({
return objectTypeToZodSchema({
context,
namespace,
result,
schema: schema as SchemaWithType<'object'>,
});
case 'string':
Expand Down Expand Up @@ -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<ts.Statement>;
result: Result;
schema: IRSchemaObject;
}): ts.Expression => {
const file = context.file({ id: zodId })!;

let expression: ts.Expression | undefined;
let identifier: ReturnType<typeof file.identifier> | 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<IRSchemaObject>(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) {
Expand Down Expand Up @@ -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<Config> = ({ context, plugin }) => {
Expand All @@ -790,9 +860,15 @@ export const handler: Plugin.Handler<Config> = ({ context, plugin }) => {
// });

context.subscribe('schema', ({ $ref, schema }) => {
const result: Result = {
circularReferenceTracker: new Set(),
hasCircularReference: false,
};

schemaToZodSchema({
$ref,
context,
result,
schema,
});
});
Expand Down
8 changes: 8 additions & 0 deletions packages/openapi-ts/test/3.1.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
});
4 changes: 2 additions & 2 deletions packages/openapi-ts/test/sample.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -61,7 +61,7 @@ const main = async () => {
// name: '@tanstack/vue-query',
},
{
// name: 'zod',
name: 'zod',
},
],
// useOptions: false,
Expand Down
36 changes: 36 additions & 0 deletions packages/openapi-ts/test/spec/3.1.x/schema-recursive.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}

0 comments on commit 322eccb

Please sign in to comment.