millas 0.2.11 → 0.2.12-beta

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.
Files changed (47) hide show
  1. package/package.json +17 -3
  2. package/src/auth/AuthController.js +42 -133
  3. package/src/auth/AuthMiddleware.js +12 -23
  4. package/src/auth/RoleMiddleware.js +7 -17
  5. package/src/commands/migrate.js +46 -31
  6. package/src/commands/serve.js +266 -37
  7. package/src/container/Application.js +88 -8
  8. package/src/controller/Controller.js +79 -300
  9. package/src/errors/ErrorRenderer.js +640 -0
  10. package/src/facades/Admin.js +49 -0
  11. package/src/facades/Auth.js +46 -0
  12. package/src/facades/Cache.js +17 -0
  13. package/src/facades/Database.js +43 -0
  14. package/src/facades/Events.js +24 -0
  15. package/src/facades/Http.js +54 -0
  16. package/src/facades/Log.js +56 -0
  17. package/src/facades/Mail.js +40 -0
  18. package/src/facades/Queue.js +23 -0
  19. package/src/facades/Storage.js +17 -0
  20. package/src/facades/Validation.js +69 -0
  21. package/src/http/MillasRequest.js +253 -0
  22. package/src/http/MillasResponse.js +196 -0
  23. package/src/http/RequestContext.js +176 -0
  24. package/src/http/ResponseDispatcher.js +144 -0
  25. package/src/http/helpers.js +164 -0
  26. package/src/http/index.js +13 -0
  27. package/src/index.js +55 -2
  28. package/src/logger/internal.js +76 -0
  29. package/src/logger/patchConsole.js +135 -0
  30. package/src/middleware/CorsMiddleware.js +22 -30
  31. package/src/middleware/LogMiddleware.js +27 -59
  32. package/src/middleware/Middleware.js +24 -15
  33. package/src/middleware/MiddlewarePipeline.js +30 -67
  34. package/src/middleware/MiddlewareRegistry.js +126 -0
  35. package/src/middleware/ThrottleMiddleware.js +22 -26
  36. package/src/orm/fields/index.js +124 -56
  37. package/src/orm/migration/ModelInspector.js +7 -3
  38. package/src/orm/model/Model.js +96 -6
  39. package/src/orm/query/QueryBuilder.js +141 -3
  40. package/src/providers/LogServiceProvider.js +88 -18
  41. package/src/providers/ProviderRegistry.js +14 -1
  42. package/src/providers/ServiceProvider.js +40 -8
  43. package/src/router/Router.js +155 -223
  44. package/src/scaffold/maker.js +24 -59
  45. package/src/scaffold/templates.js +13 -12
  46. package/src/validation/BaseValidator.js +193 -0
  47. package/src/validation/Validator.js +680 -0
