@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/Validator.ts
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Schema,
|
|
3
|
+
ValidationOutcome,
|
|
4
|
+
ValidationErrors,
|
|
5
|
+
FieldRuleDefinition,
|
|
6
|
+
CustomFn,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
import { runDbRule } from "./dbRules.ts";
|
|
9
|
+
import type { UniqueOptions } from "./dbRules.ts";
|
|
10
|
+
import { RULES } from "./stringRules.ts";
|
|
11
|
+
|
|
12
|
+
/** Resolve whether a field is effectively required given its conditional modifiers. */
|
|
13
|
+
function resolveRequired(def: FieldRuleDefinition, input: Record<string, unknown>): boolean {
|
|
14
|
+
if (def.requiredIf !== undefined) {
|
|
15
|
+
const cond = def.requiredIf;
|
|
16
|
+
if ("fn" in cond) return cond.fn(input);
|
|
17
|
+
return input[cond.field] === cond.value;
|
|
18
|
+
}
|
|
19
|
+
if (def.requiredUnless !== undefined) {
|
|
20
|
+
const cond = def.requiredUnless;
|
|
21
|
+
if ("fn" in cond) return !cond.fn(input);
|
|
22
|
+
return input[cond.field] !== cond.value;
|
|
23
|
+
}
|
|
24
|
+
return def.required;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runValidation<S extends Schema>(
|
|
28
|
+
schema: S,
|
|
29
|
+
input: Record<string, unknown>,
|
|
30
|
+
): ValidationOutcome<unknown> {
|
|
31
|
+
const errors: ValidationErrors = {};
|
|
32
|
+
const output: Record<string, unknown> = {};
|
|
33
|
+
|
|
34
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
35
|
+
let value = input[field];
|
|
36
|
+
|
|
37
|
+
// prohibited(): field must NOT be present at all
|
|
38
|
+
const isProhibited = def.rules.some((r) => r.name === "prohibited");
|
|
39
|
+
if (isProhibited && field in input) {
|
|
40
|
+
const rule = def.rules.find((r) => r.name === "prohibited")!;
|
|
41
|
+
errors[field] ??= rule.message ?? `The ${field} field must not be present.`;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (isProhibited) continue; // absent — OK
|
|
45
|
+
|
|
46
|
+
// prohibitedIf(): prohibited when another field equals a value
|
|
47
|
+
if (def.prohibitedIf !== undefined) {
|
|
48
|
+
const { field: f, value: v } = def.prohibitedIf;
|
|
49
|
+
if (input[f] === v && field in input) {
|
|
50
|
+
errors[field] ??= `The ${field} field must not be present when ${f} is ${String(v)}.`;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (input[f] === v) continue; // condition met but field absent — OK
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// prohibitedUnless(): prohibited unless another field equals a value
|
|
57
|
+
if (def.prohibitedUnless !== undefined) {
|
|
58
|
+
const { field: f, value: v } = def.prohibitedUnless;
|
|
59
|
+
if (input[f] !== v && field in input) {
|
|
60
|
+
errors[field] ??= `The ${field} field must not be present unless ${f} is ${String(v)}.`;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (input[f] !== v) continue; // condition met but field absent — OK
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// present(): field must exist (key must be in input), value may be empty
|
|
67
|
+
const isPresent = def.rules.some((r) => r.name === "present");
|
|
68
|
+
if (isPresent && !(field in input)) {
|
|
69
|
+
const rule = def.rules.find((r) => r.name === "present")!;
|
|
70
|
+
errors[field] ??= rule.message ?? `The ${field} field must be present.`;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// requiredWith(): required if ANY of the listed fields are present
|
|
75
|
+
if (def.requiredWith !== undefined) {
|
|
76
|
+
const anyPresent = def.requiredWith.some(
|
|
77
|
+
(f) => f in input && input[f] !== undefined && input[f] !== null,
|
|
78
|
+
);
|
|
79
|
+
if (anyPresent && (value === undefined || value === null || value === "")) {
|
|
80
|
+
errors[field] ??=
|
|
81
|
+
`The ${field} field is required when ${def.requiredWith.join(", ")} is present.`;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// requiredWithout(): required if ANY of the listed fields are absent
|
|
87
|
+
if (def.requiredWithout !== undefined) {
|
|
88
|
+
const anyAbsent = def.requiredWithout.some(
|
|
89
|
+
(f) => !(f in input) || input[f] === undefined || input[f] === null,
|
|
90
|
+
);
|
|
91
|
+
if (anyAbsent && (value === undefined || value === null || value === "")) {
|
|
92
|
+
errors[field] ??=
|
|
93
|
+
`The ${field} field is required when ${def.requiredWithout.join(", ")} is not present.`;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// requiredWithAll(): required when ALL listed fields are present
|
|
99
|
+
if (def.requiredWithAll !== undefined) {
|
|
100
|
+
const allPresent = def.requiredWithAll.every(
|
|
101
|
+
(f) => f in input && input[f] !== undefined && input[f] !== null,
|
|
102
|
+
);
|
|
103
|
+
if (allPresent && (value === undefined || value === null || value === "")) {
|
|
104
|
+
errors[field] ??=
|
|
105
|
+
`The ${field} field is required when ${def.requiredWithAll.join(", ")} are all present.`;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// requiredWithoutAll(): required when ALL listed fields are absent
|
|
111
|
+
if (def.requiredWithoutAll !== undefined) {
|
|
112
|
+
const allAbsent = def.requiredWithoutAll.every(
|
|
113
|
+
(f) => !(f in input) || input[f] === undefined || input[f] === null,
|
|
114
|
+
);
|
|
115
|
+
if (allAbsent && (value === undefined || value === null || value === "")) {
|
|
116
|
+
errors[field] ??=
|
|
117
|
+
`The ${field} field is required when none of ${def.requiredWithoutAll.join(", ")} are present.`;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// sometimes(): skip entirely when field is absent from input
|
|
123
|
+
if (def.sometimes && !(field in input)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if ((value === undefined || value === null) && def.defaultVal !== undefined) {
|
|
128
|
+
value = def.defaultVal;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const isRequired = resolveRequired(def, input);
|
|
132
|
+
|
|
133
|
+
if (isRequired && (value === undefined || value === null || value === "")) {
|
|
134
|
+
errors[field] ??= def.requiredMessage ?? `The ${field} field is required.`;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isRequired && (value === undefined || value === null)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (value === null) {
|
|
143
|
+
if (!def.nullable) {
|
|
144
|
+
errors[field] ??= `The ${field} field cannot be null.`;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
output[field] = null;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = validateField(field, value, def, input);
|
|
152
|
+
if (result.error !== undefined) {
|
|
153
|
+
// bail() is per-field: validateField already stopped at this field's first
|
|
154
|
+
// failing rule — the remaining fields are still validated as usual.
|
|
155
|
+
errors[field] = result.error;
|
|
156
|
+
} else {
|
|
157
|
+
output[field] = result.value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (Object.keys(errors).length > 0) {
|
|
162
|
+
return { success: false, data: undefined, errors };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { success: true, data: output, errors: undefined };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateField(
|
|
169
|
+
field: string,
|
|
170
|
+
value: unknown,
|
|
171
|
+
def: FieldRuleDefinition,
|
|
172
|
+
input: Record<string, unknown>,
|
|
173
|
+
): { value: unknown; error?: undefined } | { error: string; value?: undefined } {
|
|
174
|
+
let coerced = value;
|
|
175
|
+
|
|
176
|
+
switch (def.type) {
|
|
177
|
+
case "string":
|
|
178
|
+
if (typeof coerced !== "string") {
|
|
179
|
+
coerced = String(coerced);
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case "number":
|
|
184
|
+
if (typeof coerced === "string" && coerced.trim() !== "") {
|
|
185
|
+
const n = Number(coerced);
|
|
186
|
+
if (!isNaN(n)) coerced = n;
|
|
187
|
+
}
|
|
188
|
+
if (typeof coerced !== "number") {
|
|
189
|
+
return { error: `The ${field} field must be a number.` };
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
|
|
193
|
+
case "boolean":
|
|
194
|
+
if (typeof coerced === "string") {
|
|
195
|
+
const s = coerced.toLowerCase();
|
|
196
|
+
if (["true", "1", "on", "yes"].includes(s)) coerced = true;
|
|
197
|
+
else if (["false", "0", "off", "no"].includes(s)) coerced = false;
|
|
198
|
+
} else if (typeof coerced === "number") {
|
|
199
|
+
coerced = coerced !== 0;
|
|
200
|
+
}
|
|
201
|
+
if (typeof coerced !== "boolean") {
|
|
202
|
+
return { error: `The ${field} field must be a boolean.` };
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
|
|
206
|
+
case "array":
|
|
207
|
+
if (!Array.isArray(coerced)) {
|
|
208
|
+
return { error: `The ${field} field must be an array.` };
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
|
|
212
|
+
case "object":
|
|
213
|
+
if (typeof coerced !== "object" || Array.isArray(coerced) || coerced === null) {
|
|
214
|
+
return { error: `The ${field} field must be an object.` };
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case "date": {
|
|
219
|
+
if (coerced instanceof Date) break;
|
|
220
|
+
const d = new Date(coerced as string);
|
|
221
|
+
if (isNaN(d.getTime())) {
|
|
222
|
+
return { error: `The ${field} field must be a valid date.` };
|
|
223
|
+
}
|
|
224
|
+
coerced = d;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case "file": {
|
|
229
|
+
const isFile =
|
|
230
|
+
coerced !== null &&
|
|
231
|
+
typeof coerced === "object" &&
|
|
232
|
+
"name" in (coerced as object) &&
|
|
233
|
+
"size" in (coerced as object);
|
|
234
|
+
if (!isFile) {
|
|
235
|
+
return { error: `The ${field} field must be a file.` };
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const ruleErrors: string[] = [];
|
|
242
|
+
for (const ruleEntry of def.rules) {
|
|
243
|
+
const err = applyRule(field, coerced, ruleEntry, input);
|
|
244
|
+
if (err !== undefined) {
|
|
245
|
+
ruleErrors.push(err);
|
|
246
|
+
// bail(): skip this field's remaining rules after the first failure.
|
|
247
|
+
// Without bail(), every rule runs and all messages are collected.
|
|
248
|
+
if (def.bail === true) break;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (ruleEntry.name === "trim" && typeof coerced === "string") {
|
|
252
|
+
coerced = coerced.trim();
|
|
253
|
+
}
|
|
254
|
+
if (ruleEntry.name === "lowercase" && typeof coerced === "string") {
|
|
255
|
+
coerced = coerced.toLowerCase();
|
|
256
|
+
}
|
|
257
|
+
if (ruleEntry.name === "uppercase" && typeof coerced === "string") {
|
|
258
|
+
coerced = coerced.toUpperCase();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (ruleErrors.length > 0) {
|
|
262
|
+
return { error: ruleErrors.join(" ") };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (def.type === "object" && def.shape !== undefined) {
|
|
266
|
+
const nested = runValidation(def.shape, coerced as Record<string, unknown>);
|
|
267
|
+
if (!nested.success) {
|
|
268
|
+
const entry = Object.entries(nested.errors)[0];
|
|
269
|
+
if (entry !== undefined) {
|
|
270
|
+
const [nestedField, nestedError] = entry;
|
|
271
|
+
return { error: `${field}.${nestedField}: ${nestedError}` };
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
coerced = nested.data;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (def.type === "array" && def.children !== undefined && Array.isArray(coerced)) {
|
|
279
|
+
const children = def.children;
|
|
280
|
+
const validated: unknown[] = [];
|
|
281
|
+
for (let i = 0; i < coerced.length; i++) {
|
|
282
|
+
const item = coerced[i];
|
|
283
|
+
const itemResult = validateField(`${field}[${i}]`, item, children, {});
|
|
284
|
+
if (itemResult.error !== undefined) return { error: itemResult.error };
|
|
285
|
+
validated.push(itemResult.value);
|
|
286
|
+
}
|
|
287
|
+
coerced = validated;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { value: coerced };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type RuleEntry = { name: string; args: unknown[]; message?: string; fn?: CustomFn };
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Like runValidation() but also executes async rules (unique, exists, custom fn).
|
|
297
|
+
* Use this in validate() and FormRequest.validate() instead of runValidation()
|
|
298
|
+
* whenever the schema may contain DB-aware or custom async rules.
|
|
299
|
+
*/
|
|
300
|
+
export async function runValidationAsync<S extends Schema>(
|
|
301
|
+
schema: S,
|
|
302
|
+
input: Record<string, unknown>,
|
|
303
|
+
): Promise<ValidationOutcome<unknown>> {
|
|
304
|
+
// 1. Run all synchronous checks first
|
|
305
|
+
const syncResult = runValidation(schema, input);
|
|
306
|
+
|
|
307
|
+
// 2. Collect async rules for fields that passed sync validation
|
|
308
|
+
const asyncErrors: ValidationErrors = {};
|
|
309
|
+
|
|
310
|
+
await Promise.all(
|
|
311
|
+
Object.entries(schema).map(async ([field, def]) => {
|
|
312
|
+
// Skip fields that already failed sync validation
|
|
313
|
+
if (syncResult.success === false && syncResult.errors[field] !== undefined) return;
|
|
314
|
+
|
|
315
|
+
const fieldValue = syncResult.success
|
|
316
|
+
? (syncResult.data as Record<string, unknown>)[field]
|
|
317
|
+
: input[field];
|
|
318
|
+
|
|
319
|
+
// Skip absent optional fields
|
|
320
|
+
if (fieldValue === undefined) return;
|
|
321
|
+
|
|
322
|
+
for (const rule of def.rules) {
|
|
323
|
+
if (rule.name === "unique" || rule.name === "exists") {
|
|
324
|
+
const [table, column, options] = rule.args as [string, string, UniqueOptions];
|
|
325
|
+
const err = await runDbRule(
|
|
326
|
+
rule.name,
|
|
327
|
+
table,
|
|
328
|
+
column,
|
|
329
|
+
fieldValue,
|
|
330
|
+
options,
|
|
331
|
+
rule.message,
|
|
332
|
+
field,
|
|
333
|
+
);
|
|
334
|
+
if (err !== undefined) {
|
|
335
|
+
asyncErrors[field] = err;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (rule.name === "custom" && rule.fn !== undefined) {
|
|
342
|
+
const result = await rule.fn(fieldValue, input);
|
|
343
|
+
if (result === false) {
|
|
344
|
+
asyncErrors[field] = rule.message ?? `The ${field} field is invalid.`;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
if (typeof result === "string") {
|
|
348
|
+
asyncErrors[field] = result;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (Object.keys(asyncErrors).length > 0) {
|
|
357
|
+
const mergedErrors: ValidationErrors = {
|
|
358
|
+
...(syncResult.success ? {} : syncResult.errors),
|
|
359
|
+
...asyncErrors,
|
|
360
|
+
};
|
|
361
|
+
return { success: false, data: undefined, errors: mergedErrors };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return syncResult;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Fluent-builder rule names mapped to their key in the shared {@link RULES} registry.
|
|
369
|
+
* Rules whose name is identical in both engines (min, max, email, …) need no entry.
|
|
370
|
+
*/
|
|
371
|
+
const _registryKeys: Record<string, string> = {
|
|
372
|
+
notIn: "not_in",
|
|
373
|
+
sameAs: "same",
|
|
374
|
+
alphaNum: "alpha_num",
|
|
375
|
+
alphaDash: "alpha_dash",
|
|
376
|
+
startsWith: "starts_with",
|
|
377
|
+
endsWith: "ends_with",
|
|
378
|
+
digitsBetween: "digits_between",
|
|
379
|
+
passwordMixedCase: "password_mixed_case",
|
|
380
|
+
passwordNumbers: "password_numbers",
|
|
381
|
+
passwordSymbols: "password_symbols",
|
|
382
|
+
passwordNoSpaces: "password_no_spaces",
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/** Serialise a builder rule's args into the string `param` the RULES registry expects. */
|
|
386
|
+
function _registryParam(name: string, args: unknown[]): string {
|
|
387
|
+
if (name === "regex") {
|
|
388
|
+
const [source, flags] = args as [string, string | undefined];
|
|
389
|
+
return `/${source}/${flags ?? ""}`;
|
|
390
|
+
}
|
|
391
|
+
const first = args[0];
|
|
392
|
+
if (Array.isArray(first)) return first.map(String).join(",");
|
|
393
|
+
return args.map(String).join(",");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function applyRule(
|
|
397
|
+
field: string,
|
|
398
|
+
value: unknown,
|
|
399
|
+
rule: RuleEntry,
|
|
400
|
+
input: Record<string, unknown>,
|
|
401
|
+
): string | undefined {
|
|
402
|
+
const msg = (fallback: string): string => rule.message ?? fallback;
|
|
403
|
+
|
|
404
|
+
// Single engine: delegate to the shared stringRules registry for every rule it
|
|
405
|
+
// covers, so the fluent schema API and the "a|b|c" string syntax validate
|
|
406
|
+
// identically (one email regex, one min/max implementation, …).
|
|
407
|
+
const registryRule = RULES[_registryKeys[rule.name] ?? rule.name];
|
|
408
|
+
if (registryRule !== undefined) {
|
|
409
|
+
const failure = registryRule(value, _registryParam(rule.name, rule.args), input);
|
|
410
|
+
if (failure === null) return undefined;
|
|
411
|
+
return msg(failure.replaceAll(":attr", `The ${field} field`));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Only rules that genuinely cannot live in the registry remain here:
|
|
415
|
+
// cross-field lookups needing the field name, typed Date/File values,
|
|
416
|
+
// async/DB rules, string transforms, and presence rules handled upstream.
|
|
417
|
+
switch (rule.name) {
|
|
418
|
+
case "confirmed": {
|
|
419
|
+
const confirmation = input[`${field}_confirmation`];
|
|
420
|
+
if (value !== confirmation) {
|
|
421
|
+
return msg(`The ${field} field confirmation does not match.`);
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
case "prohibited":
|
|
427
|
+
case "present":
|
|
428
|
+
// Handled in the main runValidation loop before validateField is called.
|
|
429
|
+
break;
|
|
430
|
+
|
|
431
|
+
// ── File rules ────────────────────────────────────────────────────────
|
|
432
|
+
case "fileMimes": {
|
|
433
|
+
const exts = rule.args[0] as string[];
|
|
434
|
+
const file = value as { name: string; type?: string };
|
|
435
|
+
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
436
|
+
const mimeType = file.type ?? "";
|
|
437
|
+
const allowed = exts.map((e) => e.toLowerCase());
|
|
438
|
+
if (!allowed.includes(ext) && !allowed.some((e) => mimeType.includes(e))) {
|
|
439
|
+
return msg(`The ${field} field must be a file of type: ${exts.join(", ")}.`);
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
case "fileMax": {
|
|
445
|
+
const maxKb = rule.args[0] as number;
|
|
446
|
+
const file = value as { size: number };
|
|
447
|
+
if (file.size > maxKb * 1024) {
|
|
448
|
+
return msg(`The ${field} field must not be larger than ${maxKb} KB.`);
|
|
449
|
+
}
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
case "fileMin": {
|
|
454
|
+
const minKb = rule.args[0] as number;
|
|
455
|
+
const file = value as { size: number };
|
|
456
|
+
if (file.size < minKb * 1024) {
|
|
457
|
+
return msg(`The ${field} field must be at least ${minKb} KB.`);
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Date rules (operate on coerced Date instances) ────────────────────
|
|
463
|
+
case "after": {
|
|
464
|
+
const ref = new Date(rule.args[0] as string);
|
|
465
|
+
const val = value instanceof Date ? value : new Date(value as string);
|
|
466
|
+
if (val <= ref) {
|
|
467
|
+
return msg(`The ${field} field must be a date after ${rule.args[0]}.`);
|
|
468
|
+
}
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
case "before": {
|
|
473
|
+
const ref = new Date(rule.args[0] as string);
|
|
474
|
+
const val = value instanceof Date ? value : new Date(value as string);
|
|
475
|
+
if (val >= ref) {
|
|
476
|
+
return msg(`The ${field} field must be a date before ${rule.args[0]}.`);
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
case "afterOrEqual": {
|
|
482
|
+
const ref = new Date(rule.args[0] as string);
|
|
483
|
+
const val = value instanceof Date ? value : new Date(value as string);
|
|
484
|
+
if (val < ref) {
|
|
485
|
+
return msg(`The ${field} field must be a date on or after ${rule.args[0]}.`);
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
case "beforeOrEqual": {
|
|
491
|
+
const ref = new Date(rule.args[0] as string);
|
|
492
|
+
const val = value instanceof Date ? value : new Date(value as string);
|
|
493
|
+
if (val > ref) {
|
|
494
|
+
return msg(`The ${field} field must be a date on or before ${rule.args[0]}.`);
|
|
495
|
+
}
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
case "custom":
|
|
500
|
+
case "unique":
|
|
501
|
+
case "exists":
|
|
502
|
+
// Async rules — executed in runValidationAsync, no-op in the sync pass.
|
|
503
|
+
break;
|
|
504
|
+
|
|
505
|
+
case "trim":
|
|
506
|
+
case "lowercase":
|
|
507
|
+
case "uppercase":
|
|
508
|
+
// Transformations — applied in validateField after the rule loop.
|
|
509
|
+
break;
|
|
510
|
+
|
|
511
|
+
default:
|
|
512
|
+
throw new Error(
|
|
513
|
+
`Unknown validation rule "${rule.name}" on field "${field}". ` +
|
|
514
|
+
`Add it to the stringRules RULES registry or handle it in applyRule().`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { deepMerge } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
export interface ValidatorConfigShape {
|
|
4
|
+
/** Stop validation after the first failure per field. Default: true */
|
|
5
|
+
stopOnFirstFailure: boolean;
|
|
6
|
+
/** Default locale for error messages. Default: 'en' */
|
|
7
|
+
locale: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const defaults: ValidatorConfigShape = {
|
|
11
|
+
stopOnFirstFailure: true,
|
|
12
|
+
locale: "en",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a typed validator configuration object with defaults.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* import { ValidatorConfig } from '@zerotal/validator';
|
|
20
|
+
* export default ValidatorConfig({ locale: 'af' });
|
|
21
|
+
*/
|
|
22
|
+
export function ValidatorConfig(options: Partial<ValidatorConfigShape> = {}): ValidatorConfigShape {
|
|
23
|
+
return deepMerge(defaults, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Register this package's config namespace for typed config() dot-paths.
|
|
27
|
+
declare module "@zerotal/core" {
|
|
28
|
+
interface ConfigRegistry {
|
|
29
|
+
validator: ValidatorConfigShape;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/dbRules.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inversion-of-control hook for DB-aware validation rules.
|
|
3
|
+
*
|
|
4
|
+
* Call `registerDbRuleRunner(fn)` from your ORM provider's onBooting()
|
|
5
|
+
* so that unique() and exists() rules can query the database without
|
|
6
|
+
* creating a hard dependency between @zerotal/validator and @zerotal/orm.
|
|
7
|
+
*
|
|
8
|
+
* @zerotal/orm's DatabaseProvider calls this automatically when you register it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type DbRuleRunner = (
|
|
12
|
+
rule: "unique" | "exists",
|
|
13
|
+
table: string,
|
|
14
|
+
column: string,
|
|
15
|
+
value: unknown,
|
|
16
|
+
options: UniqueOptions,
|
|
17
|
+
) => Promise<boolean>;
|
|
18
|
+
|
|
19
|
+
export interface UniqueOptions {
|
|
20
|
+
/** Ignore the row with this id — use on update to exclude the current record. */
|
|
21
|
+
ignoreId?: number | string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let _runner: DbRuleRunner | undefined;
|
|
25
|
+
|
|
26
|
+
/** Called by DatabaseProvider.onBooting() to wire in the DB query executor. */
|
|
27
|
+
export function registerDbRuleRunner(runner: DbRuleRunner): void {
|
|
28
|
+
_runner = runner;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @internal — clear the registered runner. Used by tests to assert the
|
|
33
|
+
* "no runner registered" path deterministically, since `_runner` is a
|
|
34
|
+
* process-global that another suite's DatabaseProvider may have set.
|
|
35
|
+
*/
|
|
36
|
+
export function _resetDbRuleRunner(): void {
|
|
37
|
+
_runner = undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a DB-aware rule ('unique' or 'exists').
|
|
42
|
+
* Returns an error string on failure, undefined on success.
|
|
43
|
+
* Throws if no runner has been registered.
|
|
44
|
+
*/
|
|
45
|
+
export async function runDbRule(
|
|
46
|
+
ruleName: "unique" | "exists",
|
|
47
|
+
table: string,
|
|
48
|
+
column: string,
|
|
49
|
+
value: unknown,
|
|
50
|
+
options: UniqueOptions,
|
|
51
|
+
message?: string,
|
|
52
|
+
field?: string,
|
|
53
|
+
): Promise<string | undefined> {
|
|
54
|
+
if (_runner === undefined) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"[Zerotal] unique()/exists() rules require a database. " +
|
|
57
|
+
"Register DatabaseProvider in your app to enable DB-aware validation.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const passed = await _runner(ruleName, table, column, value, options);
|
|
62
|
+
|
|
63
|
+
if (!passed) {
|
|
64
|
+
if (message !== undefined) return message;
|
|
65
|
+
if (ruleName === "unique") return `The ${field ?? column} has already been taken.`;
|
|
66
|
+
return `The selected ${field ?? column} is invalid.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Schema, ValidationOutcome, FieldRuleDefinition, Infer } from "../types.ts";
|
|
2
|
+
import type { FieldRule } from "../FieldRule.ts";
|
|
3
|
+
import { RuleBuilder } from "../RuleBuilder.ts";
|
|
4
|
+
import { runValidation } from "../Validator.ts";
|
|
5
|
+
|
|
6
|
+
type ExtractDefs<S extends Record<string, FieldRule>> = {
|
|
7
|
+
[K in keyof S]: S[K]["_def"];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Non-HTTP validation — for CLI commands, services, background jobs.
|
|
12
|
+
* Returns { success, data, errors } — never throws, never redirects.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const result = Validator.check(body, rules => ({
|
|
16
|
+
* email: rules.string().email(),
|
|
17
|
+
* age: rules.number().min(0),
|
|
18
|
+
* }));
|
|
19
|
+
* if (!result.success) console.error(result.errors);
|
|
20
|
+
*/
|
|
21
|
+
export const ValidatorFacade = {
|
|
22
|
+
check<S extends Record<string, FieldRule>>(
|
|
23
|
+
data: Record<string, unknown>,
|
|
24
|
+
factory: (rules: RuleBuilder) => S,
|
|
25
|
+
): ValidationOutcome<Infer<ExtractDefs<S>>> {
|
|
26
|
+
const builder = new RuleBuilder();
|
|
27
|
+
const factoryResult = factory(builder);
|
|
28
|
+
|
|
29
|
+
const schema: Record<string, FieldRuleDefinition> = {};
|
|
30
|
+
for (const [key, rule] of Object.entries(factoryResult)) {
|
|
31
|
+
schema[key] = rule._def;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return runValidation(schema as Schema, data) as ValidationOutcome<Infer<ExtractDefs<S>>>;
|
|
35
|
+
},
|
|
36
|
+
};
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Ambient declarations specific to this package.
|
|
2
|
+
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
+
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
+
|
|
5
|
+
// ── Bun globals ───────────────────────────────────────────────────────────────
|
|
6
|
+
interface Request {
|
|
7
|
+
readonly params?: Record<string, string>;
|
|
8
|
+
readonly cookies: Bun.CookieMap;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── SQLInstance ───────────────────────────────────────────────────────────────
|
|
12
|
+
interface SQLInstance {
|
|
13
|
+
<T = Record<string, unknown>>(
|
|
14
|
+
strings: TemplateStringsArray,
|
|
15
|
+
...values: unknown[]
|
|
16
|
+
): Promise<T[]>;
|
|
17
|
+
begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
|
|
18
|
+
end(): Promise<void>;
|
|
19
|
+
}
|