@alt-stack/zod-openapi 1.1.2 → 1.1.3

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.
@@ -522,5 +522,223 @@ describe("openApiToZodTsCode with routes", () => {
522
522
  expect(result).toContain("'/users/{id}':");
523
523
  });
524
524
  });
525
+
526
+ describe("schema deduplication", () => {
527
+ it("should deduplicate identical error responses across endpoints", () => {
528
+ const unauthorizedError = {
529
+ type: "object",
530
+ properties: {
531
+ error: {
532
+ type: "object",
533
+ properties: {
534
+ code: { type: "string", enum: ["UNAUTHORIZED"] },
535
+ message: { type: "string" },
536
+ },
537
+ required: ["code", "message"],
538
+ },
539
+ },
540
+ required: ["error"],
541
+ };
542
+
543
+ const openapi = {
544
+ components: { schemas: {} },
545
+ paths: {
546
+ "/users": {
547
+ get: {
548
+ responses: {
549
+ "200": {
550
+ content: {
551
+ "application/json": {
552
+ schema: { type: "object", properties: {} },
553
+ },
554
+ },
555
+ },
556
+ "401": {
557
+ content: {
558
+ "application/json": { schema: unauthorizedError },
559
+ },
560
+ },
561
+ },
562
+ },
563
+ post: {
564
+ responses: {
565
+ "200": {
566
+ content: {
567
+ "application/json": {
568
+ schema: { type: "object", properties: {} },
569
+ },
570
+ },
571
+ },
572
+ "401": {
573
+ content: {
574
+ "application/json": { schema: unauthorizedError },
575
+ },
576
+ },
577
+ },
578
+ },
579
+ },
580
+ "/items": {
581
+ get: {
582
+ responses: {
583
+ "200": {
584
+ content: {
585
+ "application/json": {
586
+ schema: { type: "object", properties: {} },
587
+ },
588
+ },
589
+ },
590
+ "401": {
591
+ content: {
592
+ "application/json": { schema: unauthorizedError },
593
+ },
594
+ },
595
+ },
596
+ },
597
+ },
598
+ },
599
+ };
600
+
601
+ const result = openApiToZodTsCode(openapi, undefined, {
602
+ includeRoutes: true,
603
+ });
604
+
605
+ // Should generate a common error schema
606
+ expect(result).toContain("// Common Error Schemas (deduplicated)");
607
+ expect(result).toContain("UnauthorizedErrorSchema");
608
+
609
+ // Route-specific schemas should reference the common schema
610
+ expect(result).toContain(
611
+ "export const GetUsers401ErrorResponse = UnauthorizedErrorSchema;",
612
+ );
613
+ expect(result).toContain(
614
+ "export const PostUsers401ErrorResponse = UnauthorizedErrorSchema;",
615
+ );
616
+ expect(result).toContain(
617
+ "export const GetItems401ErrorResponse = UnauthorizedErrorSchema;",
618
+ );
619
+
620
+ // Response object should reference the canonical schema
621
+ expect(result).toContain("'401': UnauthorizedErrorSchema");
622
+ });
623
+
624
+ it("should deduplicate identical success responses across endpoints", () => {
625
+ const userSchema = {
626
+ type: "object",
627
+ properties: {
628
+ id: { type: "string" },
629
+ name: { type: "string" },
630
+ },
631
+ required: ["id", "name"],
632
+ };
633
+
634
+ const openapi = {
635
+ components: { schemas: {} },
636
+ paths: {
637
+ "/users/{id}": {
638
+ get: {
639
+ parameters: [
640
+ {
641
+ name: "id",
642
+ in: "path",
643
+ required: true,
644
+ schema: { type: "string" },
645
+ },
646
+ ],
647
+ responses: {
648
+ "200": {
649
+ content: {
650
+ "application/json": { schema: userSchema },
651
+ },
652
+ },
653
+ },
654
+ },
655
+ put: {
656
+ parameters: [
657
+ {
658
+ name: "id",
659
+ in: "path",
660
+ required: true,
661
+ schema: { type: "string" },
662
+ },
663
+ ],
664
+ responses: {
665
+ "200": {
666
+ content: {
667
+ "application/json": { schema: userSchema },
668
+ },
669
+ },
670
+ },
671
+ },
672
+ },
673
+ },
674
+ };
675
+
676
+ const result = openApiToZodTsCode(openapi, undefined, {
677
+ includeRoutes: true,
678
+ });
679
+
680
+ // The first occurrence becomes the canonical schema
681
+ expect(result).toContain("export const GetUsersId200Response =");
682
+
683
+ // The second should be an alias to the first
684
+ expect(result).toContain(
685
+ "export const PutUsersId200Response = GetUsersId200Response;",
686
+ );
687
+ });
688
+
689
+ it("should not deduplicate different schemas", () => {
690
+ const openapi = {
691
+ components: { schemas: {} },
692
+ paths: {
693
+ "/users": {
694
+ get: {
695
+ responses: {
696
+ "200": {
697
+ content: {
698
+ "application/json": {
699
+ schema: {
700
+ type: "object",
701
+ properties: { users: { type: "array" } },
702
+ },
703
+ },
704
+ },
705
+ },
706
+ "401": {
707
+ content: {
708
+ "application/json": {
709
+ schema: {
710
+ type: "object",
711
+ properties: {
712
+ error: {
713
+ type: "object",
714
+ properties: {
715
+ code: { type: "string", enum: ["UNAUTHORIZED"] },
716
+ },
717
+ },
718
+ },
719
+ },
720
+ },
721
+ },
722
+ },
723
+ },
724
+ },
725
+ },
726
+ },
727
+ };
728
+
729
+ const result = openApiToZodTsCode(openapi, undefined, {
730
+ includeRoutes: true,
731
+ });
732
+
733
+ // Both should be separate schemas since they're different
734
+ expect(result).toContain("export const GetUsers200Response =");
735
+ expect(result).toContain("export const GetUsers401ErrorResponse =");
736
+
737
+ // They should not reference each other
738
+ expect(result).not.toContain(
739
+ "GetUsers401ErrorResponse = GetUsers200Response",
740
+ );
741
+ });
742
+ });
525
743
  });
