Skip to content

Commit 27e1732

Browse files
authored
Merge pull request #3553 from hey-api/refactor/zod-plugin-walker
refactor: zod plugin
2 parents 947860e + 946684e commit 27e1732

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1256
-1368
lines changed

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ scripts/ # Build and test scripts
8888
- Object/interface keys sorted alphabetically
8989
- Destructured keys sorted alphabetically
9090

91+
## Refactoring Guidelines
92+
93+
When refactoring existing code:
94+
95+
- **Preserve all JSDoc comments** - Read the original file first and keep all existing documentation
96+
- **Match existing code patterns** - Look at similar files in the codebase for conventions
97+
- **Prefer `edit` over `write`** - Making targeted edits preserves comments better than rewriting files
98+
- **Check reference implementations** - For plugin work, use Valibot as a reference for proper patterns
99+
91100
## Pre-commit Checklist
92101

93102
Run before committing (Husky runs format + lint automatically, but also verify):

packages/openapi-ts/src/plugins/zod/mini/processor.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { ref, refs } from '@hey-api/codegen-core';
1+
import { ref } from '@hey-api/codegen-core';
22
import type { IR } from '@hey-api/shared';
33
import { createSchemaProcessor, createSchemaWalker, pathToJsonPointer } from '@hey-api/shared';
44

55
import { exportAst } from '../shared/export';
66
import type { ProcessorContext, ProcessorResult } from '../shared/processor';
7-
import type { PluginState, ZodAppliedResult } from '../shared/types';
7+
import type { ZodFinal } from '../shared/types';
88
import type { ZodPlugin } from '../types';
99
import { createVisitor } from './walker';
1010

@@ -37,32 +37,22 @@ export function createProcessor(plugin: ZodPlugin['Instance']): ProcessorResult
3737
if (!processor.markEmitted(ctx.path)) return;
3838

3939
return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => {
40-
const state = refs<PluginState>({
41-
hasLazyExpression: false,
42-
path: ctx.path,
43-
tags: ctx.tags,
44-
});
45-
4640
const visitor = createVisitor({
4741
schemaExtractor: extractor,
48-
state,
4942
});
5043
const walk = createSchemaWalker(visitor);
5144

5245
const result = walk(ctx.schema, {
5346
path: ref(ctx.path),
5447
plugin,
5548
});
56-
const ast =
57-
(visitor.applyModifiers(result, {
58-
path: ref(ctx.path),
59-
plugin,
60-
}) as ZodAppliedResult) ?? result.expression;
61-
if (result.hasLazyExpression) {
62-
state.hasLazyExpression['~ref'] = true;
63-
}
6449

65-
exportAst({ ...ctx, ast, plugin, state });
50+
const final = visitor.applyModifiers(result, {
51+
path: ref(ctx.path),
52+
plugin,
53+
}) as ZodFinal;
54+
55+
exportAst({ ...ctx, final, plugin });
6656
});
6757
}
6858

Lines changed: 57 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,65 @@
1-
import type { SchemaWithType } from '@hey-api/shared';
1+
import type { SchemaVisitorContext, SchemaWithType, Walker } from '@hey-api/shared';
22
import { childContext, deduplicateSchema } from '@hey-api/shared';
33

44
import { $ } from '../../../../ts-dsl';
55
import { identifiers } from '../../constants';
6-
import type {
7-
Ast,
8-
IrSchemaToAstOptions,
9-
ZodAppliedResult,
10-
ZodSchemaResult,
11-
} from '../../shared/types';
6+
import type { Chain } from '../../shared/chain';
7+
import type { CompositeHandlerResult, ZodFinal, ZodResult } from '../../shared/types';
8+
import type { ZodPlugin } from '../../types';
129
import { unknownToAst } from './unknown';
1310

