Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: zod plugin handles recursive schemas #1416

Merged
merged 1 commit into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 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 @@
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 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 @@

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 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 @@
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 @@
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 @@
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 @@
// });

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"
}
}
}
}
Loading