@@ -0,0 +1,680 @@
1
+ 'use strict';
2
+
3
+ const { BaseValidator, _titleCase } = require('./BaseValidator');
4
+
5
+ // ── StringValidator ────────────────────────────────────────────────────────────
6
+
7
+ class StringValidator extends BaseValidator {
8
+ /**
9
+ * @param {string} [typeError] — shown when value is not a string
10
+ *
11
+ * string('Must be text')
12
+ * string().required('Name is required').min(2, 'Too short')
13
+ */
14
+ constructor(typeError) {
15
+ super(typeError || null);
16
+ }
17
+
18
+ _checkType(value) {
19
+ if (typeof value !== 'string') return 'Must be a string';
20
+ return null;
21
+ }
22
+
23
+ /** Minimum character length. */
24
+ min(n, msg) {
25
+ return this._addRule(
26
+ v => String(v).length >= n,
27
+ msg || ((key) => `${this._fieldLabel(key)} must be at least ${n} character${n === 1 ? '' : 's'}`)
28
+ );
29
+ }
30
+
31
+ /** Maximum character length. */
32
+ max(n, msg) {
33
+ return this._addRule(
34
+ v => String(v).length <= n,
35
+ msg || ((key) => `${this._fieldLabel(key)} must not exceed ${n} character${n === 1 ? '' : 's'}`)
36
+ );
37
+ }
38
+
39
+ /** Exact character length. */
40
+ length(n, msg) {
41
+ return this._addRule(
42
+ v => String(v).length === n,
43
+ msg || ((key) => `${this._fieldLabel(key)} must be exactly ${n} character${n === 1 ? '' : 's'}`)
44
+ );
45
+ }
46
+
47
+ /** Match a regex pattern. */
48
+ matches(regex, msg) {
49
+ return this._addRule(
50
+ v => regex.test(String(v)),
51
+ msg || ((key) => `${this._fieldLabel(key)} format is invalid`)
52
+ );
53
+ }
54
+
55
+ /** Must be one of the allowed values. */
56
+ oneOf(values, msg) {
57
+ return this._addRule(
58
+ v => values.includes(v),
59
+ msg || ((key) => `${this._fieldLabel(key)} must be one of: ${values.join(', ')}`)
60
+ );
61
+ }
62
+
63
+ /** Letters only. */
64
+ alpha(msg) {
65
+ return this._addRule(
66
+ v => /^[a-zA-Z]+$/.test(v),
67
+ msg || ((key) => `${this._fieldLabel(key)} must contain only letters`)
68
+ );
69
+ }
70
+
71
+ /** Letters and numbers only. */
72
+ alphanumeric(msg) {
73
+ return this._addRule(
74
+ v => /^[a-zA-Z0-9]+$/.test(v),
75
+ msg || ((key) => `${this._fieldLabel(key)} must contain only letters and numbers`)
76
+ );
77
+ }
78
+
79
+ /** Valid URL. */
80
+ url(msg) {
81
+ return this._addRule(v => {
82
+ try { new URL(v); return true; } catch { return false; }
83
+ }, msg || ((key) => `${this._fieldLabel(key)} must be a valid URL`));
84
+ }
85
+
86
+ /** Lowercase only. */
87
+ lowercase(msg) {
88
+ return this._addRule(
89
+ v => v === v.toLowerCase(),
90
+ msg || ((key) => `${this._fieldLabel(key)} must be lowercase`)
91
+ );
92
+ }
93
+
94
+ /** Uppercase only. */
95
+ uppercase(msg) {
96
+ return this._addRule(
97
+ v => v === v.toUpperCase(),
98
+ msg || ((key) => `${this._fieldLabel(key)} must be uppercase`)
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Must match another field in the request (e.g. password confirmation).
104
+ * string().required().confirmed() // checks password_confirmation
105
+ * string().required().confirmed('confirmPassword') // custom field name
106
+ */
107
+ confirmed(field, msg) {
108
+ this._confirmedField = field || null; // resolved at run-time
109
+ this._confirmedMsg = msg || null;
110
+ return this;
111
+ }
112
+
113
+ /**
114
+ * Trim whitespace before validation (and in the returned value).
115
+ */
116
+ trim() {
117
+ this._trim = true;
118
+ return this;
119
+ }
120
+
121
+ async run(value, key, allData) {
122
+ // Apply trim
123
+ if (this._trim && typeof value === 'string') value = value.trim();
124
+
125
+ const result = await super.run(value, key, allData);
126
+ if (result.error) return result;
127
+
128
+ // confirmed() check
129
+ if (this._confirmedField !== undefined) {
130
+ const confirmKey = this._confirmedField || `${key}_confirmation`;
131
+ const match = allData[confirmKey];
132
+ if (result.value !== match) {
133
+ return {
134
+ error: this._confirmedMsg || `${this._fieldLabel(key)} confirmation does not match`,
135
+ value,
136
+ };
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+ }
143
+
144
+ // ── EmailValidator ─────────────────────────────────────────────────────────────
145
+
146
+ class EmailValidator extends StringValidator {
147
+ /**
148
+ * email()
149
+ * email('Please enter a valid email')
150
+ */
151
+ constructor(typeError) {
152
+ super(typeError);
153
+ // Auto-apply email format check
154
+ this._emailCheck = true;
155
+ }
156
+
157
+ _checkType(value) {
158
+ const base = super._checkType(value);
159
+ if (base) return base;
160
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
161
+ return this._typeError || 'Must be a valid email address';
162
+ }
163
+ return null;
164
+ }
165
+ }
166
+
167
+ // ── NumberValidator ────────────────────────────────────────────────────────────
168
+
169
+ class NumberValidator extends BaseValidator {
170
+ constructor(typeError) {
171
+ super(typeError || null);
172
+ this._integer = false;
173
+ }
174
+
175
+ _checkType(value) {
176
+ const n = Number(value);
177
+ if (isNaN(n)) return this._typeError || 'Must be a number';
178
+ return null;
179
+ }
180
+
181
+ /** Coerce string to number in the returned value. */
182
+ async run(value, key, allData) {
183
+ const result = await super.run(value, key, allData);
184
+ // Coerce to number on success
185
+ if (!result.error && result.value !== null && result.value !== undefined && result.value !== '') {
186
+ result.value = Number(result.value);
187
+ }
188
+ return result;
189
+ }
190
+
191
+ /** Must be an integer. */
192
+ integer(msg) {
193
+ return this._addRule(
194
+ v => Number.isInteger(Number(v)),
195
+ msg || ((key) => `${this._fieldLabel(key)} must be an integer`)
196
+ );
197
+ }
198
+
199
+ /** Minimum value. */
200
+ min(n, msg) {
201
+ return this._addRule(
202
+ v => Number(v) >= n,
203
+ msg || ((key) => `${this._fieldLabel(key)} must be at least ${n}`)
204
+ );
205
+ }
206
+
207
+ /** Maximum value. */
208
+ max(n, msg) {
209
+ return this._addRule(
210
+ v => Number(v) <= n,
211
+ msg || ((key) => `${this._fieldLabel(key)} must be at most ${n}`)
212
+ );
213
+ }
214
+
215
+ /** Must be positive (> 0). */
216
+ positive(msg) {
217
+ return this._addRule(
218
+ v => Number(v) > 0,
219
+ msg || ((key) => `${this._fieldLabel(key)} must be positive`)
220
+ );
221
+ }
222
+
223
+ /** Must be negative (< 0). */
224
+ negative(msg) {
225
+ return this._addRule(
226
+ v => Number(v) < 0,
227
+ msg || ((key) => `${this._fieldLabel(key)} must be negative`)
228
+ );
229
+ }
230
+
231
+ /** Must be between min and max (inclusive). */
232
+ between(min, max, msg) {
233
+ return this._addRule(
234
+ v => Number(v) >= min && Number(v) <= max,
235
+ msg || ((key) => `${this._fieldLabel(key)} must be between ${min} and ${max}`)
236
+ );
237
+ }
238
+ }
239
+
240
+ // ── BooleanValidator ───────────────────────────────────────────────────────────
241
+
242
+ class BooleanValidator extends BaseValidator {
243
+ constructor(typeError) {
244
+ super(typeError || null);
245
+ }
246
+
247
+ _checkType(value) {
248
+ const truthy = [true, 'true', '1', 1, 'yes', 'on'];
249
+ const falsy = [false,'false', '0', 0, 'no', 'off'];
250
+ if (!truthy.includes(value) && !falsy.includes(value)) {
251
+ return this._typeError || 'Must be a boolean (true/false)';
252
+ }
253
+ return null;
254
+ }
255
+
256
+ async run(value, key, allData) {
257
+ const result = await super.run(value, key, allData);
258
+ if (!result.error && result.value !== null && result.value !== undefined && result.value !== '') {
259
+ const truthy = [true, 'true', '1', 1, 'yes', 'on'];
260
+ result.value = truthy.includes(result.value);
261
+ }
262
+ return result;
263
+ }
264
+
265
+ /** Must be true. */
266
+ isTrue(msg) {
267
+ return this._addRule(
268
+ v => [true, 'true', '1', 1, 'yes', 'on'].includes(v),
269
+ msg || ((key) => `${this._fieldLabel(key)} must be accepted`)
270
+ );
271
+ }
272
+ }
273
+
274
+ // ── DateValidator ──────────────────────────────────────────────────────────────
275
+
276
+ class DateValidator extends BaseValidator {
277
+ constructor(typeError) {
278
+ super(typeError || null);
279
+ }
280
+
281
+ _checkType(value) {
282
+ const d = new Date(value);
283
+ if (isNaN(d.getTime())) return this._typeError || 'Must be a valid date';
284
+ return null;
285
+ }
286
+
287
+ async run(value, key, allData) {
288
+ const result = await super.run(value, key, allData);
289
+ if (!result.error && result.value !== null && result.value !== undefined && result.value !== '') {
290
+ result.value = new Date(result.value);
291
+ }
292
+ return result;
293
+ }
294
+
295
+ /** Must be after a given date. */
296
+ after(date, msg) {
297
+ const d = new Date(date);
298
+ return this._addRule(
299
+ v => new Date(v) > d,
300
+ msg || ((key) => `${this._fieldLabel(key)} must be after ${d.toDateString()}`)
301
+ );
302
+ }
303
+
304
+ /** Must be before a given date. */
305
+ before(date, msg) {
306
+ const d = new Date(date);
307
+ return this._addRule(
308
+ v => new Date(v) < d,
309
+ msg || ((key) => `${this._fieldLabel(key)} must be before ${d.toDateString()}`)
310
+ );
311
+ }
312
+
313
+ /** Must be in the future. */
314
+ future(msg) {
315
+ return this._addRule(
316
+ v => new Date(v) > new Date(),
317
+ msg || ((key) => `${this._fieldLabel(key)} must be a future date`)
318
+ );
319
+ }
320
+
321
+ /** Must be in the past. */
322
+ past(msg) {
323
+ return this._addRule(
324
+ v => new Date(v) < new Date(),
325
+ msg || ((key) => `${this._fieldLabel(key)} must be a past date`)
326
+ );
327
+ }
328
+ }
329
+
330
+ // ── ArrayValidator ─────────────────────────────────────────────────────────────
331
+
332
+ class ArrayValidator extends BaseValidator {
333
+ constructor(typeError) {
334
+ super(typeError || null);
335
+ this._itemValidator = null;
336
+ }
337
+
338
+ _checkType(value) {
339
+ if (!Array.isArray(value)) return this._typeError || 'Must be an array';
340
+ return null;
341
+ }
342
+
343
+ /**
344
+ * Validate each item in the array with a given validator.
345
+ * array().of(string().min(1))
346
+ * array().of(number().positive())
347
+ */
348
+ of(validator) {
349
+ this._itemValidator = validator;
350
+ return this;
351
+ }
352
+
353
+ /** Minimum number of items. */
354
+ min(n, msg) {
355
+ return this._addRule(
356
+ v => Array.isArray(v) && v.length >= n,
357
+ msg || ((key) => `${this._fieldLabel(key)} must have at least ${n} item${n === 1 ? '' : 's'}`)
358
+ );
359
+ }
360
+
361
+ /** Maximum number of items. */
362
+ max(n, msg) {
363
+ return this._addRule(
364
+ v => Array.isArray(v) && v.length <= n,
365
+ msg || ((key) => `${this._fieldLabel(key)} must have at most ${n} item${n === 1 ? '' : 's'}`)
366
+ );
367
+ }
368
+
369
+ /** Exact number of items. */
370
+ length(n, msg) {
371
+ return this._addRule(
372
+ v => Array.isArray(v) && v.length === n,
373
+ msg || ((key) => `${this._fieldLabel(key)} must have exactly ${n} item${n === 1 ? '' : 's'}`)
374
+ );
375
+ }
376
+
377
+ /** No duplicate values. */
378
+ unique(msg) {
379
+ return this._addRule(
380
+ v => Array.isArray(v) && new Set(v).size === v.length,
381
+ msg || ((key) => `${this._fieldLabel(key)} must not contain duplicates`)
382
+ );
383
+ }
384
+
385
+ async run(value, key, allData) {
386
+ const result = await super.run(value, key, allData);
387
+ if (result.error || !result.value || !this._itemValidator) return result;
388
+
389
+ // Validate each item
390
+ const items = result.value;
391
+ const errors = [];
392
+
393
+ for (let i = 0; i < items.length; i++) {
394
+ const itemResult = await this._itemValidator.run(items[i], `${key}[${i}]`, allData);
395
+ if (itemResult.error) errors.push(`Item ${i}: ${itemResult.error}`);
396
+ else items[i] = itemResult.value; // apply coercions (e.g. string→number)
397
+ }
398
+
399
+ if (errors.length) return { error: errors.join('; '), value };
400
+
401
+ return { error: null, value: items };
402
+ }
403
+ }
404
+
405
+ // ── ObjectValidator ────────────────────────────────────────────────────────────
406
+
407
+ class ObjectValidator extends BaseValidator {
408
+ /**
409
+ * Validate a nested object against a schema.
410
+ *
411
+ * object({
412
+ * street: string().required(),
413
+ * city: string().required(),
414
+ * zip: string().matches(/^\d{5}$/, 'Invalid ZIP'),
415
+ * }).optional()
416
+ */
417
+ constructor(schema = {}, typeError) {
418
+ super(typeError || null);
419
+ this._schema = schema;
420
+ }
421
+
422
+ _checkType(value) {
423
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
424
+ return this._typeError || 'Must be an object';
425
+ }
426
+ return null;
427
+ }
428
+
429
+ async run(value, key, allData) {
430
+ const result = await super.run(value, key, allData);
431
+ if (result.error || !result.value || !Object.keys(this._schema).length) return result;
432
+
433
+ // Validate each field in the nested schema
434
+ const { errors, validated } = await Validator._runSchema(this._schema, result.value);
435
+
436
+ if (Object.keys(errors).length) {
437
+ // Prefix field names with parent key
438
+ const prefixed = {};
439
+ for (const [k, v] of Object.entries(errors)) {
440
+ prefixed[`${key}.${k}`] = v;
441
+ }
442
+ return { error: prefixed, value, _nested: true };
443
+ }
444
+
445
+ return { error: null, value: validated };
446
+ }
447
+ }
448
+
449
+ // ── FileValidator ──────────────────────────────────────────────────────────────
450
+
451
+ class FileValidator extends BaseValidator {
452
+ constructor(typeError) {
453
+ super(typeError || null);
454
+ }
455
+
456
+ _checkType(value) {
457
+ // File objects from multer have .originalname, .size, .mimetype
458
+ if (!value || typeof value !== 'object' || !value.originalname) {
459
+ return this._typeError || 'Must be a valid file';
460
+ }
461
+ return null;
462
+ }
463
+
464
+ /** Must be an image (by MIME type). */
465
+ image(msg) {
466
+ return this._addRule(
467
+ v => v.mimetype && v.mimetype.startsWith('image/'),
468
+ msg || ((key) => `${this._fieldLabel(key)} must be an image`)
469
+ );
470
+ }
471
+
472
+ /**
473
+ * Maximum file size.
474
+ * file().maxSize('2mb')
475
+ * file().maxSize('500kb')
476
+ * file().maxSize(1024 * 1024) // bytes
477
+ */
478
+ maxSize(size, msg) {
479
+ const bytes = typeof size === 'string' ? _parseSize(size) : size;
480
+ return this._addRule(
481
+ v => v.size <= bytes,
482
+ msg || ((key) => `${this._fieldLabel(key)} must be smaller than ${typeof size === 'string' ? size : _formatSize(size)}`)
483
+ );
484
+ }
485
+
486
+ /** Allowed MIME types. */
487
+ mimeTypes(types, msg) {
488
+ return this._addRule(
489
+ v => types.includes(v.mimetype),
490
+ msg || ((key) => `${this._fieldLabel(key)} must be one of: ${types.join(', ')}`)
491
+ );
492
+ }
493
+
494
+ /** Allowed file extensions. */
495
+ extensions(exts, msg) {
496
+ return this._addRule(v => {
497
+ const ext = v.originalname.split('.').pop().toLowerCase();
498
+ return exts.map(e => e.toLowerCase().replace(/^\./, '')).includes(ext);
499
+ }, msg || ((key) => `${this._fieldLabel(key)} must have one of these extensions: ${exts.join(', ')}`));
500
+ }
501
+ }
502
+
503
+ function _parseSize(str) {
504
+ const n = parseFloat(str);
505
+ const unit = str.replace(/[\d.]/g, '').trim().toLowerCase();
506
+ const map = { b: 1, kb: 1024, mb: 1024**2, gb: 1024**3 };
507
+ return n * (map[unit] || 1);
508
+ }
509
+
510
+ function _formatSize(bytes) {
511
+ if (bytes >= 1024**3) return `${(bytes/1024**3).toFixed(1)}GB`;
512
+ if (bytes >= 1024**2) return `${(bytes/1024**2).toFixed(1)}MB`;
513
+ if (bytes >= 1024) return `${(bytes/1024).toFixed(1)}KB`;
514
+ return `${bytes}B`;
515
+ }
516
+
517
+ // ── Validator (the runner) ─────────────────────────────────────────────────────
518
+
519
+ /**
520
+ * Validator
521
+ *
522
+ * Runs a schema of field validators against a data object.
523
+ * Throws HttpError 422 on failure — caught by the router.
524
+ *
525
+ * Used by RequestContext.body.validate() and directly:
526
+ *
527
+ * const data = await Validator.validate(allData, {
528
+ * name: string().required('Name is required').max(100),
529
+ * email: email().required(),
530
+ * age: number().optional().min(0),
531
+ * });
532
+ *
533
+ * Also supports the legacy pipe-string format for backward compat:
534
+ * const data = await Validator.validate(allData, {
535
+ * name: 'required|string|min:2',
536
+ * email: 'required|email',
537
+ * });
538
+ */
539
+ class Validator {
540
+ static async validate(data, schema) {
541
+ const { errors, validated } = await Validator._runSchema(schema, data);
542
+
543
+ if (Object.keys(errors).length) {
544
+ const HttpError = require('../errors/HttpError');
545
+ throw new HttpError(422, 'Validation failed', errors);
546
+ }
547
+
548
+ return validated;
549
+ }
550
+
551
+ static async _runSchema(schema, data) {
552
+ const errors = {};
553
+ const validated = {};
554
+
555
+ for (const [key, rule] of Object.entries(schema)) {
556
+ const value = data[key];
557
+
558
+ // ── New API: validator instance ──────────────────────────────────────
559
+ if (rule instanceof BaseValidator) {
560
+ const result = await rule.run(value, key, data);
561
+
562
+ if (result.error) {
563
+ if (result._nested) {
564
+ // Object validator returns prefixed nested errors
565
+ Object.assign(errors, result.error);
566
+ } else {
567
+ // Resolve lazy message functions
568
+ errors[key] = typeof result.error === 'function'
569
+ ? result.error(key)
570
+ : result.error;
571
+ }
572
+ } else if (result.value !== undefined) {
573
+ validated[key] = result.value;
574
+ }
575
+ continue;
576
+ }
577
+
578
+ // ── Legacy API: pipe string ──────────────────────────────────────────
579
+ if (typeof rule === 'string') {
580
+ const err = Validator._runPipeRules(key, value, rule);
581
+ if (err) {
582
+ errors[key] = err;
583
+ } else if (value !== undefined) {
584
+ validated[key] = value;
585
+ }
586
+ continue;
587
+ }
588
+ }
589
+
590
+ return { errors, validated };
591
+ }
592
+
593
+ /** Backward-compatible pipe-string validation. */
594
+ static _runPipeRules(field, value, ruleString) {
595
+ const label = _titleCase(field);
596
+ const rules = ruleString.split('|').map(r => r.trim());
597
+ const isEmpty = value === undefined || value === null || value === '';
598
+
599
+ for (const rule of rules) {
600
+ const [name, arg] = rule.split(':');
601
+
602
+ switch (name) {
603
+ case 'required':
604
+ if (isEmpty) return `${label} is required`;
605
+ break;
606
+ case 'optional':
607
+ if (isEmpty) return null;
608
+ break;
609
+ case 'string':
610
+ if (!isEmpty && typeof value !== 'string') return `${label} must be a string`;
611
+ break;
612
+ case 'number':
613
+ if (!isEmpty && isNaN(Number(value))) return `${label} must be a number`;
614
+ break;
615
+ case 'boolean':
616
+ if (!isEmpty && ![true,false,'true','false','1','0',1,0].includes(value))
617
+ return `${label} must be a boolean`;
618
+ break;
619
+ case 'email':
620
+ if (!isEmpty && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
621
+ return `${label} must be a valid email address`;
622
+ break;
623
+ case 'min':
624
+ if (!isEmpty) {
625
+ if (typeof value === 'string' && value.length < Number(arg))
626
+ return `${label} must be at least ${arg} characters`;
627
+ if (typeof value === 'number' && value < Number(arg))
628
+ return `${label} must be at least ${arg}`;
629
+ }
630
+ break;
631
+ case 'max':
632
+ if (!isEmpty) {
633
+ if (typeof value === 'string' && value.length > Number(arg))
634
+ return `${label} must not exceed ${arg} characters`;
635
+ if (typeof value === 'number' && value > Number(arg))
636
+ return `${label} must not exceed ${arg}`;
637
+ }
638
+ break;
639
+ case 'url':
640
+ try { if (!isEmpty) new URL(value); } catch { return `${label} must be a valid URL`; }
641
+ break;
642
+ case 'in':
643
+ if (!isEmpty && !arg.split(',').includes(String(value)))
644
+ return `${label} must be one of: ${arg}`;
645
+ break;
646
+ case 'alpha':
647
+ if (!isEmpty && !/^[a-zA-Z]+$/.test(value)) return `${label} must contain only letters`;
648
+ break;
649
+ case 'alphanumeric':
650
+ if (!isEmpty && !/^[a-zA-Z0-9]+$/.test(value)) return `${label} must contain only letters and numbers`;
651
+ break;
652
+ default: break;
653
+ }
654
+ }
655
+ return null;
656
+ }
657
+ }
658
+
659
+ module.exports = {
660
+ Validator,
661
+ BaseValidator,
662
+ StringValidator,
663
+ EmailValidator,
664
+ NumberValidator,
665
+ BooleanValidator,
666
+ DateValidator,
667
+ ArrayValidator,
668
+ ObjectValidator,
669
+ FileValidator,
670
+
671
+ // Shorthand factory functions — the primary developer-facing API
672
+ string: (msg) => new StringValidator(msg),
673
+ email: (msg) => new EmailValidator(msg),
674
+ number: (msg) => new NumberValidator(msg),
675
+ boolean: (msg) => new BooleanValidator(msg),
676
+ date: (msg) => new DateValidator(msg),
677
+ array: (msg) => new ArrayValidator(msg),
678
+ object: (schema, msg) => new ObjectValidator(schema, msg),
679
+ file: (msg) => new FileValidator(msg),
680
+ };