@alt-stack/zod-openapi 1.1.3 → 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.
@@ -0,0 +1,211 @@
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 type { AnySchema } from "./types/types";
9
+
10
+ const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
11
+
12
+ function quotePropertyName(name: string): string {
13
+ return validIdentifierRegex.test(name) ? name : `'${name}'`;
14
+ }
15
+
16
+ /**
17
+ * Converts an OpenAPI schema to a TypeScript type string.
18
+ *
19
+ * @example
20
+ * schemaToTypeString({ type: 'string' }) // => 'string'
21
+ * schemaToTypeString({ type: 'object', properties: { id: { type: 'string' } } }) // => '{ id?: string }'
22
+ */
23
+ export function schemaToTypeString(schema: AnySchema): string {
24
+ if (!schema || typeof schema !== "object") return "unknown";
25
+
26
+ // Handle $ref
27
+ if (schema["$ref"] && typeof schema["$ref"] === "string") {
28
+ const match = (schema["$ref"] as string).match(
29
+ /#\/components\/schemas\/(.+)/,
30
+ );
31
+ let result = "unknown";
32
+ if (match && match[1]) {
33
+ // Decode URI-encoded schema names (e.g., %20 -> space)
34
+ result = decodeURIComponent(match[1]);
35
+ }
36
+ if (schema["nullable"] === true) {
37
+ result = `(${result} | null)`;
38
+ }
39
+ return result;
40
+ }
41
+
42
+ let result: string = "unknown";
43
+
44
+ // Handle oneOf (union)
45
+ if ("oneOf" in schema && Array.isArray(schema["oneOf"])) {
46
+ const unionMembers = (schema["oneOf"] as AnySchema[]).map((s) =>
47
+ schemaToTypeString(s),
48
+ );
49
+ result = unionMembers.length > 1 ? `(${unionMembers.join(" | ")})` : unionMembers[0] ?? "unknown";
50
+ }
51
+ // Handle allOf (intersection)
52
+ else if ("allOf" in schema && Array.isArray(schema["allOf"])) {
53
+ const intersectionMembers = (schema["allOf"] as AnySchema[]).map((s) =>
54
+ schemaToTypeString(s),
55
+ );
56
+ result = intersectionMembers.length > 1
57
+ ? `(${intersectionMembers.join(" & ")})`
58
+ : intersectionMembers[0] ?? "unknown";
59
+ }
60
+ // Handle anyOf (union, similar to oneOf)
61
+ else if ("anyOf" in schema && Array.isArray(schema["anyOf"])) {
62
+ const unionMembers = (schema["anyOf"] as AnySchema[]).map((s) =>
63
+ schemaToTypeString(s),
64
+ );
65
+ result = unionMembers.length > 1 ? `(${unionMembers.join(" | ")})` : unionMembers[0] ?? "unknown";
66
+ }
67
+ // Handle type-based schemas
68
+ else {
69
+ switch (schema["type"]) {
70
+ case "string":
71
+ if (schema["enum"] && Array.isArray(schema["enum"])) {
72
+ // String enum
73
+ result = (schema["enum"] as string[])
74
+ .map((v) => JSON.stringify(v))
75
+ .join(" | ");
76
+ } else {
77
+ result = "string";
78
+ }
79
+ break;
80
+ case "number":
81
+ case "integer":
82
+ if (schema["enum"] && Array.isArray(schema["enum"])) {
83
+ // Numeric enum
84
+ result = (schema["enum"] as number[]).map((v) => String(v)).join(" | ");
85
+ } else {
86
+ result = "number";
87
+ }
88
+ break;
89
+ case "boolean":
90
+ result = "boolean";
91
+ break;
92
+ case "null":
93
+ result = "null";
94
+ break;
95
+ case "array":
96
+ if (schema["items"]) {
97
+ const itemType = schemaToTypeString(schema["items"] as AnySchema);
98
+ result = `Array<${itemType}>`;
99
+ } else {
100
+ result = "unknown[]";
101
+ }
102
+ break;
103
+ case "object":
104
+ result = objectSchemaToTypeString(schema);
105
+ break;
106
+ default:
107
+ // Try to detect object from properties
108
+ if (schema["properties"]) {
109
+ result = objectSchemaToTypeString(schema);
110
+ } else if (schema["enum"] && Array.isArray(schema["enum"])) {
111
+ // Untyped enum
112
+ result = (schema["enum"] as unknown[])
113
+ .map((v) => JSON.stringify(v))
114
+ .join(" | ");
115
+ } else {
116
+ result = "unknown";
117
+ }
118
+ break;
119
+ }
120
+ }
121
+
122
+ // Handle nullable
123
+ if (schema["nullable"] === true) {
124
+ result = `(${result} | null)`;
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Converts an OpenAPI object schema to a TypeScript object type string.
132
+ */
133
+ function objectSchemaToTypeString(schema: AnySchema): string {
134
+ const properties = schema["properties"] as Record<string, AnySchema> | undefined;
135
+ const required = new Set((schema["required"] as string[]) ?? []);
136
+ const additionalProperties = schema["additionalProperties"];
137
+
138
+ if (!properties && !additionalProperties) {
139
+ return "Record<string, unknown>";
140
+ }
141
+
142
+ const propertyStrings: string[] = [];
143
+
144
+ if (properties) {
145
+ for (const [propName, propSchema] of Object.entries(properties)) {
146
+ const isRequired = required.has(propName);
147
+ const propType = schemaToTypeString(propSchema);
148
+ const quotedName = quotePropertyName(propName);
149
+ propertyStrings.push(
150
+ `${quotedName}${isRequired ? "" : "?"}: ${propType}`,
151
+ );
152
+ }
153
+ }
154
+
155
+ // Handle additionalProperties
156
+ if (additionalProperties === true) {
157
+ propertyStrings.push("[key: string]: unknown");
158
+ } else if (
159
+ typeof additionalProperties === "object" &&
160
+ additionalProperties !== null
161
+ ) {
162
+ const additionalType = schemaToTypeString(additionalProperties as AnySchema);
163
+ propertyStrings.push(`[key: string]: ${additionalType}`);
164
+ }
165
+
166
+ return `{ ${propertyStrings.join("; ")} }`;
167
+ }
168
+
169
+ /**
170
+ * Generates a full TypeScript interface declaration.
171
+ *
172
+ * @example
173
+ * generateInterface('User', { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] })
174
+ * // => 'export interface User { id: string; }'
175
+ */
176
+ export function generateInterface(name: string, schema: AnySchema): string {
177
+ const properties = schema["properties"] as Record<string, AnySchema> | undefined;
178
+ const required = new Set((schema["required"] as string[]) ?? []);
179
+
180
+ // For non-object types, use type alias instead of interface
181
+ if (schema["type"] !== "object" && !properties) {
182
+ return `export type ${name} = ${schemaToTypeString(schema)};`;
183
+ }
184
+
185
+ const lines: string[] = [];
186
+ lines.push(`export interface ${name} {`);
187
+
188
+ if (properties) {
189
+ for (const [propName, propSchema] of Object.entries(properties)) {
190
+ const isRequired = required.has(propName);
191
+ const propType = schemaToTypeString(propSchema);
192
+ const quotedName = quotePropertyName(propName);
193
+ lines.push(` ${quotedName}${isRequired ? "" : "?"}: ${propType};`);
194
+ }
195
+ }
196
+
197
+ // Handle additionalProperties
198
+ const additionalProperties = schema["additionalProperties"];
199
+ if (additionalProperties === true) {
200
+ lines.push(" [key: string]: unknown;");
201
+ } else if (
202
+ typeof additionalProperties === "object" &&
203
+ additionalProperties !== null
204
+ ) {
205
+ const additionalType = schemaToTypeString(additionalProperties as AnySchema);
206
+ lines.push(` [key: string]: ${additionalType};`);
207
+ }
208
+
209
+ lines.push("}");
210
+ return lines.join("\n");
211
+ }
@@ -14,6 +14,7 @@ import {
14
14
  generateRouteSchemaNames,
15
15
  type RouteInfo,
16
16
  } from "./routes";
17
+ import { generateInterface, schemaToTypeString } from "./interface-generator";
17
18
 
18
19
  const validIdentifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
19
20
 
@@ -429,27 +430,49 @@ 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
+
437
446
  for (const name of sortedSchemaNames) {
438
447
  const schema = schemas[name];
439
448
  if (schema) {
440
449
  const zodExpr = convertSchemaToZodString(schema);
441
450
  const schemaName = `${name}Schema`;
442
451
  const typeName = name;
443
- lines.push(`export const ${schemaName} = ${zodExpr};`);
444
- 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};`);
445
458
  lines.push("");
446
459
 
460
+ // Add type assertion to verify interface matches schema
461
+ typeAssertions.push(`type _Assert${typeName} = _AssertEqual<${typeName}, z.infer<typeof ${schemaName}>>;`);
462
+
447
463
  // Register component schemas so they can be referenced by route schemas
448
464
  const fingerprint = getSchemaFingerprint(schema);
449
465
  preRegisterSchema(registry, schemaName, fingerprint);
450
466
  }
451
467
  }
452
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
+
453
476
  if (options?.includeRoutes) {
454
477
  const routes = parseOpenApiPaths(openapi);
455
478
  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", () => {