zod-codegen 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +15 -0
- package/dist/src/services/code-generator.service.d.ts +15 -0
- package/dist/src/services/code-generator.service.d.ts.map +1 -1
- package/dist/src/services/code-generator.service.js +121 -3
- package/package.json +5 -5
- package/src/services/code-generator.service.ts +156 -4
- package/tests/unit/code-generator.test.ts +196 -0
package/.github/workflows/ci.yml
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## 1.5.0 (2025-12-22)
|
|
2
|
+
|
|
3
|
+
- Merge pull request #44 from julienandreu/dependabot/github_actions/actions/upload-artifact-6 ([cebfd55](https://github.com/julienandreu/zod-codegen/commit/cebfd55)), closes [#44](https://github.com/julienandreu/zod-codegen/issues/44)
|
|
4
|
+
- Merge pull request #45 from julienandreu/dependabot/npm_and_yarn/dev-dependencies-ab633d47b1 ([8663f40](https://github.com/julienandreu/zod-codegen/commit/8663f40)), closes [#45](https://github.com/julienandreu/zod-codegen/issues/45)
|
|
5
|
+
- Merge pull request #46 from julienandreu/dependabot/npm_and_yarn/production-dependencies-eefc12583a ([eac5279](https://github.com/julienandreu/zod-codegen/commit/eac5279)), closes [#46](https://github.com/julienandreu/zod-codegen/issues/46)
|
|
6
|
+
- Merge pull request #47 from julienandreu/dependabot/npm_and_yarn/types/node-25.0.2 ([687ce64](https://github.com/julienandreu/zod-codegen/commit/687ce64)), closes [#47](https://github.com/julienandreu/zod-codegen/issues/47)
|
|
7
|
+
- Merge pull request #49 from julienandreu/dependabot/npm_and_yarn/production-dependencies-5419ff3310 ([1d6485c](https://github.com/julienandreu/zod-codegen/commit/1d6485c)), closes [#49](https://github.com/julienandreu/zod-codegen/issues/49)
|
|
8
|
+
- Merge pull request #50 from julienandreu/feat/circular-dependency-detection ([527cbb6](https://github.com/julienandreu/zod-codegen/commit/527cbb6)), closes [#50](https://github.com/julienandreu/zod-codegen/issues/50)
|
|
9
|
+
- feat: detect circular dependencies and wrap with z.lazy() ([4f5f545](https://github.com/julienandreu/zod-codegen/commit/4f5f545))
|
|
10
|
+
- chore(deps-dev): bump @types/node from 24.10.1 to 25.0.2 ([da9bbc7](https://github.com/julienandreu/zod-codegen/commit/da9bbc7))
|
|
11
|
+
- chore(deps-dev): bump the dev-dependencies group with 2 updates ([29ad3ef](https://github.com/julienandreu/zod-codegen/commit/29ad3ef))
|
|
12
|
+
- chore(deps): bump zod in the production-dependencies group ([391d6ea](https://github.com/julienandreu/zod-codegen/commit/391d6ea))
|
|
13
|
+
- chore(deps): bump zod in the production-dependencies group ([41177c8](https://github.com/julienandreu/zod-codegen/commit/41177c8))
|
|
14
|
+
- ci(deps): bump actions/upload-artifact from 4 to 6 ([c19e254](https://github.com/julienandreu/zod-codegen/commit/c19e254))
|
|
15
|
+
|
|
1
16
|
## <small>1.4.1 (2025-12-08)</small>
|
|
2
17
|
|
|
3
18
|
- Merge branch 'refactor/build-config-and-class-exports' of github.com:julienandreu/zod-codegen into r ([bdd9bc5](https://github.com/julienandreu/zod-codegen/commit/bdd9bc5))
|
|
@@ -8,6 +8,8 @@ export declare class TypeScriptCodeGeneratorService implements CodeGenerator, Sc
|
|
|
8
8
|
private readonly printer;
|
|
9
9
|
private readonly namingConvention;
|
|
10
10
|
private readonly operationNameTransformer;
|
|
11
|
+
private circularSchemas;
|
|
12
|
+
private currentSchemaName;
|
|
11
13
|
constructor(options?: GeneratorOptions);
|
|
12
14
|
private readonly ZodAST;
|
|
13
15
|
generate(spec: OpenApiSpecType): string;
|
|
@@ -52,6 +54,19 @@ export declare class TypeScriptCodeGeneratorService implements CodeGenerator, Sc
|
|
|
52
54
|
private buildArrayTypeFromSchema;
|
|
53
55
|
private isReference;
|
|
54
56
|
private buildFromReference;
|
|
57
|
+
/**
|
|
58
|
+
* Determines if a reference creates a circular dependency that needs z.lazy().
|
|
59
|
+
* A reference is circular if:
|
|
60
|
+
* 1. It's a direct self-reference (schema references itself)
|
|
61
|
+
* 2. It's part of a circular dependency chain (A -> B -> A)
|
|
62
|
+
*/
|
|
63
|
+
private isCircularReference;
|
|
64
|
+
/**
|
|
65
|
+
* Detects schemas that are part of circular dependency chains.
|
|
66
|
+
* Uses a modified Tarjan's algorithm to find strongly connected components (SCCs).
|
|
67
|
+
* Schemas in SCCs with more than one node, or self-referencing schemas, are circular.
|
|
68
|
+
*/
|
|
69
|
+
private detectCircularDependencies;
|
|
55
70
|
private topologicalSort;
|
|
56
71
|
}
|
|
57
72
|
//# sourceMappingURL=code-generator.service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"code-generator.service.d.ts","sourceRoot":"","sources":["../../../src/services/code-generator.service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,KAAK,EAAC,aAAa,EAAE,aAAa,EAAC,MAAM,iCAAiC,CAAC;AAClF,OAAO,KAAK,EAAmB,eAAe,EAAgB,MAAM,qBAAqB,CAAC;AAC1F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAWpE,qBAAa,8BAA+B,YAAW,aAAa,EAAE,aAAa;IACjF,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsC;IAClE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAwC;IACtE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwD;IAChF,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA+B;IAChE,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAuC;
|
|
1
|
+
{"version":3,"file":"code-generator.service.d.ts","sourceRoot":"","sources":["../../../src/services/code-generator.service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,KAAK,EAAC,aAAa,EAAE,aAAa,EAAC,MAAM,iCAAiC,CAAC;AAClF,OAAO,KAAK,EAAmB,eAAe,EAAgB,MAAM,qBAAqB,CAAC;AAC1F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAWpE,qBAAa,8BAA+B,YAAW,aAAa,EAAE,aAAa;IACjF,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsC;IAClE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAwC;IACtE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwD;IAChF,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA+B;IAChE,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAuC;IAGhF,OAAO,CAAC,eAAe,CAAqB;IAC5C,OAAO,CAAC,iBAAiB,CAAuB;gBAEpC,OAAO,GAAE,gBAAqB;IAK1C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAGpB;IAEH,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM;IAMvC,WAAW,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,UAAO,GAAG,EAAE,CAAC,cAAc,GAAG,EAAE,CAAC,UAAU;IA2BhF,OAAO,CAAC,QAAQ;IAmBhB,OAAO,CAAC,YAAY;IA2CpB,OAAO,CAAC,sBAAsB;IAe9B,OAAO,CAAC,gBAAgB;IAsBxB,OAAO,CAAC,gBAAgB;IAmGxB,OAAO,CAAC,gCAAgC;IAwBxC,OAAO,CAAC,sBAAsB;IAqmB9B,OAAO,CAAC,kBAAkB;IAsB1B;;;OAGG;IACH,OAAO,CAAC,sBAAsB;IA0B9B,OAAO,CAAC,mBAAmB;IAgH3B,OAAO,CAAC,mBAAmB;IA6D3B,OAAO,CAAC,qBAAqB;IAwF7B,OAAO,CAAC,gBAAgB;IA0CxB,OAAO,CAAC,iBAAiB;IAmCzB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,eAAe;IAuDvB,OAAO,CAAC,wBAAwB;IA4IhC,OAAO,CAAC,gBAAgB;IAqBxB,OAAO,CAAC,6BAA6B;IAkPrC,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,aAAa;IAMrB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAuFzB,OAAO,CAAC,WAAW;IA0CnB,OAAO,CAAC,aAAa;IAmjBrB,OAAO,CAAC,iBAAiB;IAuCzB,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,oBAAoB;IAyE5B,OAAO,CAAC,8BAA8B;IActC,OAAO,CAAC,wBAAwB;IAyBhC,OAAO,CAAC,yBAAyB;IA8BjC,OAAO,CAAC,wBAAwB;IAYhC,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,kBAAkB;IA8B1B;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAmB3B;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;IAiFlC,OAAO,CAAC,eAAe;CAuCxB"}
|
|
@@ -11,6 +11,9 @@ export class TypeScriptCodeGeneratorService {
|
|
|
11
11
|
printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
|
|
12
12
|
namingConvention;
|
|
13
13
|
operationNameTransformer;
|
|
14
|
+
// Track circular dependencies for z.lazy() wrapping
|
|
15
|
+
circularSchemas = new Set();
|
|
16
|
+
currentSchemaName = null;
|
|
14
17
|
constructor(options = {}) {
|
|
15
18
|
this.namingConvention = options.namingConvention;
|
|
16
19
|
this.operationNameTransformer = options.operationNameTransformer;
|
|
@@ -63,13 +66,21 @@ export class TypeScriptCodeGeneratorService {
|
|
|
63
66
|
}
|
|
64
67
|
buildSchemas(openapi) {
|
|
65
68
|
const schemasEntries = Object.entries(openapi.components?.schemas ?? {});
|
|
66
|
-
const
|
|
69
|
+
const schemasMap = Object.fromEntries(schemasEntries);
|
|
70
|
+
// Detect circular dependencies before building schemas
|
|
71
|
+
this.circularSchemas = this.detectCircularDependencies(schemasMap);
|
|
72
|
+
const sortedSchemaNames = this.topologicalSort(schemasMap);
|
|
67
73
|
return sortedSchemaNames.reduce((schemaRegistered, name) => {
|
|
68
74
|
const schema = openapi.components?.schemas?.[name];
|
|
69
75
|
if (!schema)
|
|
70
76
|
return schemaRegistered;
|
|
77
|
+
// Set context for current schema being built
|
|
78
|
+
this.currentSchemaName = name;
|
|
79
|
+
const schemaExpression = this.buildSchema(schema);
|
|
80
|
+
// Clear context
|
|
81
|
+
this.currentSchemaName = null;
|
|
71
82
|
const variableStatement = ts.factory.createVariableStatement([ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], ts.factory.createVariableDeclarationList([
|
|
72
|
-
ts.factory.createVariableDeclaration(ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(name)), undefined, undefined,
|
|
83
|
+
ts.factory.createVariableDeclaration(ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(name)), undefined, undefined, schemaExpression),
|
|
73
84
|
], ts.NodeFlags.Const));
|
|
74
85
|
return {
|
|
75
86
|
...schemaRegistered,
|
|
@@ -1223,7 +1234,114 @@ export class TypeScriptCodeGeneratorService {
|
|
|
1223
1234
|
buildFromReference(reference) {
|
|
1224
1235
|
const { $ref = '' } = Reference.parse(reference);
|
|
1225
1236
|
const refName = $ref.split('/').pop() ?? 'never';
|
|
1226
|
-
|
|
1237
|
+
const sanitizedRefName = this.typeBuilder.sanitizeIdentifier(refName);
|
|
1238
|
+
// Check if this reference creates a circular dependency
|
|
1239
|
+
if (this.isCircularReference(refName)) {
|
|
1240
|
+
// Generate: z.lazy(() => RefSchema)
|
|
1241
|
+
return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('z'), ts.factory.createIdentifier('lazy')), undefined, [
|
|
1242
|
+
ts.factory.createArrowFunction(undefined, undefined, [], undefined, ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), ts.factory.createIdentifier(sanitizedRefName)),
|
|
1243
|
+
]);
|
|
1244
|
+
}
|
|
1245
|
+
return ts.factory.createIdentifier(sanitizedRefName);
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Determines if a reference creates a circular dependency that needs z.lazy().
|
|
1249
|
+
* A reference is circular if:
|
|
1250
|
+
* 1. It's a direct self-reference (schema references itself)
|
|
1251
|
+
* 2. It's part of a circular dependency chain (A -> B -> A)
|
|
1252
|
+
*/
|
|
1253
|
+
isCircularReference(refName) {
|
|
1254
|
+
// Case 1: Direct self-reference
|
|
1255
|
+
if (refName === this.currentSchemaName) {
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
// Case 2: Reference to a schema that's part of a circular dependency chain
|
|
1259
|
+
// and we're currently building a schema that's also in that chain
|
|
1260
|
+
if (this.circularSchemas.has(refName) &&
|
|
1261
|
+
this.currentSchemaName !== null &&
|
|
1262
|
+
this.circularSchemas.has(this.currentSchemaName)) {
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
return false;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Detects schemas that are part of circular dependency chains.
|
|
1269
|
+
* Uses a modified Tarjan's algorithm to find strongly connected components (SCCs).
|
|
1270
|
+
* Schemas in SCCs with more than one node, or self-referencing schemas, are circular.
|
|
1271
|
+
*/
|
|
1272
|
+
detectCircularDependencies(schemas) {
|
|
1273
|
+
const circularSchemas = new Set();
|
|
1274
|
+
// Build dependency graph
|
|
1275
|
+
const graph = new Map();
|
|
1276
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
1277
|
+
const dependencies = jp
|
|
1278
|
+
.query(schema, '$..["$ref"]')
|
|
1279
|
+
.filter((ref) => ref.startsWith('#/components/schemas/'))
|
|
1280
|
+
.map((ref) => ref.replace('#/components/schemas/', ''))
|
|
1281
|
+
.filter((dep) => dep in schemas);
|
|
1282
|
+
graph.set(name, dependencies);
|
|
1283
|
+
}
|
|
1284
|
+
// Tarjan's algorithm for finding SCCs
|
|
1285
|
+
let index = 0;
|
|
1286
|
+
const indices = new Map();
|
|
1287
|
+
const lowlinks = new Map();
|
|
1288
|
+
const onStack = new Set();
|
|
1289
|
+
const stack = [];
|
|
1290
|
+
const strongConnect = (node) => {
|
|
1291
|
+
indices.set(node, index);
|
|
1292
|
+
lowlinks.set(node, index);
|
|
1293
|
+
index++;
|
|
1294
|
+
stack.push(node);
|
|
1295
|
+
onStack.add(node);
|
|
1296
|
+
const neighbors = graph.get(node) ?? [];
|
|
1297
|
+
for (const neighbor of neighbors) {
|
|
1298
|
+
if (!indices.has(neighbor)) {
|
|
1299
|
+
strongConnect(neighbor);
|
|
1300
|
+
const currentLowlink = lowlinks.get(node) ?? 0;
|
|
1301
|
+
const neighborLowlink = lowlinks.get(neighbor) ?? 0;
|
|
1302
|
+
lowlinks.set(node, Math.min(currentLowlink, neighborLowlink));
|
|
1303
|
+
}
|
|
1304
|
+
else if (onStack.has(neighbor)) {
|
|
1305
|
+
const currentLowlink = lowlinks.get(node) ?? 0;
|
|
1306
|
+
const neighborIndex = indices.get(neighbor) ?? 0;
|
|
1307
|
+
lowlinks.set(node, Math.min(currentLowlink, neighborIndex));
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// If node is a root of an SCC
|
|
1311
|
+
if (lowlinks.get(node) === indices.get(node)) {
|
|
1312
|
+
const scc = [];
|
|
1313
|
+
let w;
|
|
1314
|
+
do {
|
|
1315
|
+
w = stack.pop();
|
|
1316
|
+
if (w !== undefined) {
|
|
1317
|
+
onStack.delete(w);
|
|
1318
|
+
scc.push(w);
|
|
1319
|
+
}
|
|
1320
|
+
} while (w !== undefined && w !== node);
|
|
1321
|
+
// An SCC is circular if it has more than one node
|
|
1322
|
+
// or if it has one node that references itself
|
|
1323
|
+
if (scc.length > 1) {
|
|
1324
|
+
for (const schemaName of scc) {
|
|
1325
|
+
circularSchemas.add(schemaName);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
else if (scc.length === 1) {
|
|
1329
|
+
const schemaName = scc[0];
|
|
1330
|
+
if (schemaName !== undefined) {
|
|
1331
|
+
const deps = graph.get(schemaName) ?? [];
|
|
1332
|
+
if (deps.includes(schemaName)) {
|
|
1333
|
+
circularSchemas.add(schemaName);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
for (const node of graph.keys()) {
|
|
1340
|
+
if (!indices.has(node)) {
|
|
1341
|
+
strongConnect(node);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return circularSchemas;
|
|
1227
1345
|
}
|
|
1228
1346
|
topologicalSort(schemas) {
|
|
1229
1347
|
const visited = new Set();
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"typescript": "^5.9.3",
|
|
18
18
|
"url-pattern": "^1.0.3",
|
|
19
19
|
"yargs": "^18.0.0",
|
|
20
|
-
"zod": "^4.1
|
|
20
|
+
"zod": "^4.2.1"
|
|
21
21
|
},
|
|
22
22
|
"description": "A powerful TypeScript code generator that creates Zod schemas and type-safe clients from OpenAPI specifications",
|
|
23
23
|
"keywords": [
|
|
@@ -34,18 +34,18 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@commitlint/cli": "^20.1.0",
|
|
36
36
|
"@commitlint/config-conventional": "^20.0.0",
|
|
37
|
-
"@eslint/js": "^9.39.
|
|
37
|
+
"@eslint/js": "^9.39.2",
|
|
38
38
|
"@semantic-release/changelog": "^6.0.3",
|
|
39
39
|
"@semantic-release/git": "^10.0.1",
|
|
40
40
|
"@types/debug": "^4.1.12",
|
|
41
41
|
"@types/jest": "^30.0.0",
|
|
42
42
|
"@types/js-yaml": "^4.0.9",
|
|
43
43
|
"@types/jsonpath": "^0.2.4",
|
|
44
|
-
"@types/node": "^
|
|
44
|
+
"@types/node": "^25.0.2",
|
|
45
45
|
"@types/yargs": "^17.0.35",
|
|
46
46
|
"@vitest/coverage-v8": "^4.0.14",
|
|
47
47
|
"cross-env": "^10.1.0",
|
|
48
|
-
"eslint": "^9.39.
|
|
48
|
+
"eslint": "^9.39.2",
|
|
49
49
|
"eslint-config-prettier": "^10.1.8",
|
|
50
50
|
"husky": "^9.1.7",
|
|
51
51
|
"lint-staged": "^16.2.7",
|
|
@@ -110,5 +110,5 @@
|
|
|
110
110
|
"release": "semantic-release",
|
|
111
111
|
"release:dry": "semantic-release --dry-run"
|
|
112
112
|
},
|
|
113
|
-
"version": "1.
|
|
113
|
+
"version": "1.5.0"
|
|
114
114
|
}
|
|
@@ -21,6 +21,10 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
|
|
|
21
21
|
private readonly namingConvention: NamingConvention | undefined;
|
|
22
22
|
private readonly operationNameTransformer: OperationNameTransformer | undefined;
|
|
23
23
|
|
|
24
|
+
// Track circular dependencies for z.lazy() wrapping
|
|
25
|
+
private circularSchemas = new Set<string>();
|
|
26
|
+
private currentSchemaName: string | null = null;
|
|
27
|
+
|
|
24
28
|
constructor(options: GeneratorOptions = {}) {
|
|
25
29
|
this.namingConvention = options.namingConvention;
|
|
26
30
|
this.operationNameTransformer = options.operationNameTransformer;
|
|
@@ -85,12 +89,25 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
|
|
|
85
89
|
|
|
86
90
|
private buildSchemas(openapi: OpenApiSpecType): Record<string, ts.VariableStatement> {
|
|
87
91
|
const schemasEntries = Object.entries(openapi.components?.schemas ?? {});
|
|
88
|
-
const
|
|
92
|
+
const schemasMap = Object.fromEntries(schemasEntries);
|
|
93
|
+
|
|
94
|
+
// Detect circular dependencies before building schemas
|
|
95
|
+
this.circularSchemas = this.detectCircularDependencies(schemasMap);
|
|
96
|
+
|
|
97
|
+
const sortedSchemaNames = this.topologicalSort(schemasMap);
|
|
89
98
|
|
|
90
99
|
return sortedSchemaNames.reduce<Record<string, ts.VariableStatement>>((schemaRegistered, name) => {
|
|
91
100
|
const schema = openapi.components?.schemas?.[name];
|
|
92
101
|
if (!schema) return schemaRegistered;
|
|
93
102
|
|
|
103
|
+
// Set context for current schema being built
|
|
104
|
+
this.currentSchemaName = name;
|
|
105
|
+
|
|
106
|
+
const schemaExpression = this.buildSchema(schema);
|
|
107
|
+
|
|
108
|
+
// Clear context
|
|
109
|
+
this.currentSchemaName = null;
|
|
110
|
+
|
|
94
111
|
const variableStatement = ts.factory.createVariableStatement(
|
|
95
112
|
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
|
|
96
113
|
ts.factory.createVariableDeclarationList(
|
|
@@ -99,7 +116,7 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
|
|
|
99
116
|
ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(name)),
|
|
100
117
|
undefined,
|
|
101
118
|
undefined,
|
|
102
|
-
|
|
119
|
+
schemaExpression,
|
|
103
120
|
),
|
|
104
121
|
],
|
|
105
122
|
ts.NodeFlags.Const,
|
|
@@ -2688,10 +2705,145 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
|
|
|
2688
2705
|
return false;
|
|
2689
2706
|
}
|
|
2690
2707
|
|
|
2691
|
-
private buildFromReference(reference: ReferenceType): ts.Identifier {
|
|
2708
|
+
private buildFromReference(reference: ReferenceType): ts.CallExpression | ts.Identifier {
|
|
2692
2709
|
const {$ref = ''} = Reference.parse(reference);
|
|
2693
2710
|
const refName = $ref.split('/').pop() ?? 'never';
|
|
2694
|
-
|
|
2711
|
+
const sanitizedRefName = this.typeBuilder.sanitizeIdentifier(refName);
|
|
2712
|
+
|
|
2713
|
+
// Check if this reference creates a circular dependency
|
|
2714
|
+
if (this.isCircularReference(refName)) {
|
|
2715
|
+
// Generate: z.lazy(() => RefSchema)
|
|
2716
|
+
return ts.factory.createCallExpression(
|
|
2717
|
+
ts.factory.createPropertyAccessExpression(
|
|
2718
|
+
ts.factory.createIdentifier('z'),
|
|
2719
|
+
ts.factory.createIdentifier('lazy'),
|
|
2720
|
+
),
|
|
2721
|
+
undefined,
|
|
2722
|
+
[
|
|
2723
|
+
ts.factory.createArrowFunction(
|
|
2724
|
+
undefined,
|
|
2725
|
+
undefined,
|
|
2726
|
+
[],
|
|
2727
|
+
undefined,
|
|
2728
|
+
ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
2729
|
+
ts.factory.createIdentifier(sanitizedRefName),
|
|
2730
|
+
),
|
|
2731
|
+
],
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
return ts.factory.createIdentifier(sanitizedRefName);
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
/**
|
|
2739
|
+
* Determines if a reference creates a circular dependency that needs z.lazy().
|
|
2740
|
+
* A reference is circular if:
|
|
2741
|
+
* 1. It's a direct self-reference (schema references itself)
|
|
2742
|
+
* 2. It's part of a circular dependency chain (A -> B -> A)
|
|
2743
|
+
*/
|
|
2744
|
+
private isCircularReference(refName: string): boolean {
|
|
2745
|
+
// Case 1: Direct self-reference
|
|
2746
|
+
if (refName === this.currentSchemaName) {
|
|
2747
|
+
return true;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// Case 2: Reference to a schema that's part of a circular dependency chain
|
|
2751
|
+
// and we're currently building a schema that's also in that chain
|
|
2752
|
+
if (
|
|
2753
|
+
this.circularSchemas.has(refName) &&
|
|
2754
|
+
this.currentSchemaName !== null &&
|
|
2755
|
+
this.circularSchemas.has(this.currentSchemaName)
|
|
2756
|
+
) {
|
|
2757
|
+
return true;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
return false;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
/**
|
|
2764
|
+
* Detects schemas that are part of circular dependency chains.
|
|
2765
|
+
* Uses a modified Tarjan's algorithm to find strongly connected components (SCCs).
|
|
2766
|
+
* Schemas in SCCs with more than one node, or self-referencing schemas, are circular.
|
|
2767
|
+
*/
|
|
2768
|
+
private detectCircularDependencies(schemas: Record<string, unknown>): Set<string> {
|
|
2769
|
+
const circularSchemas = new Set<string>();
|
|
2770
|
+
|
|
2771
|
+
// Build dependency graph
|
|
2772
|
+
const graph = new Map<string, string[]>();
|
|
2773
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
2774
|
+
const dependencies = jp
|
|
2775
|
+
.query(schema, '$..["$ref"]')
|
|
2776
|
+
.filter((ref: string) => ref.startsWith('#/components/schemas/'))
|
|
2777
|
+
.map((ref: string) => ref.replace('#/components/schemas/', ''))
|
|
2778
|
+
.filter((dep: string) => dep in schemas);
|
|
2779
|
+
graph.set(name, dependencies);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// Tarjan's algorithm for finding SCCs
|
|
2783
|
+
let index = 0;
|
|
2784
|
+
const indices = new Map<string, number>();
|
|
2785
|
+
const lowlinks = new Map<string, number>();
|
|
2786
|
+
const onStack = new Set<string>();
|
|
2787
|
+
const stack: string[] = [];
|
|
2788
|
+
|
|
2789
|
+
const strongConnect = (node: string): void => {
|
|
2790
|
+
indices.set(node, index);
|
|
2791
|
+
lowlinks.set(node, index);
|
|
2792
|
+
index++;
|
|
2793
|
+
stack.push(node);
|
|
2794
|
+
onStack.add(node);
|
|
2795
|
+
|
|
2796
|
+
const neighbors = graph.get(node) ?? [];
|
|
2797
|
+
for (const neighbor of neighbors) {
|
|
2798
|
+
if (!indices.has(neighbor)) {
|
|
2799
|
+
strongConnect(neighbor);
|
|
2800
|
+
const currentLowlink = lowlinks.get(node) ?? 0;
|
|
2801
|
+
const neighborLowlink = lowlinks.get(neighbor) ?? 0;
|
|
2802
|
+
lowlinks.set(node, Math.min(currentLowlink, neighborLowlink));
|
|
2803
|
+
} else if (onStack.has(neighbor)) {
|
|
2804
|
+
const currentLowlink = lowlinks.get(node) ?? 0;
|
|
2805
|
+
const neighborIndex = indices.get(neighbor) ?? 0;
|
|
2806
|
+
lowlinks.set(node, Math.min(currentLowlink, neighborIndex));
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// If node is a root of an SCC
|
|
2811
|
+
if (lowlinks.get(node) === indices.get(node)) {
|
|
2812
|
+
const scc: string[] = [];
|
|
2813
|
+
let w: string | undefined;
|
|
2814
|
+
do {
|
|
2815
|
+
w = stack.pop();
|
|
2816
|
+
if (w !== undefined) {
|
|
2817
|
+
onStack.delete(w);
|
|
2818
|
+
scc.push(w);
|
|
2819
|
+
}
|
|
2820
|
+
} while (w !== undefined && w !== node);
|
|
2821
|
+
|
|
2822
|
+
// An SCC is circular if it has more than one node
|
|
2823
|
+
// or if it has one node that references itself
|
|
2824
|
+
if (scc.length > 1) {
|
|
2825
|
+
for (const schemaName of scc) {
|
|
2826
|
+
circularSchemas.add(schemaName);
|
|
2827
|
+
}
|
|
2828
|
+
} else if (scc.length === 1) {
|
|
2829
|
+
const schemaName = scc[0];
|
|
2830
|
+
if (schemaName !== undefined) {
|
|
2831
|
+
const deps = graph.get(schemaName) ?? [];
|
|
2832
|
+
if (deps.includes(schemaName)) {
|
|
2833
|
+
circularSchemas.add(schemaName);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
};
|
|
2839
|
+
|
|
2840
|
+
for (const node of graph.keys()) {
|
|
2841
|
+
if (!indices.has(node)) {
|
|
2842
|
+
strongConnect(node);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
return circularSchemas;
|
|
2695
2847
|
}
|
|
2696
2848
|
|
|
2697
2849
|
private topologicalSort(schemas: Record<string, unknown>): string[] {
|
|
@@ -506,4 +506,200 @@ describe('TypeScriptCodeGeneratorService', () => {
|
|
|
506
506
|
expect(result).toBeDefined();
|
|
507
507
|
});
|
|
508
508
|
});
|
|
509
|
+
|
|
510
|
+
describe('circular dependencies', () => {
|
|
511
|
+
it('should use z.lazy() for direct self-referencing schemas', () => {
|
|
512
|
+
const spec: OpenApiSpecType = {
|
|
513
|
+
openapi: '3.0.0',
|
|
514
|
+
info: {
|
|
515
|
+
title: 'Test API',
|
|
516
|
+
version: '1.0.0',
|
|
517
|
+
},
|
|
518
|
+
paths: {},
|
|
519
|
+
components: {
|
|
520
|
+
schemas: {
|
|
521
|
+
TreeNode: {
|
|
522
|
+
type: 'object',
|
|
523
|
+
properties: {
|
|
524
|
+
value: {type: 'string'},
|
|
525
|
+
children: {
|
|
526
|
+
type: 'array',
|
|
527
|
+
items: {$ref: '#/components/schemas/TreeNode'},
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
required: ['value'],
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const code = generator.generate(spec);
|
|
537
|
+
expect(code).toContain('z.lazy(() => TreeNode)');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('should use z.lazy() for indirect circular dependencies (A -> B -> A)', () => {
|
|
541
|
+
const spec: OpenApiSpecType = {
|
|
542
|
+
openapi: '3.0.0',
|
|
543
|
+
info: {
|
|
544
|
+
title: 'Test API',
|
|
545
|
+
version: '1.0.0',
|
|
546
|
+
},
|
|
547
|
+
paths: {},
|
|
548
|
+
components: {
|
|
549
|
+
schemas: {
|
|
550
|
+
Person: {
|
|
551
|
+
type: 'object',
|
|
552
|
+
properties: {
|
|
553
|
+
name: {type: 'string'},
|
|
554
|
+
bestFriend: {$ref: '#/components/schemas/Friend'},
|
|
555
|
+
},
|
|
556
|
+
required: ['name'],
|
|
557
|
+
},
|
|
558
|
+
Friend: {
|
|
559
|
+
type: 'object',
|
|
560
|
+
properties: {
|
|
561
|
+
nickname: {type: 'string'},
|
|
562
|
+
person: {$ref: '#/components/schemas/Person'},
|
|
563
|
+
},
|
|
564
|
+
required: ['nickname'],
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const code = generator.generate(spec);
|
|
571
|
+
// Both references should use z.lazy()
|
|
572
|
+
expect(code).toContain('z.lazy(() => Friend)');
|
|
573
|
+
expect(code).toContain('z.lazy(() => Person)');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('should not use z.lazy() for non-circular references', () => {
|
|
577
|
+
const spec: OpenApiSpecType = {
|
|
578
|
+
openapi: '3.0.0',
|
|
579
|
+
info: {
|
|
580
|
+
title: 'Test API',
|
|
581
|
+
version: '1.0.0',
|
|
582
|
+
},
|
|
583
|
+
paths: {},
|
|
584
|
+
components: {
|
|
585
|
+
schemas: {
|
|
586
|
+
User: {
|
|
587
|
+
type: 'object',
|
|
588
|
+
properties: {
|
|
589
|
+
id: {type: 'integer'},
|
|
590
|
+
profile: {$ref: '#/components/schemas/Profile'},
|
|
591
|
+
},
|
|
592
|
+
required: ['id'],
|
|
593
|
+
},
|
|
594
|
+
Profile: {
|
|
595
|
+
type: 'object',
|
|
596
|
+
properties: {
|
|
597
|
+
name: {type: 'string'},
|
|
598
|
+
},
|
|
599
|
+
required: ['name'],
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const code = generator.generate(spec);
|
|
606
|
+
// Profile should be referenced directly, not with z.lazy()
|
|
607
|
+
expect(code).not.toContain('z.lazy(() => Profile)');
|
|
608
|
+
expect(code).toContain('profile: Profile.optional()');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('should handle complex circular chains (A -> B -> C -> A)', () => {
|
|
612
|
+
const spec: OpenApiSpecType = {
|
|
613
|
+
openapi: '3.0.0',
|
|
614
|
+
info: {
|
|
615
|
+
title: 'Test API',
|
|
616
|
+
version: '1.0.0',
|
|
617
|
+
},
|
|
618
|
+
paths: {},
|
|
619
|
+
components: {
|
|
620
|
+
schemas: {
|
|
621
|
+
Alpha: {
|
|
622
|
+
type: 'object',
|
|
623
|
+
properties: {
|
|
624
|
+
beta: {$ref: '#/components/schemas/Beta'},
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
Beta: {
|
|
628
|
+
type: 'object',
|
|
629
|
+
properties: {
|
|
630
|
+
gamma: {$ref: '#/components/schemas/Gamma'},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
Gamma: {
|
|
634
|
+
type: 'object',
|
|
635
|
+
properties: {
|
|
636
|
+
alpha: {$ref: '#/components/schemas/Alpha'},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const code = generator.generate(spec);
|
|
644
|
+
// All references in the cycle should use z.lazy()
|
|
645
|
+
expect(code).toContain('z.lazy(() => Beta)');
|
|
646
|
+
expect(code).toContain('z.lazy(() => Gamma)');
|
|
647
|
+
expect(code).toContain('z.lazy(() => Alpha)');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('should handle self-referencing schemas in arrays', () => {
|
|
651
|
+
const spec: OpenApiSpecType = {
|
|
652
|
+
openapi: '3.0.0',
|
|
653
|
+
info: {
|
|
654
|
+
title: 'Test API',
|
|
655
|
+
version: '1.0.0',
|
|
656
|
+
},
|
|
657
|
+
paths: {},
|
|
658
|
+
components: {
|
|
659
|
+
schemas: {
|
|
660
|
+
Category: {
|
|
661
|
+
type: 'object',
|
|
662
|
+
properties: {
|
|
663
|
+
name: {type: 'string'},
|
|
664
|
+
subcategories: {
|
|
665
|
+
type: 'array',
|
|
666
|
+
items: {$ref: '#/components/schemas/Category'},
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
required: ['name'],
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const code = generator.generate(spec);
|
|
676
|
+
expect(code).toContain('z.array(z.lazy(() => Category))');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('should handle self-referencing schemas in anyOf', () => {
|
|
680
|
+
const spec: OpenApiSpecType = {
|
|
681
|
+
openapi: '3.0.0',
|
|
682
|
+
info: {
|
|
683
|
+
title: 'Test API',
|
|
684
|
+
version: '1.0.0',
|
|
685
|
+
},
|
|
686
|
+
paths: {},
|
|
687
|
+
components: {
|
|
688
|
+
schemas: {
|
|
689
|
+
Expression: {
|
|
690
|
+
type: 'object',
|
|
691
|
+
properties: {
|
|
692
|
+
value: {
|
|
693
|
+
anyOf: [{type: 'string'}, {$ref: '#/components/schemas/Expression'}],
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const code = generator.generate(spec);
|
|
702
|
+
expect(code).toContain('z.lazy(() => Expression)');
|
|
703
|
+
});
|
|
704
|
+
});
|
|
509
705
|
});
|