@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.
@@ -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
+ }