@alt-stack/zod-openapi 1.1.2 → 1.2.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.
@@ -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 {
@@ -6,6 +14,7 @@ import {
6
14
  generateRouteSchemaNames,
7
15
  type RouteInfo,
8
16
  } from "./routes";
17
+ import { generateInterface, schemaToTypeString } from "./interface-generator";
9
18
 
10
19
  const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
11
20
 
@@ -39,12 +48,24 @@ function generateRouteSchemaName(
39
48
  return parts.join("");
40
49
  }
41
50
 
51
+ /**
52
+ * Result of route schema generation including declarations and name mappings.
53
+ */
54
+ interface RouteSchemaResult {
55
+ /** Schema declarations to be emitted */
56
+ declarations: string[];
57
+ /** Maps route-specific schema name to its canonical name (for deduplication) */
58
+ schemaNameToCanonical: Map<string, string>;
59
+ }
60
+
42
61
  function generateRouteSchemas(
43
62
  routes: RouteInfo[],
44
63
  convertSchema: (schema: AnySchema) => string,
45
- ): string[] {
46
- const lines: string[] = [];
47
- const schemaNames = new Set<string>();
64
+ registry: SchemaRegistry,
65
+ ): RouteSchemaResult {
66
+ const declarations: string[] = [];
67
+ const schemaNameToCanonical = new Map<string, string>();
68
+ const generatedNames = new Set<string>();
48
69
 
49
70
  for (const route of routes) {
50
71
  const names = generateRouteSchemaNames(route);
@@ -52,27 +73,62 @@ function generateRouteSchemas(
52
73
  const queryParams = route.parameters.filter((p) => p.in === "query");
53
74
  const headerParams = route.parameters.filter((p) => p.in === "header");
54
75
 
76
+ // Generate params schema with deduplication
55
77
  if (names.paramsSchemaName && pathParams.length > 0) {
56
- if (!schemaNames.has(names.paramsSchemaName)) {
57
- schemaNames.add(names.paramsSchemaName);
78
+ const paramsSchema: AnySchema = {
79
+ type: "object",
80
+ properties: Object.fromEntries(
81
+ pathParams.map((p) => [p.name, p.schema]),
82
+ ),
83
+ required: pathParams.filter((p) => p.required).map((p) => p.name),
84
+ };
85
+
86
+ const { isNew, canonicalName } = registerSchema(
87
+ registry,
88
+ names.paramsSchemaName,
89
+ paramsSchema,
90
+ );
91
+ schemaNameToCanonical.set(names.paramsSchemaName, canonicalName);
92
+
93
+ if (isNew && !generatedNames.has(names.paramsSchemaName)) {
94
+ generatedNames.add(names.paramsSchemaName);
58
95
  const properties: string[] = [];
59
- const required: string[] = [];
60
96
  for (const param of pathParams) {
61
97
  const zodExpr = convertSchema(param.schema);
62
98
  properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
63
- if (param.required) {
64
- required.push(param.name);
65
- }
66
99
  }
67
- lines.push(
100
+ declarations.push(
68
101
  `export const ${names.paramsSchemaName} = z.object({ ${properties.join(", ")} });`,
69
102
  );
103
+ } else if (!isNew && names.paramsSchemaName !== canonicalName) {
104
+ if (!generatedNames.has(names.paramsSchemaName)) {
105
+ generatedNames.add(names.paramsSchemaName);
106
+ declarations.push(
107
+ `export const ${names.paramsSchemaName} = ${canonicalName};`,
108
+ );
109
+ }
70
110
  }
71
111
  }
72
112
 
113
+ // Generate query schema with deduplication
73
114
  if (names.querySchemaName && queryParams.length > 0) {
74
- if (!schemaNames.has(names.querySchemaName)) {
75
- schemaNames.add(names.querySchemaName);
115
+ const querySchema: AnySchema = {
116
+ type: "object",
117
+ properties: Object.fromEntries(
118
+ queryParams.map((p) => [p.name, p.schema]),
119
+ ),
120
+ required: queryParams.filter((p) => p.required).map((p) => p.name),
121
+ };
122
+
123
+ const { isNew, canonicalName } = registerSchema(
124
+ registry,
125
+ names.querySchemaName,
126
+ querySchema,
127
+ );
128
+ schemaNameToCanonical.set(names.querySchemaName, canonicalName);
129
+
130
+ if (isNew && !generatedNames.has(names.querySchemaName)) {
131
+ generatedNames.add(names.querySchemaName);
76
132
  const properties: string[] = [];
77
133
  for (const param of queryParams) {
78
134
  let zodExpr = convertSchema(param.schema);
@@ -81,15 +137,38 @@ function generateRouteSchemas(
81
137
  }
82
138
  properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
83
139
  }
84
- lines.push(
140
+ declarations.push(
85
141
  `export const ${names.querySchemaName} = z.object({ ${properties.join(", ")} });`,
86
142
  );
143
+ } else if (!isNew && names.querySchemaName !== canonicalName) {
144
+ if (!generatedNames.has(names.querySchemaName)) {
145
+ generatedNames.add(names.querySchemaName);
146
+ declarations.push(
147
+ `export const ${names.querySchemaName} = ${canonicalName};`,
148
+ );
149
+ }
87
150
  }
88
151
  }
89
152
 
153
+ // Generate headers schema with deduplication
90
154
  if (names.headersSchemaName && headerParams.length > 0) {
91
- if (!schemaNames.has(names.headersSchemaName)) {
92
- schemaNames.add(names.headersSchemaName);
155
+ const headersSchema: AnySchema = {
156
+ type: "object",
157
+ properties: Object.fromEntries(
158
+ headerParams.map((p) => [p.name, p.schema]),
159
+ ),
160
+ required: headerParams.filter((p) => p.required).map((p) => p.name),
161
+ };
162
+
163
+ const { isNew, canonicalName } = registerSchema(
164
+ registry,
165
+ names.headersSchemaName,
166
+ headersSchema,
167
+ );
168
+ schemaNameToCanonical.set(names.headersSchemaName, canonicalName);
169
+
170
+ if (isNew && !generatedNames.has(names.headersSchemaName)) {
171
+ generatedNames.add(names.headersSchemaName);
93
172
  const properties: string[] = [];
94
173
  for (const param of headerParams) {
95
174
  let zodExpr = convertSchema(param.schema);
@@ -98,21 +177,43 @@ function generateRouteSchemas(
98
177
  }
99
178
  properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
100
179
  }
101
- lines.push(
180
+ declarations.push(
102
181
  `export const ${names.headersSchemaName} = z.object({ ${properties.join(", ")} });`,
103
182
  );
183
+ } else if (!isNew && names.headersSchemaName !== canonicalName) {
184
+ if (!generatedNames.has(names.headersSchemaName)) {
185
+ generatedNames.add(names.headersSchemaName);
186
+ declarations.push(
187
+ `export const ${names.headersSchemaName} = ${canonicalName};`,
188
+ );
189
+ }
104
190
  }
105
191
  }
106
192
 
193
+ // Generate body schema with deduplication
107
194
  if (names.bodySchemaName && route.requestBody) {
108
- if (!schemaNames.has(names.bodySchemaName)) {
109
- schemaNames.add(names.bodySchemaName);
195
+ const { isNew, canonicalName } = registerSchema(
196
+ registry,
197
+ names.bodySchemaName,
198
+ route.requestBody,
199
+ );
200
+ schemaNameToCanonical.set(names.bodySchemaName, canonicalName);
201
+
202
+ if (isNew && !generatedNames.has(names.bodySchemaName)) {
203
+ generatedNames.add(names.bodySchemaName);
110
204
  const zodExpr = convertSchema(route.requestBody);
111
- lines.push(`export const ${names.bodySchemaName} = ${zodExpr};`);
205
+ declarations.push(`export const ${names.bodySchemaName} = ${zodExpr};`);
206
+ } else if (!isNew && names.bodySchemaName !== canonicalName) {
207
+ if (!generatedNames.has(names.bodySchemaName)) {
208
+ generatedNames.add(names.bodySchemaName);
209
+ declarations.push(
210
+ `export const ${names.bodySchemaName} = ${canonicalName};`,
211
+ );
212
+ }
112
213
  }
113
214
  }
114
215
 
115
- // Generate schemas for ALL status codes, not just success
216
+ // Generate schemas for ALL status codes with deduplication
116
217
  for (const [statusCode, responseSchema] of Object.entries(
117
218
  route.responses,
118
219
  )) {
@@ -128,18 +229,35 @@ function generateRouteSchemas(
128
229
  suffix,
129
230
  );
130
231
 
131
- if (!schemaNames.has(responseSchemaName)) {
132
- schemaNames.add(responseSchemaName);
232
+ const { isNew, canonicalName } = registerSchema(
233
+ registry,
234
+ responseSchemaName,
235
+ responseSchema,
236
+ );
237
+ schemaNameToCanonical.set(responseSchemaName, canonicalName);
238
+
239
+ if (isNew && !generatedNames.has(responseSchemaName)) {
240
+ generatedNames.add(responseSchemaName);
133
241
  const zodExpr = convertSchema(responseSchema);
134
- lines.push(`export const ${responseSchemaName} = ${zodExpr};`);
242
+ declarations.push(`export const ${responseSchemaName} = ${zodExpr};`);
243
+ } else if (!isNew && responseSchemaName !== canonicalName) {
244
+ if (!generatedNames.has(responseSchemaName)) {
245
+ generatedNames.add(responseSchemaName);
246
+ declarations.push(
247
+ `export const ${responseSchemaName} = ${canonicalName};`,
248
+ );
249
+ }
135
250
  }
136
251
  }
137
252
  }
138
253
 
139
- return lines;
254
+ return { declarations, schemaNameToCanonical };
140
255
  }
141
256
 
142
- function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
257
+ function generateRequestResponseObjects(
258
+ routes: RouteInfo[],
259
+ schemaNameToCanonical: Map<string, string>,
260
+ ): string[] {
143
261
  const lines: string[] = [];
144
262
  const requestPaths: Record<string, Record<string, string[]>> = {};
145
263
  const responsePaths: Record<
@@ -147,6 +265,14 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
147
265
  Record<string, Record<string, string>>
148
266
  > = {};
149
267
 
268
+ /**
269
+ * Resolves a schema name to its canonical name if it exists,
270
+ * otherwise returns the original name.
271
+ */
272
+ const resolveSchemaName = (name: string): string => {
273
+ return schemaNameToCanonical.get(name) ?? name;
274
+ };
275
+
150
276
  for (const route of routes) {
151
277
  const names = generateRouteSchemaNames(route);
152
278
  const pathParams = route.parameters.filter((p) => p.in === "path");
@@ -163,16 +289,20 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
163
289
 
164
290
  const requestParts: string[] = [];
165
291
  if (names.paramsSchemaName && pathParams.length > 0) {
166
- requestParts.push(`params: ${names.paramsSchemaName}`);
292
+ requestParts.push(
293
+ `params: ${resolveSchemaName(names.paramsSchemaName)}`,
294
+ );
167
295
  }
168
296
  if (names.querySchemaName && queryParams.length > 0) {
169
- requestParts.push(`query: ${names.querySchemaName}`);
297
+ requestParts.push(`query: ${resolveSchemaName(names.querySchemaName)}`);
170
298
  }
171
299
  if (names.headersSchemaName && headerParams.length > 0) {
172
- requestParts.push(`headers: ${names.headersSchemaName}`);
300
+ requestParts.push(
301
+ `headers: ${resolveSchemaName(names.headersSchemaName)}`,
302
+ );
173
303
  }
174
304
  if (names.bodySchemaName && route.requestBody) {
175
- requestParts.push(`body: ${names.bodySchemaName}`);
305
+ requestParts.push(`body: ${resolveSchemaName(names.bodySchemaName)}`);
176
306
  }
177
307
 
178
308
  if (requestParts.length > 0) {
@@ -202,7 +332,9 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
202
332
  route.method,
203
333
  suffix,
204
334
  );
205
- responseMethodObj[route.method]![statusCode] = responseSchemaName;
335
+ // Use canonical name for the Response object
336
+ responseMethodObj[route.method]![statusCode] =
337
+ resolveSchemaName(responseSchemaName);
206
338
  }
207
339
  }
208
340
 
@@ -246,6 +378,37 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
246
378
  return lines;
247
379
  }
248
380
 
381
+ /**
382
+ * Collects all response schemas from routes for common schema detection.
383
+ */
384
+ function collectRouteSchemas(
385
+ routes: RouteInfo[],
386
+ ): Array<{ name: string; schema: AnySchema }> {
387
+ const collected: Array<{ name: string; schema: AnySchema }> = [];
388
+
389
+ for (const route of routes) {
390
+ for (const [statusCode, responseSchema] of Object.entries(
391
+ route.responses,
392
+ )) {
393
+ if (!responseSchema) continue;
394
+
395
+ const isSuccess = statusCode.startsWith("2");
396
+ const suffix = isSuccess
397
+ ? `${statusCode}Response`
398
+ : `${statusCode}ErrorResponse`;
399
+ const responseSchemaName = generateRouteSchemaName(
400
+ route.path,
401
+ route.method,
402
+ suffix,
403
+ );
404
+
405
+ collected.push({ name: responseSchemaName, schema: responseSchema });
406
+ }
407
+ }
408
+
409
+ return collected;
410
+ }
411
+
249
412
  export const openApiToZodTsCode = (
250
413
  openapi: Record<string, unknown>,
251
414
  customImportLines?: string[],
@@ -267,31 +430,85 @@ export const openApiToZodTsCode = (
267
430
  lines.push(...(customImportLines ?? []));
268
431
  lines.push("");
269
432
 
433
+ // Type assertion helper for compile-time verification
434
+ lines.push("// Type assertion helper - verifies interface matches schema at compile time");
435
+ lines.push("type _AssertEqual<T, U> = [T] extends [U] ? ([U] extends [T] ? true : never) : never;");
436
+ lines.push("");
437
+
438
+ // Create registry for schema deduplication
439
+ const registry = createSchemaRegistry();
440
+
270
441
  const sortedSchemaNames = topologicalSortSchemas(schemas);
271
442
 
443
+ // Collect all type assertions to emit after all schemas
444
+ const typeAssertions: string[] = [];
445
+
272
446
  for (const name of sortedSchemaNames) {
273
447
  const schema = schemas[name];
274
448
  if (schema) {
275
449
  const zodExpr = convertSchemaToZodString(schema);
276
450
  const schemaName = `${name}Schema`;
277
451
  const typeName = name;
278
- lines.push(`export const ${schemaName} = ${zodExpr};`);
279
- lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>;`);
452
+
453
+ // Generate interface (concrete type in .d.ts)
454
+ lines.push(generateInterface(typeName, schema));
455
+
456
+ // Generate schema with ZodType<T> annotation (simple type in .d.ts)
457
+ lines.push(`export const ${schemaName}: z.ZodType<${typeName}> = ${zodExpr};`);
280
458
  lines.push("");
459
+
460
+ // Add type assertion to verify interface matches schema
461
+ typeAssertions.push(`type _Assert${typeName} = _AssertEqual<${typeName}, z.infer<typeof ${schemaName}>>;`);
462
+
463
+ // Register component schemas so they can be referenced by route schemas
464
+ const fingerprint = getSchemaFingerprint(schema);
465
+ preRegisterSchema(registry, schemaName, fingerprint);
281
466
  }
282
467
  }
283
468
 
469
+ // Emit all type assertions
470
+ if (typeAssertions.length > 0) {
471
+ lines.push("// Compile-time type assertions - ensure interfaces match schemas");
472
+ lines.push(typeAssertions.join("\n"));
473
+ lines.push("");
474
+ }
475
+
284
476
  if (options?.includeRoutes) {
285
477
  const routes = parseOpenApiPaths(openapi);
286
478
  if (routes.length > 0) {
287
- const routeSchemas = generateRouteSchemas(
479
+ // Find common schemas that appear multiple times (for error responses, etc.)
480
+ const routeSchemaList = collectRouteSchemas(routes);
481
+ const commonSchemas = findCommonSchemas(routeSchemaList, 2);
482
+
483
+ // Generate common schemas first (e.g., UnauthorizedErrorSchema, NotFoundErrorSchema)
484
+ if (commonSchemas.length > 0) {
485
+ lines.push("// Common Error Schemas (deduplicated)");
486
+ for (const common of commonSchemas) {
487
+ const zodExpr = convertSchemaToZodString(common.schema);
488
+ lines.push(`export const ${common.name} = ${zodExpr};`);
489
+ // Pre-register so route schemas reference this instead of duplicating
490
+ preRegisterSchema(registry, common.name, common.fingerprint);
491
+ }
492
+ lines.push("");
493
+ }
494
+
495
+ // Generate route schemas with deduplication
496
+ const { declarations, schemaNameToCanonical } = generateRouteSchemas(
288
497
  routes,
289
498
  convertSchemaToZodString,
499
+ registry,
290
500
  );
291
- if (routeSchemas.length > 0) {
292
- lines.push(...routeSchemas);
501
+
502
+ if (declarations.length > 0) {
503
+ lines.push("// Route Schemas");
504
+ lines.push(...declarations);
293
505
  lines.push("");
294
- const requestResponseObjs = generateRequestResponseObjects(routes);
506
+
507
+ // Generate Request/Response objects using canonical names
508
+ const requestResponseObjs = generateRequestResponseObjects(
509
+ routes,
510
+ schemaNameToCanonical,
511
+ );
295
512
  lines.push(...requestResponseObjs);
296
513
  }
297
514
  }
@@ -289,9 +289,12 @@ describe("openApiToZodTsCode", () => {
289
289
 
290
290
  const result = openApiToZodTsCode(openapi);
291
291
  expect(result).toContain("import { z } from 'zod';");
292
- expect(result).toContain("export const UserSchema =");
292
+ // New format: interface + ZodType annotation
293
+ expect(result).toContain("export interface User {");
294
+ expect(result).toContain("export const UserSchema: z.ZodType<User> =");
293
295
  expect(result).toContain("z.object({ name: z.string() })");
294
- expect(result).toContain("export type User = z.infer<typeof UserSchema>;");
296
+ // Type assertion for compile-time verification
297
+ expect(result).toContain("type _AssertUser = _AssertEqual<User, z.infer<typeof UserSchema>>;");
295
298
  });
296
299
 
297
300
  it("should convert OpenAPI document with multiple schemas", () => {
@@ -317,10 +320,11 @@ describe("openApiToZodTsCode", () => {
317
320
  };
318
321
 
319
322
  const result = openApiToZodTsCode(openapi);
320
- expect(result).toContain("export const UserSchema =");
321
- expect(result).toContain("export const ProductSchema =");
322
- expect(result).toContain("export type User =");
323
- expect(result).toContain("export type Product =");
323
+ // New format: interface + ZodType annotation
324
+ expect(result).toContain("export interface User {");
325
+ expect(result).toContain("export interface Product {");
326
+ expect(result).toContain("export const UserSchema: z.ZodType<User> =");
327
+ expect(result).toContain("export const ProductSchema: z.ZodType<Product> =");
324
328
  });
325
329
 
326
330
  it("should include file header comment", () => {