180 lines
8.1 KiB
Markdown
180 lines
8.1 KiB
Markdown
# @wrnexus/validation
|
|
|
|
> One fluent schema, validated on the server (API bodies, env vars) and mirrored to an eval-free browser validator for forms.
|
|
|
|
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
|
|
## Overview
|
|
|
|
Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
bun add @wrnexus/validation
|
|
```
|
|
|
|
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
|
|
## API
|
|
|
|
### The `v` builder
|
|
|
|
```ts
|
|
import { v } from "@wrnexus/validation";
|
|
```
|
|
|
|
| Factory | Returns | Field methods |
|
|
| ------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
| `v.string()` | `StringSchema` | `email()`, `url()`, `uuid()`, `date()`, `length(n)`, `oneOf(string[])`, `pattern(re)`, `trim()`, `min(n)`, `max(n)` |
|
|
| `v.number()` | `NumberSchema` | `integer()`, `positive()`, `oneOf(number[])`, `min(n)`, `max(n)` |
|
|
| `v.boolean()` | `BooleanSchema` | (base methods only) |
|
|
| `v.object(fields)` | `ObjectSchema` | `parse(input)`, `describe()` |
|
|
|
|
Every field schema is chainable and shares these base methods:
|
|
|
|
- `min(n, message?)` / `max(n, message?)` — for strings, bounds the length; for numbers, bounds the value.
|
|
- `optional()` — an empty/missing value passes instead of erroring `"Required"`.
|
|
- `label(text)` — human label carried into the descriptor.
|
|
- `default(value)` — value substituted when the field is absent (implies `optional`).
|
|
- `refine(fn, message?)` — **server-only** predicate. `fn` returns `true` (ok), `false` (use `message`), or a `string` (that error). Not serialized to the client.
|
|
|
|
Each string rule accepts an optional trailing `message` to override the default error text.
|
|
|
|
### `ObjectSchema`
|
|
|
|
```ts
|
|
schema.parse(input: unknown): ParseResult
|
|
schema.describe(): SchemaDescriptor
|
|
```
|
|
|
|
`parse` coerces each field (strings stay strings, `v.number()` runs `Number()`, `v.boolean()` treats `true` / `"true"` / `"on"` as true), applies its rules and refinements, fills in `default()` values, and returns:
|
|
|
|
```ts
|
|
interface ParseResult<T = Record<string, unknown>> {
|
|
ok: boolean; // true when errors is empty
|
|
value: T; // coerced values (present pass or fail)
|
|
errors: Record<string, string>; // field name → first failing message
|
|
}
|
|
```
|
|
|
|
`describe()` returns the JSON bridge for the client:
|
|
|
|
```ts
|
|
interface SchemaDescriptor {
|
|
type: "object";
|
|
fields: Record<string, FieldDescriptor>;
|
|
}
|
|
interface FieldDescriptor {
|
|
type: "string" | "number" | "boolean";
|
|
optional?: boolean;
|
|
label?: string;
|
|
trim?: boolean; // strings only
|
|
rules: RuleDescriptor[];
|
|
}
|
|
```
|
|
|
|
### Rules and coercion
|
|
|
|
`RuleDescriptor` is a discriminated union of the serializable rules — `min`, `max`, `length`, `email`, `url`, `uuid`, `date`, `oneOf`, `pattern`, `integer`. Two exported functions apply them and are shared by the server (the client runtime reimplements the same logic):
|
|
|
|
- `applyRule(type, rule, value): string | null` — validate one already-coerced value against one rule.
|
|
- `checkField(desc, raw): { value, error }` — coerce and validate one field. Empty input (`undefined`/`null`/`""`) is `"Required"` unless `optional`. Strings with `trim` are trimmed first. Numbers that fail `Number()` yield `"Must be a number"`.
|
|
|
|
Notes on specific rules: `email`/`url`/`uuid` test built-in regexes; `date` uses `Date.parse`; `pattern` reconstructs a `RegExp` from its `source`/`flags` and passes silently if the pattern is invalid; `integer` requires `Number.isInteger`; `positive()` is implemented as `min(Number.MIN_VALUE)`.
|
|
|
|
### API helpers
|
|
|
|
```ts
|
|
invalid(errors: Record<string, string>): Response // ready 400 { ok:false, errors }
|
|
|
|
parseBody<T>(schema, req):
|
|
Promise<{ ok: true; value: T } | { ok: false; response: Response }>
|
|
```
|
|
|
|
`parseBody` reads the request body from JSON, `application/x-www-form-urlencoded`, or `multipart/form-data`, validates it, and on failure hands back a ready 400 `Response`.
|
|
|
|
### Environment config
|
|
|
|
```ts
|
|
parseEnv<T>(schema: ObjectSchema, source?): T
|
|
```
|
|
|
|
Validates env vars (from `Bun.env`, falling back to `process.env`) against a schema and coerces them (`PORT` → number, `DEBUG` → boolean). On any problem it throws **one** error listing every offending variable, so misconfiguration fails fast at startup.
|
|
|
|
### Client runtime (from `runtime.ts`)
|
|
|
|
```ts
|
|
renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string
|
|
VALIDATE_RUNTIME: string
|
|
```
|
|
|
|
- `renderSchemasScript` produces `window.__wireSchemas = { name: descriptor, … };` to inline in the page.
|
|
- `VALIDATE_RUNTIME` is a self-contained, eval-free IIFE string. Injected as a `<script>`, it binds every `form[data-schema]` and validates on submit and blur, writing messages into `[data-error="<field>"]` elements and toggling `aria-invalid` / `.wire-invalid`. On a valid submit it `fetch`es the form `action` as JSON (attaching the `wire-csrf` cookie as an `x-csrf-token` header), then follows `data-redirect` / a `redirect` in the response, surfaces server-side field errors, and fires `wire:success` / `wire:error` events. It exposes `window.__wireValidate.init(root)` and self-initializes on `DOMContentLoaded`.
|
|
|
|
## Usage
|
|
|
|
Define a schema and validate an API body:
|
|
|
|
```ts
|
|
import { v, parseBody } from "@wrnexus/validation";
|
|
|
|
export const signupSchema = v.object({
|
|
email: v.string().trim().email(),
|
|
password: v.string().min(8).max(200),
|
|
age: v.number().integer().min(13).max(120).optional(),
|
|
role: v.string().oneOf(["user", "admin"]).default("user"),
|
|
agree: v.boolean(),
|
|
});
|
|
|
|
// inside a route handler
|
|
const result = await parseBody(signupSchema, req);
|
|
if (!result.ok) return result.response; // ready 400 with field errors
|
|
const { email, password, role } = result.value;
|
|
```
|
|
|
|
Server-only refinement:
|
|
|
|
```ts
|
|
const schema = v.object({
|
|
username: v
|
|
.string()
|
|
.min(3)
|
|
.refine((name) => !RESERVED.has(String(name)), "That name is taken"),
|
|
});
|
|
```
|
|
|
|
Validate environment at startup:
|
|
|
|
```ts
|
|
import { v, parseEnv } from "@wrnexus/validation";
|
|
|
|
export const env = parseEnv(
|
|
v.object({
|
|
DATABASE_URL: v.string().min(1),
|
|
PORT: v.number().integer().default(3000),
|
|
DEBUG: v.boolean().optional(),
|
|
}),
|
|
);
|
|
// throws one readable error listing every bad variable if misconfigured
|
|
```
|
|
|
|
Wire the same schema into the browser:
|
|
|
|
```ts
|
|
import { renderSchemasScript, VALIDATE_RUNTIME } from "@wrnexus/validation";
|
|
import { signupSchema } from "./app/schemas/signup.ts";
|
|
|
|
const head = `<script>${renderSchemasScript({ signup: signupSchema.describe() })}</script>
|
|
<script>${VALIDATE_RUNTIME}</script>`;
|
|
// render a <form data-schema="signup"> with [data-error="email"] etc.
|
|
```
|
|
|
|
## Requirements / Notes
|
|
|
|
- **Bun-only.** `parseEnv` reads `Bun.env` (falling back to `process.env`); `parseBody` and `invalid` use the Web `Request`/`Response` APIs that back `Bun.serve`.
|
|
- Refinements (`refine`) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same `RuleDescriptor` list.
|
|
- No runtime dependencies. Ships as TypeScript source (`src/index.ts`) executed directly by Bun.
|
|
- Pairs with the WrNexus server (`@wrnexus/core`) for route handlers and the SSR layer that injects `renderSchemasScript` / `VALIDATE_RUNTIME`.
|