@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/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export { validate } from "./validate.ts";
|
|
2
|
+
export { FormRequest } from "./FormRequest.ts";
|
|
3
|
+
export { ValidatorFacade as Validator } from "./facades/Validator.ts";
|
|
4
|
+
export { RuleBuilder } from "./RuleBuilder.ts";
|
|
5
|
+
export {
|
|
6
|
+
StringRule,
|
|
7
|
+
NumberRule,
|
|
8
|
+
BooleanRule,
|
|
9
|
+
ArrayRule,
|
|
10
|
+
ObjectRule,
|
|
11
|
+
DateRule,
|
|
12
|
+
FileRule,
|
|
13
|
+
PasswordRule,
|
|
14
|
+
} from "./FieldRule.ts";
|
|
15
|
+
export type { FieldRule } from "./FieldRule.ts";
|
|
16
|
+
export { ValidationRedirectError, ValidationJsonError } from "./ValidationError.ts";
|
|
17
|
+
export { PrecognitionResponseError } from "./PrecognitionError.ts";
|
|
18
|
+
export { runValidation, runValidationAsync } from "./Validator.ts";
|
|
19
|
+
export { runStringRules, runStringRulesAsync } from "./stringRules.ts";
|
|
20
|
+
export type { StringRules } from "./stringRules.ts";
|
|
21
|
+
export { registerDbRuleRunner } from "./dbRules.ts";
|
|
22
|
+
export type { UniqueOptions } from "./dbRules.ts";
|
|
23
|
+
export type {
|
|
24
|
+
Schema,
|
|
25
|
+
FieldRuleDefinition,
|
|
26
|
+
ValidationErrors,
|
|
27
|
+
Infer,
|
|
28
|
+
ValidationOutcome,
|
|
29
|
+
InferFieldType,
|
|
30
|
+
} from "./types.ts";
|
|
31
|
+
|
|
32
|
+
// Config factory
|
|
33
|
+
export { ValidatorConfig } from "./config.ts";
|
|
34
|
+
export type { ValidatorConfigShape } from "./config.ts";
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe-delimited string-rule validation ("required|min:3|email").
|
|
3
|
+
*
|
|
4
|
+
* This complements the fluent `RuleBuilder` schema API with the pipe-separated string syntax
|
|
5
|
+
* (and string arrays). It is the single engine for string rules across the framework — Flow's
|
|
6
|
+
* `@validate("…")` decorator and `this.validate()` delegate here rather than rolling their own,
|
|
7
|
+
* and the fluent schema engine (`Validator.ts`) delegates to the {@link RULES} registry for
|
|
8
|
+
* every rule it covers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type StringRules = Record<string, string | string[]>;
|
|
12
|
+
|
|
13
|
+
export type RuleFn = (
|
|
14
|
+
value: unknown,
|
|
15
|
+
param: string,
|
|
16
|
+
data: Record<string, unknown>,
|
|
17
|
+
) => string | null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The shared rule registry — one implementation per rule name.
|
|
21
|
+
*
|
|
22
|
+
* Both the string-rule engine ({@link runStringRules}) and the fluent schema engine
|
|
23
|
+
* (`Validator.applyRule`) resolve rules from this map, so a rule like `email` behaves
|
|
24
|
+
* identically no matter which entry point it is validated through. Messages use the
|
|
25
|
+
* `:attr` placeholder, replaced with the field label by the caller.
|
|
26
|
+
*/
|
|
27
|
+
export const RULES: Record<string, RuleFn> = {
|
|
28
|
+
required(value) {
|
|
29
|
+
if (value === null || value === undefined || value === "") return ":attr is required.";
|
|
30
|
+
if (Array.isArray(value) && value.length === 0) return ":attr is required.";
|
|
31
|
+
return null;
|
|
32
|
+
},
|
|
33
|
+
nullable: () => null,
|
|
34
|
+
sometimes: () => null,
|
|
35
|
+
|
|
36
|
+
string(value) {
|
|
37
|
+
return typeof value !== "string" ? ":attr must be a string." : null;
|
|
38
|
+
},
|
|
39
|
+
numeric(value) {
|
|
40
|
+
return isNaN(Number(value)) ? ":attr must be numeric." : null;
|
|
41
|
+
},
|
|
42
|
+
integer(value) {
|
|
43
|
+
return !Number.isInteger(Number(value)) ? ":attr must be an integer." : null;
|
|
44
|
+
},
|
|
45
|
+
boolean(value) {
|
|
46
|
+
return ([true, false, "true", "false", "1", "0", 1, 0] as unknown[]).includes(value)
|
|
47
|
+
? null
|
|
48
|
+
: ":attr must be true or false.";
|
|
49
|
+
},
|
|
50
|
+
array(value) {
|
|
51
|
+
return Array.isArray(value) ? null : ":attr must be an array.";
|
|
52
|
+
},
|
|
53
|
+
email(value) {
|
|
54
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value ?? ""))
|
|
55
|
+
? null
|
|
56
|
+
: ":attr must be a valid email address.";
|
|
57
|
+
},
|
|
58
|
+
url(value) {
|
|
59
|
+
try {
|
|
60
|
+
new URL(String(value ?? ""));
|
|
61
|
+
return null;
|
|
62
|
+
} catch {
|
|
63
|
+
return ":attr must be a valid URL.";
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
min(value, param) {
|
|
67
|
+
const n = Number(param);
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
return value.length < n ? `:attr must be at least ${n} characters.` : null;
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return value.length < n ? `:attr must have at least ${n} items.` : null;
|
|
73
|
+
}
|
|
74
|
+
const num = Number(value);
|
|
75
|
+
return isNaN(num) || num < n ? `:attr must be at least ${n}.` : null;
|
|
76
|
+
},
|
|
77
|
+
max(value, param) {
|
|
78
|
+
const n = Number(param);
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
return value.length > n ? `:attr must not exceed ${n} characters.` : null;
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
return value.length > n ? `:attr must have no more than ${n} items.` : null;
|
|
84
|
+
}
|
|
85
|
+
const num = Number(value);
|
|
86
|
+
return isNaN(num) || num > n ? `:attr must not exceed ${n}.` : null;
|
|
87
|
+
},
|
|
88
|
+
size(value, param) {
|
|
89
|
+
const n = Number(param);
|
|
90
|
+
if (typeof value === "string") {
|
|
91
|
+
return value.length !== n ? `:attr must be exactly ${n} characters.` : null;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
return value.length !== n ? `:attr must contain exactly ${n} items.` : null;
|
|
95
|
+
}
|
|
96
|
+
return Number(value) !== n ? `:attr must be exactly ${n}.` : null;
|
|
97
|
+
},
|
|
98
|
+
between(value, param) {
|
|
99
|
+
const [lo, hi] = param.split(",").map(Number) as [number, number];
|
|
100
|
+
const n = Number(value);
|
|
101
|
+
if (typeof value === "string") {
|
|
102
|
+
return value.length < lo || value.length > hi
|
|
103
|
+
? `:attr must be between ${lo} and ${hi} characters.`
|
|
104
|
+
: null;
|
|
105
|
+
}
|
|
106
|
+
return isNaN(n) || n < lo || n > hi ? `:attr must be between ${lo} and ${hi}.` : null;
|
|
107
|
+
},
|
|
108
|
+
in(value, param) {
|
|
109
|
+
const opts = param.split(",").map((s) => s.trim());
|
|
110
|
+
return opts.includes(String(value)) ? null : `:attr must be one of: ${opts.join(", ")}.`;
|
|
111
|
+
},
|
|
112
|
+
not_in(value, param) {
|
|
113
|
+
const opts = param.split(",").map((s) => s.trim());
|
|
114
|
+
return !opts.includes(String(value)) ? null : `:attr must not be one of: ${opts.join(", ")}.`;
|
|
115
|
+
},
|
|
116
|
+
same(value, param, data) {
|
|
117
|
+
return value === data[param] ? null : `:attr must match ${param}.`;
|
|
118
|
+
},
|
|
119
|
+
different(value, param, data) {
|
|
120
|
+
return value !== data[param] ? null : `:attr must be different from ${param}.`;
|
|
121
|
+
},
|
|
122
|
+
gt(value, param, data) {
|
|
123
|
+
const ref = Number(data[param] ?? param);
|
|
124
|
+
return Number(value) > ref ? null : `:attr must be greater than ${param}.`;
|
|
125
|
+
},
|
|
126
|
+
lt(value, param, data) {
|
|
127
|
+
const ref = Number(data[param] ?? param);
|
|
128
|
+
return Number(value) < ref ? null : `:attr must be less than ${param}.`;
|
|
129
|
+
},
|
|
130
|
+
gte(value, param, data) {
|
|
131
|
+
const ref = Number(data[param] ?? param);
|
|
132
|
+
return Number(value) >= ref ? null : `:attr must be greater than or equal to ${param}.`;
|
|
133
|
+
},
|
|
134
|
+
lte(value, param, data) {
|
|
135
|
+
const ref = Number(data[param] ?? param);
|
|
136
|
+
return Number(value) <= ref ? null : `:attr must be less than or equal to ${param}.`;
|
|
137
|
+
},
|
|
138
|
+
alpha(value) {
|
|
139
|
+
return /^[a-zA-Z]+$/.test(String(value)) ? null : ":attr must only contain letters.";
|
|
140
|
+
},
|
|
141
|
+
alpha_num(value) {
|
|
142
|
+
return /^[a-zA-Z0-9]+$/.test(String(value))
|
|
143
|
+
? null
|
|
144
|
+
: ":attr must only contain letters and numbers.";
|
|
145
|
+
},
|
|
146
|
+
alpha_dash(value) {
|
|
147
|
+
return /^[a-zA-Z0-9_-]+$/.test(String(value))
|
|
148
|
+
? null
|
|
149
|
+
: ":attr must only contain letters, numbers, dashes, and underscores.";
|
|
150
|
+
},
|
|
151
|
+
starts_with(value, param) {
|
|
152
|
+
const vals = param.split(",");
|
|
153
|
+
return vals.some((v) => String(value).startsWith(v))
|
|
154
|
+
? null
|
|
155
|
+
: `:attr must start with: ${vals.join(", ")}.`;
|
|
156
|
+
},
|
|
157
|
+
ends_with(value, param) {
|
|
158
|
+
const vals = param.split(",");
|
|
159
|
+
return vals.some((v) => String(value).endsWith(v))
|
|
160
|
+
? null
|
|
161
|
+
: `:attr must end with: ${vals.join(", ")}.`;
|
|
162
|
+
},
|
|
163
|
+
regex(value, param) {
|
|
164
|
+
try {
|
|
165
|
+
const m = param.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
166
|
+
const re = m ? new RegExp(m[1]!, m[2]) : new RegExp(param);
|
|
167
|
+
return re.test(String(value)) ? null : ":attr format is invalid.";
|
|
168
|
+
} catch {
|
|
169
|
+
return ":attr format is invalid.";
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
accepted(value) {
|
|
173
|
+
return (["yes", "on", "1", "true", 1, true] as unknown[]).includes(value)
|
|
174
|
+
? null
|
|
175
|
+
: ":attr must be accepted.";
|
|
176
|
+
},
|
|
177
|
+
declined(value) {
|
|
178
|
+
return (["no", "off", "0", "false", 0, false] as unknown[]).includes(value)
|
|
179
|
+
? null
|
|
180
|
+
: ":attr must be declined.";
|
|
181
|
+
},
|
|
182
|
+
digits(value, param) {
|
|
183
|
+
const s = String(value);
|
|
184
|
+
return /^\d+$/.test(s) && s.length === Number(param) ? null : `:attr must be ${param} digits.`;
|
|
185
|
+
},
|
|
186
|
+
digits_between(value, param) {
|
|
187
|
+
const [lo, hi] = param.split(",").map(Number) as [number, number];
|
|
188
|
+
const s = String(value);
|
|
189
|
+
return /^\d+$/.test(s) && s.length >= lo && s.length <= hi
|
|
190
|
+
? null
|
|
191
|
+
: `:attr must be between ${lo} and ${hi} digits.`;
|
|
192
|
+
},
|
|
193
|
+
ip(value) {
|
|
194
|
+
const s = String(value);
|
|
195
|
+
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
196
|
+
const ipv6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
197
|
+
return ipv4.test(s) || ipv6.test(s) ? null : ":attr must be a valid IP address.";
|
|
198
|
+
},
|
|
199
|
+
password_mixed_case(value) {
|
|
200
|
+
const s = String(value);
|
|
201
|
+
return /[a-z]/.test(s) && /[A-Z]/.test(s)
|
|
202
|
+
? null
|
|
203
|
+
: ":attr must contain at least one uppercase and one lowercase letter.";
|
|
204
|
+
},
|
|
205
|
+
password_numbers(value) {
|
|
206
|
+
return /\d/.test(String(value)) ? null : ":attr must contain at least one number.";
|
|
207
|
+
},
|
|
208
|
+
password_symbols(value) {
|
|
209
|
+
return /[^a-zA-Z0-9]/.test(String(value)) ? null : ":attr must contain at least one symbol.";
|
|
210
|
+
},
|
|
211
|
+
password_no_spaces(value) {
|
|
212
|
+
return /\s/.test(String(value)) ? ":attr must not contain spaces." : null;
|
|
213
|
+
},
|
|
214
|
+
date(value) {
|
|
215
|
+
const d = new Date(String(value));
|
|
216
|
+
return isNaN(d.getTime()) ? ":attr must be a valid date." : null;
|
|
217
|
+
},
|
|
218
|
+
json(value) {
|
|
219
|
+
try {
|
|
220
|
+
JSON.parse(String(value));
|
|
221
|
+
return null;
|
|
222
|
+
} catch {
|
|
223
|
+
return ":attr must be valid JSON.";
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
function _parseRules(raw: string | string[]): Array<{ name: string; param: string }> {
|
|
229
|
+
const list = typeof raw === "string" ? raw.split("|") : raw;
|
|
230
|
+
return list.map((r) => {
|
|
231
|
+
const i = r.indexOf(":");
|
|
232
|
+
return i === -1
|
|
233
|
+
? { name: r.trim(), param: "" }
|
|
234
|
+
: { name: r.slice(0, i).trim(), param: r.slice(i + 1).trim() };
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _label(field: string): string {
|
|
239
|
+
return `The ${field.replace(/_/g, " ")} field`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validate `data` against pipe-delimited string `rules`. Returns a map of field → error messages
|
|
244
|
+
* for every field that failed (an empty object means everything passed).
|
|
245
|
+
*/
|
|
246
|
+
export function runStringRules(
|
|
247
|
+
data: Record<string, unknown>,
|
|
248
|
+
rules: StringRules,
|
|
249
|
+
): Record<string, string[]> {
|
|
250
|
+
const errors: Record<string, string[]> = {};
|
|
251
|
+
|
|
252
|
+
for (const [field, rawRules] of Object.entries(rules)) {
|
|
253
|
+
const parsed = _parseRules(rawRules);
|
|
254
|
+
const value = data[field];
|
|
255
|
+
|
|
256
|
+
// `sometimes` — skip if field absent from data
|
|
257
|
+
if (parsed.some((r) => r.name === "sometimes") && !(field in data)) continue;
|
|
258
|
+
|
|
259
|
+
// `nullable` — skip remaining rules if value is empty
|
|
260
|
+
const isEmpty = value === null || value === undefined || value === "";
|
|
261
|
+
if (parsed.some((r) => r.name === "nullable") && isEmpty) continue;
|
|
262
|
+
|
|
263
|
+
const msgs: string[] = [];
|
|
264
|
+
for (const { name, param } of parsed) {
|
|
265
|
+
if (name === "sometimes" || name === "nullable") continue;
|
|
266
|
+
|
|
267
|
+
// `confirmed` — field must equal field_confirmation
|
|
268
|
+
if (name === "confirmed") {
|
|
269
|
+
if (value !== data[`${field}_confirmation`]) {
|
|
270
|
+
msgs.push(`${_label(field)} confirmation does not match.`);
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const fn = RULES[name];
|
|
276
|
+
if (!fn) continue; // unknown string rule — skip gracefully (may be a decorator-only rule)
|
|
277
|
+
|
|
278
|
+
const msg = fn(value, param, data);
|
|
279
|
+
if (msg) msgs.push(msg.replace(":attr", _label(field)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (msgs.length > 0) errors[field] = msgs;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return errors;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Async variant of {@link runStringRules}. Currently wraps the synchronous engine; exists so
|
|
290
|
+
* callers (e.g. Flow's `await this.validate()`) have a stable async entry point as
|
|
291
|
+
* database-backed string rules (`unique`/`exists`) are layered in.
|
|
292
|
+
*/
|
|
293
|
+
export async function runStringRulesAsync(
|
|
294
|
+
data: Record<string, unknown>,
|
|
295
|
+
rules: StringRules,
|
|
296
|
+
): Promise<Record<string, string[]>> {
|
|
297
|
+
return runStringRules(data, rules);
|
|
298
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type CustomFn = (
|
|
2
|
+
value: unknown,
|
|
3
|
+
input: Record<string, unknown>,
|
|
4
|
+
) => boolean | string | Promise<boolean | string>;
|
|
5
|
+
|
|
6
|
+
export interface FieldRuleDefinition {
|
|
7
|
+
type: "string" | "number" | "boolean" | "array" | "object" | "date" | "file";
|
|
8
|
+
required: boolean;
|
|
9
|
+
/** Custom message for the basic "is required" failure (set via `.required(message)`). */
|
|
10
|
+
requiredMessage?: string;
|
|
11
|
+
nullable: boolean;
|
|
12
|
+
defaultVal: unknown;
|
|
13
|
+
/** Skip this field entirely when it is absent from input (no presence in output either). */
|
|
14
|
+
sometimes?: boolean;
|
|
15
|
+
/** Make this field required only when `field === value` in the current input. */
|
|
16
|
+
requiredIf?:
|
|
17
|
+
{ field: string; value: unknown } | { fn: (input: Record<string, unknown>) => boolean };
|
|
18
|
+
/** Make this field required unless `field === value` in the current input. */
|
|
19
|
+
requiredUnless?:
|
|
20
|
+
{ field: string; value: unknown } | { fn: (input: Record<string, unknown>) => boolean };
|
|
21
|
+
/** Required when ANY of the listed fields are present in input. */
|
|
22
|
+
requiredWith?: string[];
|
|
23
|
+
/** Required when ANY of the listed fields are absent from input. */
|
|
24
|
+
requiredWithout?: string[];
|
|
25
|
+
/** Required when ALL of the listed fields are present. */
|
|
26
|
+
requiredWithAll?: string[];
|
|
27
|
+
/** Required when ALL of the listed fields are absent. */
|
|
28
|
+
requiredWithoutAll?: string[];
|
|
29
|
+
/** Field must be absent when `field === value`. */
|
|
30
|
+
prohibitedIf?: { field: string; value: unknown };
|
|
31
|
+
/** Field must be absent unless `field === value`. */
|
|
32
|
+
prohibitedUnless?: { field: string; value: unknown };
|
|
33
|
+
/** Stop validation on the first failing rule for this field. */
|
|
34
|
+
bail?: boolean;
|
|
35
|
+
rules: Array<{ name: string; args: unknown[]; message?: string; fn?: CustomFn }>;
|
|
36
|
+
children?: FieldRuleDefinition;
|
|
37
|
+
shape?: Record<string, FieldRuleDefinition>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Schema = Record<string, FieldRuleDefinition>;
|
|
41
|
+
|
|
42
|
+
export type InferFieldType<F extends FieldRuleDefinition> = F["type"] extends "string"
|
|
43
|
+
? string
|
|
44
|
+
: F["type"] extends "number"
|
|
45
|
+
? number
|
|
46
|
+
: F["type"] extends "boolean"
|
|
47
|
+
? boolean
|
|
48
|
+
: F["type"] extends "date"
|
|
49
|
+
? Date
|
|
50
|
+
: unknown;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Infer the validated TypeScript type from a Schema.
|
|
54
|
+
* All fields are treated as required at the type level — the required/optional
|
|
55
|
+
* distinction is enforced at runtime only. Trade-off acknowledged in the plan.
|
|
56
|
+
*/
|
|
57
|
+
export type Infer<S extends Schema> = {
|
|
58
|
+
[K in keyof S]: S[K]["nullable"] extends true
|
|
59
|
+
? InferFieldType<S[K]> | null
|
|
60
|
+
: InferFieldType<S[K]>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type ValidationErrors = Record<string, string>;
|
|
64
|
+
|
|
65
|
+
export interface ValidationResult<T> {
|
|
66
|
+
success: true;
|
|
67
|
+
data: T;
|
|
68
|
+
errors: undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ValidationFailure {
|
|
72
|
+
success: false;
|
|
73
|
+
data: undefined;
|
|
74
|
+
errors: ValidationErrors;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ValidationOutcome<T> = ValidationResult<T> | ValidationFailure;
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { HttpContext } from "@zerotal/core";
|
|
2
|
+
import { safeRedirectPath } from "@zerotal/core";
|
|
3
|
+
import type { Schema, Infer, FieldRuleDefinition } from "./types.ts";
|
|
4
|
+
import type { FieldRule } from "./FieldRule.ts";
|
|
5
|
+
import { RuleBuilder } from "./RuleBuilder.ts";
|
|
6
|
+
import { runValidationAsync } from "./Validator.ts";
|
|
7
|
+
import { ValidationRedirectError, ValidationJsonError } from "./ValidationError.ts";
|
|
8
|
+
|
|
9
|
+
type SessionLike = {
|
|
10
|
+
set(key: string, value: unknown): void;
|
|
11
|
+
forget(key: string): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Maps a factory-schema (Record<string, FieldRule>) to raw FieldRuleDefinitions.
|
|
15
|
+
// Used to drive Infer<> type inference from the narrow _def types on each rule class.
|
|
16
|
+
type ExtractDefs<S extends Record<string, FieldRule>> = {
|
|
17
|
+
[K in keyof S]: S[K]["_def"];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate the HTTP request body against the given schema.
|
|
22
|
+
*
|
|
23
|
+
* On success: returns the validated, typed data object (only schema fields).
|
|
24
|
+
* On failure (Inertia or web form): stores errors in session + throws
|
|
25
|
+
* ValidationRedirectError → global handler converts to 303 redirect.
|
|
26
|
+
* On failure (JSON API): throws ValidationJsonError → global handler → 422.
|
|
27
|
+
*/
|
|
28
|
+
export async function validate<S extends Record<string, FieldRule>>(
|
|
29
|
+
ctx: HttpContext,
|
|
30
|
+
factory: (rules: RuleBuilder) => S,
|
|
31
|
+
): Promise<Infer<ExtractDefs<S>>> {
|
|
32
|
+
const builder = new RuleBuilder();
|
|
33
|
+
const factoryResult = factory(builder);
|
|
34
|
+
|
|
35
|
+
// Convert FieldRule objects → raw FieldRuleDefinitions for runValidation
|
|
36
|
+
const schema: Record<string, FieldRuleDefinition> = {};
|
|
37
|
+
for (const [key, rule] of Object.entries(factoryResult)) {
|
|
38
|
+
schema[key] = rule._def;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let body: Record<string, unknown> = {};
|
|
42
|
+
try {
|
|
43
|
+
const contentType = ctx.request.headers.get("Content-Type") ?? "";
|
|
44
|
+
if (contentType.includes("application/json")) {
|
|
45
|
+
body = (await ctx.request.json()) as Record<string, unknown>;
|
|
46
|
+
} else if (
|
|
47
|
+
contentType.includes("application/x-www-form-urlencoded") ||
|
|
48
|
+
contentType.includes("multipart/form-data")
|
|
49
|
+
) {
|
|
50
|
+
const formData = await ctx.request.formData();
|
|
51
|
+
formData.forEach((val, key) => {
|
|
52
|
+
body[key] = val;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
body = {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (ctx.request.method === "GET") {
|
|
60
|
+
ctx.url.searchParams.forEach((val, key) => {
|
|
61
|
+
body[key] ??= val;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = await runValidationAsync(schema as Schema, body);
|
|
66
|
+
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
const errors = result.errors;
|
|
69
|
+
const session = (ctx as unknown as { session?: SessionLike }).session;
|
|
70
|
+
|
|
71
|
+
session?.set("errors", errors);
|
|
72
|
+
session?.set("old", body);
|
|
73
|
+
|
|
74
|
+
const isInertia = ctx.request.headers.get("X-Inertia") === "true";
|
|
75
|
+
const accept = ctx.request.headers.get("Accept") ?? "";
|
|
76
|
+
const isJson = accept.includes("application/json") && !isInertia;
|
|
77
|
+
|
|
78
|
+
if (isJson) {
|
|
79
|
+
throw new ValidationJsonError(errors);
|
|
80
|
+
} else {
|
|
81
|
+
// Referer is attacker-controllable and this is a redirect target, so it must go
|
|
82
|
+
// through the same origin check every other redirect in the framework uses. Without it,
|
|
83
|
+
// a plain <a href="https://app.com/search"> on an attacker's page bounced the victim
|
|
84
|
+
// straight back off-site under the app's own domain — no CSRF token involved.
|
|
85
|
+
const back = safeRedirectPath(ctx.request.headers.get("Referer"), ctx.url.origin) ?? "/";
|
|
86
|
+
throw new ValidationRedirectError(back, errors);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const session = (ctx as unknown as { session?: { forget(key: string): void } }).session;
|
|
91
|
+
session?.forget("errors");
|
|
92
|
+
session?.forget("old");
|
|
93
|
+
|
|
94
|
+
return result.data as Infer<ExtractDefs<S>>;
|
|
95
|
+
}
|