Skip to content

Commit

Permalink
fix: zod plugin handles recursive schemas
Browse files Browse the repository at this point in the history
  • Loading branch information
mrlubos committed Dec 11, 2024
1 parent 318e06e commit 2a605b7
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,

Check warning on line 36 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L36

Added line #L36 was not covered by tests
schema,
}: {
context: IRContext;
namespace: Array<ts.Statement>;
result: Result;

Check warning on line 41 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L41

Added line #L41 was not covered by tests
schema: SchemaWithType<'array'>;
}): ts.CallExpression => {
const functionName = compiler.propertyAccessExpression({
Expand Down Expand Up @@ -61,6 +69,7 @@ const arrayTypeToZodSchema = ({
schemaToZodSchema({
context,
namespace,
result,

Check warning on line 72 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L72

Added line #L72 was not covered by tests
schema: item,
}),
);
Expand Down Expand Up @@ -299,11 +308,12 @@ const numberTypeToZodSchema = ({
const objectTypeToZodSchema = ({
context,
// namespace,

result,

Check warning on line 311 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L311

Added line #L311 was not covered by tests
schema,
}: {
context: IRContext;
namespace: Array<ts.Statement>;
result: Result;

Check warning on line 316 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L316

Added line #L316 was not covered by tests
schema: SchemaWithType<'object'>;
}) => {
const properties: Array<ts.PropertyAssignment> = [];
Expand All @@ -320,6 +330,7 @@ const objectTypeToZodSchema = ({

let propertyExpression = schemaToZodSchema({
context,
result,

Check warning on line 333 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L333

Added line #L333 was not covered by tests
schema: property,
});

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

const schemaTypeToZodSchema = ({
// $ref,
context,
namespace,
result,

Check warning on line 589 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L589

Added line #L589 was not covered by tests
schema,
}: {
$ref?: string;
context: IRContext;
namespace: Array<ts.Statement>;
result: Result;

Check warning on line 594 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L594

Added line #L594 was not covered by tests
schema: IRSchemaObject;
}): ts.Expression => {
switch (schema.type as Required<IRSchemaObject>['type']) {
case 'array':
return arrayTypeToZodSchema({
context,
namespace,
result,

Check warning on line 602 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L602

Added line #L602 was not covered by tests
schema: schema as SchemaWithType<'array'>,
});
case 'boolean':
Expand Down Expand Up @@ -624,6 +636,7 @@ const schemaTypeToZodSchema = ({
return objectTypeToZodSchema({
context,
namespace,
result,

Check warning on line 639 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L639

Added line #L639 was not covered by tests
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,

Check warning on line 689 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L689

Added line #L689 was not covered by tests
schema,
}: {
$ref?: string;
context: IRContext;
namespace?: Array<ts.Statement>;
result: Result;

Check warning on line 695 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L695

Added line #L695 was not covered by tests
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',
});
}
}

Check warning on line 715 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L701-L715

Added lines #L701 - L715 were not covered by tests

if (schema.$ref) {
const isCircularReference = result.circularReferenceTracker.has(
schema.$ref,
);

Check warning on line 721 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L718-L721

Added lines #L718 - L721 were not covered by tests
// 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({

Check warning on line 725 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L725

Added line #L725 was not covered by tests
$ref: schema.$ref,
nameTransformer,
namespace: 'value',
});
if (identifier.name) {
expression = compiler.identifier({ text: identifier.name || '' });
} else {

if (!identifierRef.name) {

Check warning on line 731 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L730-L731

Added lines #L730 - L731 were not covered by tests
const ref = context.resolveIrRef<IRSchemaObject>(schema.$ref);
expression = schemaToZodSchema({
context,
result,

Check warning on line 735 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L735

Added line #L735 was not covered by tests
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;
}

Check warning on line 768 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L738-L768

Added lines #L738 - L768 were not covered by tests
}
} else if (schema.type) {
expression = schemaTypeToZodSchema({
$ref,
context,
namespace,
result,

Check warning on line 774 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L774

Added line #L774 was not covered by tests
schema,
});
} else if (schema.items) {
Expand Down Expand Up @@ -745,29 +810,34 @@ const schemaToZodSchema = ({
expression = schemaTypeToZodSchema({
context,
namespace,
result,

Check warning on line 813 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L813

Added line #L813 was not covered by tests
schema: {
type: 'unknown',
},
});
}

if ($ref) {
result.circularReferenceTracker.delete($ref);
}

Check warning on line 823 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L820-L823

Added lines #L820 - L823 were not covered by tests
// 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) {

Check warning on line 825 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L825

Added line #L825 was not covered by tests
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,

Check warning on line 835 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L828-L835

Added lines #L828 - L835 were not covered by tests
});
file.add(statement);
}

return expression;
return expression!;

Check warning on line 840 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L840

Added line #L840 was not covered by tests
};

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,
};

Check warning on line 867 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L863-L867

Added lines #L863 - L867 were not covered by tests
schemaToZodSchema({
$ref,
context,
result,

Check warning on line 871 in packages/openapi-ts/src/plugins/zod/plugin.ts

View check run for this annotation

Codecov / codecov/patch

packages/openapi-ts/src/plugins/zod/plugin.ts#L871

Added line #L871 was not covered by tests
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 2a605b7

Please sign in to comment.