@alt-stack/zod-openapi 1.2.0 → 1.3.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.
@@ -5,14 +5,75 @@
5
5
  * rather than requiring z.infer<> resolution at the type level.
6
6
  */
7
7
 
8
+ import {
9
+ getSchemaExportedVariableNameForPrimitiveType,
10
+ getSchemaExportedVariableNameForStringFormat,
11
+ } from "./registry";
8
12
  import type { AnySchema } from "./types/types";
9
13
 
10
14
  const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
11
15
 
16
+ type SchemaToTypeOptions = {
17
+ outputSchemaNames?: Set<string>;
18
+ };
19
+
12
20
  function quotePropertyName(name: string): string {
13
21
  return validIdentifierRegex.test(name) ? name : `'${name}'`;
14
22
  }
15
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
+
16
77
  /**
17
78
  * Converts an OpenAPI schema to a TypeScript type string.
18
79
  *
@@ -20,7 +81,10 @@ function quotePropertyName(name: string): string {
20
81
  * schemaToTypeString({ type: 'string' }) // => 'string'
21
82
  * schemaToTypeString({ type: 'object', properties: { id: { type: 'string' } } }) // => '{ id?: string }'
22
83
  */
23
- export function schemaToTypeString(schema: AnySchema): string {
84
+ export function schemaToTypeString(
85
+ schema: AnySchema,
86
+ options?: SchemaToTypeOptions,
87
+ ): string {
24
88
  if (!schema || typeof schema !== "object") return "unknown";
25
89
 
26
90
  // Handle $ref
@@ -44,14 +108,14 @@ export function schemaToTypeString(schema: AnySchema): string {
44
108
  // Handle oneOf (union)
45
109
  if ("oneOf" in schema && Array.isArray(schema["oneOf"])) {
46
110
  const unionMembers = (schema["oneOf"] as AnySchema[]).map((s) =>
47
- schemaToTypeString(s),
111
+ schemaToTypeString(s, options),
48
112
  );
49
113
  result = unionMembers.length > 1 ? `(${unionMembers.join(" | ")})` : unionMembers[0] ?? "unknown";
50
114
  }
51
115
  // Handle allOf (intersection)
52
116
  else if ("allOf" in schema && Array.isArray(schema["allOf"])) {
53
117
  const intersectionMembers = (schema["allOf"] as AnySchema[]).map((s) =>
54
- schemaToTypeString(s),
118
+ schemaToTypeString(s, options),
55
119
  );
56
120
  result = intersectionMembers.length > 1
57
121
  ? `(${intersectionMembers.join(" & ")})`
@@ -60,15 +124,18 @@ export function schemaToTypeString(schema: AnySchema): string {
60
124
  // Handle anyOf (union, similar to oneOf)
61
125
  else if ("anyOf" in schema && Array.isArray(schema["anyOf"])) {
62
126
  const unionMembers = (schema["anyOf"] as AnySchema[]).map((s) =>
63
- schemaToTypeString(s),
127
+ schemaToTypeString(s, options),
64
128
  );
65
129
  result = unionMembers.length > 1 ? `(${unionMembers.join(" | ")})` : unionMembers[0] ?? "unknown";
66
130
  }
67
131
  // Handle type-based schemas
68
132
  else {
69
133
  switch (schema["type"]) {
70
- case "string":
71
- if (schema["enum"] && Array.isArray(schema["enum"])) {
134
+ case "string": {
135
+ const registeredAlias = getRegisteredOutputAlias(schema, options);
136
+ if (registeredAlias) {
137
+ result = registeredAlias;
138
+ } else if (schema["enum"] && Array.isArray(schema["enum"])) {
72
139
  // String enum
73
140
  result = (schema["enum"] as string[])
74
141
  .map((v) => JSON.stringify(v))
@@ -77,36 +144,41 @@ export function schemaToTypeString(schema: AnySchema): string {
77
144
  result = "string";
78
145
  }
79
146
  break;
147
+ }
80
148
  case "number":
81
- case "integer":
82
- if (schema["enum"] && Array.isArray(schema["enum"])) {
149
+ case "integer": {
150
+ const registeredAlias = getRegisteredOutputAlias(schema, options);
151
+ if (registeredAlias) {
152
+ result = registeredAlias;
153
+ } else if (schema["enum"] && Array.isArray(schema["enum"])) {
83
154
  // Numeric enum
84
155
  result = (schema["enum"] as number[]).map((v) => String(v)).join(" | ");
85
156
  } else {
86
157
  result = "number";
87
158
  }
88
159
  break;
160
+ }
89
161
  case "boolean":
90
- result = "boolean";
162
+ result = getRegisteredOutputAlias(schema, options) ?? "boolean";
91
163
  break;
92
164
  case "null":
93
165
  result = "null";
94
166
  break;
95
167
  case "array":
96
168
  if (schema["items"]) {
97
- const itemType = schemaToTypeString(schema["items"] as AnySchema);
169
+ const itemType = schemaToTypeString(schema["items"] as AnySchema, options);
98
170
  result = `Array<${itemType}>`;
99
171
  } else {
100
172
  result = "unknown[]";
101
173
  }
102
174
  break;
103
175
  case "object":
104
- result = objectSchemaToTypeString(schema);
176
+ result = objectSchemaToTypeString(schema, options);
105
177
  break;
106
178
  default:
107
179
  // Try to detect object from properties
108
180
  if (schema["properties"]) {
109
- result = objectSchemaToTypeString(schema);
181
+ result = objectSchemaToTypeString(schema, options);
110
182
  } else if (schema["enum"] && Array.isArray(schema["enum"])) {
111
183
  // Untyped enum
112
184
  result = (schema["enum"] as unknown[])
@@ -130,7 +202,10 @@ export function schemaToTypeString(schema: AnySchema): string {
130
202
  /**
131
203
  * Converts an OpenAPI object schema to a TypeScript object type string.
132
204
  */
133
- function objectSchemaToTypeString(schema: AnySchema): string {
205
+ function objectSchemaToTypeString(
206
+ schema: AnySchema,
207
+ options?: SchemaToTypeOptions,
208
+ ): string {
134
209
  const properties = schema["properties"] as Record<string, AnySchema> | undefined;
135
210
  const required = new Set((schema["required"] as string[]) ?? []);
136
211
  const additionalProperties = schema["additionalProperties"];
@@ -144,7 +219,7 @@ function objectSchemaToTypeString(schema: AnySchema): string {
144
219
  if (properties) {
145
220
  for (const [propName, propSchema] of Object.entries(properties)) {
146
221
  const isRequired = required.has(propName);
147
- const propType = schemaToTypeString(propSchema);
222
+ const propType = schemaToTypeString(propSchema, options);
148
223
  const quotedName = quotePropertyName(propName);
149
224
  propertyStrings.push(
150
225
  `${quotedName}${isRequired ? "" : "?"}: ${propType}`,
@@ -159,7 +234,7 @@ function objectSchemaToTypeString(schema: AnySchema): string {
159
234
  typeof additionalProperties === "object" &&
160
235
  additionalProperties !== null
161
236
  ) {
162
- const additionalType = schemaToTypeString(additionalProperties as AnySchema);
237
+ const additionalType = schemaToTypeString(additionalProperties as AnySchema, options);
163
238
  propertyStrings.push(`[key: string]: ${additionalType}`);
164
239
  }
165
240
 
@@ -173,13 +248,17 @@ function objectSchemaToTypeString(schema: AnySchema): string {
173
248
  * generateInterface('User', { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] })
174
249
  * // => 'export interface User { id: string; }'
175
250
  */
176
- export function generateInterface(name: string, schema: AnySchema): string {
251
+ export function generateInterface(
252
+ name: string,
253
+ schema: AnySchema,
254
+ options?: SchemaToTypeOptions,
255
+ ): string {
177
256
  const properties = schema["properties"] as Record<string, AnySchema> | undefined;
178
257
  const required = new Set((schema["required"] as string[]) ?? []);
179
258
 
180
259
  // For non-object types, use type alias instead of interface
181
260
  if (schema["type"] !== "object" && !properties) {
182
- return `export type ${name} = ${schemaToTypeString(schema)};`;
261
+ return `export type ${name} = ${schemaToTypeString(schema, options)};`;
183
262
  }
184
263
 
185
264
  const lines: string[] = [];
@@ -188,7 +267,7 @@ export function generateInterface(name: string, schema: AnySchema): string {
188
267
  if (properties) {
189
268
  for (const [propName, propSchema] of Object.entries(properties)) {
190
269
  const isRequired = required.has(propName);
191
- const propType = schemaToTypeString(propSchema);
270
+ const propType = schemaToTypeString(propSchema, options);
192
271
  const quotedName = quotePropertyName(propName);
193
272
  lines.push(` ${quotedName}${isRequired ? "" : "?"}: ${propType};`);
194
273
  }
@@ -202,7 +281,7 @@ export function generateInterface(name: string, schema: AnySchema): string {
202
281
  typeof additionalProperties === "object" &&
203
282
  additionalProperties !== null
204
283
  ) {
205
- const additionalType = schemaToTypeString(additionalProperties as AnySchema);
284
+ const additionalType = schemaToTypeString(additionalProperties as AnySchema, options);
206
285
  lines.push(` [key: string]: ${additionalType};`);
207
286
  }
208
287
 
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,7 +14,7 @@ import {
14
14
  generateRouteSchemaNames,
15
15
  type RouteInfo,
16
16
  } from "./routes";
17
- import { generateInterface, schemaToTypeString } from "./interface-generator";
17
+ import { generateInterface, schemaExportNameToOutputAlias } from "./interface-generator";
18
18
 
19
19
  const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
20
20
 
@@ -442,6 +442,8 @@ export const openApiToZodTsCode = (
442
442
 
443
443
  // Collect all type assertions to emit after all schemas
444
444
  const typeAssertions: string[] = [];
445
+ const outputSchemaNames = new Set<string>();
446
+ const schemaBlocks: string[] = [];
445
447
 
446
448
  for (const name of sortedSchemaNames) {
447
449
  const schema = schemas[name];
@@ -451,11 +453,11 @@ export const openApiToZodTsCode = (
451
453
  const typeName = name;
452
454
 
453
455
  // Generate interface (concrete type in .d.ts)
454
- lines.push(generateInterface(typeName, schema));
456
+ schemaBlocks.push(generateInterface(typeName, schema, { outputSchemaNames }));
455
457
 
456
458
  // Generate schema with ZodType<T> annotation (simple type in .d.ts)
457
- lines.push(`export const ${schemaName}: z.ZodType<${typeName}> = ${zodExpr};`);
458
- lines.push("");
459
+ schemaBlocks.push(`export const ${schemaName} = ${zodExpr};`);
460
+ schemaBlocks.push("");
459
461
 
460
462
  // Add type assertion to verify interface matches schema
461
463
  typeAssertions.push(`type _Assert${typeName} = _AssertEqual<${typeName}, z.infer<typeof ${schemaName}>>;`);
@@ -466,6 +468,17 @@ export const openApiToZodTsCode = (
466
468
  }
467
469
  }
468
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
+
469
482
  // Emit all type assertions
470
483
  if (typeAssertions.length > 0) {
471
484
  lines.push("// Compile-time type assertions - ensure interfaces match schemas");
@@ -289,10 +289,9 @@ describe("openApiToZodTsCode", () => {
289
289
 
290
290
  const result = openApiToZodTsCode(openapi);
291
291
  expect(result).toContain("import { z } from 'zod';");
292
- // New format: interface + ZodType annotation
292
+ // New format: interface + schema without explicit type annotation
293
293
  expect(result).toContain("export interface User {");
294
- expect(result).toContain("export const UserSchema: z.ZodType<User> =");
295
- expect(result).toContain("z.object({ name: z.string() })");
294
+ expect(result).toContain("export const UserSchema = z.object({ name: z.string() })");
296
295
  // Type assertion for compile-time verification
297
296
  expect(result).toContain("type _AssertUser = _AssertEqual<User, z.infer<typeof UserSchema>>;");
298
297
  });
@@ -320,11 +319,11 @@ describe("openApiToZodTsCode", () => {
320
319
  };
321
320
 
322
321
  const result = openApiToZodTsCode(openapi);
323
- // New format: interface + ZodType annotation
322
+ // New format: interface + schema without explicit type annotation
324
323
  expect(result).toContain("export interface User {");
325
324
  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> =");
325
+ expect(result).toContain("export const UserSchema = z.object({ name: z.string() })");
326
+ expect(result).toContain("export const ProductSchema = z.object({ id: z.number() })");
328
327
  });
329
328
 
330
329
  it("should include file header comment", () => {