@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { RequestContext, ForbiddenError } from "@zerotal/core";
|
|
2
|
+
import type { HttpContext } from "@zerotal/core";
|
|
3
|
+
import { safeRedirectPath } from "@zerotal/core";
|
|
4
|
+
import type { FieldRule } from "./FieldRule.ts";
|
|
5
|
+
import type { FieldRuleDefinition, Infer, Schema } from "./types.ts";
|
|
6
|
+
import { RuleBuilder } from "./RuleBuilder.ts";
|
|
7
|
+
import { runValidationAsync } from "./Validator.ts";
|
|
8
|
+
import { ValidationRedirectError, ValidationJsonError } from "./ValidationError.ts";
|
|
9
|
+
import { PrecognitionResponseError } from "./PrecognitionError.ts";
|
|
10
|
+
|
|
11
|
+
// Maps { title: StringRule; body: StringRule } → { title: StringRuleDef; body: StringRuleDef }
|
|
12
|
+
// The same pattern used by the top-level validate() function.
|
|
13
|
+
type ExtractDefs<S extends Record<string, FieldRule>> = {
|
|
14
|
+
[K in keyof S]: S[K]["_def"];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type SessionLike = {
|
|
18
|
+
set(key: string, value: unknown): void;
|
|
19
|
+
forget(key: string): void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Base class for encapsulating validation rules and optional authorization
|
|
24
|
+
* in a reusable, testable class rather than inline in each handler.
|
|
25
|
+
*
|
|
26
|
+
* Define rules via an instance `rules(r)` method. Because it is an instance
|
|
27
|
+
* method, you have full access to `this.context` inside your schema — enabling
|
|
28
|
+
* patterns like unique-except-current-user validation:
|
|
29
|
+
*
|
|
30
|
+
* ```typescript
|
|
31
|
+
* rules(r: RuleBuilder) {
|
|
32
|
+
* return {
|
|
33
|
+
* email: r.string().email().unique('users', 'email', this.context.user?.id),
|
|
34
|
+
* name: r.string().min(2),
|
|
35
|
+
* };
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Type inference: `validate()` uses `ReturnType<T['rules']>` to extract the
|
|
40
|
+
* narrow return type of the concrete subclass's `rules()` method. TypeScript
|
|
41
|
+
* evaluates this against the actual instantiated `T` at the call site — NOT
|
|
42
|
+
* the base class constraint — so the returned data is fully typed.
|
|
43
|
+
*
|
|
44
|
+
* IMPORTANT: Do NOT add an explicit return type to your `rules()` override.
|
|
45
|
+
* Annotating it as `Record<string, FieldRule>` would widen the inferred type
|
|
46
|
+
* and cause `validate()` to return `Record<string, unknown>` instead.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // app/requests/StorePostRequest.ts
|
|
50
|
+
* export class StorePostRequest extends FormRequest {
|
|
51
|
+
* authorize() { return !!this.context.user; }
|
|
52
|
+
*
|
|
53
|
+
* rules(r: RuleBuilder) {
|
|
54
|
+
* return {
|
|
55
|
+
* title: r.string().min(3).max(255),
|
|
56
|
+
* body: r.string().min(10),
|
|
57
|
+
* };
|
|
58
|
+
* }
|
|
59
|
+
* }
|
|
60
|
+
*
|
|
61
|
+
* // In a file-based route or controller:
|
|
62
|
+
* const data = await StorePostRequest.validate();
|
|
63
|
+
* // data.title → string (fully typed — no casts needed)
|
|
64
|
+
*/
|
|
65
|
+
export abstract class FormRequest {
|
|
66
|
+
/**
|
|
67
|
+
* Access the current request's HttpContext from within instance methods.
|
|
68
|
+
* Pulled from AsyncLocalStorage — no constructor injection needed.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* authorize() {
|
|
72
|
+
* return !!this.context.user;
|
|
73
|
+
* }
|
|
74
|
+
*/
|
|
75
|
+
protected get context(): HttpContext {
|
|
76
|
+
return RequestContext.get();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Authorize the request before validation.
|
|
81
|
+
* Return true to allow (default), false to deny with 403.
|
|
82
|
+
* Access the current request via `this.context`.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* authorize(): boolean {
|
|
86
|
+
* return !!this.context.user;
|
|
87
|
+
* }
|
|
88
|
+
*/
|
|
89
|
+
authorize(): boolean | Promise<boolean> {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Define the validation rules for this request.
|
|
95
|
+
* Override in subclasses WITHOUT an explicit return type so TypeScript infers
|
|
96
|
+
* the narrow schema shape and `validate()` produces a fully-typed result.
|
|
97
|
+
*
|
|
98
|
+
* You have full access to `this.context` inside this method — use it for
|
|
99
|
+
* rules that depend on the current user, route params, or session state.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* rules(r: RuleBuilder) {
|
|
103
|
+
* return {
|
|
104
|
+
* email: r.string().email().unique('users', 'email', this.context.user?.id),
|
|
105
|
+
* name: r.string().min(2).max(100),
|
|
106
|
+
* };
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
rules(_r: RuleBuilder): Record<string, FieldRule> {
|
|
111
|
+
throw new Error(`${this.constructor.name}.rules() is not implemented.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Attach a helper method to all FormRequest instances at runtime.
|
|
116
|
+
* Designed for framework packages (e.g. @zerotal/auth) that expose
|
|
117
|
+
* convenience methods like `this.user()` or `this.auth()`.
|
|
118
|
+
*
|
|
119
|
+
* Pair with a declaration merge on the exported `FormRequest` interface for
|
|
120
|
+
* TypeScript autocomplete on the added method.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* // In @zerotal/auth's provider:
|
|
124
|
+
* FormRequest.macro('user', function(this: FormRequest) {
|
|
125
|
+
* return this.context.user;
|
|
126
|
+
* });
|
|
127
|
+
*/
|
|
128
|
+
static macro(name: string, fn: (this: FormRequest, ...args: unknown[]) => unknown): void {
|
|
129
|
+
(FormRequest.prototype as unknown as Record<string, unknown>)[name] = fn;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate the HTTP request against this class's rules.
|
|
134
|
+
*
|
|
135
|
+
* The return type is fully inferred from the concrete `rules()` override —
|
|
136
|
+
* no manual type assertions needed in the caller. TypeScript resolves
|
|
137
|
+
* `ReturnType<T['rules']>` against the actual `T` at the call site, not the
|
|
138
|
+
* base class constraint. HttpContext is pulled from per-request
|
|
139
|
+
* AsyncLocalStorage automatically (zero arguments).
|
|
140
|
+
*
|
|
141
|
+
* On success: returns the validated, typed data object.
|
|
142
|
+
* On failure: throws ValidationRedirectError (Inertia / web form) or
|
|
143
|
+
* ValidationJsonError (JSON API), and stores errors in the session.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* const data = await StorePostRequest.validate();
|
|
147
|
+
* data.title // string — not unknown
|
|
148
|
+
*/
|
|
149
|
+
static async validate<T extends FormRequest>(
|
|
150
|
+
this: new () => T,
|
|
151
|
+
): Promise<Infer<ExtractDefs<ReturnType<T["rules"]>>>> {
|
|
152
|
+
const ctx = RequestContext.get();
|
|
153
|
+
const instance = new this();
|
|
154
|
+
|
|
155
|
+
// Authorization check before touching the request body
|
|
156
|
+
const authorized = await instance.authorize();
|
|
157
|
+
if (!authorized) {
|
|
158
|
+
throw new ForbiddenError("This action is not authorized.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Parse request body
|
|
162
|
+
let body: Record<string, unknown> = {};
|
|
163
|
+
try {
|
|
164
|
+
const ct = ctx.request.headers.get("Content-Type") ?? "";
|
|
165
|
+
if (ct.includes("application/json")) {
|
|
166
|
+
body = (await ctx.request.json()) as Record<string, unknown>;
|
|
167
|
+
} else if (
|
|
168
|
+
ct.includes("application/x-www-form-urlencoded") ||
|
|
169
|
+
ct.includes("multipart/form-data")
|
|
170
|
+
) {
|
|
171
|
+
const fd = await ctx.request.formData();
|
|
172
|
+
fd.forEach((val, key) => {
|
|
173
|
+
body[key] = val;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
body = {};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (ctx.request.method === "GET") {
|
|
181
|
+
ctx.url.searchParams.forEach((val, key) => {
|
|
182
|
+
body[key] ??= val;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Build schema from the instance rules() method — same pipeline as validate()
|
|
187
|
+
const builder = new RuleBuilder();
|
|
188
|
+
const fieldRules = instance.rules(builder);
|
|
189
|
+
const schema: Record<string, FieldRuleDefinition> = {};
|
|
190
|
+
for (const [key, rule] of Object.entries(fieldRules)) {
|
|
191
|
+
schema[key] = rule._def;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result = await runValidationAsync(schema as Schema, body);
|
|
195
|
+
const session = (ctx as unknown as { session?: SessionLike }).session;
|
|
196
|
+
|
|
197
|
+
// Precognition: validate without running controller side effects, then short-circuit with a
|
|
198
|
+
// 204 (no errors) or 422 (errors, optionally filtered to the requested fields).
|
|
199
|
+
if (ctx.request.headers.get("Precognition") === "true") {
|
|
200
|
+
const validateOnly = (ctx.request.headers.get("Precognition-Validate-Only") ?? "")
|
|
201
|
+
.split(",")
|
|
202
|
+
.map((s) => s.trim())
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
|
|
205
|
+
let errors = result.success ? {} : result.errors;
|
|
206
|
+
if (validateOnly.length) {
|
|
207
|
+
errors = Object.fromEntries(
|
|
208
|
+
Object.entries(errors).filter(([key]) => validateOnly.includes(key)),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw Object.keys(errors).length
|
|
213
|
+
? new PrecognitionResponseError(422, errors)
|
|
214
|
+
: new PrecognitionResponseError(204);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!result.success) {
|
|
218
|
+
session?.set("errors", result.errors);
|
|
219
|
+
session?.set("old", body);
|
|
220
|
+
|
|
221
|
+
const isInertia = ctx.request.headers.get("X-Inertia") === "true";
|
|
222
|
+
const accept = ctx.request.headers.get("Accept") ?? "";
|
|
223
|
+
const isJson = accept.includes("application/json") && !isInertia;
|
|
224
|
+
|
|
225
|
+
if (isJson) {
|
|
226
|
+
throw new ValidationJsonError(result.errors);
|
|
227
|
+
} else {
|
|
228
|
+
// Same origin check as validate() — see the note there. Referer is client input.
|
|
229
|
+
const back = safeRedirectPath(ctx.request.headers.get("Referer"), ctx.url.origin) ?? "/";
|
|
230
|
+
throw new ValidationRedirectError(back, result.errors);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
session?.forget("errors");
|
|
235
|
+
session?.forget("old");
|
|
236
|
+
|
|
237
|
+
return result.data as Infer<ExtractDefs<ReturnType<T["rules"]>>>;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Open interface for declaration merging.
|
|
243
|
+
* Framework packages (e.g. @zerotal/auth) augment this to expose typed helpers
|
|
244
|
+
* injected at runtime via FormRequest.macro().
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* // In @zerotal/auth/src/types.ts:
|
|
248
|
+
* declare module '@zerotal/validator' {
|
|
249
|
+
* interface FormRequest {
|
|
250
|
+
* user(): AuthenticatedUser | undefined;
|
|
251
|
+
* }
|
|
252
|
+
* }
|
|
253
|
+
*/
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
255
|
+
export interface FormRequest {}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
import type { ValidationErrors } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown by `FormRequest.validate()` during an Inertia **Precognition** request
|
|
6
|
+
* (`Precognition: true` header). A precognition request validates the incoming data without running
|
|
7
|
+
* the controller's side effects, so the client can show live validation feedback.
|
|
8
|
+
*
|
|
9
|
+
* The global exception handler in `@zerotal/core` renders this by calling `toResponse()`:
|
|
10
|
+
* - success → `204 No Content` with `Precognition-Success: true`
|
|
11
|
+
* - failure → `422` with the (optionally field-filtered) errors as JSON
|
|
12
|
+
*
|
|
13
|
+
* Both responses carry `Precognition: true` and `Vary: Precognition`.
|
|
14
|
+
*/
|
|
15
|
+
export class PrecognitionResponseError extends ZerotalError {
|
|
16
|
+
override readonly name = "PrecognitionResponse";
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
public readonly precognitionStatus: 204 | 422,
|
|
20
|
+
public readonly errors: ValidationErrors = {},
|
|
21
|
+
) {
|
|
22
|
+
super("Precognition", "E_PRECOGNITION", precognitionStatus);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toResponse(): Response {
|
|
26
|
+
const headers: Record<string, string> = {
|
|
27
|
+
Precognition: "true",
|
|
28
|
+
Vary: "Precognition",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (this.precognitionStatus === 204) {
|
|
32
|
+
headers["Precognition-Success"] = "true";
|
|
33
|
+
return new Response(null, { status: 204, headers });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new Response(JSON.stringify({ message: "Validation failed", errors: this.errors }), {
|
|
37
|
+
status: 422,
|
|
38
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StringRule,
|
|
3
|
+
NumberRule,
|
|
4
|
+
BooleanRule,
|
|
5
|
+
ArrayRule,
|
|
6
|
+
ObjectRule,
|
|
7
|
+
DateRule,
|
|
8
|
+
FileRule,
|
|
9
|
+
PasswordRule,
|
|
10
|
+
} from "./FieldRule.ts";
|
|
11
|
+
import type { FieldRule } from "./FieldRule.ts";
|
|
12
|
+
import type { Schema } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export class RuleBuilder {
|
|
15
|
+
/**
|
|
16
|
+
* A required value — shorthand for a required string, the most common entry point. Chain
|
|
17
|
+
* the usual string constraints onto it: `rule.required().min(2).max(50)`,
|
|
18
|
+
* `rule.required().email()`, `rule.required().min(8).confirmed()`. For other types start
|
|
19
|
+
* from the typed builder instead (`rule.number()`, `rule.boolean()`, …), which are also
|
|
20
|
+
* required by default.
|
|
21
|
+
*/
|
|
22
|
+
required(message?: string): StringRule {
|
|
23
|
+
return this.string().required(message);
|
|
24
|
+
}
|
|
25
|
+
string(): StringRule {
|
|
26
|
+
return new StringRule();
|
|
27
|
+
}
|
|
28
|
+
number(): NumberRule {
|
|
29
|
+
return new NumberRule();
|
|
30
|
+
}
|
|
31
|
+
boolean(): BooleanRule {
|
|
32
|
+
return new BooleanRule();
|
|
33
|
+
}
|
|
34
|
+
date(): DateRule {
|
|
35
|
+
return new DateRule();
|
|
36
|
+
}
|
|
37
|
+
/** Validates a File object from multipart/form-data. */
|
|
38
|
+
file(): FileRule {
|
|
39
|
+
return new FileRule();
|
|
40
|
+
}
|
|
41
|
+
/** Validates password strength — chain min/mixedCase/numbers/symbols. */
|
|
42
|
+
password(): PasswordRule {
|
|
43
|
+
return new PasswordRule();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
email(message?: string): StringRule {
|
|
47
|
+
return this.string().email(message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
url(message?: string): StringRule {
|
|
51
|
+
return this.string().url(message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
uuid(message?: string): StringRule {
|
|
55
|
+
return this.string().uuid(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
array<T extends FieldRule>(item: T): ArrayRule<T> {
|
|
59
|
+
return new ArrayRule(item);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
object<S extends Schema>(shape: { [K in keyof S]: FieldRule }): ObjectRule<S> {
|
|
63
|
+
return new ObjectRule<S>(shape);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
import type { ValidationErrors } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown by validate() when validation fails and the request is an Inertia
|
|
6
|
+
* request (X-Inertia: true) or a web form (Accept: text/html).
|
|
7
|
+
*
|
|
8
|
+
* The global exception handler in @zerotal/core catches this by checking
|
|
9
|
+
* err?.name === 'ValidationRedirectError' and returns a 303 redirect.
|
|
10
|
+
*/
|
|
11
|
+
export class ValidationRedirectError extends ZerotalError {
|
|
12
|
+
override readonly name = "ValidationRedirectError";
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
public readonly redirectTo: string,
|
|
16
|
+
public readonly errors: ValidationErrors,
|
|
17
|
+
) {
|
|
18
|
+
super("Validation failed", "E_VALIDATION_REDIRECT", 303);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Thrown by validate() when validation fails and the request is a JSON API
|
|
24
|
+
* request (Accept: application/json, no X-Inertia header).
|
|
25
|
+
*
|
|
26
|
+
* The global exception handler in @zerotal/core catches this by checking
|
|
27
|
+
* err?.name === 'ValidationJsonError' and returns a 422 response.
|
|
28
|
+
*/
|
|
29
|
+
export class ValidationJsonError extends ZerotalError {
|
|
30
|
+
override readonly name = "ValidationJsonError";
|
|
31
|
+
|
|
32
|
+
constructor(public readonly errors: ValidationErrors) {
|
|
33
|
+
super("Validation failed", "E_VALIDATION", 422);
|
|
34
|
+
}
|
|
35
|
+
}
|