14-
export function arrayToAst(
15-
options: IrSchemaToAstOptions & {
16-
applyModifiers: (result: ZodSchemaResult, opts: { optional?: boolean }) => ZodAppliedResult;
17-
schema: SchemaWithType<'array'>;
18-
},
19-
): Omit<Ast, 'typeName'> {
20-
const { applyModifiers, plugin, walk } = options;
21-
let { schema } = options;
22-
23-
const result: Partial<Omit<Ast, 'typeName'>> = {};
11+
export function arrayToAst({
12+
applyModifiers,
13+
plugin,
14+
schema,
15+
walk,
16+
walkerCtx,
17+
}: {
18+
applyModifiers: (result: ZodResult, opts: { optional?: boolean }) => ZodFinal;
19+
plugin: ZodPlugin['Instance'];
20+
schema: SchemaWithType<'array'>;
21+
walk: Walker<ZodResult, ZodPlugin['Instance']>;
22+
walkerCtx: SchemaVisitorContext<ZodPlugin['Instance']>;
23+
}): CompositeHandlerResult {
24+
const childResults: Array<ZodResult> = [];
25+
let schemaCopy = schema;
2426

2527
const z = plugin.external('zod.z');
2628
const functionName = $(z).attr(identifiers.array);
2729

28-
if (!schema.items) {
29-
result.expression = functionName.call(
30+
let arrayExpression: ReturnType<typeof $.call> | undefined;
31+
32+
if (!schemaCopy.items) {
33+
arrayExpression = functionName.call(
3034
unknownToAst({
31-
...options,
32-
schema: {
33-
type: 'unknown',
34-
},
35-
}).expression,
35+
plugin,
36+
schema: { type: 'unknown' },
37+
}),
3638
);
3739
} else {
38-
schema = deduplicateSchema({ schema });
40+
schemaCopy = deduplicateSchema({ schema: schemaCopy });
3941

40-
// at least one item is guaranteed
41-
const itemExpressions = schema.items!.map((item, index) => {
42-
const itemResult = walk(
43-
item,
44-
childContext(
45-
{
46-
path: options.state.path,
47-
plugin: options.plugin,
48-
},
49-
'items',
50-
index,
51-
),
52-
);
53-
if (itemResult.hasLazyExpression) {
54-
result.hasLazyExpression = true;
55-
}
42+
const itemExpressions: Array<Chain> = [];
43+
44+
schemaCopy.items!.forEach((item, index) => {
45+
const itemResult = walk(item, childContext(walkerCtx, 'items', index));
46+
childResults.push(itemResult);
5647

5748
const finalExpr = applyModifiers(itemResult, { optional: false });
58-
return finalExpr.expression;
49+
itemExpressions.push(finalExpr.expression);
5950
});
6051

6152
if (itemExpressions.length === 1) {
62-
result.expression = functionName.call(...itemExpressions);
53+
arrayExpression = functionName.call(...itemExpressions);
6354
} else {
64-
if (schema.logicalOperator === 'and') {
65-
const firstSchema = schema.items![0]!;
66-
// we want to add an intersection, but not every schema can use the same API.
67-
// if the first item contains another array or not an object, we cannot use
68-
// `.intersection()` as that does not exist on `.union()` and non-object schemas.
69-
let intersectionExpression: ReturnType<typeof $.expr | typeof $.call>;
70-
if (
71-
firstSchema.logicalOperator === 'or' ||
72-
(firstSchema.type && firstSchema.type !== 'object')
73-
) {
74-
intersectionExpression = $(z)
55+
if (schemaCopy.logicalOperator === 'and') {
56+
arrayExpression = functionName.call(
57+
$(z)
7558
.attr(identifiers.intersection)
76-
.call(...itemExpressions);
77-
} else {
78-
intersectionExpression = itemExpressions[0]!;
79-
for (let i = 1; i < itemExpressions.length; i++) {
80-
intersectionExpression = $(z)
81-
.attr(identifiers.intersection)
82-
.call(intersectionExpression, itemExpressions[i]);
83-
}
84-
}
85-
86-
result.expression = functionName.call(intersectionExpression);
59+
.call(...itemExpressions),
60+
);
8761
} else {
88-
result.expression = $(z)
62+
arrayExpression = $(z)
8963
.attr(identifiers.array)
9064
.call(
9165
$(z)
@@ -96,23 +70,28 @@ export function arrayToAst(
9670
}
9771
}
9872

99-
const checks: Array<ReturnType<typeof $.call>> = [];
100-
101-
if (schema.minItems === schema.maxItems && schema.minItems !== undefined) {
102-
checks.push($(z).attr(identifiers.length).call($.fromValue(schema.minItems)));
73+
if (schemaCopy.minItems === schemaCopy.maxItems && schemaCopy.minItems !== undefined) {
74+
arrayExpression = arrayExpression
75+
.attr(identifiers.length)
76+
.call($.fromValue(schemaCopy.minItems));
10377
} else {
104-
if (schema.minItems !== undefined) {
105-
checks.push($(z).attr(identifiers.minLength).call($.fromValue(schema.minItems)));
78+
const checks: Array<ReturnType<typeof $.call>> = [];
79+
80+
if (schemaCopy.minItems !== undefined) {
81+
checks.push($(z).attr(identifiers.minLength).call($.fromValue(schemaCopy.minItems)));
10682
}
10783

108-
if (schema.maxItems !== undefined) {
109-
checks.push($(z).attr(identifiers.maxLength).call($.fromValue(schema.maxItems)));
84+
if (schemaCopy.maxItems !== undefined) {
85+
checks.push($(z).attr(identifiers.maxLength).call($.fromValue(schemaCopy.maxItems)));
11086
}
111-
}
11287

113-
if (checks.length > 0) {
114-
result.expression = result.expression.attr(identifiers.check).call(...checks);
88+
if (checks.length) {
89+
arrayExpression = arrayExpression.attr(identifiers.check).call(...checks);
90+
}
11591
}
11692

117-
return result as Omit<Ast, 'typeName'>;
93+
return {
94+
childResults,
95+
expression: arrayExpression,
96+
};
11897
}

packages/openapi-ts/src/plugins/zod/mini/toAst/boolean.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,21 @@ import type { SchemaWithType } from '@hey-api/shared';
22

33
import { $ } from '../../../../ts-dsl';
44
import { identifiers } from '../../constants';
5-
import type { Ast, IrSchemaToAstOptions } from '../../shared/types';
5+
import type { Chain } from '../../shared/chain';
6+
import type { ZodPlugin } from '../../types';
67

78
export function booleanToAst({
89
plugin,
910
schema,
10-
}: Pick<IrSchemaToAstOptions, 'plugin'> & {
11+
}: {
12+
plugin: ZodPlugin['Instance'];
1113
schema: SchemaWithType<'boolean'>;
12-
}): Omit<Ast, 'typeName'> {
13-
const result: Partial<Omit<Ast, 'typeName'>> = {};
14-
let chain: ReturnType<typeof $.call>;
15-
14+
}): Chain {
1615
const z = plugin.external('zod.z');
1716

1817
if (typeof schema.const === 'boolean') {
19-
chain = $(z).attr(identifiers.literal).call($.literal(schema.const));
20-
result.expression = chain;
21-
return result as Omit<Ast, 'typeName'>;
18+
return $(z).attr(identifiers.literal).call($.literal(schema.const));
2219
}
2320

24-
chain = $(z).attr(identifiers.boolean).call();
25-
result.expression = chain;
26-
return result as Omit<Ast, 'typeName'>;
21+
return $(z).attr(identifiers.boolean).call();
2722
}

packages/openapi-ts/src/plugins/zod/mini/toAst/enum.ts

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { $ } from '../../../../ts-dsl';
44
import { identifiers } from '../../constants';
55
import type { EnumResolverContext } from '../../resolvers';
66
import type { Chain } from '../../shared/chain';
7-
import type { Ast, IrSchemaToAstOptions } from '../../shared/types';
7+
import type { ZodPlugin } from '../../types';
88
import { unknownToAst } from './unknown';
99

1010
function itemsNode(ctx: EnumResolverContext): ReturnType<EnumResolverContext['nodes']['items']> {
@@ -55,13 +55,15 @@ function baseNode(ctx: EnumResolverContext): Chain {
5555
return $(z)
5656
.attr(identifiers.enum)
5757
.call($.array(...enumMembers));
58-
} else if (literalMembers.length === 1) {
58+
}
59+
60+
if (literalMembers.length === 1) {
5961
return literalMembers[0]!;
60-
} else {
61-
return $(z)
62-
.attr(identifiers.union)
63-
.call($.array(...literalMembers));
6462
}
63+
64+
return $(z)
65+
.attr(identifiers.union)
66+
.call($.array(...literalMembers));
6567
}
6668

6769
function enumResolver(ctx: EnumResolverContext): Chain {
@@ -80,31 +82,12 @@ function enumResolver(ctx: EnumResolverContext): Chain {
8082
export function enumToAst({
8183
plugin,
8284
schema,
83-
state,
84-
}: Pick<IrSchemaToAstOptions, 'plugin' | 'state'> & {
85+
}: {
86+
plugin: ZodPlugin['Instance'];
8587
schema: SchemaWithType<'enum'>;
86-
}): Omit<Ast, 'typeName'> {
88+
}): Chain {
8789
const z = plugin.external('zod.z');
8890

89-
const { literalMembers } = itemsNode({
90-
$,
91-
chain: { current: $(z) },
92-
nodes: { base: baseNode, items: itemsNode },
93-
plugin,
94-
schema,
95-
symbols: { z },
96-
utils: { ast: {}, state },
97-
});
98-
99-
if (!literalMembers.length) {
100-
return unknownToAst({
101-
plugin,
102-
schema: {
103-
type: 'unknown',
104-
},
105-
});
106-
}
107-
10891
const ctx: EnumResolverContext = {
10992
$,
11093
chain: {
@@ -119,16 +102,19 @@ export function enumToAst({
119102
symbols: {
120103
z,
121104
},
122-
utils: {
123-
ast: {},
124-
state,
125-
},
126105
};
127106

128-
const resolver = plugin.config['~resolvers']?.enum;
129-
const node = resolver?.(ctx) ?? enumResolver(ctx);
107+
const { literalMembers } = itemsNode(ctx);
130108

131-
return {
132-
expression: node,
133-
};
109+
if (!literalMembers.length) {
110+
return unknownToAst({
111+
plugin,
112+
schema: {
113+
type: 'unknown',
114+
},
115+
});
116+
}
117+
118+
const resolver = plugin.config['~resolvers']?.enum;
119+
return resolver?.(ctx) ?? enumResolver(ctx);
134120
}

packages/openapi-ts/src/plugins/zod/mini/toAst/never.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import type { SchemaWithType } from '@hey-api/shared';
22

33
import { $ } from '../../../../ts-dsl';
44
import { identifiers } from '../../constants';
5-
import type { Ast, IrSchemaToAstOptions } from '../../shared/types';
5+
import type { Chain } from '../../shared/chain';
6+
import type { ZodPlugin } from '../../types';
67

78
export function neverToAst({
89
plugin,
9-
}: Pick<IrSchemaToAstOptions, 'plugin'> & {
10+
}: {
11+
plugin: ZodPlugin['Instance'];
1012
schema: SchemaWithType<'never'>;
11-
}): Omit<Ast, 'typeName'> {
13+
}): Chain {
1214
const z = plugin.external('zod.z');
13-
const result: Partial<Omit<Ast, 'typeName'>> = {};
14-
result.expression = $(z).attr(identifiers.never).call();
15-
return result as Omit<Ast, 'typeName'>;
15+
return $(z).attr(identifiers.never).call();
1616
}

0 commit comments

Comments
 (0)