@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.
@@ -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