@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.
- package/.turbo/turbo-build.log +10 -10
- package/dist/index.cjs +229 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -3
- package/dist/index.d.ts +34 -3
- package/dist/index.js +226 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/interface-generator.spec.ts +668 -0
- package/src/interface-generator.ts +290 -0
- package/src/registry.ts +32 -2
- package/src/to-typescript.spec.ts +60 -0
- package/src/to-typescript.ts +39 -3
- package/src/to-zod.spec.ts +10 -6
|
@@ -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: {
|
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, 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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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) {
|
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", () => {
|