@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Validation
|
|
3
|
+
description: Validate request input against typed rule chains and turn failures into the right HTTP response automatically.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Validation
|
|
7
|
+
|
|
8
|
+
The validator checks incoming request data against rule chains and produces
|
|
9
|
+
fully-typed output — no manual type assertions needed. It offers two entry
|
|
10
|
+
points: `FormRequest` (class-based, recommended) and the standalone `validate()`
|
|
11
|
+
function (for one-off use).
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# in your project root
|
|
17
|
+
bun add @zerotal/validator
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The validator has no provider to register — import its classes and functions
|
|
21
|
+
directly. Database-aware rules (`unique()`, `exists()`) do require
|
|
22
|
+
[`DatabaseProvider`](/docs/database) to be registered; see
|
|
23
|
+
[Database rules](#database-rules).
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
Create `config/validator.ts` with the `ValidatorConfig()` helper so every field
|
|
28
|
+
stays type-checked while defaults are filled in:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// config/validator.ts
|
|
32
|
+
import { ValidatorConfig } from "@zerotal/validator";
|
|
33
|
+
|
|
34
|
+
export default ValidatorConfig({
|
|
35
|
+
stopOnFirstFailure: true, // stop after the first failure per field
|
|
36
|
+
locale: "en", // default locale for error messages
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Field | Required | Default | Description |
|
|
41
|
+
| -------------------- | -------- | ------- | -------------------------------------------------- |
|
|
42
|
+
| `stopOnFirstFailure` | no | `true` | Stop validation after the first failure per field. |
|
|
43
|
+
| `locale` | no | `"en"` | Default locale for error messages. |
|
|
44
|
+
|
|
45
|
+
## Which entry point should I use?
|
|
46
|
+
|
|
47
|
+
- **`FormRequest`** — the default for HTTP handlers. Encapsulates rules and
|
|
48
|
+
authorization in a reusable, testable class, reads the request from the active
|
|
49
|
+
context, and throws the right error on failure.
|
|
50
|
+
- **`validate(ctx, factory)`** — for one-off HTTP validation where a dedicated
|
|
51
|
+
class would be overkill. Same failure behavior as `FormRequest`.
|
|
52
|
+
- **`Validator.check(data, factory)`** — for non-HTTP code (CLI commands,
|
|
53
|
+
services, background jobs). Returns a result object and never throws or
|
|
54
|
+
redirects. See [Non-HTTP validation](#non-http-validation).
|
|
55
|
+
|
|
56
|
+
## FormRequest
|
|
57
|
+
|
|
58
|
+
### Defining a request
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// app/requests/posts/StorePostRequest.ts
|
|
62
|
+
import { FormRequest, RuleBuilder } from "@zerotal/validator";
|
|
63
|
+
|
|
64
|
+
export class StorePostRequest extends FormRequest {
|
|
65
|
+
// Return true to allow, false to deny with a 403 ForbiddenError.
|
|
66
|
+
// Access the current request via this.context.
|
|
67
|
+
authorize(): boolean {
|
|
68
|
+
return !!this.context.user;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Do NOT annotate the return type — TypeScript needs to infer the narrow shape
|
|
72
|
+
// for validate() to produce a typed result.
|
|
73
|
+
rules(r: RuleBuilder) {
|
|
74
|
+
return {
|
|
75
|
+
title: r.string().min(3).max(255),
|
|
76
|
+
body: r.string().min(10),
|
|
77
|
+
tags: r.array(r.string()).optional(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
> **Warning** — Do not add an explicit return type to `rules()`. Annotating it
|
|
84
|
+
> (e.g. as `Record<string, FieldRule>`) widens the inferred type and makes
|
|
85
|
+
> `validate()` return `Record<string, unknown>` instead of your typed shape.
|
|
86
|
+
|
|
87
|
+
### Calling validate
|
|
88
|
+
|
|
89
|
+
Call `validate()` as a static method on the class. It reads `HttpContext` from
|
|
90
|
+
`AsyncLocalStorage` — no arguments needed.
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// in a controller
|
|
94
|
+
const data = await StorePostRequest.validate();
|
|
95
|
+
|
|
96
|
+
data.title; // string — fully typed, no cast needed
|
|
97
|
+
data.body; // string
|
|
98
|
+
data.tags; // string[] | undefined
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
On failure, `validate()` stores `errors` and `old` (previous input) in the
|
|
102
|
+
session, then branches on the request type:
|
|
103
|
+
|
|
104
|
+
| Request type | Detected by | Error thrown | HTTP result |
|
|
105
|
+
| ------------ | ------------------------------------------------------ | ------------------------- | ----------------------------------------------- |
|
|
106
|
+
| JSON / API | `Accept: application/json` (and no `X-Inertia` header) | `ValidationJsonError` | 422 JSON with field errors |
|
|
107
|
+
| Inertia | `X-Inertia: true` header | `ValidationRedirectError` | Redirect back; errors surfaced via shared props |
|
|
108
|
+
| HTML form | neither header | `ValidationRedirectError` | Redirect back; errors in session |
|
|
109
|
+
|
|
110
|
+
Both `ValidationJsonError` and `ValidationRedirectError` are exported from
|
|
111
|
+
`@zerotal/validator`. The exception handler renders them appropriately — you
|
|
112
|
+
never need to catch them yourself.
|
|
113
|
+
|
|
114
|
+
### Authorization
|
|
115
|
+
|
|
116
|
+
`authorize()` runs **before** validation. Returning `false` throws a
|
|
117
|
+
`ForbiddenError` (403) without touching the request body.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// in a FormRequest subclass
|
|
121
|
+
|
|
122
|
+
// Check the current user's role:
|
|
123
|
+
authorize(): boolean {
|
|
124
|
+
return (this.context.user as { role?: string })?.role === "admin";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Use Gate.allows() for policy-based auth:
|
|
128
|
+
authorize(): boolean {
|
|
129
|
+
return Gate.via(PostPolicy).allows("create", new Post());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Async authorize is also supported:
|
|
133
|
+
async authorize(): Promise<boolean> {
|
|
134
|
+
const post = await Post.findOrFail(Number(this.context.params["id"]));
|
|
135
|
+
return Gate.via(PostPolicy).allows("update", post);
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Accessing context inside rules
|
|
140
|
+
|
|
141
|
+
Because `rules()` is an instance method, you have full access to `this.context`
|
|
142
|
+
for rules that depend on the current user, route params, or session state:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// in a FormRequest subclass
|
|
146
|
+
rules(r: RuleBuilder) {
|
|
147
|
+
const userId = this.context.user?.id;
|
|
148
|
+
return {
|
|
149
|
+
email: r.string().email()
|
|
150
|
+
.unique("users", "email", userId), // ignore current user on update
|
|
151
|
+
name: r.string().min(2).max(100),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Standalone validate
|
|
157
|
+
|
|
158
|
+
For simple one-off validation without a dedicated class:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// in a controller
|
|
162
|
+
import { validate } from "@zerotal/validator";
|
|
163
|
+
|
|
164
|
+
// validate(ctx, factory) reads the request body from the HttpContext and
|
|
165
|
+
// returns the validated, typed data directly. On failure it throws —
|
|
166
|
+
// ValidationJsonError (→ 422 JSON) or ValidationRedirectError (→ 303 back) —
|
|
167
|
+
// which the global exception handler turns into the right response.
|
|
168
|
+
const data = await validate(ctx, (r) => ({
|
|
169
|
+
title: r.string().min(3),
|
|
170
|
+
count: r.number().integer().min(1),
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
data.title; // string — fully typed, no cast needed
|
|
174
|
+
data.count; // number
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Non-HTTP validation
|
|
178
|
+
|
|
179
|
+
For CLI commands, services, or background jobs — where there is no request to
|
|
180
|
+
redirect and no response to throw — use the `Validator` facade. It returns a
|
|
181
|
+
`{ success, data, errors }` outcome and never throws.
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
// in a command or service
|
|
185
|
+
import { Validator } from "@zerotal/validator";
|
|
186
|
+
|
|
187
|
+
const result = Validator.check(payload, (r) => ({
|
|
188
|
+
email: r.string().email(),
|
|
189
|
+
age: r.number().min(0),
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
if (!result.success) {
|
|
193
|
+
console.error(result.errors);
|
|
194
|
+
} else {
|
|
195
|
+
result.data.email; // string — fully typed
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## RuleBuilder types
|
|
200
|
+
|
|
201
|
+
Start every rule chain with a type method on `RuleBuilder`:
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
// in a rules() method or factory
|
|
205
|
+
const r = new RuleBuilder(); // or the `r` param in rules()
|
|
206
|
+
|
|
207
|
+
r.string(); // StringRule
|
|
208
|
+
r.number(); // NumberRule
|
|
209
|
+
r.boolean(); // BooleanRule
|
|
210
|
+
r.date(); // DateRule
|
|
211
|
+
r.file(); // FileRule — multipart/form-data uploads
|
|
212
|
+
r.password(); // PasswordRule — strength validation
|
|
213
|
+
|
|
214
|
+
// Convenience shorthands on RuleBuilder:
|
|
215
|
+
r.required(); // r.string().required() — required string, the common case
|
|
216
|
+
r.email(); // r.string().email()
|
|
217
|
+
r.url(); // r.string().url()
|
|
218
|
+
r.uuid(); // r.string().uuid()
|
|
219
|
+
|
|
220
|
+
r.array(r.string()); // ArrayRule<StringRule> — array of strings
|
|
221
|
+
r.object({/* … */}); // ObjectRule — nested object
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Common modifiers
|
|
225
|
+
|
|
226
|
+
These are available on **every** rule type:
|
|
227
|
+
|
|
228
|
+
| Method | Description |
|
|
229
|
+
| --------------------------------- | ---------------------------------------------------------------------------- |
|
|
230
|
+
| `.required(msg?)` | Field must be present and non-empty |
|
|
231
|
+
| `.optional()` | Field may be absent; result type becomes `T \| undefined` |
|
|
232
|
+
| `.nullable()` | Field may be `null` |
|
|
233
|
+
| `.default(val)` | Use `val` when field is absent; implies `.optional()` |
|
|
234
|
+
| `.sometimes()` | Only validate when field is present in input (PATCH-friendly) |
|
|
235
|
+
| `.bail()` | Stop after the first failing rule on this field |
|
|
236
|
+
| `.custom(fn)` | Run a custom sync/async function; return `true`, `false`, or an error string |
|
|
237
|
+
| `.requiredIf(field, value)` | Required when another field equals `value` |
|
|
238
|
+
| `.requiredUnless(field, value)` | Required unless another field equals `value` |
|
|
239
|
+
| `.requiredWith(fields[])` | Required when ANY listed field is present |
|
|
240
|
+
| `.requiredWithout(fields[])` | Required when ANY listed field is absent |
|
|
241
|
+
| `.requiredWithAll(fields[])` | Required when ALL listed fields are present |
|
|
242
|
+
| `.requiredWithoutAll(fields[])` | Required when ALL listed fields are absent |
|
|
243
|
+
| `.prohibitedIf(field, value)` | Must be absent when another field equals `value` |
|
|
244
|
+
| `.prohibitedUnless(field, value)` | Must be absent unless another field equals `value` |
|
|
245
|
+
| `.accepted()` | Truthy: `true`, `1`, `'1'`, `'yes'`, `'on'`, `'true'` |
|
|
246
|
+
| `.declined()` | Falsy: `false`, `0`, `'0'`, `'no'`, `'off'`, `'false'` |
|
|
247
|
+
|
|
248
|
+
### Custom validator
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// in a rules() method or factory
|
|
252
|
+
username: r.string().custom(async (value) => {
|
|
253
|
+
const taken = await User.findBy("username", value as string);
|
|
254
|
+
return taken ? "This username is already taken." : true;
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## String rules
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
// in a rules() method or factory
|
|
262
|
+
r.string()
|
|
263
|
+
.min(3) // minimum character length
|
|
264
|
+
.max(255) // maximum character length
|
|
265
|
+
.size(10) // exact character length
|
|
266
|
+
.email() // valid email address
|
|
267
|
+
.url() // valid URL
|
|
268
|
+
.uuid() // valid UUID v4
|
|
269
|
+
.ip() // valid IPv4 or IPv6
|
|
270
|
+
.json() // parseable JSON string
|
|
271
|
+
.regex(/^[A-Z]+$/) // matches regex
|
|
272
|
+
.matches(/^[A-Z]+$/, "msg") // alias for regex()
|
|
273
|
+
.in(["a", "b", "c"]) // value in list
|
|
274
|
+
.notIn(["x", "y"]) // value not in list
|
|
275
|
+
.alpha() // letters only (a-z, A-Z)
|
|
276
|
+
.alphaNum() // letters and digits
|
|
277
|
+
.alphaDash() // letters, digits, hyphens, underscores
|
|
278
|
+
.numeric() // digits only (optional leading minus)
|
|
279
|
+
.digits(6) // exactly 6 digits
|
|
280
|
+
.digitsBetween(4, 8) // between 4 and 8 digits
|
|
281
|
+
.startsWith("https://") // must start with prefix
|
|
282
|
+
.endsWith(".pdf") // must end with suffix
|
|
283
|
+
.trim() // strip leading/trailing whitespace (transform)
|
|
284
|
+
.lowercase() // convert to lowercase (transform)
|
|
285
|
+
.uppercase() // convert to uppercase (transform)
|
|
286
|
+
.confirmed() // value must equal `{field}_confirmation` in input
|
|
287
|
+
.sameAs("password") // value must equal another field
|
|
288
|
+
.present() // key must exist in input (may be empty)
|
|
289
|
+
.prohibited() // key must be absent from input
|
|
290
|
+
.password(); // semantic alias — chain constraints after this
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Number rules
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// in a rules() method or factory
|
|
297
|
+
r.number()
|
|
298
|
+
.min(0) // minimum value (inclusive)
|
|
299
|
+
.max(100) // maximum value (inclusive)
|
|
300
|
+
.between(1, 10) // min and max in one call
|
|
301
|
+
.integer() // must be a whole number
|
|
302
|
+
.positive() // shorthand for .min(0)
|
|
303
|
+
.notIn([0, -1]); // value not in list
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Boolean rules
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// in a rules() method or factory
|
|
310
|
+
r.boolean()
|
|
311
|
+
.accepted() // truthy — for "agree to terms" checkboxes
|
|
312
|
+
.declined(); // falsy
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Date rules
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
// in a rules() method or factory
|
|
319
|
+
r.date()
|
|
320
|
+
.after("2026-01-01") // strictly after date
|
|
321
|
+
.before(new Date()) // strictly before date
|
|
322
|
+
.afterOrEqual("2026-01-01") // on or after
|
|
323
|
+
.beforeOrEqual("2026-12-31"); // on or before
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Array rules
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
// in a rules() method or factory
|
|
330
|
+
r.array(r.string()) // array of strings
|
|
331
|
+
.min(1) // minimum item count
|
|
332
|
+
.max(10) // maximum item count
|
|
333
|
+
.size(3); // exact item count
|
|
334
|
+
|
|
335
|
+
r.array(r.number().integer()); // array of integers
|
|
336
|
+
|
|
337
|
+
// Nested objects in an array:
|
|
338
|
+
r.array(r.object({ name: r.string(), age: r.number() }));
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Object rules
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
// in a rules() method or factory
|
|
345
|
+
r.object({
|
|
346
|
+
street: r.string(),
|
|
347
|
+
city: r.string(),
|
|
348
|
+
country: r.string().in(["US", "CA", "GB"]),
|
|
349
|
+
zip: r
|
|
350
|
+
.string()
|
|
351
|
+
.regex(/^\d{5}$/)
|
|
352
|
+
.optional(),
|
|
353
|
+
});
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## File rules
|
|
357
|
+
|
|
358
|
+
Validates `File` objects from `multipart/form-data` uploads:
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
// in a rules() method or factory
|
|
362
|
+
avatar: r.file()
|
|
363
|
+
.mimes(["jpg", "jpeg", "png", "webp"]) // allowed extensions
|
|
364
|
+
.max(2048) // maximum KB
|
|
365
|
+
.min(1) // minimum KB
|
|
366
|
+
.optional();
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## Password rules
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
// in a rules() method or factory
|
|
373
|
+
r.password()
|
|
374
|
+
.min(8) // minimum length (default: 8)
|
|
375
|
+
.mixedCase() // requires upper + lower case letters
|
|
376
|
+
.numbers() // requires at least one digit
|
|
377
|
+
.symbols() // requires at least one symbol
|
|
378
|
+
.uncompromised() // must not contain spaces
|
|
379
|
+
.confirmed(); // must equal password_confirmation field
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Database rules
|
|
383
|
+
|
|
384
|
+
These rules require [`DatabaseProvider`](/docs/database) to be registered; they
|
|
385
|
+
throw at runtime otherwise.
|
|
386
|
+
|
|
387
|
+
### unique — value must not already exist in the DB
|
|
388
|
+
|
|
389
|
+
The third argument ignores a record on update — pass the current record's ID, or
|
|
390
|
+
a `UniqueOptions` object (`{ ignoreId }`) for clarity. A fourth argument
|
|
391
|
+
overrides the error message.
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
// in a rules() method or factory
|
|
395
|
+
email: r.string().email().unique("users", "email");
|
|
396
|
+
|
|
397
|
+
// Ignore the current record when updating (pass the record's ID):
|
|
398
|
+
email: r.string().email().unique("users", "email", this.context.user?.id);
|
|
399
|
+
|
|
400
|
+
// Equivalent, using the options object:
|
|
401
|
+
email: r.string().email().unique("users", "email", { ignoreId: userId });
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### exists — value must exist in the DB
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
// in a rules() method or factory
|
|
408
|
+
userId: r.number().exists("users", "id");
|
|
409
|
+
roleSlug: r.string().exists("roles", "slug");
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
> **Note** — These rules stay decoupled from the ORM: `DatabaseProvider`
|
|
413
|
+
> registers the query executor via `registerDbRuleRunner()` during its boot, so
|
|
414
|
+
> `@zerotal/validator` never depends directly on `@zerotal/orm`.
|
|
415
|
+
|
|
416
|
+
## Precognition
|
|
417
|
+
|
|
418
|
+
When a request carries a `Precognition: true` header, `FormRequest.validate()`
|
|
419
|
+
runs the rules without executing the controller body, then short-circuits with a
|
|
420
|
+
`204` (no errors) or `422` (with errors). A `Precognition-Validate-Only` header
|
|
421
|
+
(comma-separated field names) narrows the validated set to just those fields —
|
|
422
|
+
useful for live, field-by-field client validation.
|
|
423
|
+
|
|
424
|
+
## Error handling
|
|
425
|
+
|
|
426
|
+
### JSON / API requests
|
|
427
|
+
|
|
428
|
+
When `Accept: application/json` is present (and no `X-Inertia` header),
|
|
429
|
+
validation throws `ValidationJsonError`:
|
|
430
|
+
|
|
431
|
+
```json
|
|
432
|
+
{
|
|
433
|
+
"message": "The given data was invalid.",
|
|
434
|
+
"errors": {
|
|
435
|
+
"title": ["The title must be at least 3 characters."],
|
|
436
|
+
"email": ["The email has already been taken."]
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
The response is sent as `422 Unprocessable Entity`. `ExceptionHandler` catches
|
|
442
|
+
the error and renders this automatically.
|
|
443
|
+
|
|
444
|
+
### Inertia / HTML form requests
|
|
445
|
+
|
|
446
|
+
When the request is an Inertia request or has no JSON `Accept` header, validation
|
|
447
|
+
throws `ValidationRedirectError`. `ExceptionHandler` converts this to a redirect
|
|
448
|
+
back to the previous page.
|
|
449
|
+
|
|
450
|
+
Errors and old input are stored in the [session](/docs/session) under the keys
|
|
451
|
+
`'errors'` and `'old'`. Read them on the next request:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// in a controller
|
|
455
|
+
const errors = ctx.flashed<Record<string, string[]>>("errors");
|
|
456
|
+
const old = ctx.flashed<Record<string, unknown>>("old");
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
In [Inertia](/docs/inertia), errors are passed automatically as the `errors`
|
|
460
|
+
prop via shared props — no manual session reading required.
|
|
461
|
+
|
|
462
|
+
## Testing
|
|
463
|
+
|
|
464
|
+
Set your suite up once as described in [Testing](/docs/testing). Validation is
|
|
465
|
+
worth testing at the rule level, because that is where the mistakes are and it
|
|
466
|
+
needs no request.
|
|
467
|
+
|
|
468
|
+
**`Validator.check()` never throws**, so a rule set tests as a pure function —
|
|
469
|
+
assert on the outcome rather than on a caught error:
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
// tests/validation/RegisterRules.test.ts
|
|
473
|
+
import { test, expect } from "bun:test";
|
|
474
|
+
import { Validator } from "@zerotal/validator";
|
|
475
|
+
|
|
476
|
+
const rules = (r) => ({
|
|
477
|
+
email: r.string().email(),
|
|
478
|
+
age: r.number().min(18),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("rejects an underage applicant", () => {
|
|
482
|
+
const result = Validator.check({ email: "jane@example.com", age: 17 }, rules);
|
|
483
|
+
|
|
484
|
+
expect(result.success).toBe(false);
|
|
485
|
+
expect(result.errors.age).toBeDefined();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("returns typed data on success", () => {
|
|
489
|
+
const result = Validator.check({ email: "jane@example.com", age: 30 }, rules);
|
|
490
|
+
|
|
491
|
+
expect(result.success).toBe(true);
|
|
492
|
+
if (result.success) expect(result.data.email).toBe("jane@example.com");
|
|
493
|
+
});
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
**Test the values that sit either side of a boundary**, not a comfortable middle.
|
|
497
|
+
`age: 18` and `age: 17` prove `min(18)`; `age: 30` proves nothing that `age: 19`
|
|
498
|
+
would not.
|
|
499
|
+
|
|
500
|
+
**A `FormRequest` fails the HTTP request**, so test it through the route it
|
|
501
|
+
guards. The status tells you which failure mode you got:
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
// tests/http/register.test.ts
|
|
505
|
+
const res = await app.post("/register", { email: "not-an-email" });
|
|
506
|
+
|
|
507
|
+
res.assertUnprocessable(); // 422 for a JSON request
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
> **Note** — The same failure redirects with flashed errors for a form request
|
|
511
|
+
> and returns `422` for a JSON one. Assert the shape your route actually serves;
|
|
512
|
+
> a test posting JSON to a form endpoint will pass for the wrong reason.
|
|
513
|
+
|
|
514
|
+
## References
|
|
515
|
+
|
|
516
|
+
### RuleBuilder
|
|
517
|
+
|
|
518
|
+
| Method | Signature | Description |
|
|
519
|
+
| ---------- | --------------------------------------------------- | ----------------------------------------- |
|
|
520
|
+
| `required` | `required(message?: string): StringRule` | Required string — the common entry point. |
|
|
521
|
+
| `string` | `string(): StringRule` | Begin a string rule chain. |
|
|
522
|
+
| `number` | `number(): NumberRule` | Begin a number rule chain. |
|
|
523
|
+
| `boolean` | `boolean(): BooleanRule` | Begin a boolean rule chain. |
|
|
524
|
+
| `date` | `date(): DateRule` | Begin a date rule chain. |
|
|
525
|
+
| `file` | `file(): FileRule` | Validate a multipart/form-data upload. |
|
|
526
|
+
| `password` | `password(): PasswordRule` | Validate password strength. |
|
|
527
|
+
| `email` | `email(message?): StringRule` | Shorthand for `string().email()`. |
|
|
528
|
+
| `url` | `url(message?): StringRule` | Shorthand for `string().url()`. |
|
|
529
|
+
| `uuid` | `uuid(message?): StringRule` | Shorthand for `string().uuid()`. |
|
|
530
|
+
| `array` | `array<T extends FieldRule>(item: T): ArrayRule<T>` | Array whose items match `item`. |
|
|
531
|
+
| `object` | `object<S>(shape): ObjectRule<S>` | Nested object matching `shape`. |
|
|
532
|
+
|
|
533
|
+
### Entry points
|
|
534
|
+
|
|
535
|
+
| Member | Signature | Description |
|
|
536
|
+
| ----------------- | -------------------------------------------- | ---------------------------------------------------- |
|
|
537
|
+
| `FormRequest` | `static validate(): Promise<Infer<…>>` | Validate the active request against the class rules. |
|
|
538
|
+
| `FormRequest` | `authorize(): boolean \| Promise<boolean>` | Allow/deny the request before validation. |
|
|
539
|
+
| `FormRequest` | `rules(r: RuleBuilder)` | Return the field-to-rule schema (no return type). |
|
|
540
|
+
| `validate` | `validate(ctx, factory): Promise<Infer<…>>` | One-off HTTP validation; throws on failure. |
|
|
541
|
+
| `Validator.check` | `check(data, factory): ValidationOutcome<…>` | Non-HTTP validation; returns a result, never throws. |
|
|
542
|
+
|
|
543
|
+
## Next steps
|
|
544
|
+
|
|
545
|
+
- [Requests Context](/docs/context#reading-input) — read the input that `FormRequest` validates.
|
|
546
|
+
- [Errors](/docs/errors) — how `ExceptionHandler` renders validation failures.
|
|
547
|
+
- [Authorization](/docs/authorization) — back `authorize()` with policies.
|
|
548
|
+
- [Database](/docs/database) — register `DatabaseProvider` for `unique()` and `exists()`.
|
|
549
|
+
- [Inertia](/docs/inertia) — where the `errors` shared prop comes from.
|