@alt-stack/zod-openapi 1.1.2 → 1.1.3
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 +8 -8
- package/dist/index.cjs +243 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +243 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/schema-dedup.ts +199 -0
- package/src/to-typescript.spec.ts +218 -0
- package/src/to-typescript.ts +228 -34
|
@@ -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
|
|
package/src/to-typescript.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { topologicalSortSchemas } from "./dependencies";
|
|
2
|
+
import {
|
|
3
|
+
createSchemaRegistry,
|
|
4
|
+
findCommonSchemas,
|
|
5
|
+
getSchemaFingerprint,
|
|
6
|
+
preRegisterSchema,
|
|
7
|
+
registerSchema,
|
|
8
|
+
type SchemaRegistry,
|
|
9
|
+
} from "./schema-dedup";
|
|
2
10
|
import { convertSchemaToZodString } from "./to-zod";
|
|
3
11
|
import type { AnySchema } from "./types/types";
|
|
4
12
|
import {
|
|
@@ -39,12 +47,24 @@ function generateRouteSchemaName(
|
|
|
39
47
|
return parts.join("");
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Result of route schema generation including declarations and name mappings.
|
|
52
|
+
*/
|
|
53
|
+
interface RouteSchemaResult {
|
|
54
|
+
/** Schema declarations to be emitted */
|
|
55
|
+
declarations: string[];
|
|
56
|
+
/** Maps route-specific schema name to its canonical name (for deduplication) */
|
|
57
|
+
schemaNameToCanonical: Map<string, string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function generateRouteSchemas(
|
|
43
61
|
routes: RouteInfo[],
|
|
44
62
|
convertSchema: (schema: AnySchema) => string,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
63
|
+
registry: SchemaRegistry,
|
|
64
|
+
): RouteSchemaResult {
|
|
65
|
+
const declarations: string[] = [];
|
|
66
|
+
const schemaNameToCanonical = new Map<string, string>();
|
|
67
|
+
const generatedNames = new Set<string>();
|
|
48
68
|
|
|
49
69
|
for (const route of routes) {
|
|
50
70
|
const names = generateRouteSchemaNames(route);
|
|
@@ -52,27 +72,62 @@ function generateRouteSchemas(
|
|
|
52
72
|
const queryParams = route.parameters.filter((p) => p.in === "query");
|
|
53
73
|
const headerParams = route.parameters.filter((p) => p.in === "header");
|
|
54
74
|
|
|
75
|
+
// Generate params schema with deduplication
|
|
55
76
|
if (names.paramsSchemaName && pathParams.length > 0) {
|
|
56
|
-
|
|
57
|
-
|
|
77
|
+
const paramsSchema: AnySchema = {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: Object.fromEntries(
|
|
80
|
+
pathParams.map((p) => [p.name, p.schema]),
|
|
81
|
+
),
|
|
82
|
+
required: pathParams.filter((p) => p.required).map((p) => p.name),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const { isNew, canonicalName } = registerSchema(
|
|
86
|
+
registry,
|
|
87
|
+
names.paramsSchemaName,
|
|
88
|
+
paramsSchema,
|
|
89
|
+
);
|
|
90
|
+
schemaNameToCanonical.set(names.paramsSchemaName, canonicalName);
|
|
91
|
+
|
|
92
|
+
if (isNew && !generatedNames.has(names.paramsSchemaName)) {
|
|
93
|
+
generatedNames.add(names.paramsSchemaName);
|
|
58
94
|
const properties: string[] = [];
|
|
59
|
-
const required: string[] = [];
|
|
60
95
|
for (const param of pathParams) {
|
|
61
96
|
const zodExpr = convertSchema(param.schema);
|
|
62
97
|
properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
|
|
63
|
-
if (param.required) {
|
|
64
|
-
required.push(param.name);
|
|
65
|
-
}
|
|
66
98
|
}
|
|
67
|
-
|
|
99
|
+
declarations.push(
|
|
68
100
|
`export const ${names.paramsSchemaName} = z.object({ ${properties.join(", ")} });`,
|
|
69
101
|
);
|
|
102
|
+
} else if (!isNew && names.paramsSchemaName !== canonicalName) {
|
|
103
|
+
if (!generatedNames.has(names.paramsSchemaName)) {
|
|
104
|
+
generatedNames.add(names.paramsSchemaName);
|
|
105
|
+
declarations.push(
|
|
106
|
+
`export const ${names.paramsSchemaName} = ${canonicalName};`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
70
109
|
}
|
|
71
110
|
}
|
|
72
111
|
|
|
112
|
+
// Generate query schema with deduplication
|
|
73
113
|
if (names.querySchemaName && queryParams.length > 0) {
|
|
74
|
-
|
|
75
|
-
|
|
114
|
+
const querySchema: AnySchema = {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: Object.fromEntries(
|
|
117
|
+
queryParams.map((p) => [p.name, p.schema]),
|
|
118
|
+
),
|
|
119
|
+
required: queryParams.filter((p) => p.required).map((p) => p.name),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const { isNew, canonicalName } = registerSchema(
|
|
123
|
+
registry,
|
|
124
|
+
names.querySchemaName,
|
|
125
|
+
querySchema,
|
|
126
|
+
);
|
|
127
|
+
schemaNameToCanonical.set(names.querySchemaName, canonicalName);
|
|
128
|
+
|
|
129
|
+
if (isNew && !generatedNames.has(names.querySchemaName)) {
|
|
130
|
+
generatedNames.add(names.querySchemaName);
|
|
76
131
|
const properties: string[] = [];
|
|
77
132
|
for (const param of queryParams) {
|
|
78
133
|
let zodExpr = convertSchema(param.schema);
|
|
@@ -81,15 +136,38 @@ function generateRouteSchemas(
|
|
|
81
136
|
}
|
|
82
137
|
properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
|
|
83
138
|
}
|
|
84
|
-
|
|
139
|
+
declarations.push(
|
|
85
140
|
`export const ${names.querySchemaName} = z.object({ ${properties.join(", ")} });`,
|
|
86
141
|
);
|
|
142
|
+
} else if (!isNew && names.querySchemaName !== canonicalName) {
|
|
143
|
+
if (!generatedNames.has(names.querySchemaName)) {
|
|
144
|
+
generatedNames.add(names.querySchemaName);
|
|
145
|
+
declarations.push(
|
|
146
|
+
`export const ${names.querySchemaName} = ${canonicalName};`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
87
149
|
}
|
|
88
150
|
}
|
|
89
151
|
|
|
152
|
+
// Generate headers schema with deduplication
|
|
90
153
|
if (names.headersSchemaName && headerParams.length > 0) {
|
|
91
|
-
|
|
92
|
-
|
|
154
|
+
const headersSchema: AnySchema = {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: Object.fromEntries(
|
|
157
|
+
headerParams.map((p) => [p.name, p.schema]),
|
|
158
|
+
),
|
|
159
|
+
required: headerParams.filter((p) => p.required).map((p) => p.name),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const { isNew, canonicalName } = registerSchema(
|
|
163
|
+
registry,
|
|
164
|
+
names.headersSchemaName,
|
|
165
|
+
headersSchema,
|
|
166
|
+
);
|
|
167
|
+
schemaNameToCanonical.set(names.headersSchemaName, canonicalName);
|
|
168
|
+
|
|
169
|
+
if (isNew && !generatedNames.has(names.headersSchemaName)) {
|
|
170
|
+
generatedNames.add(names.headersSchemaName);
|
|
93
171
|
const properties: string[] = [];
|
|
94
172
|
for (const param of headerParams) {
|
|
95
173
|
let zodExpr = convertSchema(param.schema);
|
|
@@ -98,21 +176,43 @@ function generateRouteSchemas(
|
|
|
98
176
|
}
|
|
99
177
|
properties.push(`${quotePropertyName(param.name)}: ${zodExpr}`);
|
|
100
178
|
}
|
|
101
|
-
|
|
179
|
+
declarations.push(
|
|
102
180
|
`export const ${names.headersSchemaName} = z.object({ ${properties.join(", ")} });`,
|
|
103
181
|
);
|
|
182
|
+
} else if (!isNew && names.headersSchemaName !== canonicalName) {
|
|
183
|
+
if (!generatedNames.has(names.headersSchemaName)) {
|
|
184
|
+
generatedNames.add(names.headersSchemaName);
|
|
185
|
+
declarations.push(
|
|
186
|
+
`export const ${names.headersSchemaName} = ${canonicalName};`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
104
189
|
}
|
|
105
190
|
}
|
|
106
191
|
|
|
192
|
+
// Generate body schema with deduplication
|
|
107
193
|
if (names.bodySchemaName && route.requestBody) {
|
|
108
|
-
|
|
109
|
-
|
|
194
|
+
const { isNew, canonicalName } = registerSchema(
|
|
195
|
+
registry,
|
|
196
|
+
names.bodySchemaName,
|
|
197
|
+
route.requestBody,
|
|
198
|
+
);
|
|
199
|
+
schemaNameToCanonical.set(names.bodySchemaName, canonicalName);
|
|
200
|
+
|
|
201
|
+
if (isNew && !generatedNames.has(names.bodySchemaName)) {
|
|
202
|
+
generatedNames.add(names.bodySchemaName);
|
|
110
203
|
const zodExpr = convertSchema(route.requestBody);
|
|
111
|
-
|
|
204
|
+
declarations.push(`export const ${names.bodySchemaName} = ${zodExpr};`);
|
|
205
|
+
} else if (!isNew && names.bodySchemaName !== canonicalName) {
|
|
206
|
+
if (!generatedNames.has(names.bodySchemaName)) {
|
|
207
|
+
generatedNames.add(names.bodySchemaName);
|
|
208
|
+
declarations.push(
|
|
209
|
+
`export const ${names.bodySchemaName} = ${canonicalName};`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
112
212
|
}
|
|
113
213
|
}
|
|
114
214
|
|
|
115
|
-
// Generate schemas for ALL status codes
|
|
215
|
+
// Generate schemas for ALL status codes with deduplication
|
|
116
216
|
for (const [statusCode, responseSchema] of Object.entries(
|
|
117
217
|
route.responses,
|
|
118
218
|
)) {
|
|
@@ -128,18 +228,35 @@ function generateRouteSchemas(
|
|
|
128
228
|
suffix,
|
|
129
229
|
);
|
|
130
230
|
|
|
131
|
-
|
|
132
|
-
|
|
231
|
+
const { isNew, canonicalName } = registerSchema(
|
|
232
|
+
registry,
|
|
233
|
+
responseSchemaName,
|
|
234
|
+
responseSchema,
|
|
235
|
+
);
|
|
236
|
+
schemaNameToCanonical.set(responseSchemaName, canonicalName);
|
|
237
|
+
|
|
238
|
+
if (isNew && !generatedNames.has(responseSchemaName)) {
|
|
239
|
+
generatedNames.add(responseSchemaName);
|
|
133
240
|
const zodExpr = convertSchema(responseSchema);
|
|
134
|
-
|
|
241
|
+
declarations.push(`export const ${responseSchemaName} = ${zodExpr};`);
|
|
242
|
+
} else if (!isNew && responseSchemaName !== canonicalName) {
|
|
243
|
+
if (!generatedNames.has(responseSchemaName)) {
|
|
244
|
+
generatedNames.add(responseSchemaName);
|
|
245
|
+
declarations.push(
|
|
246
|
+
`export const ${responseSchemaName} = ${canonicalName};`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
135
249
|
}
|
|
136
250
|
}
|
|
137
251
|
}
|
|
138
252
|
|
|
139
|
-
return
|
|
253
|
+
return { declarations, schemaNameToCanonical };
|
|
140
254
|
}
|
|
141
255
|
|
|
142
|
-
function generateRequestResponseObjects(
|
|
256
|
+
function generateRequestResponseObjects(
|
|
257
|
+
routes: RouteInfo[],
|
|
258
|
+
schemaNameToCanonical: Map<string, string>,
|
|
259
|
+
): string[] {
|
|
143
260
|
const lines: string[] = [];
|
|
144
261
|
const requestPaths: Record<string, Record<string, string[]>> = {};
|
|
145
262
|
const responsePaths: Record<
|
|
@@ -147,6 +264,14 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
|
|
|
147
264
|
Record<string, Record<string, string>>
|
|
148
265
|
> = {};
|
|
149
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Resolves a schema name to its canonical name if it exists,
|
|
269
|
+
* otherwise returns the original name.
|
|
270
|
+
*/
|
|
271
|
+
const resolveSchemaName = (name: string): string => {
|
|
272
|
+
return schemaNameToCanonical.get(name) ?? name;
|
|
273
|
+
};
|
|
274
|
+
|
|
150
275
|
for (const route of routes) {
|
|
151
276
|
const names = generateRouteSchemaNames(route);
|
|
152
277
|
const pathParams = route.parameters.filter((p) => p.in === "path");
|
|
@@ -163,16 +288,20 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
|
|
|
163
288
|
|
|
164
289
|
const requestParts: string[] = [];
|
|
165
290
|
if (names.paramsSchemaName && pathParams.length > 0) {
|
|
166
|
-
requestParts.push(
|
|
291
|
+
requestParts.push(
|
|
292
|
+
`params: ${resolveSchemaName(names.paramsSchemaName)}`,
|
|
293
|
+
);
|
|
167
294
|
}
|
|
168
295
|
if (names.querySchemaName && queryParams.length > 0) {
|
|
169
|
-
requestParts.push(`query: ${names.querySchemaName}`);
|
|
296
|
+
requestParts.push(`query: ${resolveSchemaName(names.querySchemaName)}`);
|
|
170
297
|
}
|
|
171
298
|
if (names.headersSchemaName && headerParams.length > 0) {
|
|
172
|
-
requestParts.push(
|
|
299
|
+
requestParts.push(
|
|
300
|
+
`headers: ${resolveSchemaName(names.headersSchemaName)}`,
|
|
301
|
+
);
|
|
173
302
|
}
|
|
174
303
|
if (names.bodySchemaName && route.requestBody) {
|
|
175
|
-
requestParts.push(`body: ${names.bodySchemaName}`);
|
|
304
|
+
requestParts.push(`body: ${resolveSchemaName(names.bodySchemaName)}`);
|
|
176
305
|
}
|
|
177
306
|
|
|
178
307
|
if (requestParts.length > 0) {
|
|
@@ -202,7 +331,9 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
|
|
|
202
331
|
route.method,
|
|
203
332
|
suffix,
|
|
204
333
|
);
|
|
205
|
-
|
|
334
|
+
// Use canonical name for the Response object
|
|
335
|
+
responseMethodObj[route.method]![statusCode] =
|
|
336
|
+
resolveSchemaName(responseSchemaName);
|
|
206
337
|
}
|
|
207
338
|
}
|
|
208
339
|
|
|
@@ -246,6 +377,37 @@ function generateRequestResponseObjects(routes: RouteInfo[]): string[] {
|
|
|
246
377
|
return lines;
|
|
247
378
|
}
|
|
248
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Collects all response schemas from routes for common schema detection.
|
|
382
|
+
*/
|
|
383
|
+
function collectRouteSchemas(
|
|
384
|
+
routes: RouteInfo[],
|
|
385
|
+
): Array<{ name: string; schema: AnySchema }> {
|
|
386
|
+
const collected: Array<{ name: string; schema: AnySchema }> = [];
|
|
387
|
+
|
|
388
|
+
for (const route of routes) {
|
|
389
|
+
for (const [statusCode, responseSchema] of Object.entries(
|
|
390
|
+
route.responses,
|
|
391
|
+
)) {
|
|
392
|
+
if (!responseSchema) continue;
|
|
393
|
+
|
|
394
|
+
const isSuccess = statusCode.startsWith("2");
|
|
395
|
+
const suffix = isSuccess
|
|
396
|
+
? `${statusCode}Response`
|
|
397
|
+
: `${statusCode}ErrorResponse`;
|
|
398
|
+
const responseSchemaName = generateRouteSchemaName(
|
|
399
|
+
route.path,
|
|
400
|
+
route.method,
|
|
401
|
+
suffix,
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
collected.push({ name: responseSchemaName, schema: responseSchema });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return collected;
|
|
409
|
+
}
|
|
410
|
+
|
|
249
411
|
export const openApiToZodTsCode = (
|
|
250
412
|
openapi: Record<string, unknown>,
|
|
251
413
|
customImportLines?: string[],
|
|
@@ -267,6 +429,9 @@ export const openApiToZodTsCode = (
|
|
|
267
429
|
lines.push(...(customImportLines ?? []));
|
|
268
430
|
lines.push("");
|
|
269
431
|
|
|
432
|
+
// Create registry for schema deduplication
|
|
433
|
+
const registry = createSchemaRegistry();
|
|
434
|
+
|
|
270
435
|
const sortedSchemaNames = topologicalSortSchemas(schemas);
|
|
271
436
|
|
|
272
437
|
for (const name of sortedSchemaNames) {
|
|
@@ -278,20 +443,49 @@ export const openApiToZodTsCode = (
|
|
|
278
443
|
lines.push(`export const ${schemaName} = ${zodExpr};`);
|
|
279
444
|
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>;`);
|
|
280
445
|
lines.push("");
|
|
446
|
+
|
|
447
|
+
// Register component schemas so they can be referenced by route schemas
|
|
448
|
+
const fingerprint = getSchemaFingerprint(schema);
|
|
449
|
+
preRegisterSchema(registry, schemaName, fingerprint);
|
|
281
450
|
}
|
|
282
451
|
}
|
|
283
452
|
|
|
284
453
|
if (options?.includeRoutes) {
|
|
285
454
|
const routes = parseOpenApiPaths(openapi);
|
|
286
455
|
if (routes.length > 0) {
|
|
287
|
-
|
|
456
|
+
// Find common schemas that appear multiple times (for error responses, etc.)
|
|
457
|
+
const routeSchemaList = collectRouteSchemas(routes);
|
|
458
|
+
const commonSchemas = findCommonSchemas(routeSchemaList, 2);
|
|
459
|
+
|
|
460
|
+
// Generate common schemas first (e.g., UnauthorizedErrorSchema, NotFoundErrorSchema)
|
|
461
|
+
if (commonSchemas.length > 0) {
|
|
462
|
+
lines.push("// Common Error Schemas (deduplicated)");
|
|
463
|
+
for (const common of commonSchemas) {
|
|
464
|
+
const zodExpr = convertSchemaToZodString(common.schema);
|
|
465
|
+
lines.push(`export const ${common.name} = ${zodExpr};`);
|
|
466
|
+
// Pre-register so route schemas reference this instead of duplicating
|
|
467
|
+
preRegisterSchema(registry, common.name, common.fingerprint);
|
|
468
|
+
}
|
|
469
|
+
lines.push("");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Generate route schemas with deduplication
|
|
473
|
+
const { declarations, schemaNameToCanonical } = generateRouteSchemas(
|
|
288
474
|
routes,
|
|
289
475
|
convertSchemaToZodString,
|
|
476
|
+
registry,
|
|
290
477
|
);
|
|
291
|
-
|
|
292
|
-
|
|
478
|
+
|
|
479
|
+
if (declarations.length > 0) {
|
|
480
|
+
lines.push("// Route Schemas");
|
|
481
|
+
lines.push(...declarations);
|
|
293
482
|
lines.push("");
|
|
294
|
-
|
|
483
|
+
|
|
484
|
+
// Generate Request/Response objects using canonical names
|
|
485
|
+
const requestResponseObjs = generateRequestResponseObjects(
|
|
486
|
+
routes,
|
|
487
|
+
schemaNameToCanonical,
|
|
488
|
+
);
|
|
295
489
|
lines.push(...requestResponseObjs);
|
|
296
490
|
}
|
|
297
491
|
}
|