snap-validate 0.4.1 → 0.4.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.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Snap Validate - BaseValidator
3
+ * The chainable validator. Each method pushes a rule (sync) or async rule
4
+ * onto the instance and returns `this` for fluent chaining.
5
+ */
6
+
7
+ const { ValidationResult } = require('./ValidationResult');
8
+ const {
9
+ isRegexSafe,
10
+ safeRegexTest,
11
+ safeRegexTestSync
12
+ } = require('../utils/safeRegex');
13
+ const { validate, validateAsync } = require('../schema/validate');
14
+
15
+ class BaseValidator {
16
+ constructor(value) {
17
+ this.value = value;
18
+ this.rules = [];
19
+ this.asyncRules = [];
20
+ this.isOptional = false;
21
+ // Deprecated no-op: a timer cannot interrupt a synchronous regex, so this
22
+ // value is no longer consulted. Retained only for backward compatibility.
23
+ this.regexTimeout = 1000;
24
+ this.fieldName = null; // Track field name for better error messages
25
+ }
26
+
27
+ // Set field name for contextual error messages
28
+ setFieldName(name) {
29
+ this.fieldName = name;
30
+ return this;
31
+ }
32
+
33
+ // Helper to format error messages with field context
34
+ _formatError(message) {
35
+ if (
36
+ this.fieldName &&
37
+ !message.toLowerCase().includes(this.fieldName.toLowerCase())
38
+ ) {
39
+ return `${this.fieldName}: ${message}`;
40
+ }
41
+ return message;
42
+ }
43
+
44
+ // Returns true when the field is optional and effectively empty, so a rule
45
+ // can short-circuit as valid. Pass `false` to NOT treat '' as empty
46
+ // (array/object rules, where an empty string is a real, invalid value).
47
+ _shouldSkipOptional(emptyStringCounts = true) {
48
+ if (!this.isOptional) {
49
+ return false;
50
+ }
51
+ const v = this.value;
52
+ return v === null || v === undefined || (emptyStringCounts && v === '');
53
+ }
54
+
55
+ required(message = 'This field is required') {
56
+ this.rules.push(() => {
57
+ if (this._shouldSkipOptional()) {
58
+ return new ValidationResult(true);
59
+ }
60
+
61
+ if (
62
+ this.value === null ||
63
+ this.value === undefined ||
64
+ this.value === ''
65
+ ) {
66
+ return new ValidationResult(false, [this._formatError(message)]);
67
+ }
68
+ return new ValidationResult(true);
69
+ });
70
+ return this;
71
+ }
72
+
73
+ optional() {
74
+ this.isOptional = true;
75
+ return this;
76
+ }
77
+
78
+ // Deprecated: retained for backward compatibility and chainability. Regex
79
+ // execution cannot be interrupted by a timeout on a single thread, so this
80
+ // value is no longer used. Safe to remove in a future major version.
81
+ setRegexTimeout(timeoutMs) {
82
+ this.regexTimeout = timeoutMs;
83
+ return this;
84
+ }
85
+
86
+ // Transform/sanitize data before validation
87
+ transform(fn, errorMessage = 'Transform function failed') {
88
+ this.rules.push(() => {
89
+ if (this.value != null && this.value !== '') {
90
+ try {
91
+ this.value = fn(this.value);
92
+ } catch (error) {
93
+ return new ValidationResult(false, [
94
+ this._formatError(`${errorMessage}: ${error.message}`)
95
+ ]);
96
+ }
97
+ }
98
+ return new ValidationResult(true);
99
+ });
100
+ return this;
101
+ }
102
+
103
+ // Check if value equals another value
104
+ equals(compareValue, message) {
105
+ const defaultMessage = `Must equal ${compareValue}`;
106
+ this.rules.push(() => {
107
+ if (this._shouldSkipOptional()) {
108
+ return new ValidationResult(true);
109
+ }
110
+
111
+ if (this.value !== compareValue) {
112
+ return new ValidationResult(false, [
113
+ this._formatError(message || defaultMessage)
114
+ ]);
115
+ }
116
+ return new ValidationResult(true);
117
+ });
118
+ return this;
119
+ }
120
+
121
+ // Check if value is one of allowed values
122
+ oneOf(allowedValues, message) {
123
+ const defaultMessage = `Must be one of: ${allowedValues.join(', ')}`;
124
+ this.rules.push(() => {
125
+ if (this._shouldSkipOptional()) {
126
+ return new ValidationResult(true);
127
+ }
128
+
129
+ if (!allowedValues.includes(this.value)) {
130
+ return new ValidationResult(false, [
131
+ this._formatError(message || defaultMessage)
132
+ ]);
133
+ }
134
+ return new ValidationResult(true);
135
+ });
136
+ return this;
137
+ }
138
+
139
+ // Check if value is between min and max (for numbers)
140
+ between(min, max, message) {
141
+ const defaultMessage = `Must be between ${min} and ${max}`;
142
+ this.rules.push(() => {
143
+ if (this._shouldSkipOptional()) {
144
+ return new ValidationResult(true);
145
+ }
146
+
147
+ const numValue =
148
+ typeof this.value === 'number' ? this.value : parseFloat(this.value);
149
+
150
+ if (isNaN(numValue) || numValue < min || numValue > max) {
151
+ return new ValidationResult(false, [
152
+ this._formatError(message || defaultMessage)
153
+ ]);
154
+ }
155
+ return new ValidationResult(true);
156
+ });
157
+ return this;
158
+ }
159
+
160
+ min(length, message = `Minimum length is ${length}`) {
161
+ this.rules.push(() => {
162
+ if (this._shouldSkipOptional()) {
163
+ return new ValidationResult(true);
164
+ }
165
+
166
+ if (this.value != null && this.value !== '') {
167
+ if (typeof this.value === 'string' || Array.isArray(this.value)) {
168
+ if (this.value.length < length) {
169
+ return new ValidationResult(false, [this._formatError(message)]);
170
+ }
171
+ } else if (typeof this.value === 'number') {
172
+ if (this.value < length) {
173
+ return new ValidationResult(false, [this._formatError(message)]);
174
+ }
175
+ } else {
176
+ return new ValidationResult(false, [
177
+ this._formatError('Value must be a string, array, or number')
178
+ ]);
179
+ }
180
+ }
181
+ return new ValidationResult(true);
182
+ });
183
+ return this;
184
+ }
185
+
186
+ max(length, message = `Maximum length is ${length}`) {
187
+ this.rules.push(() => {
188
+ if (this._shouldSkipOptional()) {
189
+ return new ValidationResult(true);
190
+ }
191
+
192
+ if (this.value != null && this.value !== '') {
193
+ if (typeof this.value === 'string' || Array.isArray(this.value)) {
194
+ if (this.value.length > length) {
195
+ return new ValidationResult(false, [this._formatError(message)]);
196
+ }
197
+ } else if (typeof this.value === 'number') {
198
+ if (this.value > length) {
199
+ return new ValidationResult(false, [this._formatError(message)]);
200
+ }
201
+ } else {
202
+ return new ValidationResult(false, [
203
+ this._formatError('Value must be a string, array or number')
204
+ ]);
205
+ }
206
+ }
207
+ return new ValidationResult(true);
208
+ });
209
+ return this;
210
+ }
211
+
212
+ // Validate that value is an array
213
+ array(message = 'Must be an array') {
214
+ this.rules.push(() => {
215
+ if (this._shouldSkipOptional(false)) {
216
+ return new ValidationResult(true);
217
+ }
218
+
219
+ if (!Array.isArray(this.value)) {
220
+ return new ValidationResult(false, [this._formatError(message)]);
221
+ }
222
+ return new ValidationResult(true);
223
+ });
224
+ return this;
225
+ }
226
+
227
+ // Validate each item in an array
228
+ arrayOf(validator, message = 'Invalid array items') {
229
+ this.rules.push(() => {
230
+ if (this._shouldSkipOptional(false)) {
231
+ return new ValidationResult(true);
232
+ }
233
+
234
+ if (!Array.isArray(this.value)) {
235
+ return new ValidationResult(false, [
236
+ this._formatError('Value must be an array')
237
+ ]);
238
+ }
239
+
240
+ const errors = [];
241
+ this.value.forEach((item, index) => {
242
+ try {
243
+ const itemValidator =
244
+ typeof validator === 'function' ? validator(item) : validator;
245
+ const result = itemValidator.validate();
246
+
247
+ if (!result.isValid) {
248
+ errors.push(`[${index}]: ${result.errors.join(', ')}`);
249
+ }
250
+ } catch (error) {
251
+ errors.push(`[${index}]: Validation error - ${error.message}`);
252
+ }
253
+ });
254
+
255
+ if (errors.length > 0) {
256
+ return new ValidationResult(false, [
257
+ this._formatError(`${message}: ${errors.join('; ')}`)
258
+ ]);
259
+ }
260
+
261
+ return new ValidationResult(true);
262
+ });
263
+ return this;
264
+ }
265
+
266
+ // Async array validation
267
+ arrayOfAsync(validator, message = 'Invalid array items') {
268
+ this.asyncRules.push(async () => {
269
+ if (this._shouldSkipOptional(false)) {
270
+ return new ValidationResult(true);
271
+ }
272
+
273
+ if (!Array.isArray(this.value)) {
274
+ return new ValidationResult(false, [
275
+ this._formatError('Value must be an array')
276
+ ]);
277
+ }
278
+
279
+ const errors = [];
280
+ for (let index = 0; index < this.value.length; index++) {
281
+ try {
282
+ const item = this.value[index];
283
+ const itemValidator =
284
+ typeof validator === 'function' ? validator(item) : validator;
285
+
286
+ const result =
287
+ itemValidator.asyncRules && itemValidator.asyncRules.length > 0
288
+ ? await itemValidator.validateAsync()
289
+ : itemValidator.validate();
290
+
291
+ if (!result.isValid) {
292
+ errors.push(`[${index}]: ${result.errors.join(', ')}`);
293
+ }
294
+ } catch (error) {
295
+ errors.push(`[${index}]: Validation error - ${error.message}`);
296
+ }
297
+ }
298
+
299
+ if (errors.length > 0) {
300
+ return new ValidationResult(false, [
301
+ this._formatError(`${message}: ${errors.join('; ')}`)
302
+ ]);
303
+ }
304
+
305
+ return new ValidationResult(true);
306
+ });
307
+ return this;
308
+ }
309
+
310
+ // Validate nested objects
311
+ object(schema, message = 'Invalid object structure') {
312
+ this.rules.push(() => {
313
+ if (this._shouldSkipOptional(false)) {
314
+ return new ValidationResult(true);
315
+ }
316
+
317
+ if (
318
+ typeof this.value !== 'object' ||
319
+ this.value === null ||
320
+ Array.isArray(this.value)
321
+ ) {
322
+ return new ValidationResult(false, [this._formatError(message)]);
323
+ }
324
+
325
+ try {
326
+ const result = validate(schema, this.value);
327
+
328
+ if (!result.isValid) {
329
+ const fieldErrors = result.getErrors();
330
+ const errorMessages = Object.entries(fieldErrors)
331
+ .map(([field, errs]) => `${field}: ${errs.join(', ')}`)
332
+ .join('; ');
333
+
334
+ return new ValidationResult(false, [
335
+ this._formatError(`Object validation failed - ${errorMessages}`)
336
+ ]);
337
+ }
338
+
339
+ return new ValidationResult(true);
340
+ } catch (error) {
341
+ return new ValidationResult(false, [
342
+ this._formatError(`Object validation error: ${error.message}`)
343
+ ]);
344
+ }
345
+ });
346
+ return this;
347
+ }
348
+
349
+ // Async nested object validation
350
+ objectAsync(schema, message = 'Invalid object structure') {
351
+ this.asyncRules.push(async () => {
352
+ if (this._shouldSkipOptional(false)) {
353
+ return new ValidationResult(true);
354
+ }
355
+
356
+ if (
357
+ typeof this.value !== 'object' ||
358
+ this.value === null ||
359
+ Array.isArray(this.value)
360
+ ) {
361
+ return new ValidationResult(false, [this._formatError(message)]);
362
+ }
363
+
364
+ try {
365
+ const result = await validateAsync(schema, this.value);
366
+
367
+ if (!result.isValid) {
368
+ const fieldErrors = result.getErrors();
369
+ const errorMessages = Object.entries(fieldErrors)
370
+ .map(([field, errs]) => `${field}: ${errs.join(', ')}`)
371
+ .join('; ');
372
+
373
+ return new ValidationResult(false, [
374
+ this._formatError(`Object validation failed - ${errorMessages}`)
375
+ ]);
376
+ }
377
+
378
+ return new ValidationResult(true);
379
+ } catch (error) {
380
+ return new ValidationResult(false, [
381
+ this._formatError(`Object validation error: ${error.message}`)
382
+ ]);
383
+ }
384
+ });
385
+ return this;
386
+ }
387
+
388
+ pattern(regex, message = 'Invalid format') {
389
+ if (!isRegexSafe(regex)) {
390
+ throw new Error(
391
+ 'Potentially unsafe regex pattern detected. Please use a simple pattern.'
392
+ );
393
+ }
394
+
395
+ this.rules.push(() => {
396
+ if (this._shouldSkipOptional()) {
397
+ return new ValidationResult(true);
398
+ }
399
+
400
+ if (this.value != null && this.value !== '') {
401
+ const stringValue = String(this.value);
402
+
403
+ try {
404
+ if (!safeRegexTestSync(regex, stringValue)) {
405
+ return new ValidationResult(false, [this._formatError(message)]);
406
+ }
407
+ } catch (error) {
408
+ if (error.message.includes('Input too long')) {
409
+ return new ValidationResult(false, [
410
+ this._formatError('Input too long for pattern validation')
411
+ ]);
412
+ }
413
+ return new ValidationResult(false, [
414
+ this._formatError('Pattern validation failed')
415
+ ]);
416
+ }
417
+ }
418
+ return new ValidationResult(true);
419
+ });
420
+ return this;
421
+ }
422
+
423
+ patternAsync(regex, message = 'Invalid format') {
424
+ if (!isRegexSafe(regex)) {
425
+ throw new Error(
426
+ 'Potentially unsafe regex pattern detected. Please use a simple pattern.'
427
+ );
428
+ }
429
+
430
+ this.asyncRules.push(async () => {
431
+ if (this._shouldSkipOptional()) {
432
+ return new ValidationResult(true);
433
+ }
434
+
435
+ if (this.value != null && this.value !== '') {
436
+ const stringValue = String(this.value);
437
+
438
+ if (stringValue.length > 10000) {
439
+ return new ValidationResult(false, [
440
+ this._formatError('Input too long for pattern validation')
441
+ ]);
442
+ }
443
+
444
+ try {
445
+ const result = await safeRegexTest(regex, stringValue);
446
+ if (!result) {
447
+ return new ValidationResult(false, [this._formatError(message)]);
448
+ }
449
+ } catch {
450
+ return new ValidationResult(false, [
451
+ this._formatError('Pattern validation failed')
452
+ ]);
453
+ }
454
+ }
455
+ return new ValidationResult(true);
456
+ });
457
+ return this;
458
+ }
459
+
460
+ when(condition, validator) {
461
+ this.rules.push(() => {
462
+ const shouldValidate =
463
+ typeof condition === 'function' ? condition(this.value) : condition;
464
+
465
+ if (shouldValidate) {
466
+ if (typeof validator === 'function') {
467
+ const conditionalValidator = validator(this.value);
468
+ return conditionalValidator.validate();
469
+ } else {
470
+ return validator.validate();
471
+ }
472
+ }
473
+
474
+ return new ValidationResult(true);
475
+ });
476
+ return this;
477
+ }
478
+
479
+ custom(validatorFn, message = 'Custom validation failed') {
480
+ this.rules.push(() => {
481
+ if (this._shouldSkipOptional()) {
482
+ return new ValidationResult(true);
483
+ }
484
+
485
+ try {
486
+ const result = validatorFn(this.value);
487
+
488
+ if (typeof result === 'boolean') {
489
+ return result
490
+ ? new ValidationResult(true)
491
+ : new ValidationResult(false, [this._formatError(message)]);
492
+ }
493
+
494
+ if (result && typeof result === 'object' && 'isValid' in result) {
495
+ return result;
496
+ }
497
+
498
+ if (typeof result === 'string') {
499
+ return new ValidationResult(false, [this._formatError(result)]);
500
+ }
501
+
502
+ return new ValidationResult(true);
503
+ } catch (error) {
504
+ return new ValidationResult(false, [
505
+ this._formatError(`Custom validation error: ${error.message}`)
506
+ ]);
507
+ }
508
+ });
509
+ return this;
510
+ }
511
+
512
+ customAsync(validatorFn, message = 'Async validation failed') {
513
+ this.asyncRules.push(async () => {
514
+ if (this._shouldSkipOptional()) {
515
+ return new ValidationResult(true);
516
+ }
517
+
518
+ try {
519
+ const result = await validatorFn(this.value);
520
+
521
+ if (typeof result === 'boolean') {
522
+ return result
523
+ ? new ValidationResult(true)
524
+ : new ValidationResult(false, [this._formatError(message)]);
525
+ }
526
+
527
+ if (result && typeof result === 'object' && 'isValid' in result) {
528
+ return result;
529
+ }
530
+
531
+ if (typeof result === 'string') {
532
+ return new ValidationResult(false, [this._formatError(result)]);
533
+ }
534
+
535
+ return new ValidationResult(true);
536
+ } catch (error) {
537
+ return new ValidationResult(false, [
538
+ this._formatError(`Async validation error: ${error.message}`)
539
+ ]);
540
+ }
541
+ });
542
+ return this;
543
+ }
544
+
545
+ validate() {
546
+ const result = new ValidationResult(true);
547
+
548
+ for (const rule of this.rules) {
549
+ try {
550
+ const ruleResult = rule();
551
+ if (!ruleResult.isValid) {
552
+ result.isValid = false;
553
+ result.errors.push(...ruleResult.errors);
554
+ }
555
+ } catch (error) {
556
+ result.isValid = false;
557
+ result.errors.push(
558
+ this._formatError(`Validation error: ${error.message}`)
559
+ );
560
+ }
561
+ }
562
+
563
+ return result;
564
+ }
565
+
566
+ async validateAsync() {
567
+ const syncResult = this.validate();
568
+
569
+ if (!syncResult.isValid) {
570
+ return syncResult;
571
+ }
572
+
573
+ const result = new ValidationResult(true, [...syncResult.errors]);
574
+
575
+ for (const asyncRule of this.asyncRules) {
576
+ try {
577
+ const ruleResult = await asyncRule();
578
+ if (!ruleResult.isValid) {
579
+ result.isValid = false;
580
+ result.errors.push(...ruleResult.errors);
581
+ }
582
+ } catch (error) {
583
+ result.isValid = false;
584
+ result.errors.push(
585
+ this._formatError(`Async validation error: ${error.message}`)
586
+ );
587
+ }
588
+ }
589
+
590
+ return result;
591
+ }
592
+ }
593
+
594
+ module.exports = { BaseValidator };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Snap Validate - ValidationResult
3
+ * The result type returned by every validation rule.
4
+ */
5
+
6
+ class ValidationResult {
7
+ constructor(isValid, errors = []) {
8
+ this.isValid = isValid;
9
+ this.errors = errors;
10
+ }
11
+
12
+ addError(message) {
13
+ this.errors.push(message);
14
+ this.isValid = false;
15
+ return this;
16
+ }
17
+ }
18
+
19
+ module.exports = { ValidationResult };