@alt-stack/zod-openapi 1.0.1

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,308 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { z } from "zod";
3
+ import {
4
+ registerZodSchemaToOpenApiSchema,
5
+ getSchemaExportedVariableNameForStringFormat,
6
+ clearZodSchemaToOpenApiSchemaRegistry,
7
+ schemaRegistry,
8
+ } from "./registry";
9
+
10
+ describe("ZodSchemaRegistry", () => {
11
+ beforeEach(() => {
12
+ clearZodSchemaToOpenApiSchemaRegistry();
13
+ });
14
+
15
+ describe("registerZodSchemaToOpenApiSchema", () => {
16
+ describe("string format registration", () => {
17
+ it("should register schema with single string format", () => {
18
+ const schema = z.string().email();
19
+ registerZodSchemaToOpenApiSchema(schema, {
20
+ schemaExportedVariableName: "emailSchema",
21
+ type: "string",
22
+ format: "email",
23
+ });
24
+
25
+ const result = getSchemaExportedVariableNameForStringFormat("email");
26
+ expect(result).toBe("emailSchema");
27
+ });
28
+
29
+ it("should register schema with multiple string formats", () => {
30
+ const schema = z.string();
31
+ registerZodSchemaToOpenApiSchema(schema, {
32
+ schemaExportedVariableName: "dateSchema",
33
+ type: "string",
34
+ formats: ["date", "iso-date"],
35
+ });
36
+
37
+ expect(getSchemaExportedVariableNameForStringFormat("date")).toBe(
38
+ "dateSchema",
39
+ );
40
+ expect(getSchemaExportedVariableNameForStringFormat("iso-date")).toBe(
41
+ "dateSchema",
42
+ );
43
+ });
44
+
45
+ it("should throw error when registering duplicate format", () => {
46
+ const schema1 = z.string().email();
47
+ const schema2 = z.string().email();
48
+
49
+ registerZodSchemaToOpenApiSchema(schema1, {
50
+ schemaExportedVariableName: "emailSchema1",
51
+ type: "string",
52
+ format: "email",
53
+ });
54
+
55
+ expect(() => {
56
+ registerZodSchemaToOpenApiSchema(schema2, {
57
+ schemaExportedVariableName: "emailSchema2",
58
+ type: "string",
59
+ format: "email",
60
+ });
61
+ }).toThrow("duplicate Zod OpenAPI registration");
62
+ });
63
+
64
+ it("should allow same schema to be registered multiple times", () => {
65
+ const schema = z.string().email();
66
+ registerZodSchemaToOpenApiSchema(schema, {
67
+ schemaExportedVariableName: "emailSchema",
68
+ type: "string",
69
+ format: "email",
70
+ });
71
+
72
+ expect(() => {
73
+ registerZodSchemaToOpenApiSchema(schema, {
74
+ schemaExportedVariableName: "emailSchema",
75
+ type: "string",
76
+ format: "email",
77
+ });
78
+ }).not.toThrow();
79
+ });
80
+ });
81
+
82
+ describe("primitive type registration", () => {
83
+ it("should register number schema", () => {
84
+ const schema = z.number();
85
+ registerZodSchemaToOpenApiSchema(schema, {
86
+ schemaExportedVariableName: "numberSchema",
87
+ type: "number",
88
+ });
89
+
90
+ expect(schemaRegistry.isRegistered(schema)).toBe(true);
91
+ });
92
+
93
+ it("should register integer schema", () => {
94
+ const schema = z.number().int();
95
+ registerZodSchemaToOpenApiSchema(schema, {
96
+ schemaExportedVariableName: "integerSchema",
97
+ type: "integer",
98
+ });
99
+
100
+ expect(schemaRegistry.isRegistered(schema)).toBe(true);
101
+ });
102
+
103
+ it("should register boolean schema", () => {
104
+ const schema = z.boolean();
105
+ registerZodSchemaToOpenApiSchema(schema, {
106
+ schemaExportedVariableName: "booleanSchema",
107
+ type: "boolean",
108
+ });
109
+
110
+ expect(schemaRegistry.isRegistered(schema)).toBe(true);
111
+ });
112
+ });
113
+
114
+ describe("multiple registrations", () => {
115
+ it("should register multiple schemas with different formats", () => {
116
+ const emailSchema = z.string().email();
117
+ const uuidSchema = z.string().uuid();
118
+ const dateSchema = z.string();
119
+
120
+ registerZodSchemaToOpenApiSchema(emailSchema, {
121
+ schemaExportedVariableName: "emailSchema",
122
+ type: "string",
123
+ format: "email",
124
+ });
125
+
126
+ registerZodSchemaToOpenApiSchema(uuidSchema, {
127
+ schemaExportedVariableName: "uuidSchema",
128
+ type: "string",
129
+ format: "uuid",
130
+ });
131
+
132
+ registerZodSchemaToOpenApiSchema(dateSchema, {
133
+ schemaExportedVariableName: "dateSchema",
134
+ type: "string",
135
+ formats: ["date", "date-time"],
136
+ });
137
+
138
+ expect(getSchemaExportedVariableNameForStringFormat("email")).toBe(
139
+ "emailSchema",
140
+ );
141
+ expect(getSchemaExportedVariableNameForStringFormat("uuid")).toBe(
142
+ "uuidSchema",
143
+ );
144
+ expect(getSchemaExportedVariableNameForStringFormat("date")).toBe(
145
+ "dateSchema",
146
+ );
147
+ expect(getSchemaExportedVariableNameForStringFormat("date-time")).toBe(
148
+ "dateSchema",
149
+ );
150
+ });
151
+ });
152
+ });
153
+
154
+ describe("getSchemaExportedVariableNameForStringFormat", () => {
155
+ it("should return undefined for unregistered format", () => {
156
+ const result = getSchemaExportedVariableNameForStringFormat("email");
157
+ expect(result).toBeUndefined();
158
+ });
159
+
160
+ it("should return variable name for registered single format", () => {
161
+ const schema = z.string().email();
162
+ registerZodSchemaToOpenApiSchema(schema, {
163
+ schemaExportedVariableName: "customEmail",
164
+ type: "string",
165
+ format: "email",
166
+ });
167
+
168
+ const result = getSchemaExportedVariableNameForStringFormat("email");
169
+ expect(result).toBe("customEmail");
170
+ });
171
+
172
+ it("should return variable name for registered format in formats array", () => {
173
+ const schema = z.string();
174
+ registerZodSchemaToOpenApiSchema(schema, {
175
+ schemaExportedVariableName: "customDate",
176
+ type: "string",
177
+ formats: ["date", "iso-date"],
178
+ });
179
+
180
+ expect(getSchemaExportedVariableNameForStringFormat("date")).toBe(
181
+ "customDate",
182
+ );
183
+ expect(getSchemaExportedVariableNameForStringFormat("iso-date")).toBe(
184
+ "customDate",
185
+ );
186
+ });
187
+
188
+ it("should not return variable name for unregistered format in same schema", () => {
189
+ const schema = z.string();
190
+ registerZodSchemaToOpenApiSchema(schema, {
191
+ schemaExportedVariableName: "customDate",
192
+ type: "string",
193
+ formats: ["date"],
194
+ });
195
+
196
+ const result = getSchemaExportedVariableNameForStringFormat("email");
197
+ expect(result).toBeUndefined();
198
+ });
199
+ });
200
+
201
+ describe("schemaRegistry methods", () => {
202
+ describe("getOpenApiSchema", () => {
203
+ it("should return registration for registered schema", () => {
204
+ const schema = z.string().email();
205
+ const registration = {
206
+ schemaExportedVariableName: "emailSchema",
207
+ type: "string" as const,
208
+ format: "email" as const,
209
+ };
210
+
211
+ registerZodSchemaToOpenApiSchema(schema, registration);
212
+ const result = schemaRegistry.getOpenApiSchema(schema);
213
+
214
+ expect(result).toEqual(registration);
215
+ });
216
+
217
+ it("should return undefined for unregistered schema", () => {
218
+ const schema = z.string();
219
+ const result = schemaRegistry.getOpenApiSchema(schema);
220
+ expect(result).toBeUndefined();
221
+ });
222
+ });
223
+
224
+ describe("isRegistered", () => {
225
+ it("should return true for registered schema", () => {
226
+ const schema = z.string().email();
227
+ registerZodSchemaToOpenApiSchema(schema, {
228
+ schemaExportedVariableName: "emailSchema",
229
+ type: "string",
230
+ format: "email",
231
+ });
232
+
233
+ expect(schemaRegistry.isRegistered(schema)).toBe(true);
234
+ });
235
+
236
+ it("should return false for unregistered schema", () => {
237
+ const schema = z.string();
238
+ expect(schemaRegistry.isRegistered(schema)).toBe(false);
239
+ });
240
+ });
241
+
242
+ describe("clear", () => {
243
+ it("should clear all registered schemas", () => {
244
+ const schema = z.string().email();
245
+ registerZodSchemaToOpenApiSchema(schema, {
246
+ schemaExportedVariableName: "emailSchema",
247
+ type: "string",
248
+ format: "email",
249
+ });
250
+
251
+ expect(schemaRegistry.isRegistered(schema)).toBe(true);
252
+
253
+ clearZodSchemaToOpenApiSchemaRegistry();
254
+
255
+ expect(schemaRegistry.isRegistered(schema)).toBe(false);
256
+ expect(
257
+ getSchemaExportedVariableNameForStringFormat("email"),
258
+ ).toBeUndefined();
259
+ });
260
+ });
261
+ });
262
+
263
+ describe("edge cases", () => {
264
+ it("should handle registration with description", () => {
265
+ const schema = z.string().email();
266
+ registerZodSchemaToOpenApiSchema(schema, {
267
+ schemaExportedVariableName: "emailSchema",
268
+ type: "string",
269
+ format: "email",
270
+ description: "Custom email schema",
271
+ });
272
+
273
+ const result = schemaRegistry.getOpenApiSchema(schema);
274
+ expect(result?.description).toBe("Custom email schema");
275
+ });
276
+
277
+ it("should handle all supported string formats", () => {
278
+ const formats = [
279
+ "color-hex",
280
+ "date",
281
+ "date-time",
282
+ "email",
283
+ "iso-date",
284
+ "iso-date-time",
285
+ "objectid",
286
+ "uri",
287
+ "url",
288
+ "uuid",
289
+ ];
290
+
291
+ for (const format of formats) {
292
+ const schema = z.string();
293
+ registerZodSchemaToOpenApiSchema(schema, {
294
+ schemaExportedVariableName: `${format}Schema`,
295
+ type: "string",
296
+ format: format as any,
297
+ });
298
+
299
+ const result = getSchemaExportedVariableNameForStringFormat(
300
+ format as any,
301
+ );
302
+ expect(result).toBe(`${format}Schema`);
303
+
304
+ clearZodSchemaToOpenApiSchemaRegistry();
305
+ }
306
+ });
307
+ });
308
+ });
@@ -0,0 +1,227 @@
1
+ import { z } from "zod";
2
+
3
+ // ============================================================================
4
+ // Constants
5
+ // ============================================================================
6
+
7
+ const SUPPORTED_STRING_FORMATS_MAP = {
8
+ "color-hex": 1,
9
+ date: 1,
10
+ "date-time": 1,
11
+ email: 1,
12
+ "iso-date": 1,
13
+ "iso-date-time": 1,
14
+ objectid: 1,
15
+ uri: 1,
16
+ url: 1,
17
+ uuid: 1,
18
+ } as const;
19
+
20
+ export const SUPPORTED_STRING_FORMATS = Object.keys(
21
+ SUPPORTED_STRING_FORMATS_MAP,
22
+ ) as unknown as keyof typeof SUPPORTED_STRING_FORMATS_MAP;
23
+
24
+ type SupportedStringFormat = typeof SUPPORTED_STRING_FORMATS;
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ export type ZodOpenApiRegistrationString<
31
+ F extends SupportedStringFormat = SupportedStringFormat,
32
+ > = {
33
+ /** The name of the schema variable, IMPORTANT: must be named the same as the variable name */
34
+ schemaExportedVariableName: string;
35
+ type: "string";
36
+ description?: string;
37
+ format: F;
38
+ };
39
+
40
+ export type ZodOpenApiRegistrationStrings<
41
+ Fs extends
42
+ readonly SupportedStringFormat[] = readonly SupportedStringFormat[],
43
+ > = {
44
+ /** The name of the schema variable, IMPORTANT: must be named the same as the variable name */
45
+ schemaExportedVariableName: string;
46
+ type: "string";
47
+ description?: string;
48
+ formats: Fs;
49
+ };
50
+
51
+ export type ZodOpenApiRegistrationPrimitive = {
52
+ /** The name of the schema variable, IMPORTANT: must be named the same as the variable name */
53
+ schemaExportedVariableName: string;
54
+ description?: string;
55
+ type: "number" | "integer" | "boolean";
56
+ };
57
+
58
+ export type ZodOpenApiRegistration =
59
+ | ZodOpenApiRegistrationString
60
+ | ZodOpenApiRegistrationStrings
61
+ | ZodOpenApiRegistrationPrimitive;
62
+
63
+ // ============================================================================
64
+ // Type Guards
65
+ // ============================================================================
66
+
67
+ function isStringRegistration(
68
+ reg: ZodOpenApiRegistration,
69
+ ): reg is ZodOpenApiRegistrationString {
70
+ return reg.type === "string" && "format" in reg;
71
+ }
72
+
73
+ function isStringsRegistration(
74
+ reg: ZodOpenApiRegistration,
75
+ ): reg is ZodOpenApiRegistrationStrings {
76
+ return reg.type === "string" && "formats" in reg;
77
+ }
78
+
79
+ // ============================================================================
80
+ // Helper Functions
81
+ // ============================================================================
82
+
83
+ type TypeFormatPair = { type: string; format: string | undefined };
84
+
85
+ function getTypeFormatPairs(reg: ZodOpenApiRegistration): TypeFormatPair[] {
86
+ if (isStringRegistration(reg)) {
87
+ return [{ type: "string", format: reg.format }];
88
+ }
89
+
90
+ if (isStringsRegistration(reg)) {
91
+ return reg.formats.map((f) => ({ type: "string", format: f }));
92
+ }
93
+
94
+ return [];
95
+ }
96
+
97
+ // ============================================================================
98
+ // Registry Class
99
+ // ============================================================================
100
+
101
+ /**
102
+ * Global registry for mapping Zod schemas to OpenAPI schema representations
103
+ */
104
+ class ZodSchemaRegistry {
105
+ private readonly map = new Map<z.ZodTypeAny, ZodOpenApiRegistration>();
106
+
107
+ /**
108
+ * Register a Zod schema with its OpenAPI representation
109
+ */
110
+ register<F extends SupportedStringFormat>(
111
+ schema: z.ZodTypeAny,
112
+ registration: ZodOpenApiRegistrationString<F>,
113
+ ): void;
114
+ register<Fs extends readonly SupportedStringFormat[]>(
115
+ schema: z.ZodTypeAny,
116
+ registration: ZodOpenApiRegistrationStrings<Fs>,
117
+ ): void;
118
+ register(
119
+ schema: z.ZodTypeAny,
120
+ registration: ZodOpenApiRegistrationPrimitive,
121
+ ): void;
122
+ register(schema: z.ZodTypeAny, registration: ZodOpenApiRegistration): void {
123
+ const newPairs = getTypeFormatPairs(registration);
124
+
125
+ if (newPairs.length > 0) {
126
+ for (const [existingSchema, existingRegistration] of this.map.entries()) {
127
+ if (existingSchema === schema) continue;
128
+
129
+ const existingPairs = getTypeFormatPairs(existingRegistration);
130
+ for (const { type, format } of newPairs) {
131
+ if (
132
+ existingPairs.some((p) => p.type === type && p.format === format)
133
+ ) {
134
+ throw new Error(
135
+ `duplicate Zod OpenAPI registration for (type, format)=('${type}', '${format as string}')`,
136
+ );
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ this.map.set(schema, registration);
143
+ }
144
+
145
+ /**
146
+ * Get the OpenAPI schema for a given Zod schema
147
+ */
148
+ getOpenApiSchema(schema: z.ZodTypeAny): ZodOpenApiRegistration | undefined {
149
+ return this.map.get(schema);
150
+ }
151
+
152
+ /**
153
+ * Check if a Zod schema is registered
154
+ */
155
+ isRegistered(schema: z.ZodTypeAny): boolean {
156
+ return this.map.has(schema);
157
+ }
158
+
159
+ /**
160
+ * Clear all registered schemas
161
+ */
162
+ clear(): void {
163
+ this.map.clear();
164
+ }
165
+
166
+ /**
167
+ * Reverse-lookup helper: given a string format, return the registered schema's exported variable name
168
+ */
169
+ getSchemaExportedVariableNameForStringFormat(
170
+ format: SupportedStringFormat,
171
+ ): string | undefined {
172
+ for (const registration of this.map.values()) {
173
+ if (registration.type !== "string") continue;
174
+
175
+ if (
176
+ isStringRegistration(registration) &&
177
+ registration.format === format
178
+ ) {
179
+ return registration.schemaExportedVariableName;
180
+ }
181
+
182
+ if (
183
+ isStringsRegistration(registration) &&
184
+ registration.formats.includes(format)
185
+ ) {
186
+ return registration.schemaExportedVariableName;
187
+ }
188
+ }
189
+ return undefined;
190
+ }
191
+ }
192
+
193
+ // ============================================================================
194
+ // Global Registry Instance
195
+ // ============================================================================
196
+
197
+ export const schemaRegistry = new ZodSchemaRegistry();
198
+
199
+ // ============================================================================
200
+ // Public API
201
+ // ============================================================================
202
+
203
+ /**
204
+ * Helper function to register a Zod schema with its OpenAPI representation
205
+ */
206
+ export function registerZodSchemaToOpenApiSchema(
207
+ schema: z.ZodTypeAny,
208
+ openApiSchema: ZodOpenApiRegistration,
209
+ ): void {
210
+ schemaRegistry.register(schema, openApiSchema as any);
211
+ }
212
+
213
+ /**
214
+ * Convenience helper to get an exported schema variable name for a given string format
215
+ */
216
+ export function getSchemaExportedVariableNameForStringFormat(
217
+ format: SupportedStringFormat,
218
+ ): string | undefined {
219
+ return schemaRegistry.getSchemaExportedVariableNameForStringFormat(format);
220
+ }
221
+
222
+ /**
223
+ * Clear all registered schemas in the global registry
224
+ */
225
+ export function clearZodSchemaToOpenApiSchemaRegistry(): void {
226
+ schemaRegistry.clear();
227
+ }