@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.
- package/.turbo/turbo-build.log +10 -10
- package/dist/index.cjs +161 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +158 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/interface-generator.spec.ts +595 -0
- package/src/interface-generator.ts +211 -0
- package/src/to-typescript.ts +25 -2
- package/src/to-zod.spec.ts +10 -6
|
@@ -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
|
+
}
|
package/src/to-typescript.ts
CHANGED
|
@@ -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
|
-
|
|
444
|
-
|
|
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) {
|
package/src/to-zod.spec.ts
CHANGED
|
@@ -289,9 +289,12 @@ describe("openApiToZodTsCode", () => {
|
|
|
289
289
|
|
|
290
290
|
const result = openApiToZodTsCode(openapi);
|
|
291
291
|
expect(result).toContain("import { z } from 'zod';");
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
expect(result).toContain("export
|
|
322
|
-
expect(result).toContain("export
|
|
323
|
-
expect(result).toContain("export
|
|
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", () => {
|