@alt-stack/zod-openapi 1.1.3 → 1.3.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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Generates TypeScript interface/type strings from OpenAPI schemas.
3
+ *
4
+ * This produces concrete types that appear directly in .d.ts files,
5
+ * rather than requiring z.infer<> resolution at the type level.
6
+ */
7
+
8
+ import {
9
+ getSchemaExportedVariableNameForPrimitiveType,
10
+ getSchemaExportedVariableNameForStringFormat,
11
+ } from "./registry";
12
+ import type { AnySchema } from "./types/types";
13
+
14
+ const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
15
+
16
+ type SchemaToTypeOptions = {
17
+ outputSchemaNames?: Set<string>;
18
+ };
19
+
20
+ function quotePropertyName(name: string): string {
21
+ return validIdentifierRegex.test(name) ? name : `'${name}'`;
22
+ }
23
+
24
+ function toPascalCase(name: string): string {
25
+ return name
26
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
27
+ .replace(/[^a-zA-Z0-9]/g, " ")
28
+ .split(" ")
29
+ .filter(Boolean)
30
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
31
+ .join("");
32
+ }
33
+
34
+ export function schemaExportNameToOutputAlias(name: string): string {
35
+ return `${toPascalCase(name)}Output`;
36
+ }
37
+
38
+ function registerOutputSchemaName(
39
+ schemaName: string,
40
+ options?: SchemaToTypeOptions,
41
+ ): string {
42
+ options?.outputSchemaNames?.add(schemaName);
43
+ return schemaExportNameToOutputAlias(schemaName);
44
+ }
45
+
46
+ function getRegisteredOutputAlias(
47
+ schema: AnySchema,
48
+ options?: SchemaToTypeOptions,
49
+ ): string | undefined {
50
+ if (!schema || typeof schema !== "object") return undefined;
51
+
52
+ if (schema["type"] === "string" && typeof schema["format"] === "string") {
53
+ const customSchemaName = getSchemaExportedVariableNameForStringFormat(
54
+ schema["format"],
55
+ );
56
+ if (customSchemaName) {
57
+ return registerOutputSchemaName(customSchemaName, options);
58
+ }
59
+ }
60
+
61
+ if (
62
+ schema["type"] === "number" ||
63
+ schema["type"] === "integer" ||
64
+ schema["type"] === "boolean"
65
+ ) {
66
+ const customSchemaName = getSchemaExportedVariableNameForPrimitiveType(
67
+ schema["type"],
68
+ );
69
+ if (customSchemaName) {
70
+ return registerOutputSchemaName(customSchemaName, options);
71
+ }
72
+ }
73
+
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Converts an OpenAPI schema to a TypeScript type string.
79
+ *
80
+ * @example
81
+ * schemaToTypeString({ type: 'string' }) // => 'string'
82
+ * schemaToTypeString({ type: 'object', properties: { id: { type: 'string' } } }) // => '{ id?: string }'
83
+ */
84
+ export function schemaToTypeString(
85
+ schema: AnySchema,
86
+ options?: SchemaToTypeOptions,
87
+ ): string {
88
+ if (!schema || typeof schema !== "object") return "unknown";
89
+
90
+ // Handle $ref
91
+ if (schema["$ref"] && typeof schema["$ref"] === "string") {
92
+ const match = (schema["$ref"] as string).match(
93
+ /#\/components\/schemas\/(.+)/,
94
+ );
95
+ let result = "unknown";
96
+ if (match && match[1]) {
97
+ // Decode URI-encoded schema names (e.g., %20 -> space)
98
+ result = decodeURIComponent(match[1]);
99
+ }
100
+ if (schema["nullable"] === true) {
101
+ result = `(${result} | null)`;
102
+ }
103
+ return result;
104
+ }
105
+
106
+ let result: string = "unknown";
107
+
108
+ // Handle oneOf (union)
109
+ if ("oneOf" in schema && Array.isArray(schema["oneOf"])) {
110
+ const unionMembers = (schema["oneOf"] as AnySchema[]).map((s) =>
111
+ schemaToTypeString(s, options),
112
+ );
113
+ result = unionMembers.length > 1 ? `(${unionMembers.join(" | ")})` : unionMembers[0] ?? "unknown";
114
+ }
115
+ // Handle allOf (intersection)
116
+ else if ("allOf" in schema && Array.isArray(schema["allOf"])) {
117
+ const intersectionMembers = (schema["allOf"] as AnySchema[]).map((s) =>
118
+ schemaToTypeString(s, options),
119
+ );
120
+ result = intersectionMembers.length > 1
121
+ ? `(${intersectionMembers.join(" & ")})`
122
+ : intersectionMembers[0] ?? "unknown";
123
+ }
124
+ // Handle anyOf (union, similar to oneOf)
125
+ else if ("anyOf" in schema && Array.isArray(schema["anyOf"])) {
126
+ const unionMembers = (schema["anyOf"] as AnySchema[]).map((s) =>
127
+ schemaToTypeString(s, options),
128
+ );
129
+ result = unionMembers.length > 1 ? `(${unionMembers.join(" | ")})` : unionMembers[0] ?? "unknown";
130
+ }
131
+ // Handle type-based schemas
132
+ else {
133
+ switch (schema["type"]) {
134
+ case "string": {
135
+ const registeredAlias = getRegisteredOutputAlias(schema, options);
136
+ if (registeredAlias) {
137
+ result = registeredAlias;
138
+ } else if (schema["enum"] && Array.isArray(schema["enum"])) {
139
+ // String enum
140
+ result = (schema["enum"] as string[])
141
+ .map((v) => JSON.stringify(v))
142
+ .join(" | ");
143
+ } else {
144
+ result = "string";
145
+ }
146
+ break;
147
+ }
148
+ case "number":
149
+ case "integer": {
150
+ const registeredAlias = getRegisteredOutputAlias(schema, options);
151
+ if (registeredAlias) {
152
+ result = registeredAlias;
153
+ } else if (schema["enum"] && Array.isArray(schema["enum"])) {
154
+ // Numeric enum
155
+ result = (schema["enum"] as number[]).map((v) => String(v)).join(" | ");
156
+ } else {
157
+ result = "number";
158
+ }
159
+ break;
160
+ }
161
+ case "boolean":
162
+ result = getRegisteredOutputAlias(schema, options) ?? "boolean";
163
+ break;
164
+ case "null":
165
+ result = "null";
166
+ break;
167
+ case "array":
168
+ if (schema["items"]) {
169
+ const itemType = schemaToTypeString(schema["items"] as AnySchema, options);
170
+ result = `Array<${itemType}>`;
171
+ } else {
172
+ result = "unknown[]";
173
+ }
174
+ break;
175
+ case "object":
176
+ result = objectSchemaToTypeString(schema, options);
177
+ break;
178
+ default:
179
+ // Try to detect object from properties
180
+ if (schema["properties"]) {
181
+ result = objectSchemaToTypeString(schema, options);
182
+ } else if (schema["enum"] && Array.isArray(schema["enum"])) {
183
+ // Untyped enum
184
+ result = (schema["enum"] as unknown[])
185
+ .map((v) => JSON.stringify(v))
186
+ .join(" | ");
187
+ } else {
188
+ result = "unknown";
189
+ }
190
+ break;
191
+ }
192
+ }
193
+
194
+ // Handle nullable
195
+ if (schema["nullable"] === true) {
196
+ result = `(${result} | null)`;
197
+ }
198
+
199
+ return result;
200
+ }
201
+
202
+ /**
203
+ * Converts an OpenAPI object schema to a TypeScript object type string.
204
+ */
205
+ function objectSchemaToTypeString(
206
+ schema: AnySchema,
207
+ options?: SchemaToTypeOptions,
208
+ ): string {
209
+ const properties = schema["properties"] as Record<string, AnySchema> | undefined;
210
+ const required = new Set((schema["required"] as string[]) ?? []);
211
+ const additionalProperties = schema["additionalProperties"];
212
+
213
+ if (!properties && !additionalProperties) {
214
+ return "Record<string, unknown>";
215
+ }
216
+
217
+ const propertyStrings: string[] = [];
218
+
219
+ if (properties) {
220
+ for (const [propName, propSchema] of Object.entries(properties)) {
221
+ const isRequired = required.has(propName);
222
+ const propType = schemaToTypeString(propSchema, options);
223
+ const quotedName = quotePropertyName(propName);
224
+ propertyStrings.push(
225
+ `${quotedName}${isRequired ? "" : "?"}: ${propType}`,
226
+ );
227
+ }
228
+ }
229
+
230
+ // Handle additionalProperties
231
+ if (additionalProperties === true) {
232
+ propertyStrings.push("[key: string]: unknown");
233
+ } else if (
234
+ typeof additionalProperties === "object" &&
235
+ additionalProperties !== null
236
+ ) {
237
+ const additionalType = schemaToTypeString(additionalProperties as AnySchema, options);
238
+ propertyStrings.push(`[key: string]: ${additionalType}`);
239
+ }
240
+
241
+ return `{ ${propertyStrings.join("; ")} }`;
242
+ }
243
+
244
+ /**
245
+ * Generates a full TypeScript interface declaration.
246
+ *
247
+ * @example
248
+ * generateInterface('User', { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] })
249
+ * // => 'export interface User { id: string; }'
250
+ */
251
+ export function generateInterface(
252
+ name: string,
253
+ schema: AnySchema,
254
+ options?: SchemaToTypeOptions,
255
+ ): string {
256
+ const properties = schema["properties"] as Record<string, AnySchema> | undefined;
257
+ const required = new Set((schema["required"] as string[]) ?? []);
258
+
259
+ // For non-object types, use type alias instead of interface
260
+ if (schema["type"] !== "object" && !properties) {
261
+ return `export type ${name} = ${schemaToTypeString(schema, options)};`;
262
+ }
263
+
264
+ const lines: string[] = [];
265
+ lines.push(`export interface ${name} {`);
266
+
267
+ if (properties) {
268
+ for (const [propName, propSchema] of Object.entries(properties)) {
269
+ const isRequired = required.has(propName);
270
+ const propType = schemaToTypeString(propSchema, options);
271
+ const quotedName = quotePropertyName(propName);
272
+ lines.push(` ${quotedName}${isRequired ? "" : "?"}: ${propType};`);
273
+ }
274
+ }
275
+
276
+ // Handle additionalProperties
277
+ const additionalProperties = schema["additionalProperties"];
278
+ if (additionalProperties === true) {
279
+ lines.push(" [key: string]: unknown;");
280
+ } else if (
281
+ typeof additionalProperties === "object" &&
282
+ additionalProperties !== null
283
+ ) {
284
+ const additionalType = schemaToTypeString(additionalProperties as AnySchema, options);
285
+ lines.push(` [key: string]: ${additionalType};`);
286
+ }
287
+
288
+ lines.push("}");
289
+ return lines.join("\n");
290
+ }
package/src/registry.ts CHANGED
@@ -76,6 +76,12 @@ function isStringsRegistration(
76
76
  return reg.type === "string" && "formats" in reg;
77
77
  }
78
78
 
79
+ function isSupportedStringFormat(
80
+ format: string,
81
+ ): format is SupportedStringFormat {
82
+ return Object.prototype.hasOwnProperty.call(SUPPORTED_STRING_FORMATS_MAP, format);
83
+ }
84
+
79
85
  // ============================================================================
80
86
  // Helper Functions
81
87
  // ============================================================================
@@ -167,8 +173,9 @@ class ZodSchemaRegistry {
167
173
  * Reverse-lookup helper: given a string format, return the registered schema's exported variable name
168
174
  */
169
175
  getSchemaExportedVariableNameForStringFormat(
170
- format: SupportedStringFormat,
176
+ format: SupportedStringFormat | string,
171
177
  ): string | undefined {
178
+ if (!isSupportedStringFormat(format)) return undefined;
172
179
  for (const registration of this.map.values()) {
173
180
  if (registration.type !== "string") continue;
174
181
 
@@ -188,6 +195,20 @@ class ZodSchemaRegistry {
188
195
  }
189
196
  return undefined;
190
197
  }
198
+
199
+ /**
200
+ * Reverse-lookup helper: given a primitive type, return the registered schema's exported variable name
201
+ */
202
+ getSchemaExportedVariableNameForPrimitiveType(
203
+ type: ZodOpenApiRegistrationPrimitive["type"],
204
+ ): string | undefined {
205
+ for (const registration of this.map.values()) {
206
+ if (registration.type === type) {
207
+ return registration.schemaExportedVariableName;
208
+ }
209
+ }
210
+ return undefined;
211
+ }
191
212
  }
192
213
 
193
214
  // ============================================================================
@@ -214,11 +235,20 @@ export function registerZodSchemaToOpenApiSchema(
214
235
  * Convenience helper to get an exported schema variable name for a given string format
215
236
  */
216
237
  export function getSchemaExportedVariableNameForStringFormat(
217
- format: SupportedStringFormat,
238
+ format: SupportedStringFormat | string,
218
239
  ): string | undefined {
219
240
  return schemaRegistry.getSchemaExportedVariableNameForStringFormat(format);
220
241
  }
221
242
 
243
+ /**
244
+ * Convenience helper to get an exported schema variable name for a given primitive type
245
+ */
246
+ export function getSchemaExportedVariableNameForPrimitiveType(
247
+ type: ZodOpenApiRegistrationPrimitive["type"],
248
+ ): string | undefined {
249
+ return schemaRegistry.getSchemaExportedVariableNameForPrimitiveType(type);
250
+ }
251
+
222
252
  /**
223
253
  * Clear all registered schemas in the global registry
224
254
  */
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { z } from "zod";
2
3
  import { openApiToZodTsCode } from "./to-typescript";
3
4
  import {
4
5
  registerZodSchemaToOpenApiSchema,
@@ -70,6 +71,65 @@ describe("openApiToZodTsCode with routes", () => {
70
71
  expect(result).toContain("'200': GetUsersId200Response");
71
72
  });
72
73
 
74
+ it("should use output alias for registered schemas in routes", () => {
75
+ const uuidSchema = z.string().uuid();
76
+ registerZodSchemaToOpenApiSchema(uuidSchema, {
77
+ schemaExportedVariableName: "uuidSchema",
78
+ type: "string",
79
+ format: "uuid",
80
+ });
81
+
82
+ const openapi = {
83
+ components: {
84
+ schemas: {
85
+ User: {
86
+ type: "object",
87
+ properties: {
88
+ id: { type: "string", format: "uuid" },
89
+ },
90
+ required: ["id"],
91
+ },
92
+ },
93
+ },
94
+ paths: {
95
+ "/users/{id}": {
96
+ get: {
97
+ parameters: [
98
+ {
99
+ name: "id",
100
+ in: "path",
101
+ required: true,
102
+ schema: { type: "string" },
103
+ },
104
+ ],
105
+ responses: {
106
+ "200": {
107
+ content: {
108
+ "application/json": {
109
+ schema: { $ref: "#/components/schemas/User" },
110
+ },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ },
116
+ },
117
+ };
118
+
119
+ const result = openApiToZodTsCode(
120
+ openapi,
121
+ ['import { uuidSchema } from "./custom-schemas";'],
122
+ { includeRoutes: true },
123
+ );
124
+
125
+ expect(result).toContain(
126
+ "type UuidSchemaOutput = z.output<typeof uuidSchema>;",
127
+ );
128
+ expect(result).toContain("id: UuidSchemaOutput;");
129
+ expect(result).toContain("export const GetUsersId200Response = UserSchema;");
130
+ expect(result).toContain("'200': GetUsersId200Response");
131
+ });
132
+
73
133
  it("should generate Request with body schema", () => {
74
134
  const openapi = {
75
135
  components: {
@@ -14,6 +14,7 @@ import {
14
14
  generateRouteSchemaNames,
15
15
  type RouteInfo,
16
16
  } from "./routes";
17
+ import { generateInterface, schemaExportNameToOutputAlias } from "./interface-generator";
17
18
 
18
19
  const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
19
20
 
@@ -429,20 +430,37 @@ export const openApiToZodTsCode = (
429
430
  lines.push(...(customImportLines ?? []));
430
431
  lines.push("");
431
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
+
432
438
  // Create registry for schema deduplication
433
439
  const registry = createSchemaRegistry();
434
440
 
435
441
  const sortedSchemaNames = topologicalSortSchemas(schemas);
436
442
 
443
+ // Collect all type assertions to emit after all schemas
444
+ const typeAssertions: string[] = [];
445
+ const outputSchemaNames = new Set<string>();
446
+ const schemaBlocks: string[] = [];
447
+
437
448
  for (const name of sortedSchemaNames) {
438
449
  const schema = schemas[name];
439
450
  if (schema) {
440
451
  const zodExpr = convertSchemaToZodString(schema);
441
452
  const schemaName = `${name}Schema`;
442
453
  const typeName = name;
443
- lines.push(`export const ${schemaName} = ${zodExpr};`);
444
- lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>;`);
445
- lines.push("");
454
+
455
+ // Generate interface (concrete type in .d.ts)
456
+ schemaBlocks.push(generateInterface(typeName, schema, { outputSchemaNames }));
457
+
458
+ // Generate schema with ZodType<T> annotation (simple type in .d.ts)
459
+ schemaBlocks.push(`export const ${schemaName}: z.ZodType<${typeName}> = ${zodExpr};`);
460
+ schemaBlocks.push("");
461
+
462
+ // Add type assertion to verify interface matches schema
463
+ typeAssertions.push(`type _Assert${typeName} = _AssertEqual<${typeName}, z.infer<typeof ${schemaName}>>;`);
446
464
 
447
465
  // Register component schemas so they can be referenced by route schemas
448
466
  const fingerprint = getSchemaFingerprint(schema);
@@ -450,6 +468,24 @@ export const openApiToZodTsCode = (
450
468
  }
451
469
  }
452
470
 
471
+ if (outputSchemaNames.size > 0) {
472
+ lines.push("// Zod output aliases for registered schemas");
473
+ for (const schemaName of outputSchemaNames) {
474
+ const aliasName = schemaExportNameToOutputAlias(schemaName);
475
+ lines.push(`type ${aliasName} = z.output<typeof ${schemaName}>;`);
476
+ }
477
+ lines.push("");
478
+ }
479
+
480
+ lines.push(...schemaBlocks);
481
+
482
+ // Emit all type assertions
483
+ if (typeAssertions.length > 0) {
484
+ lines.push("// Compile-time type assertions - ensure interfaces match schemas");
485
+ lines.push(typeAssertions.join("\n"));
486
+ lines.push("");
487
+ }
488
+
453
489
  if (options?.includeRoutes) {
454
490
  const routes = parseOpenApiPaths(openapi);
455
491
  if (routes.length > 0) {
@@ -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", () => {