526
744
 
@@ -1,4 +1,12 @@
1
1
  import { topologicalSortSchemas } from "./dependencies";
2
+ import {
3
+ createSchemaRegistry,
4
+ findCommonSchemas,
5
+ getSchemaFingerprint,
6
+ preRegisterSchema,
7
+ registerSchema,
8
+ type SchemaRegistry,
9
+ } from "./schema-dedup";
2
10
  import { convertSchemaToZodString } from "./to-zod";
3
11
  import type { AnySchema } from "./types/types";
4
12
  import {
@@ -39,12 +47,24 @@ function generateRouteSchemaName(
39
47
  return parts.join("");
40
48
  }
41
49
 
50
+ /**
51
+ * Result of route schema generation including declarations and name mappings.
52
+ */
53
+ interface RouteSchemaResult {
54
+ /** Schema declarations to be emitted */
55
+ declarations: string[];
56
+ /** Maps route-specific schema name to its canonical name (for deduplication) */
57
+ schemaNameToCanonical: Map<string, string>;
58
+ }
59
+
42
60
  function generateRouteSchemas(
43
61
  routes: RouteInfo[],
44
62
  convertSchema: (schema: AnySchema) => string,
45
- ): string[] {
46
- const lines: string[] = [];
47
- const schemaNames = new Set<string>();
63
+ registry: SchemaRegistry,
64
+ ): RouteSchemaResult {
65
+ const declarations: string[] = [];
66
+ const schemaNameToCanonical = new Map<string, string>();
67
+ const generatedNames = new Set<string>();
48
68
 
49
69
  for (const route of routes) {
50
70
  const names = generateRouteSchemaNames(route);
@@ -52,27 +72,62 @@ function generateRouteSchemas(
52
72
  const queryParams = route.parameters.filter((p) => p.in === "query");
53
73
  const headerParams = route.parameters.filter((p) => p.in === "header");
54
74
 
75
+ // Generate params schema with deduplication
55
76
  if (names.paramsSchemaName && pathParams.length > 0) {
56
- if (!schemaNames.has(names.paramsSchemaName)) {
57
- schemaNames.add(names.paramsSchemaName);
77
+ const paramsSchema: AnySchema = {
78
+ type: "object",
79
+ properties: Object.fromEntries(
80
+ pathParams.map((p) => [p.name, p.schema]),
81
+ ),
82
+ required: pathParams.filter((p) => p.required).map((p) => p.name),
83
+ };
84
+
85
+ const { isNew, canonicalName } = registerSchema(
86
+ registry,
87
+ names.paramsSchemaName,
88
+ paramsSchema,
89
+ );
90
+ schemaNameToCanonical.set(names.paramsSchemaName, canonicalName);
91
+
92
+ if (isNew && !generatedNames.has(names.paramsSchemaName)) {
93
+ generatedNames.add(names.paramsSchemaName);
58
94
  const properties: string[] = [];
59
- const required: string[] = [];
60
95
  for (const param of pathParams) {
61
96
  const zodExpr = convertSchema(param.schema);
62
97
  properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
63
- if (param.required) {
64
- required.push(param.name);
65
- }
66
98
  }
67
- lines.push(
99
+ declarations.push(
68
100
  `export const ${names.paramsSchemaName} = z.object({ ${properties.join(", ")} });`,
69
101
  );
102
+ } else if (!isNew && names.paramsSchemaName !== canonicalName) {
103
+ if (!generatedNames.has(names.paramsSchemaName)) {
104
+ generatedNames.add(names.paramsSchemaName);
105
+ declarations.push(
106
+ `export const ${names.paramsSchemaName} = ${canonicalName};`,
107
+ );
108
+ }
70
109
  }
71
110
  }
72
111
 
112
+ // Generate query schema with deduplication
73
113
  if (names.querySchemaName && queryParams.length > 0) {
74
- if (!schemaNames.has(names.querySchemaName)) {
75
- schemaNames.add(names.querySchemaName);
114
+ const querySchema: AnySchema = {
115
+ type: "object",
116
+ properties: Object.fromEntries(
117
+ queryParams.map((p) => [p.name, p.schema]),
118
+ ),
119
+ required: queryParams.filter((p) => p.required).map((p) => p.name),
120
+ };
121
+
122
+ const { isNew, canonicalName } = registerSchema(
123
+ registry,
124
+ names.querySchemaName,
125
+ querySchema,
126
+ );
127
+ schemaNameToCanonical.set(names.querySchemaName, canonicalName);
128
+
129
+ if (isNew && !generatedNames.has(names.querySchemaName)) {
130
+ generatedNames.add(names.querySchemaName);
76
131
  const properties: string[] = [];
77
132
  for (const param of queryParams) {
78
133
  let zodExpr = convertSchema(param.schema);
@@ -81,15 +136,38 @@ function generateRouteSchemas(
81
136
  }
82
137
  properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
83
138
  }
84
- lines.push(
139
+ declarations.push(
85
140
  `export const ${names.querySchemaName} = z.object({ ${properties.join(", ")} });`,
86
141
  );
142
+ } else if (!isNew && names.querySchemaName !== canonicalName) {
143
+ if (!generatedNames.has(names.querySchemaName)) {
144
+ generatedNames.add(names.querySchemaName);
145
+ declarations.push(
146
+ `export const ${names.querySchemaName} = ${canonicalName};`,
147
+ );
148
+ }
87
149
  }
88
150
  }
89
151
 
152
+ // Generate headers schema with deduplication
90
153
  if (names.headersSchemaName && headerParams.length > 0) {
91
- if (!schemaNames.has(names.headersSchemaName)) {
92
- schemaNames.add(names.headersSchemaName);
154
+ const headersSchema: AnySchema = {
155
+ type: "object",
156
+ properties: Object.fromEntries(
157
+ headerParams.map((p) => [p.name, p.schema]),
158
+ ),
159
+ required: headerParams.filter((p) => p.required).map((p) => p.name),
160
+ };
161
+
162
+ const { isNew, canonicalName } = registerSchema(
163
+ registry,
164
+ names.headersSchemaName,
165
+ headersSchema,
166
+ );
167
+ schemaNameToCanonical.set(names.headersSchemaName, canonicalName);
168
+
169
+ if (isNew && !generatedNames.has(names.headersSchemaName)) {
170
+ generatedNames.add(names.headersSchemaName);
93
171
  const properties: string[] = [];
94
172
  for (const param of headerParams) {
95
173
  let zodExpr = convertSchema(param.schema);
@@ -98,21 +176,43 @@ function generateRouteSchemas(
98
176
  }
99
177
  properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
100
178
  }
101
- lines.push(
179
+ declarations.push(
102
180
  `export const ${names.headersSchemaName} = z.object({ ${properties.join(", ")} });`,
103
181
  );
182
+ } else if (!isNew && names.headersSchemaName !== canonicalName) {
183
+ if (!generatedNames.has(names.headersSchemaName)) {
184
+ generatedNames.add(names.headersSchemaName);
185
+ declarations.push(
186
+ `export const ${names.headersSchemaName} = ${canonicalName};`,
187
+ );
188
+ }
104
189
  }
105
190
  }
106
191
 
192
+ // Generate body schema with deduplication
107
193
  if (names.bodySchemaName && route.requestBody) {
108
- if (!schemaNames.has(names.bodySchemaName)) {
109
- schemaNames.add(names.bodySchemaName);
194
+ const { isNew, canonicalName } = registerSchema(
195
+ registry,
196
+ names.bodySchemaName,
197
+ route.requestBody,
198
+ );
199
+ schemaNameToCanonical.set(names.bodySchemaName, canonicalName);
200
+
201
+ if (isNew && !generatedNames.has(names.bodySchemaName)) {
202
+ generatedNames.add(names.bodySchemaName);
110
203
  const zodExpr = convertSchema(route.requestBody);
111
- lines.push(`export const ${names.bodySchemaName} = ${zodExpr};`);
204
+ declarations.push(`export const ${names.bodySchemaName} = ${zodExpr};`);
205
+ } else if (!isNew && names.bodySchemaName !== canonicalName) {
206
+ if (!generatedNames.has(names.bodySchemaName)) {
207
+ generatedNames.add(names.bodySchemaName);
208
+ declarations.push(
209
+ `export const ${names.bodySchemaName} = ${canonicalName};`,
210
+ );
211
+ }
112
212
  }
113
213
  }
114
214
 
115
- // Generate schemas for ALL status codes, not just success
215
+ // Generate schemas for ALL status codes with deduplication
116
216
  for (const [statusCode, responseSchema] of Object.entries(
117
217
  route.responses,
118
218
  )) {
@@ -128,18 +228,35 @@ function generateRouteSchemas(
128
228
  suffix,
129
229
  );
130
230
 
131
- if (!schemaNames.has(responseSchemaName)) {
132
- schemaNames.add(responseSchemaName);
231
+ const { isNew, canonicalName } = registerSchema(
232
+ registry,
233
+ responseSchemaName,
234
+ responseSchema,
235
+ );
236
+ schemaNameToCanonical.set(responseSchemaName, canonicalName);
237
+
238
+ if (isNew && !generatedNames.has(responseSchemaName)) {
239
+ generatedNames.add(responseSchemaName);
133
240
  const zodExpr = convertSchema(responseSchema);
134
- lines.push(`export const ${responseSchemaName} = ${zodExpr};`);
241
+ declarations.push(`export const ${responseSchemaName} = ${zodExpr};`);
242
+ } else if (!isNew && responseSchemaName !== canonicalName) {
243
+ if (!generatedNames.has(responseSchemaName)) {
244
+ generatedNames.add(responseSchemaName);
245
+ declarations.push(
246
+ `export const ${responseSchemaName} = ${canonicalName};`,
247
+ );
248
+ }
135
249
  }
136
250
  }
137
251
  }
138
252
 
139
- return lines;
253
+ return { declarations, schemaNameToCanonical };
140
254
  }
141
255
 
142
- function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
256
+ function generateRequestResponseObjects(
257
+ routes: RouteInfo[],
258
+ schemaNameToCanonical: Map<string, string>,
259
+ ): string[] {
143
260
  const lines: string[] = [];
144
261
  const requestPaths: Record<string, Record<string, string[]>> = {};
145
262
  const responsePaths: Record<
@@ -147,6 +264,14 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
147
264
  Record<string, Record<string, string>>
148
265
  > = {};
149
266
 
267
+ /**
268
+ * Resolves a schema name to its canonical name if it exists,
269
+ * otherwise returns the original name.
270
+ */
271
+ const resolveSchemaName = (name: string): string => {
272
+ return schemaNameToCanonical.get(name) ?? name;
273
+ };
274
+
150
275
  for (const route of routes) {
151
276
  const names = generateRouteSchemaNames(route);
152
277
  const pathParams = route.parameters.filter((p) => p.in === "path");
@@ -163,16 +288,20 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
163
288
 
164
289
  const requestParts: string[] = [];
165
290
  if (names.paramsSchemaName && pathParams.length > 0) {
166
- requestParts.push(`params: ${names.paramsSchemaName}`);
291
+ requestParts.push(
292
+ `params: ${resolveSchemaName(names.paramsSchemaName)}`,
293
+ );
167
294
  }
168
295
  if (names.querySchemaName && queryParams.length > 0) {
169
- requestParts.push(`query: ${names.querySchemaName}`);
296
+ requestParts.push(`query: ${resolveSchemaName(names.querySchemaName)}`);
170
297
  }
171
298
  if (names.headersSchemaName && headerParams.length > 0) {
172
- requestParts.push(`headers: ${names.headersSchemaName}`);
299
+ requestParts.push(
300
+ `headers: ${resolveSchemaName(names.headersSchemaName)}`,
301
+ );
173
302
  }
174
303
  if (names.bodySchemaName && route.requestBody) {
175
- requestParts.push(`body: ${names.bodySchemaName}`);
304
+ requestParts.push(`body: ${resolveSchemaName(names.bodySchemaName)}`);
176
305
  }
177
306
 
178
307
  if (requestParts.length > 0) {
@@ -202,7 +331,9 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
202
331
  route.method,
203
332
  suffix,
204
333
  );
205
- responseMethodObj[route.method]![statusCode] = responseSchemaName;
334
+ // Use canonical name for the Response object
335
+ responseMethodObj[route.method]![statusCode] =
336
+ resolveSchemaName(responseSchemaName);
206
337
  }
207
338
  }
208
339
 
@@ -246,6 +377,37 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
246
377
  return lines;
247
378
  }
248
379
 
380
+ /**
381
+ * Collects all response schemas from routes for common schema detection.
382
+ */
383
+ function collectRouteSchemas(
384
+ routes: RouteInfo[],
385
+ ): Array<{ name: string; schema: AnySchema }> {
386
+ const collected: Array<{ name: string; schema: AnySchema }> = [];
387
+
388
+ for (const route of routes) {
389
+ for (const [statusCode, responseSchema] of Object.entries(
390
+ route.responses,
391
+ )) {
392
+ if (!responseSchema) continue;
393
+
394
+ const isSuccess = statusCode.startsWith("2");
395
+ const suffix = isSuccess
396
+ ? `${statusCode}Response`
397
+ : `${statusCode}ErrorResponse`;
398
+ const responseSchemaName = generateRouteSchemaName(
399
+ route.path,
400
+ route.method,
401
+ suffix,
402
+ );
403
+
404
+ collected.push({ name: responseSchemaName, schema: responseSchema });
405
+ }
406
+ }
407
+
408
+ return collected;
409
+ }
410
+
249
411
  export const openApiToZodTsCode = (
250
412
  openapi: Record<string, unknown>,
251
413
  customImportLines?: string[],
@@ -267,6 +429,9 @@ export const openApiToZodTsCode = (
267
429
  lines.push(...(customImportLines ?? []));
268
430
  lines.push("");
269
431
 
432
+ // Create registry for schema deduplication
433
+ const registry = createSchemaRegistry();
434
+
270
435
  const sortedSchemaNames = topologicalSortSchemas(schemas);
271
436
 
272
437
  for (const name of sortedSchemaNames) {
@@ -278,20 +443,49 @@ export const openApiToZodTsCode = (
278
443
  lines.push(`export const ${schemaName} = ${zodExpr};`);
279
444
  lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>;`);
280
445
  lines.push("");
446
+
447
+ // Register component schemas so they can be referenced by route schemas
448
+ const fingerprint = getSchemaFingerprint(schema);
449
+ preRegisterSchema(registry, schemaName, fingerprint);
281
450
  }
282
451
  }
283
452
 
284
453
  if (options?.includeRoutes) {
285
454
  const routes = parseOpenApiPaths(openapi);
286
455
  if (routes.length > 0) {
287
- const routeSchemas = generateRouteSchemas(
456
+ // Find common schemas that appear multiple times (for error responses, etc.)
457
+ const routeSchemaList = collectRouteSchemas(routes);
458
+ const commonSchemas = findCommonSchemas(routeSchemaList, 2);
459
+
460
+ // Generate common schemas first (e.g., UnauthorizedErrorSchema, NotFoundErrorSchema)
461
+ if (commonSchemas.length > 0) {
462
+ lines.push("// Common Error Schemas (deduplicated)");
463
+ for (const common of commonSchemas) {
464
+ const zodExpr = convertSchemaToZodString(common.schema);
465
+ lines.push(`export const ${common.name} = ${zodExpr};`);
466
+ // Pre-register so route schemas reference this instead of duplicating
467
+ preRegisterSchema(registry, common.name, common.fingerprint);
468
+ }
469
+ lines.push("");
470
+ }
471
+
472
+ // Generate route schemas with deduplication
473
+ const { declarations, schemaNameToCanonical } = generateRouteSchemas(
288
474
  routes,
289
475
  convertSchemaToZodString,
476
+ registry,
290
477
  );
291
- if (routeSchemas.length > 0) {
292
- lines.push(...routeSchemas);
478
+
479
+ if (declarations.length > 0) {
480
+ lines.push("// Route Schemas");
481
+ lines.push(...declarations);
293
482
  lines.push("");
294
- const requestResponseObjs = generateRequestResponseObjects(routes);
483
+
484
+ // Generate Request/Response objects using canonical names
485
+ const requestResponseObjs = generateRequestResponseObjects(
486
+ routes,
487
+ schemaNameToCanonical,
488
+ );
295
489
  lines.push(...requestResponseObjs);
296
490
  }
297
491
  }