@geekmidas/envkit 0.0.2 → 0.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/envkit",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,6 +59,73 @@ export class ConfigParser<TResponse extends EmptyObject> {
59
59
  export class EnvironmentParser<T extends EmptyObject> {
60
60
  constructor(private readonly config: T) {}
61
61
 
62
+ private wrapSchema = (schema: z.ZodType, name: string): z.ZodType => {
63
+ // Create a proxy that intercepts all method calls on the schema
64
+ return new Proxy(schema, {
65
+ get: (target, prop) => {
66
+ if (prop === 'parse') {
67
+ return () => {
68
+ const value = get(this.config, name);
69
+ try {
70
+ return target.parse(value);
71
+ } catch (error) {
72
+ if (error instanceof z.ZodError) {
73
+ // Modify the error to include the environment variable name
74
+ const modifiedIssues = error.issues.map((issue) => ({
75
+ ...issue,
76
+ message: `Environment variable "${name}": ${issue.message}`,
77
+ path: [name, ...issue.path],
78
+ }));
79
+ throw new z.ZodError(modifiedIssues);
80
+ }
81
+ throw error;
82
+ }
83
+ };
84
+ }
85
+
86
+ if (prop === 'safeParse') {
87
+ return () => {
88
+ const value = get(this.config, name);
89
+ const result = target.safeParse(value);
90
+
91
+ if (!result.success) {
92
+ // Modify the error to include the environment variable name
93
+ const modifiedIssues = result.error.issues.map(
94
+ (issue: z.core.$ZodIssue) => ({
95
+ ...issue,
96
+ message: `Environment variable "${name}": ${issue.message}`,
97
+ path: [name, ...issue.path],
98
+ }),
99
+ );
100
+ return {
101
+ success: false as const,
102
+ error: new z.ZodError(modifiedIssues),
103
+ };
104
+ }
105
+
106
+ return result;
107
+ };
108
+ }
109
+
110
+ // For any method that returns a new schema (like transform, optional, etc.),
111
+ // wrap the result as well
112
+ const originalProp = target[prop as keyof typeof target];
113
+ if (typeof originalProp === 'function') {
114
+ return (...args: any[]) => {
115
+ const result = originalProp.apply(target, args);
116
+ // If the result is a ZodType, wrap it too
117
+ if (result && typeof result === 'object' && 'parse' in result) {
118
+ return this.wrapSchema(result, name);
119
+ }
120
+ return result;
121
+ };
122
+ }
123
+
124
+ return originalProp;
125
+ },
126
+ });
127
+ };
128
+
62
129
  private getZodGetter = (name: string) => {
63
130
  // Return an object that has all Zod schemas but with our wrapper
64
131
  return new Proxy(
@@ -67,60 +134,33 @@ export class EnvironmentParser<T extends EmptyObject> {
67
134
  get: (target, prop) => {
68
135
  // deno-lint-ignore ban-ts-comment
69
136
  // @ts-ignore
70
- const func = target[prop];
137
+ const value = target[prop];
71
138
 
72
- if (typeof func === 'function') {
139
+ if (typeof value === 'function') {
73
140
  // Return a wrapper around each Zod schema creator
74
141
  return (...args: any[]) => {
75
- const schema = func(...args);
76
- // Add a custom parse method that gets the value from config
77
- const originalParse = schema.parse;
78
- const originalSafeParse = schema.safeParse;
79
-
80
- schema.parse = () => {
81
- const value = get(this.config, name);
82
- try {
83
- return originalParse.call(schema, value);
84
- } catch (error) {
85
- if (error instanceof z.ZodError) {
86
- // Modify the error to include the environment variable name
87
- const modifiedIssues = error.issues.map((issue) => ({
88
- ...issue,
89
- message: `Environment variable "${name}": ${issue.message}`,
90
- path: [name, ...issue.path],
91
- }));
92
- throw new z.ZodError(modifiedIssues);
93
- }
94
- throw error;
95
- }
96
- };
142
+ const schema = value(...args);
143
+ return this.wrapSchema(schema, name);
144
+ };
145
+ }
97
146
 
98
- schema.safeParse = () => {
99
- const value = get(this.config, name);
100
- const result = originalSafeParse.call(schema, value);
101
-
102
- if (!result.success) {
103
- // Modify the error to include the environment variable name
104
- const modifiedIssues = result.error.issues.map(
105
- (issue: z.core.$ZodIssue) => ({
106
- ...issue,
107
- message: `Environment variable "${name}": ${issue.message}`,
108
- path: [name, ...issue.path],
109
- }),
110
- );
111
- return {
112
- success: false as const,
113
- error: new z.ZodError(modifiedIssues),
147
+ // Handle objects like z.coerce
148
+ if (value && typeof value === 'object') {
149
+ return new Proxy(value, {
150
+ get: (nestedTarget, nestedProp) => {
151
+ const nestedValue = nestedTarget[nestedProp as keyof typeof nestedTarget];
152
+ if (typeof nestedValue === 'function') {
153
+ return (...args: any[]) => {
154
+ const schema = nestedValue(...args);
155
+ return this.wrapSchema(schema, name);
114
156
  };
115
157
  }
116
-
117
- return result;
118
- };
119
-
120
- return schema;
121
- };
158
+ return nestedValue;
159
+ },
160
+ });
122
161
  }
123
- return func;
162
+
163
+ return value;
124
164
  },
125
165
  },
126
166
  );
@@ -0,0 +1,394 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod/v4';
3
+ import { ConfigParser } from '../EnvironmentParser';
4
+
5
+ describe('ConfigParser', () => {
6
+ describe('Basic functionality', () => {
7
+ it('should parse simple Zod schemas', () => {
8
+ const config = {
9
+ name: z.string().default('Test'),
10
+ age: z.number().default(25),
11
+ active: z.boolean().default(true),
12
+ };
13
+
14
+ const parser = new ConfigParser(config);
15
+ const result = parser.parse();
16
+
17
+ expect(result).toEqual({
18
+ name: 'Test',
19
+ age: 25,
20
+ active: true,
21
+ });
22
+ });
23
+
24
+ it('should handle optional values', () => {
25
+ const config = {
26
+ required: z.string().default('value'),
27
+ optional: z.string().optional(),
28
+ };
29
+
30
+ const parser = new ConfigParser(config);
31
+ const result = parser.parse();
32
+
33
+ expect(result).toEqual({
34
+ required: 'value',
35
+ optional: undefined,
36
+ });
37
+ });
38
+
39
+ it('should validate and use provided default values', () => {
40
+ const config = {
41
+ port: z.number().default(3000),
42
+ host: z.string().default('localhost'),
43
+ debug: z.boolean().default(false),
44
+ };
45
+
46
+ const parser = new ConfigParser(config);
47
+ const result = parser.parse();
48
+
49
+ expect(result).toEqual({
50
+ port: 3000,
51
+ host: 'localhost',
52
+ debug: false,
53
+ });
54
+ });
55
+ });
56
+
57
+ describe('Nested objects', () => {
58
+ it('should parse nested configuration objects', () => {
59
+ const config = {
60
+ database: {
61
+ host: z.string().default('localhost'),
62
+ port: z.number().default(5432),
63
+ ssl: z.boolean().default(false),
64
+ },
65
+ api: {
66
+ key: z.string().default('default-key'),
67
+ timeout: z.number().default(5000),
68
+ },
69
+ };
70
+
71
+ const parser = new ConfigParser(config);
72
+ const result = parser.parse();
73
+
74
+ expect(result).toEqual({
75
+ database: {
76
+ host: 'localhost',
77
+ port: 5432,
78
+ ssl: false,
79
+ },
80
+ api: {
81
+ key: 'default-key',
82
+ timeout: 5000,
83
+ },
84
+ });
85
+ });
86
+
87
+ it('should handle deeply nested objects', () => {
88
+ const config = {
89
+ app: {
90
+ name: z.string().default('MyApp'),
91
+ version: z.string().default('1.0.0'),
92
+ features: {
93
+ auth: {
94
+ enabled: z.boolean().default(true),
95
+ provider: z.string().default('local'),
96
+ },
97
+ cache: {
98
+ enabled: z.boolean().default(false),
99
+ ttl: z.number().default(3600),
100
+ },
101
+ },
102
+ },
103
+ };
104
+
105
+ const parser = new ConfigParser(config);
106
+ const result = parser.parse();
107
+
108
+ expect(result).toEqual({
109
+ app: {
110
+ name: 'MyApp',
111
+ version: '1.0.0',
112
+ features: {
113
+ auth: {
114
+ enabled: true,
115
+ provider: 'local',
116
+ },
117
+ cache: {
118
+ enabled: false,
119
+ ttl: 3600,
120
+ },
121
+ },
122
+ },
123
+ });
124
+ });
125
+
126
+ it('should handle mixed nested and flat configuration', () => {
127
+ const config = {
128
+ appName: z.string().default('Test App'),
129
+ database: {
130
+ url: z.string().default('postgres://localhost/test'),
131
+ poolSize: z.number().default(10),
132
+ },
133
+ port: z.number().default(3000),
134
+ features: {
135
+ logging: {
136
+ level: z.string().default('info'),
137
+ pretty: z.boolean().default(true),
138
+ },
139
+ },
140
+ };
141
+
142
+ const parser = new ConfigParser(config);
143
+ const result = parser.parse();
144
+
145
+ expect(result).toEqual({
146
+ appName: 'Test App',
147
+ database: {
148
+ url: 'postgres://localhost/test',
149
+ poolSize: 10,
150
+ },
151
+ port: 3000,
152
+ features: {
153
+ logging: {
154
+ level: 'info',
155
+ pretty: true,
156
+ },
157
+ },
158
+ });
159
+ });
160
+ });
161
+
162
+ describe('Error handling', () => {
163
+ it('should throw ZodError for schemas without defaults', () => {
164
+ const config = {
165
+ required: z.string(),
166
+ alsoRequired: z.number(),
167
+ };
168
+
169
+ const parser = new ConfigParser(config);
170
+
171
+ expect(() => parser.parse()).toThrow(z.ZodError);
172
+ });
173
+
174
+ it('should collect multiple validation errors', () => {
175
+ const config = {
176
+ field1: z.string(),
177
+ field2: z.number(),
178
+ field3: z.boolean(),
179
+ };
180
+
181
+ const parser = new ConfigParser(config);
182
+
183
+ try {
184
+ parser.parse();
185
+ // Should not reach here
186
+ expect(true).toBe(false);
187
+ } catch (error) {
188
+ expect(error).toBeInstanceOf(z.ZodError);
189
+ const zodError = error as z.ZodError;
190
+ expect(zodError.issues).toHaveLength(3);
191
+ }
192
+ });
193
+
194
+ it('should include correct paths in nested validation errors', () => {
195
+ const config = {
196
+ database: {
197
+ host: z.string(),
198
+ port: z.number(),
199
+ },
200
+ api: {
201
+ key: z.string(),
202
+ },
203
+ };
204
+
205
+ const parser = new ConfigParser(config);
206
+
207
+ try {
208
+ parser.parse();
209
+ // Should not reach here
210
+ expect(true).toBe(false);
211
+ } catch (error) {
212
+ expect(error).toBeInstanceOf(z.ZodError);
213
+ const zodError = error as z.ZodError;
214
+
215
+ const paths = zodError.issues.map((err) => err.path.join('.'));
216
+ expect(paths).toContain('database.host');
217
+ expect(paths).toContain('database.port');
218
+ expect(paths).toContain('api.key');
219
+ }
220
+ });
221
+
222
+ it('should use default values that pass validation', () => {
223
+ const config = {
224
+ port: z.number().min(1000).max(65535).default(3000),
225
+ email: z.string().email().default('admin@example.com'),
226
+ };
227
+
228
+ const parser = new ConfigParser(config);
229
+ const result = parser.parse();
230
+
231
+ expect(result).toEqual({
232
+ port: 3000,
233
+ email: 'admin@example.com',
234
+ });
235
+ });
236
+ });
237
+
238
+ describe('Type safety', () => {
239
+ it('should infer correct types for simple configuration', () => {
240
+ const config = {
241
+ name: z.string().default('test'),
242
+ count: z.number().default(42),
243
+ enabled: z.boolean().default(true),
244
+ };
245
+
246
+ const parser = new ConfigParser(config);
247
+ const result = parser.parse();
248
+
249
+ // TypeScript should infer the correct types
250
+ type ResultType = typeof result;
251
+ type ExpectedType = {
252
+ name: string;
253
+ count: number;
254
+ enabled: boolean;
255
+ };
256
+
257
+ const _typeCheck: ResultType extends ExpectedType ? true : false = true;
258
+ const _typeCheck2: ExpectedType extends ResultType ? true : false = true;
259
+
260
+ expect(_typeCheck).toBe(true);
261
+ expect(_typeCheck2).toBe(true);
262
+ });
263
+
264
+ it('should infer correct types for nested configuration', () => {
265
+ const config = {
266
+ database: {
267
+ host: z.string().default('localhost'),
268
+ port: z.number().default(5432),
269
+ },
270
+ features: {
271
+ auth: z.boolean().default(true),
272
+ },
273
+ };
274
+
275
+ const parser = new ConfigParser(config);
276
+ const result = parser.parse();
277
+
278
+ // TypeScript should infer the correct nested structure
279
+ type ResultType = typeof result;
280
+ type ExpectedType = {
281
+ database: { host: string; port: number };
282
+ features: { auth: boolean };
283
+ };
284
+
285
+ const _typeCheck: ResultType extends ExpectedType ? true : false = true;
286
+ const _typeCheck2: ExpectedType extends ResultType ? true : false = true;
287
+
288
+ expect(_typeCheck).toBe(true);
289
+ expect(_typeCheck2).toBe(true);
290
+ });
291
+
292
+ it('should handle optional types correctly', () => {
293
+ const config = {
294
+ required: z.string().default('value'),
295
+ optional: z.string().optional(),
296
+ nullable: z.string().nullable().default(null),
297
+ };
298
+
299
+ const parser = new ConfigParser(config);
300
+ const result = parser.parse();
301
+ });
302
+ });
303
+
304
+ describe('Complex schemas', () => {
305
+ it('should handle enum schemas', () => {
306
+ const config = {
307
+ environment: z
308
+ .enum(['development', 'staging', 'production'])
309
+ .default('development'),
310
+ logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
311
+ };
312
+
313
+ const parser = new ConfigParser(config);
314
+ const result = parser.parse();
315
+
316
+ expect(result).toEqual({
317
+ environment: 'development',
318
+ logLevel: 'info',
319
+ });
320
+ });
321
+
322
+ it('should handle union schemas', () => {
323
+ const config = {
324
+ port: z.union([z.string(), z.number()]).default(3000),
325
+ timeout: z.union([z.number(), z.null()]).default(null),
326
+ };
327
+
328
+ const parser = new ConfigParser(config);
329
+ const result = parser.parse();
330
+
331
+ expect(result).toEqual({
332
+ port: 3000,
333
+ timeout: null,
334
+ });
335
+ });
336
+
337
+ it('should handle array schemas', () => {
338
+ const config = {
339
+ tags: z.array(z.string()).default(['tag1', 'tag2']),
340
+ ports: z.array(z.number()).default([3000, 3001]),
341
+ };
342
+
343
+ const parser = new ConfigParser(config);
344
+ const result = parser.parse();
345
+
346
+ expect(result).toEqual({
347
+ tags: ['tag1', 'tag2'],
348
+ ports: [3000, 3001],
349
+ });
350
+ });
351
+
352
+ it('should handle record schemas', () => {
353
+ const config = {
354
+ metadata: z
355
+ .record(z.string(), z.string())
356
+ .default({ key1: 'value1', key2: 'value2' }),
357
+ counters: z
358
+ .record(z.string(), z.number())
359
+ .default({ count1: 1, count2: 2 }),
360
+ };
361
+
362
+ const parser = new ConfigParser(config);
363
+ const result = parser.parse();
364
+
365
+ expect(result).toEqual({
366
+ metadata: { key1: 'value1', key2: 'value2' },
367
+ counters: { count1: 1, count2: 2 },
368
+ });
369
+ });
370
+
371
+ it('should handle transformed schemas', () => {
372
+ const config = {
373
+ portString: z.string().transform(Number).default(8080),
374
+ booleanString: z
375
+ .string()
376
+ .transform((v) => v === 'true')
377
+ .default(false),
378
+ jsonString: z
379
+ .string()
380
+ .transform((v) => JSON.parse(v))
381
+ .default({ key: 'value' }),
382
+ };
383
+
384
+ const parser = new ConfigParser(config);
385
+ const result = parser.parse();
386
+
387
+ expect(result).toEqual({
388
+ portString: 8080,
389
+ booleanString: false,
390
+ jsonString: { key: 'value' },
391
+ });
392
+ });
393
+ });
394
+ });