@zerotal/validator 1.0.0
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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/package.json +53 -0
- package/src/FieldRule.ts +723 -0
- package/src/FormRequest.ts +255 -0
- package/src/PrecognitionError.ts +41 -0
- package/src/RuleBuilder.ts +65 -0
- package/src/ValidationError.ts +35 -0
- package/src/Validator.ts +519 -0
- package/src/config.ts +31 -0
- package/src/dbRules.ts +70 -0
- package/src/facades/Validator.ts +36 -0
- package/src/global.d.ts +19 -0
- package/src/index.ts +34 -0
- package/src/stringRules.ts +298 -0
- package/src/types.ts +77 -0
- package/src/validate.ts +95 -0
package/src/FieldRule.ts
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import type { FieldRuleDefinition, Schema, CustomFn } from "./types.ts";
|
|
2
|
+
import type { UniqueOptions } from "./dbRules.ts";
|
|
3
|
+
|
|
4
|
+
// Narrow definition types — each class's _def has a literal 'type' field so
|
|
5
|
+
// that Infer<> can resolve the correct TypeScript type per field class.
|
|
6
|
+
type StringDef = Omit<FieldRuleDefinition, "type"> & { type: "string" };
|
|
7
|
+
type NumberDef = Omit<FieldRuleDefinition, "type"> & { type: "number" };
|
|
8
|
+
type BooleanDef = Omit<FieldRuleDefinition, "type"> & { type: "boolean" };
|
|
9
|
+
type ArrayDef = Omit<FieldRuleDefinition, "type"> & { type: "array" };
|
|
10
|
+
type ObjectDef = Omit<FieldRuleDefinition, "type"> & { type: "object" };
|
|
11
|
+
|
|
12
|
+
type RuleEntry = { name: string; args: unknown[]; message?: string; fn?: CustomFn };
|
|
13
|
+
|
|
14
|
+
function addRule(
|
|
15
|
+
rules: Array<RuleEntry>,
|
|
16
|
+
name: string,
|
|
17
|
+
args: unknown[],
|
|
18
|
+
message: string | undefined,
|
|
19
|
+
): void {
|
|
20
|
+
if (message !== undefined) {
|
|
21
|
+
rules.push({ name, args, message });
|
|
22
|
+
} else {
|
|
23
|
+
rules.push({ name, args });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export abstract class FieldRule {
|
|
28
|
+
abstract readonly _def: FieldRuleDefinition;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mark this field as required (the default). Useful for readability, for re-asserting
|
|
32
|
+
* after `.optional()`, or to attach a custom "is required" message:
|
|
33
|
+
*
|
|
34
|
+
* rule.string().required("Please enter your name").min(2)
|
|
35
|
+
*/
|
|
36
|
+
required(message?: string): this {
|
|
37
|
+
this._def.required = true;
|
|
38
|
+
if (message !== undefined) this._def.requiredMessage = message;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
optional(): this {
|
|
43
|
+
this._def.required = false;
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
nullable(): this {
|
|
48
|
+
this._def.nullable = true;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
default(value: unknown): this {
|
|
53
|
+
this._def.defaultVal = value;
|
|
54
|
+
this._def.required = false;
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Only run this field's rules if the field is present in the input.
|
|
60
|
+
* Absent fields are silently skipped and excluded from the output.
|
|
61
|
+
* Use for optional PATCH fields that should only be validated when provided.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* bio: rules.string().sometimes().max(500)
|
|
65
|
+
*/
|
|
66
|
+
sometimes(): this {
|
|
67
|
+
this._def.sometimes = true;
|
|
68
|
+
this._def.required = false;
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Make this field required only when another field equals a specific value.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* address: rules.string().requiredIf('hasShipping', true)
|
|
77
|
+
* address: rules.string().requiredIf(input => input.method === 'pickup')
|
|
78
|
+
*/
|
|
79
|
+
requiredIf(
|
|
80
|
+
fieldOrFn: string | ((input: Record<string, unknown>) => boolean),
|
|
81
|
+
value?: unknown,
|
|
82
|
+
): this {
|
|
83
|
+
if (typeof fieldOrFn === "function") {
|
|
84
|
+
this._def.requiredIf = { fn: fieldOrFn };
|
|
85
|
+
} else {
|
|
86
|
+
this._def.requiredIf = { field: fieldOrFn, value };
|
|
87
|
+
}
|
|
88
|
+
this._def.required = false;
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Make this field required unless another field equals a specific value.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* phone: rules.string().requiredUnless('contactMethod', 'email')
|
|
97
|
+
*/
|
|
98
|
+
requiredUnless(
|
|
99
|
+
fieldOrFn: string | ((input: Record<string, unknown>) => boolean),
|
|
100
|
+
value?: unknown,
|
|
101
|
+
): this {
|
|
102
|
+
if (typeof fieldOrFn === "function") {
|
|
103
|
+
this._def.requiredUnless = { fn: fieldOrFn };
|
|
104
|
+
} else {
|
|
105
|
+
this._def.requiredUnless = { field: fieldOrFn, value };
|
|
106
|
+
}
|
|
107
|
+
this._def.required = false;
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The value must be truthy: `true`, `1`, `'1'`, `'yes'`, `'on'`, `'true'`.
|
|
113
|
+
* Use for "agree to terms" checkboxes.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* terms: rules.boolean().accepted()
|
|
117
|
+
*/
|
|
118
|
+
accepted(message?: string): this {
|
|
119
|
+
addRule(this._def.rules, "accepted", [], message);
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The value must be falsy: `false`, `0`, `'0'`, `'no'`, `'off'`, `'false'`.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* newsletter: rules.boolean().declined()
|
|
128
|
+
*/
|
|
129
|
+
declined(message?: string): this {
|
|
130
|
+
addRule(this._def.rules, "declined", [], message);
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run a custom synchronous or asynchronous validation function.
|
|
136
|
+
* Return `true` to pass, `false` for a generic error, or a `string` as the error message.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* username: rules.string().custom(async (value) => {
|
|
140
|
+
* const taken = await Username.isTaken(value as string);
|
|
141
|
+
* return taken ? 'This username is already taken.' : true;
|
|
142
|
+
* })
|
|
143
|
+
*/
|
|
144
|
+
custom(fn: CustomFn, message?: string): this {
|
|
145
|
+
const entry: RuleEntry = { name: "custom", args: [], fn };
|
|
146
|
+
if (message !== undefined) entry.message = message;
|
|
147
|
+
this._def.rules.push(entry);
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* This field is required when ANY of the listed fields are present in input.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* cardCvv: rules.string().requiredWith(['cardNumber', 'cardExpiry'])
|
|
156
|
+
*/
|
|
157
|
+
requiredWith(fields: string[]): this {
|
|
158
|
+
this._def.requiredWith = fields;
|
|
159
|
+
this._def.required = false;
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* This field is required when ANY of the listed fields are absent from input.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* email: rules.string().requiredWithout(['phone'])
|
|
168
|
+
*/
|
|
169
|
+
requiredWithout(fields: string[]): this {
|
|
170
|
+
this._def.requiredWithout = fields;
|
|
171
|
+
this._def.required = false;
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* This field is required when ALL of the listed fields are present.
|
|
177
|
+
*/
|
|
178
|
+
requiredWithAll(fields: string[]): this {
|
|
179
|
+
this._def.requiredWithAll = fields;
|
|
180
|
+
this._def.required = false;
|
|
181
|
+
return this;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* This field is required when ALL of the listed fields are absent.
|
|
186
|
+
*/
|
|
187
|
+
requiredWithoutAll(fields: string[]): this {
|
|
188
|
+
this._def.requiredWithoutAll = fields;
|
|
189
|
+
this._def.required = false;
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* This field must be absent when another field equals the given value.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* adminCode: rules.string().prohibitedIf('role', 'guest')
|
|
198
|
+
*/
|
|
199
|
+
prohibitedIf(field: string, value: unknown): this {
|
|
200
|
+
this._def.prohibitedIf = { field, value };
|
|
201
|
+
return this;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* This field must be absent unless another field equals the given value.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* discount: rules.number().prohibitedUnless('role', 'admin')
|
|
209
|
+
*/
|
|
210
|
+
prohibitedUnless(field: string, value: unknown): this {
|
|
211
|
+
this._def.prohibitedUnless = { field, value };
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Stop validating this field after the first failing rule.
|
|
217
|
+
* Without `bail()`, all rules are checked and all errors collected.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* email: rules.string().email().unique('users', 'email').bail()
|
|
221
|
+
*/
|
|
222
|
+
bail(): this {
|
|
223
|
+
this._def.bail = true;
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export class StringRule extends FieldRule {
|
|
229
|
+
readonly _def: StringDef = {
|
|
230
|
+
type: "string",
|
|
231
|
+
required: true,
|
|
232
|
+
nullable: false,
|
|
233
|
+
defaultVal: undefined,
|
|
234
|
+
rules: [],
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
min(length: number, message?: string): this {
|
|
238
|
+
addRule(this._def.rules, "min", [length], message);
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
max(length: number, message?: string): this {
|
|
243
|
+
addRule(this._def.rules, "max", [length], message);
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
email(message?: string): this {
|
|
248
|
+
addRule(this._def.rules, "email", [], message);
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
url(message?: string): this {
|
|
253
|
+
addRule(this._def.rules, "url", [], message);
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
regex(pattern: RegExp, message?: string): this {
|
|
258
|
+
addRule(this._def.rules, "regex", [pattern.source, pattern.flags], message);
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
uuid(message?: string): this {
|
|
263
|
+
return this.regex(
|
|
264
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
265
|
+
message ?? "Must be a valid UUID",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
trim(): this {
|
|
270
|
+
addRule(this._def.rules, "trim", [], undefined);
|
|
271
|
+
return this;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
lowercase(): this {
|
|
275
|
+
addRule(this._def.rules, "lowercase", [], undefined);
|
|
276
|
+
return this;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
uppercase(): this {
|
|
280
|
+
addRule(this._def.rules, "uppercase", [], undefined);
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
in(values: string[], message?: string): this {
|
|
285
|
+
addRule(this._def.rules, "in", [values], message);
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
confirmed(message?: string): this {
|
|
290
|
+
addRule(this._def.rules, "confirmed", [], message);
|
|
291
|
+
return this;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Value must NOT be in the given list. */
|
|
295
|
+
notIn(values: string[], message?: string): this {
|
|
296
|
+
addRule(this._def.rules, "notIn", [values], message);
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Value must equal another field in the input (e.g. password confirmation). */
|
|
301
|
+
sameAs(field: string, message?: string): this {
|
|
302
|
+
addRule(this._def.rules, "sameAs", [field], message);
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Only letters (a–z, A–Z). */
|
|
307
|
+
alpha(message?: string): this {
|
|
308
|
+
addRule(this._def.rules, "alpha", [], message);
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Letters and digits only. */
|
|
313
|
+
alphaNum(message?: string): this {
|
|
314
|
+
addRule(this._def.rules, "alphaNum", [], message);
|
|
315
|
+
return this;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Letters, digits, hyphens, and underscores. */
|
|
319
|
+
alphaDash(message?: string): this {
|
|
320
|
+
addRule(this._def.rules, "alphaDash", [], message);
|
|
321
|
+
return this;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** String must start with the given prefix. */
|
|
325
|
+
startsWith(prefix: string, message?: string): this {
|
|
326
|
+
addRule(this._def.rules, "startsWith", [prefix], message);
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** String must end with the given suffix. */
|
|
331
|
+
endsWith(suffix: string, message?: string): this {
|
|
332
|
+
addRule(this._def.rules, "endsWith", [suffix], message);
|
|
333
|
+
return this;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Exact character count. */
|
|
337
|
+
size(n: number, message?: string): this {
|
|
338
|
+
addRule(this._def.rules, "size", [n], message);
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Assert the value does not already exist in `table.column`.
|
|
344
|
+
* Requires DatabaseProvider to be registered.
|
|
345
|
+
*
|
|
346
|
+
* Pass the current record's ID as the third argument to ignore it on update
|
|
347
|
+
* (accepts a plain ID or a full `UniqueOptions` object for advanced cases).
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* email: rules.string().email().unique('users', 'email')
|
|
351
|
+
* email: rules.string().email().unique('users', 'email', user.id) // ignore on update
|
|
352
|
+
* email: rules.string().email().unique('users', 'email', { ignoreId: user.id })
|
|
353
|
+
*/
|
|
354
|
+
unique(
|
|
355
|
+
table: string,
|
|
356
|
+
column: string,
|
|
357
|
+
ignoreOrOpts?: string | number | UniqueOptions,
|
|
358
|
+
message?: string,
|
|
359
|
+
): this {
|
|
360
|
+
const options: UniqueOptions =
|
|
361
|
+
typeof ignoreOrOpts === "string" || typeof ignoreOrOpts === "number"
|
|
362
|
+
? { ignoreId: ignoreOrOpts }
|
|
363
|
+
: (ignoreOrOpts ?? {});
|
|
364
|
+
const entry: RuleEntry = { name: "unique", args: [table, column, options] };
|
|
365
|
+
if (message !== undefined) entry.message = message;
|
|
366
|
+
this._def.rules.push(entry);
|
|
367
|
+
return this;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Assert the value already exists in `table.column`.
|
|
372
|
+
* Requires DatabaseProvider to be registered.
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* role: rules.string().exists('roles', 'name')
|
|
376
|
+
*/
|
|
377
|
+
exists(table: string, column: string, message?: string): this {
|
|
378
|
+
const entry: RuleEntry = { name: "exists", args: [table, column, {}] };
|
|
379
|
+
if (message !== undefined) entry.message = message;
|
|
380
|
+
this._def.rules.push(entry);
|
|
381
|
+
return this;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Alias for regex() — matches the given pattern.
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* rules.string().matches(/^[A-Z]{2}\d{4}$/, 'Invalid format')
|
|
389
|
+
*/
|
|
390
|
+
matches(pattern: RegExp, message?: string): this {
|
|
391
|
+
return this.regex(pattern, message);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Mark this field as a password.
|
|
396
|
+
* A semantic signal — use it as the starting point then chain your
|
|
397
|
+
* specific constraints (min, max, matches, etc.).
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
* password: rules.string().password().min(8).max(128)
|
|
401
|
+
* .matches(/^(?=.*[A-Z])(?=.*\d).+$/, 'Must contain uppercase and digit')
|
|
402
|
+
*/
|
|
403
|
+
password(): this {
|
|
404
|
+
return this;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Must be a valid IPv4 or IPv6 address. */
|
|
408
|
+
ip(message?: string): this {
|
|
409
|
+
addRule(this._def.rules, "ip", [], message);
|
|
410
|
+
return this;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Must be a valid JSON string (parseable). */
|
|
414
|
+
json(message?: string): this {
|
|
415
|
+
addRule(this._def.rules, "json", [], message);
|
|
416
|
+
return this;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Must consist of exactly `n` digits. */
|
|
420
|
+
digits(n: number, message?: string): this {
|
|
421
|
+
addRule(this._def.rules, "digits", [n], message);
|
|
422
|
+
return this;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Digit count must be between `min` and `max` (inclusive). */
|
|
426
|
+
digitsBetween(min: number, max: number, message?: string): this {
|
|
427
|
+
addRule(this._def.rules, "digitsBetween", [min, max], message);
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Must be numeric (digits only, optional leading minus). */
|
|
432
|
+
numeric(message?: string): this {
|
|
433
|
+
addRule(this._def.rules, "numeric", [], message);
|
|
434
|
+
return this;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Field must be absent from the input (the key must not be present). */
|
|
438
|
+
prohibited(message?: string): this {
|
|
439
|
+
addRule(this._def.rules, "prohibited", [], message);
|
|
440
|
+
return this;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Field must be present in the input (but may be empty). */
|
|
444
|
+
present(message?: string): this {
|
|
445
|
+
addRule(this._def.rules, "present", [], message);
|
|
446
|
+
return this;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export class NumberRule extends FieldRule {
|
|
451
|
+
readonly _def: NumberDef = {
|
|
452
|
+
type: "number",
|
|
453
|
+
required: true,
|
|
454
|
+
nullable: false,
|
|
455
|
+
defaultVal: undefined,
|
|
456
|
+
rules: [],
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
min(value: number, message?: string): this {
|
|
460
|
+
addRule(this._def.rules, "min", [value], message);
|
|
461
|
+
return this;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
max(value: number, message?: string): this {
|
|
465
|
+
addRule(this._def.rules, "max", [value], message);
|
|
466
|
+
return this;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
integer(message?: string): this {
|
|
470
|
+
addRule(this._def.rules, "integer", [], message);
|
|
471
|
+
return this;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
positive(message?: string): this {
|
|
475
|
+
return this.min(0, message ?? "Must be a positive number");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Value must be within [min, max] inclusive. Shorthand for min()+max(). */
|
|
479
|
+
between(min: number, max: number, message?: string): this {
|
|
480
|
+
addRule(this._def.rules, "between", [min, max], message);
|
|
481
|
+
return this;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Value must NOT be in the given list. */
|
|
485
|
+
notIn(values: number[], message?: string): this {
|
|
486
|
+
addRule(this._def.rules, "notIn", [values], message);
|
|
487
|
+
return this;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Assert the value already exists in `table.column`.
|
|
492
|
+
* Requires DatabaseProvider to be registered.
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* userId: rules.number().exists('users', 'id')
|
|
496
|
+
*/
|
|
497
|
+
exists(table: string, column: string, message?: string): this {
|
|
498
|
+
const entry: RuleEntry = { name: "exists", args: [table, column, {}] };
|
|
499
|
+
if (message !== undefined) entry.message = message;
|
|
500
|
+
this._def.rules.push(entry);
|
|
501
|
+
return this;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export class BooleanRule extends FieldRule {
|
|
506
|
+
readonly _def: BooleanDef = {
|
|
507
|
+
type: "boolean",
|
|
508
|
+
required: true,
|
|
509
|
+
nullable: false,
|
|
510
|
+
defaultVal: undefined,
|
|
511
|
+
rules: [],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export class ArrayRule<T extends FieldRule> extends FieldRule {
|
|
516
|
+
readonly _def: ArrayDef;
|
|
517
|
+
|
|
518
|
+
constructor(item: T) {
|
|
519
|
+
super();
|
|
520
|
+
this._def = {
|
|
521
|
+
type: "array",
|
|
522
|
+
required: true,
|
|
523
|
+
nullable: false,
|
|
524
|
+
defaultVal: undefined,
|
|
525
|
+
rules: [],
|
|
526
|
+
children: item._def,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
min(length: number, message?: string): this {
|
|
531
|
+
addRule(this._def.rules, "min", [length], message);
|
|
532
|
+
return this;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
max(length: number, message?: string): this {
|
|
536
|
+
addRule(this._def.rules, "max", [length], message);
|
|
537
|
+
return this;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Exact item count. */
|
|
541
|
+
size(n: number, message?: string): this {
|
|
542
|
+
addRule(this._def.rules, "size", [n], message);
|
|
543
|
+
return this;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export class ObjectRule<S extends Schema> extends FieldRule {
|
|
548
|
+
readonly _def: ObjectDef;
|
|
549
|
+
|
|
550
|
+
constructor(shape: { [K in keyof S]: FieldRule }) {
|
|
551
|
+
super();
|
|
552
|
+
const shapeRecord: Record<string, FieldRuleDefinition> = {};
|
|
553
|
+
for (const [k, v] of Object.entries(shape)) {
|
|
554
|
+
shapeRecord[k] = (v as FieldRule)._def;
|
|
555
|
+
}
|
|
556
|
+
this._def = {
|
|
557
|
+
type: "object",
|
|
558
|
+
required: true,
|
|
559
|
+
nullable: false,
|
|
560
|
+
defaultVal: undefined,
|
|
561
|
+
rules: [],
|
|
562
|
+
shape: shapeRecord,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── DateRule ──────────────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
interface DateDef extends FieldRuleDefinition {
|
|
570
|
+
type: "date";
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export class DateRule extends FieldRule {
|
|
574
|
+
readonly _def: DateDef;
|
|
575
|
+
|
|
576
|
+
constructor() {
|
|
577
|
+
super();
|
|
578
|
+
this._def = {
|
|
579
|
+
type: "date",
|
|
580
|
+
required: true,
|
|
581
|
+
nullable: false,
|
|
582
|
+
defaultVal: undefined,
|
|
583
|
+
rules: [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The date must be after the given date (exclusive). */
|
|
588
|
+
after(date: Date | string, message?: string): this {
|
|
589
|
+
const iso = date instanceof Date ? date.toISOString() : date;
|
|
590
|
+
addRule(this._def.rules, "after", [iso], message);
|
|
591
|
+
return this;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** The date must be before the given date (exclusive). */
|
|
595
|
+
before(date: Date | string, message?: string): this {
|
|
596
|
+
const iso = date instanceof Date ? date.toISOString() : date;
|
|
597
|
+
addRule(this._def.rules, "before", [iso], message);
|
|
598
|
+
return this;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The date must be on or after the given date (inclusive). */
|
|
602
|
+
afterOrEqual(date: Date | string, message?: string): this {
|
|
603
|
+
const iso = date instanceof Date ? date.toISOString() : date;
|
|
604
|
+
addRule(this._def.rules, "afterOrEqual", [iso], message);
|
|
605
|
+
return this;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The date must be on or before the given date (inclusive). */
|
|
609
|
+
beforeOrEqual(date: Date | string, message?: string): this {
|
|
610
|
+
const iso = date instanceof Date ? date.toISOString() : date;
|
|
611
|
+
addRule(this._def.rules, "beforeOrEqual", [iso], message);
|
|
612
|
+
return this;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── FileRule ──────────────────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
interface FileDef extends FieldRuleDefinition {
|
|
619
|
+
type: "file";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Validates a `File` object (from multipart/form-data uploads).
|
|
624
|
+
* Works with Bun's native FormData File objects.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* avatar: rules.file().mimes(['jpg', 'png', 'webp']).max(2048)
|
|
628
|
+
*/
|
|
629
|
+
export class FileRule extends FieldRule {
|
|
630
|
+
readonly _def: FileDef;
|
|
631
|
+
|
|
632
|
+
constructor() {
|
|
633
|
+
super();
|
|
634
|
+
this._def = {
|
|
635
|
+
type: "file",
|
|
636
|
+
required: true,
|
|
637
|
+
nullable: false,
|
|
638
|
+
defaultVal: undefined,
|
|
639
|
+
rules: [],
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Allowed MIME type extensions (without the dot). E.g. `['jpg', 'png', 'pdf']`. */
|
|
644
|
+
mimes(extensions: string[], message?: string): this {
|
|
645
|
+
addRule(this._def.rules, "fileMimes", [extensions], message);
|
|
646
|
+
return this;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Maximum file size in kilobytes. */
|
|
650
|
+
max(kb: number, message?: string): this {
|
|
651
|
+
addRule(this._def.rules, "fileMax", [kb], message);
|
|
652
|
+
return this;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Minimum file size in kilobytes. */
|
|
656
|
+
min(kb: number, message?: string): this {
|
|
657
|
+
addRule(this._def.rules, "fileMin", [kb], message);
|
|
658
|
+
return this;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ── PasswordRule ──────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Validates password strength. Chains onto StringRule.
|
|
666
|
+
*
|
|
667
|
+
* @example
|
|
668
|
+
* password: rules.password().min(8).mixedCase().numbers().symbols()
|
|
669
|
+
*/
|
|
670
|
+
export class PasswordRule extends FieldRule {
|
|
671
|
+
readonly _def: FieldRuleDefinition & { type: "string" };
|
|
672
|
+
|
|
673
|
+
constructor() {
|
|
674
|
+
super();
|
|
675
|
+
this._def = {
|
|
676
|
+
type: "string",
|
|
677
|
+
required: true,
|
|
678
|
+
nullable: false,
|
|
679
|
+
defaultVal: undefined,
|
|
680
|
+
rules: [],
|
|
681
|
+
};
|
|
682
|
+
// Default minimum length
|
|
683
|
+
addRule(this._def.rules, "min", [8], undefined);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Minimum length (default: 8). */
|
|
687
|
+
min(length: number, message?: string): this {
|
|
688
|
+
// Remove existing min rule and re-add
|
|
689
|
+
this._def.rules = this._def.rules.filter((r) => r.name !== "min");
|
|
690
|
+
addRule(this._def.rules, "min", [length], message);
|
|
691
|
+
return this;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Must contain at least one uppercase and one lowercase letter. */
|
|
695
|
+
mixedCase(message?: string): this {
|
|
696
|
+
addRule(this._def.rules, "passwordMixedCase", [], message);
|
|
697
|
+
return this;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Must contain at least one number (0-9). */
|
|
701
|
+
numbers(message?: string): this {
|
|
702
|
+
addRule(this._def.rules, "passwordNumbers", [], message);
|
|
703
|
+
return this;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Must contain at least one symbol (!@#$%^&* etc.). */
|
|
707
|
+
symbols(message?: string): this {
|
|
708
|
+
addRule(this._def.rules, "passwordSymbols", [], message);
|
|
709
|
+
return this;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Must not contain spaces. */
|
|
713
|
+
uncompromised(message?: string): this {
|
|
714
|
+
addRule(this._def.rules, "passwordNoSpaces", [], message);
|
|
715
|
+
return this;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Value must equal the `{field}_confirmation` input field. */
|
|
719
|
+
confirmed(message?: string): this {
|
|
720
|
+
addRule(this._def.rules, "confirmed", [], message);
|
|
721
|
+
return this;
|
|
722
|
+
}
|
|
723
|
+
}
|