@alt-stack/zod-openapi 1.0.1

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.
@@ -0,0 +1,440 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ extractSchemaDependencies,
4
+ topologicalSortSchemas,
5
+ } from "./dependencies";
6
+ import type { AnySchema } from "./types/types";
7
+
8
+ describe("extractSchemaDependencies", () => {
9
+ describe("basic $ref extraction", () => {
10
+ it("should extract single $ref dependency", () => {
11
+ const schema: AnySchema = {
12
+ $ref: "#/components/schemas/User",
13
+ };
14
+
15
+ const result = extractSchemaDependencies(schema);
16
+ expect(result).toEqual(["User"]);
17
+ });
18
+
19
+ it("should extract multiple $ref dependencies", () => {
20
+ const schema: AnySchema = {
21
+ type: "object",
22
+ properties: {
23
+ user: { $ref: "#/components/schemas/User" },
24
+ profile: { $ref: "#/components/schemas/Profile" },
25
+ },
26
+ };
27
+
28
+ const result = extractSchemaDependencies(schema);
29
+ expect(result.sort()).toEqual(["Profile", "User"]);
30
+ });
31
+
32
+ it("should handle $ref with URL encoding", () => {
33
+ const schema: AnySchema = {
34
+ $ref: "#/components/schemas/User%20Profile",
35
+ };
36
+
37
+ const result = extractSchemaDependencies(schema);
38
+ expect(result).toEqual(["User Profile"]);
39
+ });
40
+ });
41
+
42
+ describe("nested schema dependencies", () => {
43
+ it("should extract dependencies from nested objects", () => {
44
+ const schema: AnySchema = {
45
+ type: "object",
46
+ properties: {
47
+ user: {
48
+ type: "object",
49
+ properties: {
50
+ profile: { $ref: "#/components/schemas/Profile" },
51
+ },
52
+ },
53
+ },
54
+ };
55
+
56
+ const result = extractSchemaDependencies(schema);
57
+ expect(result).toEqual(["Profile"]);
58
+ });
59
+
60
+ it("should extract dependencies from arrays", () => {
61
+ const schema: AnySchema = {
62
+ type: "array",
63
+ items: {
64
+ $ref: "#/components/schemas/User",
65
+ },
66
+ };
67
+
68
+ const result = extractSchemaDependencies(schema);
69
+ expect(result).toEqual(["User"]);
70
+ });
71
+
72
+ it("should extract dependencies from nested arrays", () => {
73
+ const schema: AnySchema = {
74
+ type: "array",
75
+ items: {
76
+ type: "array",
77
+ items: {
78
+ $ref: "#/components/schemas/User",
79
+ },
80
+ },
81
+ };
82
+
83
+ const result = extractSchemaDependencies(schema);
84
+ expect(result).toEqual(["User"]);
85
+ });
86
+ });
87
+
88
+ describe("oneOf dependencies", () => {
89
+ it("should extract dependencies from oneOf", () => {
90
+ const schema: AnySchema = {
91
+ oneOf: [
92
+ { $ref: "#/components/schemas/User" },
93
+ { $ref: "#/components/schemas/Admin" },
94
+ ],
95
+ };
96
+
97
+ const result = extractSchemaDependencies(schema);
98
+ expect(result.sort()).toEqual(["Admin", "User"]);
99
+ });
100
+
101
+ it("should extract nested dependencies from oneOf", () => {
102
+ const schema: AnySchema = {
103
+ oneOf: [
104
+ {
105
+ type: "object",
106
+ properties: {
107
+ user: { $ref: "#/components/schemas/User" },
108
+ },
109
+ },
110
+ ],
111
+ };
112
+
113
+ const result = extractSchemaDependencies(schema);
114
+ expect(result).toEqual(["User"]);
115
+ });
116
+ });
117
+
118
+ describe("allOf dependencies", () => {
119
+ it("should extract dependencies from allOf", () => {
120
+ const schema: AnySchema = {
121
+ allOf: [
122
+ { $ref: "#/components/schemas/Base" },
123
+ { $ref: "#/components/schemas/Extended" },
124
+ ],
125
+ };
126
+
127
+ const result = extractSchemaDependencies(schema);
128
+ expect(result.sort()).toEqual(["Base", "Extended"]);
129
+ });
130
+ });
131
+
132
+ describe("complex nested structures", () => {
133
+ it("should extract all dependencies from complex schema", () => {
134
+ const schema: AnySchema = {
135
+ type: "object",
136
+ properties: {
137
+ users: {
138
+ type: "array",
139
+ items: { $ref: "#/components/schemas/User" },
140
+ },
141
+ metadata: {
142
+ type: "object",
143
+ properties: {
144
+ creator: { $ref: "#/components/schemas/User" },
145
+ updater: { $ref: "#/components/schemas/User" },
146
+ },
147
+ },
148
+ },
149
+ };
150
+
151
+ const result = extractSchemaDependencies(schema);
152
+ expect(result).toEqual(["User"]);
153
+ });
154
+
155
+ it("should handle duplicate dependencies", () => {
156
+ const schema: AnySchema = {
157
+ type: "object",
158
+ properties: {
159
+ user1: { $ref: "#/components/schemas/User" },
160
+ user2: { $ref: "#/components/schemas/User" },
161
+ },
162
+ };
163
+
164
+ const result = extractSchemaDependencies(schema);
165
+ expect(result).toEqual(["User"]);
166
+ });
167
+ });
168
+
169
+ describe("edge cases", () => {
170
+ it("should handle invalid $ref format", () => {
171
+ const schema: AnySchema = {
172
+ $ref: "invalid-ref",
173
+ };
174
+
175
+ const result = extractSchemaDependencies(schema);
176
+ expect(result).toEqual([]);
177
+ });
178
+
179
+ it("should handle null schema", () => {
180
+ const result = extractSchemaDependencies(null);
181
+ expect(result).toEqual([]);
182
+ });
183
+
184
+ it("should handle schema without $ref", () => {
185
+ const schema: AnySchema = {
186
+ type: "string",
187
+ };
188
+
189
+ const result = extractSchemaDependencies(schema);
190
+ expect(result).toEqual([]);
191
+ });
192
+
193
+ it("should handle circular references without infinite loop", () => {
194
+ const user: AnySchema = {
195
+ type: "object",
196
+ properties: {
197
+ friend: { $ref: "#/components/schemas/User" },
198
+ },
199
+ };
200
+
201
+ const result = extractSchemaDependencies(user);
202
+ expect(result).toEqual(["User"]);
203
+ });
204
+ });
205
+ });
206
+
207
+ describe("topologicalSortSchemas", () => {
208
+ describe("simple dependency ordering", () => {
209
+ it("should sort schemas with single dependency", () => {
210
+ const schemas: Record<string, AnySchema> = {
211
+ User: {
212
+ type: "object",
213
+ properties: {
214
+ profile: { $ref: "#/components/schemas/Profile" },
215
+ },
216
+ },
217
+ Profile: {
218
+ type: "object",
219
+ properties: {
220
+ name: { type: "string" },
221
+ },
222
+ },
223
+ };
224
+
225
+ const result = topologicalSortSchemas(schemas);
226
+ expect(result.indexOf("Profile")).toBeLessThan(result.indexOf("User"));
227
+ });
228
+
229
+ it("should sort schemas with multiple dependencies", () => {
230
+ const schemas: Record<string, AnySchema> = {
231
+ User: {
232
+ type: "object",
233
+ properties: {
234
+ profile: { $ref: "#/components/schemas/Profile" },
235
+ settings: { $ref: "#/components/schemas/Settings" },
236
+ },
237
+ },
238
+ Profile: {
239
+ type: "object",
240
+ properties: {
241
+ name: { type: "string" },
242
+ },
243
+ },
244
+ Settings: {
245
+ type: "object",
246
+ properties: {
247
+ theme: { type: "string" },
248
+ },
249
+ },
250
+ };
251
+
252
+ const result = topologicalSortSchemas(schemas);
253
+ expect(result.indexOf("Profile")).toBeLessThan(result.indexOf("User"));
254
+ expect(result.indexOf("Settings")).toBeLessThan(result.indexOf("User"));
255
+ });
256
+
257
+ it("should sort schemas with transitive dependencies", () => {
258
+ const schemas: Record<string, AnySchema> = {
259
+ A: {
260
+ type: "object",
261
+ properties: {
262
+ b: { $ref: "#/components/schemas/B" },
263
+ },
264
+ },
265
+ B: {
266
+ type: "object",
267
+ properties: {
268
+ c: { $ref: "#/components/schemas/C" },
269
+ },
270
+ },
271
+ C: {
272
+ type: "object",
273
+ properties: {
274
+ value: { type: "string" },
275
+ },
276
+ },
277
+ };
278
+
279
+ const result = topologicalSortSchemas(schemas);
280
+ expect(result.indexOf("C")).toBeLessThan(result.indexOf("B"));
281
+ expect(result.indexOf("B")).toBeLessThan(result.indexOf("A"));
282
+ });
283
+ });
284
+
285
+ describe("independent schemas", () => {
286
+ it("should handle schemas without dependencies", () => {
287
+ const schemas: Record<string, AnySchema> = {
288
+ User: {
289
+ type: "object",
290
+ properties: {
291
+ name: { type: "string" },
292
+ },
293
+ },
294
+ Product: {
295
+ type: "object",
296
+ properties: {
297
+ id: { type: "number" },
298
+ },
299
+ },
300
+ };
301
+
302
+ const result = topologicalSortSchemas(schemas);
303
+ expect(result).toContain("User");
304
+ expect(result).toContain("Product");
305
+ expect(result.length).toBe(2);
306
+ });
307
+
308
+ it("should handle empty schemas object", () => {
309
+ const schemas: Record<string, AnySchema> = {};
310
+ const result = topologicalSortSchemas(schemas);
311
+ expect(result).toEqual([]);
312
+ });
313
+ });
314
+
315
+ describe("circular dependencies", () => {
316
+ it("should handle circular dependencies gracefully", () => {
317
+ const schemas: Record<string, AnySchema> = {
318
+ A: {
319
+ type: "object",
320
+ properties: {
321
+ b: { $ref: "#/components/schemas/B" },
322
+ },
323
+ },
324
+ B: {
325
+ type: "object",
326
+ properties: {
327
+ a: { $ref: "#/components/schemas/A" },
328
+ },
329
+ },
330
+ };
331
+
332
+ const result = topologicalSortSchemas(schemas);
333
+ expect(result).toContain("A");
334
+ expect(result).toContain("B");
335
+ expect(result.length).toBe(2);
336
+ });
337
+
338
+ it("should handle three-way circular dependency", () => {
339
+ const schemas: Record<string, AnySchema> = {
340
+ A: {
341
+ type: "object",
342
+ properties: {
343
+ b: { $ref: "#/components/schemas/B" },
344
+ },
345
+ },
346
+ B: {
347
+ type: "object",
348
+ properties: {
349
+ c: { $ref: "#/components/schemas/C" },
350
+ },
351
+ },
352
+ C: {
353
+ type: "object",
354
+ properties: {
355
+ a: { $ref: "#/components/schemas/A" },
356
+ },
357
+ },
358
+ };
359
+
360
+ const result = topologicalSortSchemas(schemas);
361
+ expect(result).toContain("A");
362
+ expect(result).toContain("B");
363
+ expect(result).toContain("C");
364
+ expect(result.length).toBe(3);
365
+ });
366
+ });
367
+
368
+ describe("external dependencies", () => {
369
+ it("should ignore dependencies that are not in schemas", () => {
370
+ const schemas: Record<string, AnySchema> = {
371
+ User: {
372
+ type: "object",
373
+ properties: {
374
+ external: { $ref: "#/components/schemas/External" },
375
+ local: { $ref: "#/components/schemas/Profile" },
376
+ },
377
+ },
378
+ Profile: {
379
+ type: "object",
380
+ properties: {
381
+ name: { type: "string" },
382
+ },
383
+ },
384
+ };
385
+
386
+ const result = topologicalSortSchemas(schemas);
387
+ expect(result.indexOf("Profile")).toBeLessThan(result.indexOf("User"));
388
+ expect(result).not.toContain("External");
389
+ });
390
+ });
391
+
392
+ describe("complex scenarios", () => {
393
+ it("should handle mixed dependency structure", () => {
394
+ const schemas: Record<string, AnySchema> = {
395
+ User: {
396
+ type: "object",
397
+ properties: {
398
+ profile: { $ref: "#/components/schemas/Profile" },
399
+ },
400
+ },
401
+ Profile: {
402
+ type: "object",
403
+ properties: {
404
+ avatar: { $ref: "#/components/schemas/Image" },
405
+ },
406
+ },
407
+ Image: {
408
+ type: "object",
409
+ properties: {
410
+ url: { type: "string" },
411
+ },
412
+ },
413
+ Product: {
414
+ type: "object",
415
+ properties: {
416
+ id: { type: "number" },
417
+ },
418
+ },
419
+ };
420
+
421
+ const result = topologicalSortSchemas(schemas);
422
+ expect(result.indexOf("Image")).toBeLessThan(result.indexOf("Profile"));
423
+ expect(result.indexOf("Profile")).toBeLessThan(result.indexOf("User"));
424
+ expect(result).toContain("Product");
425
+ expect(result.length).toBe(4);
426
+ });
427
+
428
+ it("should preserve all schema names", () => {
429
+ const schemas: Record<string, AnySchema> = {
430
+ A: { type: "string" },
431
+ B: { type: "number" },
432
+ C: { type: "boolean" },
433
+ };
434
+
435
+ const result = topologicalSortSchemas(schemas);
436
+ expect(result.sort()).toEqual(["A", "B", "C"]);
437
+ });
438
+ });
439
+ });
440
+
@@ -0,0 +1,176 @@
1
+ import type { AnySchema } from "./types/types";
2
+
3
+ /**
4
+ * Extracts all schema references ($ref) from an OpenAPI schema by recursively
5
+ * traversing its structure.
6
+ *
7
+ * This function is used during the OpenAPI-to-Zod conversion process to identify
8
+ * which schemas a given schema depends on. It traverses all OpenAPI schema
9
+ * structures including objects, arrays, unions (oneOf), intersections (allOf),
10
+ * conditionals (if/then/else), and discriminator mappings to find all $ref
11
+ * references that point to other schemas in the components/schemas section.
12
+ *
13
+ * The extracted dependency names are used by `topologicalSortSchemas` to build
14
+ * a dependency graph and determine the correct order for generating Zod schemas,
15
+ * ensuring that referenced schemas are defined before they are used in the
16
+ * generated TypeScript code.
17
+ *
18
+ * @param schema - The OpenAPI schema to extract dependencies from
19
+ * @returns An array of schema names that this schema references (via $ref)
20
+ */
21
+ export function extractSchemaDependencies(schema: AnySchema): string[] {
22
+ const dependencies: Set<string> = new Set();
23
+ const visited = new WeakSet();
24
+
25
+ function traverse(obj: any): void {
26
+ if (!obj || typeof obj !== "object") return;
27
+
28
+ if (visited.has(obj)) return;
29
+ visited.add(obj);
30
+
31
+ if (obj["$ref"] && typeof obj["$ref"] === "string") {
32
+ const match = (obj["$ref"] as string).match(
33
+ /#\/components\/schemas\/(.+)/,
34
+ );
35
+ if (match && match[1]) {
36
+ dependencies.add(decodeURIComponent(match[1]));
37
+ }
38
+ return;
39
+ }
40
+
41
+ if (Array.isArray(obj)) {
42
+ obj.forEach(traverse);
43
+ return;
44
+ }
45
+
46
+ if (obj.properties && typeof obj.properties === "object") {
47
+ for (const propValue of Object.values(obj.properties)) {
48
+ traverse(propValue);
49
+ }
50
+ }
51
+
52
+ const schemaKeys = [
53
+ "items",
54
+ "oneOf",
55
+ "allOf",
56
+ "anyOf",
57
+ "not",
58
+ "if",
59
+ "then",
60
+ "else",
61
+ "prefixItems",
62
+ "contains",
63
+ "propertyNames",
64
+ "dependentSchemas",
65
+ ];
66
+
67
+ for (const key of schemaKeys) {
68
+ if (obj[key]) {
69
+ traverse(obj[key]);
70
+ }
71
+ }
72
+
73
+ if (
74
+ obj.additionalProperties &&
75
+ typeof obj.additionalProperties === "object"
76
+ ) {
77
+ traverse(obj.additionalProperties);
78
+ }
79
+
80
+ if (obj.discriminator?.mapping) {
81
+ Object.values(obj.discriminator.mapping).forEach(traverse);
82
+ }
83
+ }
84
+
85
+ traverse(schema);
86
+ return Array.from(dependencies);
87
+ }
88
+
89
+ /**
90
+ * Sorts OpenAPI schemas topologically based on their dependencies to ensure
91
+ * correct generation order.
92
+ *
93
+ * When converting OpenAPI schemas to Zod schemas and generating TypeScript code,
94
+ * schemas must be defined before they are referenced. For example, if `UserSchema`
95
+ * references `ProfileSchema` (via $ref), then `ProfileSchema` must be generated
96
+ * before `UserSchema` to avoid "undefined variable" errors in the generated code.
97
+ *
98
+ * This function uses Kahn's algorithm for topological sorting to order schemas
99
+ * such that all dependencies come before their dependents. It:
100
+ * 1. Extracts dependencies for each schema using `extractSchemaDependencies`
101
+ * 2. Builds a dependency graph and computes in-degrees
102
+ * 3. Sorts schemas starting with those that have no dependencies (in-degree 0)
103
+ * 4. Handles circular dependencies gracefully by appending any remaining schemas
104
+ * that couldn't be sorted (though this indicates a problematic schema structure)
105
+ *
106
+ * This function is called by `openApiToZodTsCode` to determine the order in which
107
+ * schemas should be converted and emitted in the generated TypeScript file.
108
+ *
109
+ * @param schemas - A record mapping schema names to their OpenAPI schema definitions
110
+ * @returns An array of schema names sorted in topological order (dependencies before dependents)
111
+ */
112
+ export function topologicalSortSchemas(
113
+ schemas: Record<string, AnySchema>,
114
+ ): string[] {
115
+ const schemaNames = Object.keys(schemas);
116
+ const dependencies: Map<string, string[]> = new Map();
117
+ const inDegree: Map<string, number> = new Map();
118
+ const sorted: string[] = [];
119
+ const queue: string[] = [];
120
+ const dependents: Map<string, string[]> = new Map();
121
+
122
+ for (const name of schemaNames) {
123
+ dependencies.set(name, []);
124
+ dependents.set(name, []);
125
+ inDegree.set(name, 0);
126
+ }
127
+
128
+ for (const name of schemaNames) {
129
+ const schemaValue = schemas[name];
130
+ if (schemaValue) {
131
+ const deps = extractSchemaDependencies(schemaValue);
132
+ const validDeps = deps.filter((dep) => schemaNames.includes(dep));
133
+ dependencies.set(name, validDeps);
134
+
135
+ for (const dep of validDeps) {
136
+ const currentDependents = dependents.get(dep) || [];
137
+ currentDependents.push(name);
138
+ dependents.set(dep, currentDependents);
139
+ }
140
+ }
141
+ }
142
+
143
+ for (const [name, deps] of dependencies.entries()) {
144
+ inDegree.set(name, deps.length);
145
+ }
146
+
147
+ for (const [name, degree] of inDegree.entries()) {
148
+ if (degree === 0) {
149
+ queue.push(name);
150
+ }
151
+ }
152
+
153
+ while (queue.length > 0) {
154
+ const current = queue.shift()!;
155
+ sorted.push(current);
156
+
157
+ const currentDependents = dependents.get(current) || [];
158
+ for (const dependent of currentDependents) {
159
+ const newDegree = (inDegree.get(dependent) || 0) - 1;
160
+ inDegree.set(dependent, newDegree);
161
+ if (newDegree === 0) {
162
+ queue.push(dependent);
163
+ }
164
+ }
165
+ }
166
+
167
+ if (sorted.length !== schemaNames.length) {
168
+ for (const name of schemaNames) {
169
+ if (!sorted.includes(name)) {
170
+ sorted.push(name);
171
+ }
172
+ }
173
+ }
174
+
175
+ return sorted;
176
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ export { openApiToZodTsCode } from "./to-typescript.js";
2
+ export {
3
+ registerZodSchemaToOpenApiSchema,
4
+ clearZodSchemaToOpenApiSchemaRegistry,
5
+ getSchemaExportedVariableNameForStringFormat,
6
+ SUPPORTED_STRING_FORMATS,
7
+ schemaRegistry,
8
+ } from "./registry.js";
9
+ export type {
10
+ ZodOpenApiRegistrationString,
11
+ ZodOpenApiRegistrationStrings,
12
+ ZodOpenApiRegistrationPrimitive,
13
+ ZodOpenApiRegistration,
14
+ } from "./registry.js";
15
+ export { convertSchemaToZodString } from "./to-zod.js";
16
+ export {
17
+ parseOpenApiPaths,
18
+ generateRouteSchemaNames,
19
+ } from "./routes.js";
20
+ export type {
21
+ HttpMethod,
22
+ RouteParameter,
23
+ RouteInfo,
24
+ RouteSchemaNames,
25
+ } from "./routes.js";
26
+ export type { AnySchema, OpenAPIObjectSchema } from "./types/types.js";
27
+