@alt-stack/zod-openapi 1.1.2 → 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 +404 -42
- 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 +401 -41
- 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/schema-dedup.ts +199 -0
- package/src/to-typescript.spec.ts +218 -0
- package/src/to-typescript.ts +253 -36
- 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
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { AnySchema } from "./types/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema deduplication utilities for optimizing generated TypeScript types.
|
|
5
|
+
*
|
|
6
|
+
* This module provides fingerprinting and registry functionality to detect
|
|
7
|
+
* structurally identical schemas and generate them only once, reducing
|
|
8
|
+
* memory usage in consuming TypeScript projects.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recursively sorts an object's keys to create a stable representation.
|
|
13
|
+
* This ensures that {a: 1, b: 2} and {b: 2, a: 1} produce the same fingerprint.
|
|
14
|
+
*/
|
|
15
|
+
function sortObjectDeep(obj: unknown): unknown {
|
|
16
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
17
|
+
if (Array.isArray(obj)) return obj.map(sortObjectDeep);
|
|
18
|
+
|
|
19
|
+
const sorted: Record<string, unknown> = {};
|
|
20
|
+
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
|
21
|
+
for (const key of keys) {
|
|
22
|
+
sorted[key] = sortObjectDeep((obj as Record<string, unknown>)[key]);
|
|
23
|
+
}
|
|
24
|
+
return sorted;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generates a canonical fingerprint for an OpenAPI schema.
|
|
29
|
+
* Identical schemas will produce identical fingerprints.
|
|
30
|
+
*/
|
|
31
|
+
export function getSchemaFingerprint(schema: AnySchema): string {
|
|
32
|
+
return JSON.stringify(sortObjectDeep(schema));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Registry for tracking unique schemas and their canonical names.
|
|
37
|
+
*/
|
|
38
|
+
export interface SchemaRegistry {
|
|
39
|
+
/** Map from fingerprint to the first schema name that used it */
|
|
40
|
+
fingerprintToName: Map<string, string>;
|
|
41
|
+
/** Map from schema name to its fingerprint (for reverse lookup) */
|
|
42
|
+
nameToFingerprint: Map<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a new empty schema registry.
|
|
47
|
+
*/
|
|
48
|
+
export function createSchemaRegistry(): SchemaRegistry {
|
|
49
|
+
return {
|
|
50
|
+
fingerprintToName: new Map(),
|
|
51
|
+
nameToFingerprint: new Map(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Result of registering a schema.
|
|
57
|
+
*/
|
|
58
|
+
export interface RegisterSchemaResult {
|
|
59
|
+
/** Whether this is a new unique schema */
|
|
60
|
+
isNew: boolean;
|
|
61
|
+
/** The canonical name for this schema (may be different from input name if duplicate) */
|
|
62
|
+
canonicalName: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registers a schema in the registry. If an identical schema already exists,
|
|
67
|
+
* returns the existing canonical name instead.
|
|
68
|
+
*/
|
|
69
|
+
export function registerSchema(
|
|
70
|
+
registry: SchemaRegistry,
|
|
71
|
+
name: string,
|
|
72
|
+
schema: AnySchema,
|
|
73
|
+
): RegisterSchemaResult {
|
|
74
|
+
const fingerprint = getSchemaFingerprint(schema);
|
|
75
|
+
|
|
76
|
+
const existing = registry.fingerprintToName.get(fingerprint);
|
|
77
|
+
if (existing) {
|
|
78
|
+
return { isNew: false, canonicalName: existing };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
registry.fingerprintToName.set(fingerprint, name);
|
|
82
|
+
registry.nameToFingerprint.set(name, fingerprint);
|
|
83
|
+
return { isNew: true, canonicalName: name };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pre-registers a schema with a specific fingerprint.
|
|
88
|
+
* Used for common schemas that should take priority.
|
|
89
|
+
*/
|
|
90
|
+
export function preRegisterSchema(
|
|
91
|
+
registry: SchemaRegistry,
|
|
92
|
+
name: string,
|
|
93
|
+
fingerprint: string,
|
|
94
|
+
): void {
|
|
95
|
+
registry.fingerprintToName.set(fingerprint, name);
|
|
96
|
+
registry.nameToFingerprint.set(name, fingerprint);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extracts the error code from an OpenAPI error schema.
|
|
101
|
+
* Looks for patterns like: { error: { code: enum(['UNAUTHORIZED']) } }
|
|
102
|
+
*/
|
|
103
|
+
export function extractErrorCode(schema: AnySchema): string | null {
|
|
104
|
+
const properties = schema?.["properties"] as Record<string, AnySchema> | undefined;
|
|
105
|
+
const errorObj = properties?.["error"] as AnySchema | undefined;
|
|
106
|
+
const errorProps = errorObj?.["properties"] as Record<string, AnySchema> | undefined;
|
|
107
|
+
const codeSchema = errorProps?.["code"] as AnySchema | undefined;
|
|
108
|
+
const codeEnum = codeSchema?.["enum"] as string[] | undefined;
|
|
109
|
+
|
|
110
|
+
if (Array.isArray(codeEnum) && codeEnum.length === 1) {
|
|
111
|
+
return codeEnum[0]!;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Converts an error code like UNAUTHORIZED or NOT_FOUND to PascalCase.
|
|
118
|
+
* UNAUTHORIZED -> Unauthorized
|
|
119
|
+
* NOT_FOUND -> NotFound
|
|
120
|
+
*/
|
|
121
|
+
export function errorCodeToPascalCase(code: string): string {
|
|
122
|
+
return code
|
|
123
|
+
.split("_")
|
|
124
|
+
.map((part) => part.charAt(0) + part.slice(1).toLowerCase())
|
|
125
|
+
.join("");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generates a common error schema name from an error code.
|
|
130
|
+
* UNAUTHORIZED -> UnauthorizedErrorSchema
|
|
131
|
+
*/
|
|
132
|
+
export function generateCommonErrorSchemaName(errorCode: string): string {
|
|
133
|
+
return `${errorCodeToPascalCase(errorCode)}ErrorSchema`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Represents a common schema that appears multiple times.
|
|
138
|
+
*/
|
|
139
|
+
export interface CommonSchema {
|
|
140
|
+
/** The canonical name for this schema */
|
|
141
|
+
name: string;
|
|
142
|
+
/** The schema definition */
|
|
143
|
+
schema: AnySchema;
|
|
144
|
+
/** The fingerprint for deduplication */
|
|
145
|
+
fingerprint: string;
|
|
146
|
+
/** Number of times this schema appears */
|
|
147
|
+
count: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Scans schemas and identifies those that appear multiple times.
|
|
152
|
+
* Returns common schemas sorted by count (most common first).
|
|
153
|
+
*/
|
|
154
|
+
export function findCommonSchemas(
|
|
155
|
+
schemas: Array<{ name: string; schema: AnySchema }>,
|
|
156
|
+
minCount: number = 2,
|
|
157
|
+
): CommonSchema[] {
|
|
158
|
+
const fingerprints = new Map<
|
|
159
|
+
string,
|
|
160
|
+
{ schema: AnySchema; names: string[]; errorCode: string | null }
|
|
161
|
+
>();
|
|
162
|
+
|
|
163
|
+
// Count occurrences of each unique schema
|
|
164
|
+
for (const { name, schema } of schemas) {
|
|
165
|
+
const fingerprint = getSchemaFingerprint(schema);
|
|
166
|
+
const existing = fingerprints.get(fingerprint);
|
|
167
|
+
|
|
168
|
+
if (existing) {
|
|
169
|
+
existing.names.push(name);
|
|
170
|
+
} else {
|
|
171
|
+
fingerprints.set(fingerprint, {
|
|
172
|
+
schema,
|
|
173
|
+
names: [name],
|
|
174
|
+
errorCode: extractErrorCode(schema),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Filter to schemas appearing minCount+ times
|
|
180
|
+
const commonSchemas: CommonSchema[] = [];
|
|
181
|
+
for (const [fingerprint, data] of fingerprints) {
|
|
182
|
+
if (data.names.length >= minCount) {
|
|
183
|
+
// Generate a semantic name if it's an error schema, otherwise use first occurrence
|
|
184
|
+
const name = data.errorCode
|
|
185
|
+
? generateCommonErrorSchemaName(data.errorCode)
|
|
186
|
+
: data.names[0]!;
|
|
187
|
+
|
|
188
|
+
commonSchemas.push({
|
|
189
|
+
name,
|
|
190
|
+
schema: data.schema,
|
|
191
|
+
fingerprint,
|
|
192
|
+
count: data.names.length,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sort by count descending (most common first)
|
|
198
|
+
return commonSchemas.sort((a, b) => b.count - a.count);
|
|
199
|
+
}
|
|
@@ -522,5 +522,223 @@ describe("openApiToZodTsCode with routes", () => {
|
|
|
522
522
|
expect(result).toContain("'/users/{id}':");
|
|
523
523
|
});
|
|
524
524
|
});
|
|
525
|
+
|
|
526
|
+
describe("schema deduplication", () => {
|
|
527
|
+
it("should deduplicate identical error responses across endpoints", () => {
|
|
528
|
+
const unauthorizedError = {
|
|
529
|
+
type: "object",
|
|
530
|
+
properties: {
|
|
531
|
+
error: {
|
|
532
|
+
type: "object",
|
|
533
|
+
properties: {
|
|
534
|
+
code: { type: "string", enum: ["UNAUTHORIZED"] },
|
|
535
|
+
message: { type: "string" },
|
|
536
|
+
},
|
|
537
|
+
required: ["code", "message"],
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
required: ["error"],
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const openapi = {
|
|
544
|
+
components: { schemas: {} },
|
|
545
|
+
paths: {
|
|
546
|
+
"/users": {
|
|
547
|
+
get: {
|
|
548
|
+
responses: {
|
|
549
|
+
"200": {
|
|
550
|
+
content: {
|
|
551
|
+
"application/json": {
|
|
552
|
+
schema: { type: "object", properties: {} },
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
"401": {
|
|
557
|
+
content: {
|
|
558
|
+
"application/json": { schema: unauthorizedError },
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
post: {
|
|
564
|
+
responses: {
|
|
565
|
+
"200": {
|
|
566
|
+
content: {
|
|
567
|
+
"application/json": {
|
|
568
|
+
schema: { type: "object", properties: {} },
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
"401": {
|
|
573
|
+
content: {
|
|
574
|
+
"application/json": { schema: unauthorizedError },
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
"/items": {
|
|
581
|
+
get: {
|
|
582
|
+
responses: {
|
|
583
|
+
"200": {
|
|
584
|
+
content: {
|
|
585
|
+
"application/json": {
|
|
586
|
+
schema: { type: "object", properties: {} },
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
"401": {
|
|
591
|
+
content: {
|
|
592
|
+
"application/json": { schema: unauthorizedError },
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const result = openApiToZodTsCode(openapi, undefined, {
|
|
602
|
+
includeRoutes: true,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Should generate a common error schema
|
|
606
|
+
expect(result).toContain("// Common Error Schemas (deduplicated)");
|
|
607
|
+
expect(result).toContain("UnauthorizedErrorSchema");
|
|
608
|
+
|
|
609
|
+
// Route-specific schemas should reference the common schema
|
|
610
|
+
expect(result).toContain(
|
|
611
|
+
"export const GetUsers401ErrorResponse = UnauthorizedErrorSchema;",
|
|
612
|
+
);
|
|
613
|
+
expect(result).toContain(
|
|
614
|
+
"export const PostUsers401ErrorResponse = UnauthorizedErrorSchema;",
|
|
615
|
+
);
|
|
616
|
+
expect(result).toContain(
|
|
617
|
+
"export const GetItems401ErrorResponse = UnauthorizedErrorSchema;",
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Response object should reference the canonical schema
|
|
621
|
+
expect(result).toContain("'401': UnauthorizedErrorSchema");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("should deduplicate identical success responses across endpoints", () => {
|
|
625
|
+
const userSchema = {
|
|
626
|
+
type: "object",
|
|
627
|
+
properties: {
|
|
628
|
+
id: { type: "string" },
|
|
629
|
+
name: { type: "string" },
|
|
630
|
+
},
|
|
631
|
+
required: ["id", "name"],
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const openapi = {
|
|
635
|
+
components: { schemas: {} },
|
|
636
|
+
paths: {
|
|
637
|
+
"/users/{id}": {
|
|
638
|
+
get: {
|
|
639
|
+
parameters: [
|
|
640
|
+
{
|
|
641
|
+
name: "id",
|
|
642
|
+
in: "path",
|
|
643
|
+
required: true,
|
|
644
|
+
schema: { type: "string" },
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
responses: {
|
|
648
|
+
"200": {
|
|
649
|
+
content: {
|
|
650
|
+
"application/json": { schema: userSchema },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
put: {
|
|
656
|
+
parameters: [
|
|
657
|
+
{
|
|
658
|
+
name: "id",
|
|
659
|
+
in: "path",
|
|
660
|
+
required: true,
|
|
661
|
+
schema: { type: "string" },
|
|
662
|
+
},
|
|
663
|
+
],
|
|
664
|
+
responses: {
|
|
665
|
+
"200": {
|
|
666
|
+
content: {
|
|
667
|
+
"application/json": { schema: userSchema },
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const result = openApiToZodTsCode(openapi, undefined, {
|
|
677
|
+
includeRoutes: true,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// The first occurrence becomes the canonical schema
|
|
681
|
+
expect(result).toContain("export const GetUsersId200Response =");
|
|
682
|
+
|
|
683
|
+
// The second should be an alias to the first
|
|
684
|
+
expect(result).toContain(
|
|
685
|
+
"export const PutUsersId200Response = GetUsersId200Response;",
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("should not deduplicate different schemas", () => {
|
|
690
|
+
const openapi = {
|
|
691
|
+
components: { schemas: {} },
|
|
692
|
+
paths: {
|
|
693
|
+
"/users": {
|
|
694
|
+
get: {
|
|
695
|
+
responses: {
|
|
696
|
+
"200": {
|
|
697
|
+
content: {
|
|
698
|
+
"application/json": {
|
|
699
|
+
schema: {
|
|
700
|
+
type: "object",
|
|
701
|
+
properties: { users: { type: "array" } },
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
"401": {
|
|
707
|
+
content: {
|
|
708
|
+
"application/json": {
|
|
709
|
+
schema: {
|
|
710
|
+
type: "object",
|
|
711
|
+
properties: {
|
|
712
|
+
error: {
|
|
713
|
+
type: "object",
|
|
714
|
+
properties: {
|
|
715
|
+
code: { type: "string", enum: ["UNAUTHORIZED"] },
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const result = openApiToZodTsCode(openapi, undefined, {
|
|
730
|
+
includeRoutes: true,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// Both should be separate schemas since they're different
|
|
734
|
+
expect(result).toContain("export const GetUsers200Response =");
|
|
735
|
+
expect(result).toContain("export const GetUsers401ErrorResponse =");
|
|
736
|
+
|
|
737
|
+
// They should not reference each other
|
|
738
|
+
expect(result).not.toContain(
|
|
739
|
+
"GetUsers401ErrorResponse = GetUsers200Response",
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
});
|
|
525
743
|
});
|
|
526
744
|
|