@chidchanun/bcp 0.1.18 → 0.1.19
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/docs/README.md +30 -5
- package/docs/releases/0.1.19.md +106 -0
- package/docs/validation.md +342 -0
- package/package.json +5 -1
- package/packages/client/src/validation.ts +1118 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
export type ValidationPathSegment =
|
|
2
|
+
| string
|
|
3
|
+
| number;
|
|
4
|
+
|
|
5
|
+
export type ValidationPath =
|
|
6
|
+
readonly ValidationPathSegment[];
|
|
7
|
+
|
|
8
|
+
export interface ValidationIssue {
|
|
9
|
+
path: ValidationPath;
|
|
10
|
+
message: string;
|
|
11
|
+
code: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ValidationSuccess<T> {
|
|
15
|
+
success: true;
|
|
16
|
+
data: T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ValidationFailure {
|
|
20
|
+
success: false;
|
|
21
|
+
issues: ValidationIssue[];
|
|
22
|
+
fieldErrors: Record<string, string[]>;
|
|
23
|
+
formErrors: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ValidationResult<T> =
|
|
27
|
+
| ValidationSuccess<T>
|
|
28
|
+
| ValidationFailure;
|
|
29
|
+
|
|
30
|
+
export interface Validator<T> {
|
|
31
|
+
safeParse(
|
|
32
|
+
input: unknown,
|
|
33
|
+
path?: ValidationPath
|
|
34
|
+
): ValidationResult<T>;
|
|
35
|
+
parse(input: unknown): T;
|
|
36
|
+
optional(): Validator<T | undefined>;
|
|
37
|
+
nullable(): Validator<T | null>;
|
|
38
|
+
refine(
|
|
39
|
+
predicate: (value: T) => boolean,
|
|
40
|
+
message: string,
|
|
41
|
+
code?: string
|
|
42
|
+
): Validator<T>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type InferValidator<TValidator> =
|
|
46
|
+
TValidator extends Validator<infer TValue>
|
|
47
|
+
? TValue
|
|
48
|
+
: never;
|
|
49
|
+
|
|
50
|
+
export type ValidationShape =
|
|
51
|
+
Record<string, Validator<any>>;
|
|
52
|
+
|
|
53
|
+
export type InferValidationShape<
|
|
54
|
+
TShape extends ValidationShape
|
|
55
|
+
> = {
|
|
56
|
+
[TKey in keyof TShape]:
|
|
57
|
+
InferValidator<TShape[TKey]>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface StringValidationOptions {
|
|
61
|
+
trim?: boolean;
|
|
62
|
+
minLength?: number;
|
|
63
|
+
maxLength?: number;
|
|
64
|
+
email?: boolean;
|
|
65
|
+
pattern?: RegExp;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface NumberValidationOptions {
|
|
69
|
+
coerce?: boolean;
|
|
70
|
+
integer?: boolean;
|
|
71
|
+
min?: number;
|
|
72
|
+
max?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BooleanValidationOptions {
|
|
76
|
+
coerce?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ArrayValidationOptions {
|
|
80
|
+
minLength?: number;
|
|
81
|
+
maxLength?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ObjectValidationOptions {
|
|
85
|
+
allowUnknown?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class ValidationError
|
|
89
|
+
extends Error {
|
|
90
|
+
readonly issues:
|
|
91
|
+
ValidationIssue[];
|
|
92
|
+
readonly fieldErrors:
|
|
93
|
+
Record<string, string[]>;
|
|
94
|
+
readonly formErrors:
|
|
95
|
+
string[];
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
issues: readonly ValidationIssue[],
|
|
99
|
+
message = "Validation failed."
|
|
100
|
+
) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.name =
|
|
103
|
+
"ValidationError";
|
|
104
|
+
|
|
105
|
+
const failure =
|
|
106
|
+
createFailure(
|
|
107
|
+
issues
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
this.issues =
|
|
111
|
+
failure.issues;
|
|
112
|
+
this.fieldErrors =
|
|
113
|
+
failure.fieldErrors;
|
|
114
|
+
this.formErrors =
|
|
115
|
+
failure.formErrors;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
class BcpValidator<T>
|
|
120
|
+
implements Validator<T> {
|
|
121
|
+
constructor(
|
|
122
|
+
private readonly parser:
|
|
123
|
+
(
|
|
124
|
+
input: unknown,
|
|
125
|
+
path: ValidationPath
|
|
126
|
+
) => ValidationResult<T>
|
|
127
|
+
) {}
|
|
128
|
+
|
|
129
|
+
safeParse(
|
|
130
|
+
input: unknown,
|
|
131
|
+
path: ValidationPath = []
|
|
132
|
+
): ValidationResult<T> {
|
|
133
|
+
return this.parser(
|
|
134
|
+
input,
|
|
135
|
+
[
|
|
136
|
+
...path,
|
|
137
|
+
]
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
parse(input: unknown): T {
|
|
142
|
+
const result =
|
|
143
|
+
this.safeParse(
|
|
144
|
+
input
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (!result.success) {
|
|
148
|
+
throw new ValidationError(
|
|
149
|
+
result.issues
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result.data;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
optional():
|
|
157
|
+
Validator<T | undefined> {
|
|
158
|
+
return new BcpValidator<
|
|
159
|
+
T | undefined
|
|
160
|
+
>(
|
|
161
|
+
(
|
|
162
|
+
input,
|
|
163
|
+
path
|
|
164
|
+
) => {
|
|
165
|
+
if (
|
|
166
|
+
input ===
|
|
167
|
+
undefined
|
|
168
|
+
) {
|
|
169
|
+
return success(
|
|
170
|
+
undefined
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return this.safeParse(
|
|
175
|
+
input,
|
|
176
|
+
path
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
nullable():
|
|
183
|
+
Validator<T | null> {
|
|
184
|
+
return new BcpValidator<
|
|
185
|
+
T | null
|
|
186
|
+
>(
|
|
187
|
+
(
|
|
188
|
+
input,
|
|
189
|
+
path
|
|
190
|
+
) => {
|
|
191
|
+
if (
|
|
192
|
+
input === null
|
|
193
|
+
) {
|
|
194
|
+
return success(
|
|
195
|
+
null
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return this.safeParse(
|
|
200
|
+
input,
|
|
201
|
+
path
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
refine(
|
|
208
|
+
predicate:
|
|
209
|
+
(value: T) => boolean,
|
|
210
|
+
message: string,
|
|
211
|
+
code = "custom"
|
|
212
|
+
): Validator<T> {
|
|
213
|
+
return new BcpValidator<T>(
|
|
214
|
+
(
|
|
215
|
+
input,
|
|
216
|
+
path
|
|
217
|
+
) => {
|
|
218
|
+
const result =
|
|
219
|
+
this.safeParse(
|
|
220
|
+
input,
|
|
221
|
+
path
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
if (!result.success) {
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let accepted =
|
|
229
|
+
false;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
accepted =
|
|
233
|
+
predicate(
|
|
234
|
+
result.data
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
accepted =
|
|
238
|
+
false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!accepted) {
|
|
242
|
+
return failure(
|
|
243
|
+
path,
|
|
244
|
+
message,
|
|
245
|
+
code
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function string(
|
|
256
|
+
options:
|
|
257
|
+
StringValidationOptions = {}
|
|
258
|
+
): Validator<string> {
|
|
259
|
+
validateLengthOption(
|
|
260
|
+
options.minLength,
|
|
261
|
+
"minLength"
|
|
262
|
+
);
|
|
263
|
+
validateLengthOption(
|
|
264
|
+
options.maxLength,
|
|
265
|
+
"maxLength"
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (
|
|
269
|
+
options.minLength !== undefined &&
|
|
270
|
+
options.maxLength !== undefined &&
|
|
271
|
+
options.minLength >
|
|
272
|
+
options.maxLength
|
|
273
|
+
) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"BCP Validation: string minLength cannot be greater than maxLength."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return new BcpValidator<string>(
|
|
280
|
+
(
|
|
281
|
+
input,
|
|
282
|
+
path
|
|
283
|
+
) => {
|
|
284
|
+
if (
|
|
285
|
+
typeof input !==
|
|
286
|
+
"string"
|
|
287
|
+
) {
|
|
288
|
+
return requiredOrTypeFailure(
|
|
289
|
+
input,
|
|
290
|
+
path,
|
|
291
|
+
"string"
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const value =
|
|
296
|
+
options.trim
|
|
297
|
+
? input.trim()
|
|
298
|
+
: input;
|
|
299
|
+
|
|
300
|
+
if (
|
|
301
|
+
options.minLength !== undefined &&
|
|
302
|
+
value.length <
|
|
303
|
+
options.minLength
|
|
304
|
+
) {
|
|
305
|
+
return failure(
|
|
306
|
+
path,
|
|
307
|
+
`Must contain at least ${options.minLength} character(s).`,
|
|
308
|
+
"too_small"
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (
|
|
313
|
+
options.maxLength !== undefined &&
|
|
314
|
+
value.length >
|
|
315
|
+
options.maxLength
|
|
316
|
+
) {
|
|
317
|
+
return failure(
|
|
318
|
+
path,
|
|
319
|
+
`Must contain at most ${options.maxLength} character(s).`,
|
|
320
|
+
"too_big"
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (
|
|
325
|
+
options.email &&
|
|
326
|
+
!isEmail(
|
|
327
|
+
value
|
|
328
|
+
)
|
|
329
|
+
) {
|
|
330
|
+
return failure(
|
|
331
|
+
path,
|
|
332
|
+
"Must be a valid email address.",
|
|
333
|
+
"invalid_email"
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (
|
|
338
|
+
options.pattern &&
|
|
339
|
+
!testPattern(
|
|
340
|
+
options.pattern,
|
|
341
|
+
value
|
|
342
|
+
)
|
|
343
|
+
) {
|
|
344
|
+
return failure(
|
|
345
|
+
path,
|
|
346
|
+
"Must match the required format.",
|
|
347
|
+
"invalid_string"
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return success(
|
|
352
|
+
value
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function number(
|
|
359
|
+
options:
|
|
360
|
+
NumberValidationOptions = {}
|
|
361
|
+
): Validator<number> {
|
|
362
|
+
return new BcpValidator<number>(
|
|
363
|
+
(
|
|
364
|
+
input,
|
|
365
|
+
path
|
|
366
|
+
) => {
|
|
367
|
+
let value =
|
|
368
|
+
input;
|
|
369
|
+
|
|
370
|
+
if (
|
|
371
|
+
options.coerce &&
|
|
372
|
+
typeof value ===
|
|
373
|
+
"string" &&
|
|
374
|
+
value.trim() !==
|
|
375
|
+
""
|
|
376
|
+
) {
|
|
377
|
+
value =
|
|
378
|
+
Number(
|
|
379
|
+
value
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (
|
|
384
|
+
typeof value !==
|
|
385
|
+
"number" ||
|
|
386
|
+
!Number.isFinite(
|
|
387
|
+
value
|
|
388
|
+
)
|
|
389
|
+
) {
|
|
390
|
+
return requiredOrTypeFailure(
|
|
391
|
+
input,
|
|
392
|
+
path,
|
|
393
|
+
"number"
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (
|
|
398
|
+
options.integer &&
|
|
399
|
+
!Number.isInteger(
|
|
400
|
+
value
|
|
401
|
+
)
|
|
402
|
+
) {
|
|
403
|
+
return failure(
|
|
404
|
+
path,
|
|
405
|
+
"Must be an integer.",
|
|
406
|
+
"invalid_integer"
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (
|
|
411
|
+
options.min !== undefined &&
|
|
412
|
+
value < options.min
|
|
413
|
+
) {
|
|
414
|
+
return failure(
|
|
415
|
+
path,
|
|
416
|
+
`Must be greater than or equal to ${options.min}.`,
|
|
417
|
+
"too_small"
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (
|
|
422
|
+
options.max !== undefined &&
|
|
423
|
+
value > options.max
|
|
424
|
+
) {
|
|
425
|
+
return failure(
|
|
426
|
+
path,
|
|
427
|
+
`Must be less than or equal to ${options.max}.`,
|
|
428
|
+
"too_big"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return success(
|
|
433
|
+
value
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function boolean(
|
|
440
|
+
options:
|
|
441
|
+
BooleanValidationOptions = {}
|
|
442
|
+
): Validator<boolean> {
|
|
443
|
+
return new BcpValidator<boolean>(
|
|
444
|
+
(
|
|
445
|
+
input,
|
|
446
|
+
path
|
|
447
|
+
) => {
|
|
448
|
+
if (
|
|
449
|
+
typeof input ===
|
|
450
|
+
"boolean"
|
|
451
|
+
) {
|
|
452
|
+
return success(
|
|
453
|
+
input
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (
|
|
458
|
+
options.coerce &&
|
|
459
|
+
typeof input ===
|
|
460
|
+
"string"
|
|
461
|
+
) {
|
|
462
|
+
const normalized =
|
|
463
|
+
input
|
|
464
|
+
.trim()
|
|
465
|
+
.toLowerCase();
|
|
466
|
+
|
|
467
|
+
if (
|
|
468
|
+
[
|
|
469
|
+
"1",
|
|
470
|
+
"true",
|
|
471
|
+
"on",
|
|
472
|
+
"yes",
|
|
473
|
+
].includes(
|
|
474
|
+
normalized
|
|
475
|
+
)
|
|
476
|
+
) {
|
|
477
|
+
return success(
|
|
478
|
+
true
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (
|
|
483
|
+
[
|
|
484
|
+
"0",
|
|
485
|
+
"false",
|
|
486
|
+
"off",
|
|
487
|
+
"no",
|
|
488
|
+
].includes(
|
|
489
|
+
normalized
|
|
490
|
+
)
|
|
491
|
+
) {
|
|
492
|
+
return success(
|
|
493
|
+
false
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return requiredOrTypeFailure(
|
|
499
|
+
input,
|
|
500
|
+
path,
|
|
501
|
+
"boolean"
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function literal<
|
|
508
|
+
const TValue extends
|
|
509
|
+
| string
|
|
510
|
+
| number
|
|
511
|
+
| boolean
|
|
512
|
+
| null
|
|
513
|
+
>(
|
|
514
|
+
expected: TValue
|
|
515
|
+
): Validator<TValue> {
|
|
516
|
+
return new BcpValidator<TValue>(
|
|
517
|
+
(
|
|
518
|
+
input,
|
|
519
|
+
path
|
|
520
|
+
) => {
|
|
521
|
+
if (
|
|
522
|
+
input !==
|
|
523
|
+
expected
|
|
524
|
+
) {
|
|
525
|
+
return failure(
|
|
526
|
+
path,
|
|
527
|
+
`Must equal ${JSON.stringify(expected)}.`,
|
|
528
|
+
"invalid_literal"
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return success(
|
|
533
|
+
expected
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function enumValue<
|
|
540
|
+
const TValues extends
|
|
541
|
+
readonly [
|
|
542
|
+
string,
|
|
543
|
+
...string[],
|
|
544
|
+
]
|
|
545
|
+
>(
|
|
546
|
+
values: TValues
|
|
547
|
+
): Validator<TValues[number]> {
|
|
548
|
+
const accepted =
|
|
549
|
+
new Set<string>(
|
|
550
|
+
values
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
return new BcpValidator<
|
|
554
|
+
TValues[number]
|
|
555
|
+
>(
|
|
556
|
+
(
|
|
557
|
+
input,
|
|
558
|
+
path
|
|
559
|
+
) => {
|
|
560
|
+
if (
|
|
561
|
+
typeof input !==
|
|
562
|
+
"string" ||
|
|
563
|
+
!accepted.has(
|
|
564
|
+
input
|
|
565
|
+
)
|
|
566
|
+
) {
|
|
567
|
+
return failure(
|
|
568
|
+
path,
|
|
569
|
+
`Must be one of: ${values.join(", ")}.`,
|
|
570
|
+
"invalid_enum"
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return success(
|
|
575
|
+
input as TValues[number]
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function array<T>(
|
|
582
|
+
item: Validator<T>,
|
|
583
|
+
options:
|
|
584
|
+
ArrayValidationOptions = {}
|
|
585
|
+
): Validator<T[]> {
|
|
586
|
+
validateLengthOption(
|
|
587
|
+
options.minLength,
|
|
588
|
+
"minLength"
|
|
589
|
+
);
|
|
590
|
+
validateLengthOption(
|
|
591
|
+
options.maxLength,
|
|
592
|
+
"maxLength"
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
return new BcpValidator<T[]>(
|
|
596
|
+
(
|
|
597
|
+
input,
|
|
598
|
+
path
|
|
599
|
+
) => {
|
|
600
|
+
if (
|
|
601
|
+
!Array.isArray(
|
|
602
|
+
input
|
|
603
|
+
)
|
|
604
|
+
) {
|
|
605
|
+
return requiredOrTypeFailure(
|
|
606
|
+
input,
|
|
607
|
+
path,
|
|
608
|
+
"array"
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (
|
|
613
|
+
options.minLength !== undefined &&
|
|
614
|
+
input.length <
|
|
615
|
+
options.minLength
|
|
616
|
+
) {
|
|
617
|
+
return failure(
|
|
618
|
+
path,
|
|
619
|
+
`Must contain at least ${options.minLength} item(s).`,
|
|
620
|
+
"too_small"
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (
|
|
625
|
+
options.maxLength !== undefined &&
|
|
626
|
+
input.length >
|
|
627
|
+
options.maxLength
|
|
628
|
+
) {
|
|
629
|
+
return failure(
|
|
630
|
+
path,
|
|
631
|
+
`Must contain at most ${options.maxLength} item(s).`,
|
|
632
|
+
"too_big"
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const output:
|
|
637
|
+
T[] = [];
|
|
638
|
+
const issues:
|
|
639
|
+
ValidationIssue[] = [];
|
|
640
|
+
|
|
641
|
+
for (
|
|
642
|
+
let index = 0;
|
|
643
|
+
index < input.length;
|
|
644
|
+
index += 1
|
|
645
|
+
) {
|
|
646
|
+
const result =
|
|
647
|
+
item.safeParse(
|
|
648
|
+
input[index],
|
|
649
|
+
[
|
|
650
|
+
...path,
|
|
651
|
+
index,
|
|
652
|
+
]
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
if (result.success) {
|
|
656
|
+
output.push(
|
|
657
|
+
result.data
|
|
658
|
+
);
|
|
659
|
+
} else {
|
|
660
|
+
issues.push(
|
|
661
|
+
...result.issues
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return issues.length > 0
|
|
667
|
+
? createFailure(
|
|
668
|
+
issues
|
|
669
|
+
)
|
|
670
|
+
: success(
|
|
671
|
+
output
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function object<
|
|
678
|
+
TShape extends ValidationShape
|
|
679
|
+
>(
|
|
680
|
+
shape: TShape,
|
|
681
|
+
options:
|
|
682
|
+
ObjectValidationOptions = {}
|
|
683
|
+
): Validator<
|
|
684
|
+
InferValidationShape<TShape>
|
|
685
|
+
> {
|
|
686
|
+
return new BcpValidator<
|
|
687
|
+
InferValidationShape<TShape>
|
|
688
|
+
>(
|
|
689
|
+
(
|
|
690
|
+
input,
|
|
691
|
+
path
|
|
692
|
+
) => {
|
|
693
|
+
if (
|
|
694
|
+
!isPlainObject(
|
|
695
|
+
input
|
|
696
|
+
)
|
|
697
|
+
) {
|
|
698
|
+
return requiredOrTypeFailure(
|
|
699
|
+
input,
|
|
700
|
+
path,
|
|
701
|
+
"object"
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const source =
|
|
706
|
+
input as Record<
|
|
707
|
+
string,
|
|
708
|
+
unknown
|
|
709
|
+
>;
|
|
710
|
+
const output:
|
|
711
|
+
Record<string, unknown> =
|
|
712
|
+
options.allowUnknown
|
|
713
|
+
? {
|
|
714
|
+
...source,
|
|
715
|
+
}
|
|
716
|
+
: {};
|
|
717
|
+
const issues:
|
|
718
|
+
ValidationIssue[] = [];
|
|
719
|
+
|
|
720
|
+
for (
|
|
721
|
+
const [
|
|
722
|
+
key,
|
|
723
|
+
validator,
|
|
724
|
+
]
|
|
725
|
+
of Object.entries(
|
|
726
|
+
shape
|
|
727
|
+
)
|
|
728
|
+
) {
|
|
729
|
+
const result =
|
|
730
|
+
validator.safeParse(
|
|
731
|
+
source[key],
|
|
732
|
+
[
|
|
733
|
+
...path,
|
|
734
|
+
key,
|
|
735
|
+
]
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
if (result.success) {
|
|
739
|
+
output[key] =
|
|
740
|
+
result.data;
|
|
741
|
+
} else {
|
|
742
|
+
issues.push(
|
|
743
|
+
...result.issues
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return issues.length > 0
|
|
749
|
+
? createFailure(
|
|
750
|
+
issues
|
|
751
|
+
)
|
|
752
|
+
: success(
|
|
753
|
+
output as
|
|
754
|
+
InferValidationShape<TShape>
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function union<
|
|
761
|
+
const TValidators extends
|
|
762
|
+
readonly [
|
|
763
|
+
Validator<any>,
|
|
764
|
+
...Validator<any>[],
|
|
765
|
+
]
|
|
766
|
+
>(
|
|
767
|
+
validators: TValidators
|
|
768
|
+
): Validator<
|
|
769
|
+
InferValidator<
|
|
770
|
+
TValidators[number]
|
|
771
|
+
>
|
|
772
|
+
> {
|
|
773
|
+
return new BcpValidator<
|
|
774
|
+
InferValidator<
|
|
775
|
+
TValidators[number]
|
|
776
|
+
>
|
|
777
|
+
>(
|
|
778
|
+
(
|
|
779
|
+
input,
|
|
780
|
+
path
|
|
781
|
+
) => {
|
|
782
|
+
const issues:
|
|
783
|
+
ValidationIssue[] = [];
|
|
784
|
+
|
|
785
|
+
for (
|
|
786
|
+
const validator
|
|
787
|
+
of validators
|
|
788
|
+
) {
|
|
789
|
+
const result =
|
|
790
|
+
validator.safeParse(
|
|
791
|
+
input,
|
|
792
|
+
path
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
if (result.success) {
|
|
796
|
+
return result;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
issues.push(
|
|
800
|
+
...result.issues
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return createFailure(
|
|
805
|
+
issues.length > 0
|
|
806
|
+
? issues
|
|
807
|
+
: [
|
|
808
|
+
{
|
|
809
|
+
path,
|
|
810
|
+
message:
|
|
811
|
+
"Value did not match any allowed type.",
|
|
812
|
+
code:
|
|
813
|
+
"invalid_union",
|
|
814
|
+
},
|
|
815
|
+
]
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export function optional<T>(
|
|
822
|
+
validator: Validator<T>
|
|
823
|
+
): Validator<T | undefined> {
|
|
824
|
+
return validator.optional();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export function nullable<T>(
|
|
828
|
+
validator: Validator<T>
|
|
829
|
+
): Validator<T | null> {
|
|
830
|
+
return validator.nullable();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export function safeParse<T>(
|
|
834
|
+
validator: Validator<T>,
|
|
835
|
+
input: unknown
|
|
836
|
+
): ValidationResult<T> {
|
|
837
|
+
return validator.safeParse(
|
|
838
|
+
input
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export function parse<T>(
|
|
843
|
+
validator: Validator<T>,
|
|
844
|
+
input: unknown
|
|
845
|
+
): T {
|
|
846
|
+
return validator.parse(
|
|
847
|
+
input
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function validateFormData<T>(
|
|
852
|
+
validator: Validator<T>,
|
|
853
|
+
formData: FormData
|
|
854
|
+
): ValidationResult<T> {
|
|
855
|
+
return validator.safeParse(
|
|
856
|
+
formDataToObject(
|
|
857
|
+
formData
|
|
858
|
+
)
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function formDataToObject(
|
|
863
|
+
formData: FormData
|
|
864
|
+
): Record<string, unknown> {
|
|
865
|
+
const result:
|
|
866
|
+
Record<string, unknown> = {};
|
|
867
|
+
|
|
868
|
+
for (
|
|
869
|
+
const [
|
|
870
|
+
key,
|
|
871
|
+
value,
|
|
872
|
+
]
|
|
873
|
+
of formData.entries()
|
|
874
|
+
) {
|
|
875
|
+
const existing =
|
|
876
|
+
result[key];
|
|
877
|
+
|
|
878
|
+
if (
|
|
879
|
+
existing === undefined
|
|
880
|
+
) {
|
|
881
|
+
result[key] =
|
|
882
|
+
value;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (
|
|
887
|
+
Array.isArray(
|
|
888
|
+
existing
|
|
889
|
+
)
|
|
890
|
+
) {
|
|
891
|
+
existing.push(
|
|
892
|
+
value
|
|
893
|
+
);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
result[key] = [
|
|
898
|
+
existing,
|
|
899
|
+
value,
|
|
900
|
+
];
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return result;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export function flattenValidationIssues(
|
|
907
|
+
issues: readonly ValidationIssue[]
|
|
908
|
+
): Pick<
|
|
909
|
+
ValidationFailure,
|
|
910
|
+
"fieldErrors" | "formErrors"
|
|
911
|
+
> {
|
|
912
|
+
const fieldErrors:
|
|
913
|
+
Record<string, string[]> = {};
|
|
914
|
+
const formErrors:
|
|
915
|
+
string[] = [];
|
|
916
|
+
|
|
917
|
+
for (
|
|
918
|
+
const issue
|
|
919
|
+
of issues
|
|
920
|
+
) {
|
|
921
|
+
if (
|
|
922
|
+
issue.path.length ===
|
|
923
|
+
0
|
|
924
|
+
) {
|
|
925
|
+
formErrors.push(
|
|
926
|
+
issue.message
|
|
927
|
+
);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const field =
|
|
932
|
+
formatValidationPath(
|
|
933
|
+
issue.path
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
fieldErrors[field] ??=
|
|
937
|
+
[];
|
|
938
|
+
fieldErrors[field].push(
|
|
939
|
+
issue.message
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
fieldErrors,
|
|
945
|
+
formErrors,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export function getFieldError(
|
|
950
|
+
result:
|
|
951
|
+
ValidationResult<unknown>,
|
|
952
|
+
field: string
|
|
953
|
+
): string | undefined {
|
|
954
|
+
if (result.success) {
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return result
|
|
959
|
+
.fieldErrors[field]?.[0];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export function isValidationError(
|
|
963
|
+
value: unknown
|
|
964
|
+
): value is ValidationError {
|
|
965
|
+
return value instanceof
|
|
966
|
+
ValidationError;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export function formatValidationPath(
|
|
970
|
+
path: ValidationPath
|
|
971
|
+
): string {
|
|
972
|
+
return path
|
|
973
|
+
.map(
|
|
974
|
+
String
|
|
975
|
+
)
|
|
976
|
+
.join(".");
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export const v = {
|
|
980
|
+
string,
|
|
981
|
+
number,
|
|
982
|
+
boolean,
|
|
983
|
+
literal,
|
|
984
|
+
enum:
|
|
985
|
+
enumValue,
|
|
986
|
+
array,
|
|
987
|
+
object,
|
|
988
|
+
union,
|
|
989
|
+
optional,
|
|
990
|
+
nullable,
|
|
991
|
+
} as const;
|
|
992
|
+
|
|
993
|
+
function success<T>(
|
|
994
|
+
data: T
|
|
995
|
+
): ValidationSuccess<T> {
|
|
996
|
+
return {
|
|
997
|
+
success: true,
|
|
998
|
+
data,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function failure(
|
|
1003
|
+
path: ValidationPath,
|
|
1004
|
+
message: string,
|
|
1005
|
+
code: string
|
|
1006
|
+
): ValidationFailure {
|
|
1007
|
+
return createFailure([
|
|
1008
|
+
{
|
|
1009
|
+
path:
|
|
1010
|
+
[
|
|
1011
|
+
...path,
|
|
1012
|
+
],
|
|
1013
|
+
message,
|
|
1014
|
+
code,
|
|
1015
|
+
},
|
|
1016
|
+
]);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function createFailure(
|
|
1020
|
+
sourceIssues:
|
|
1021
|
+
readonly ValidationIssue[]
|
|
1022
|
+
): ValidationFailure {
|
|
1023
|
+
const issues =
|
|
1024
|
+
sourceIssues.map(
|
|
1025
|
+
(issue) => ({
|
|
1026
|
+
...issue,
|
|
1027
|
+
path: [
|
|
1028
|
+
...issue.path,
|
|
1029
|
+
],
|
|
1030
|
+
})
|
|
1031
|
+
);
|
|
1032
|
+
const flattened =
|
|
1033
|
+
flattenValidationIssues(
|
|
1034
|
+
issues
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
return {
|
|
1038
|
+
success: false,
|
|
1039
|
+
issues,
|
|
1040
|
+
...flattened,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function requiredOrTypeFailure(
|
|
1045
|
+
input: unknown,
|
|
1046
|
+
path: ValidationPath,
|
|
1047
|
+
expected: string
|
|
1048
|
+
): ValidationFailure {
|
|
1049
|
+
if (
|
|
1050
|
+
input === undefined ||
|
|
1051
|
+
input === null ||
|
|
1052
|
+
input === ""
|
|
1053
|
+
) {
|
|
1054
|
+
return failure(
|
|
1055
|
+
path,
|
|
1056
|
+
"Required.",
|
|
1057
|
+
"required"
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return failure(
|
|
1062
|
+
path,
|
|
1063
|
+
`Expected ${expected}.`,
|
|
1064
|
+
"invalid_type"
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function validateLengthOption(
|
|
1069
|
+
value: number | undefined,
|
|
1070
|
+
name: string
|
|
1071
|
+
): void {
|
|
1072
|
+
if (
|
|
1073
|
+
value !== undefined &&
|
|
1074
|
+
(
|
|
1075
|
+
!Number.isInteger(
|
|
1076
|
+
value
|
|
1077
|
+
) ||
|
|
1078
|
+
value < 0
|
|
1079
|
+
)
|
|
1080
|
+
) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
`BCP Validation: ${name} must be a non-negative integer.`
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function isPlainObject(
|
|
1088
|
+
value: unknown
|
|
1089
|
+
): value is Record<string, unknown> {
|
|
1090
|
+
return (
|
|
1091
|
+
typeof value ===
|
|
1092
|
+
"object" &&
|
|
1093
|
+
value !== null &&
|
|
1094
|
+
!Array.isArray(
|
|
1095
|
+
value
|
|
1096
|
+
)
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function isEmail(
|
|
1101
|
+
value: string
|
|
1102
|
+
): boolean {
|
|
1103
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
|
1104
|
+
value
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function testPattern(
|
|
1109
|
+
pattern: RegExp,
|
|
1110
|
+
value: string
|
|
1111
|
+
): boolean {
|
|
1112
|
+
pattern.lastIndex =
|
|
1113
|
+
0;
|
|
1114
|
+
|
|
1115
|
+
return pattern.test(
|
|
1116
|
+
value
|
|
1117
|
+
);
|
|
1118
|
+
}
|