431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
/**
|
|
* @wrnexus/validation — one schema, validated on the server (API) and the browser
|
|
* (forms). A schema is a fluent builder; `.parse()` runs server-side and returns
|
|
* coerced values + field errors, while `.describe()` emits a JSON descriptor the
|
|
* eval-free client validator interprets. Define schemas once in `app/schemas/`.
|
|
*/
|
|
|
|
// --- Descriptor (the JSON bridge between server and client) ----------------
|
|
|
|
export type RuleDescriptor =
|
|
| { kind: "min"; n: number; message?: string }
|
|
| { kind: "max"; n: number; message?: string }
|
|
| { kind: "length"; n: number; message?: string }
|
|
| { kind: "email"; message?: string }
|
|
| { kind: "url"; message?: string }
|
|
| { kind: "uuid"; message?: string }
|
|
| { kind: "date"; message?: string }
|
|
| { kind: "oneOf"; values: (string | number)[]; message?: string }
|
|
| { kind: "pattern"; source: string; flags?: string; message?: string }
|
|
| { kind: "integer"; message?: string };
|
|
|
|
export interface FieldDescriptor {
|
|
type: "string" | "number" | "boolean" | "unknown";
|
|
optional?: boolean;
|
|
/** Message used when a required field is empty. Defaults to "Required". */
|
|
requiredMessage?: string;
|
|
label?: string;
|
|
/** Trim string input before validating. */
|
|
trim?: boolean;
|
|
rules: RuleDescriptor[];
|
|
}
|
|
|
|
export interface SchemaDescriptor {
|
|
type: "object";
|
|
fields: Record<string, FieldDescriptor>;
|
|
}
|
|
|
|
export interface ParseResult<T = Record<string, unknown>> {
|
|
ok: boolean;
|
|
/** Coerced values (present whether or not validation passed). */
|
|
value: T;
|
|
/** Field name → message, only for fields that failed. */
|
|
errors: Record<string, string>;
|
|
}
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
/**
|
|
* Apply one rule to an already-coerced value. Shared by the server; the client
|
|
* runtime (runtime.ts) mirrors this exactly. Returns an error message or null.
|
|
*/
|
|
export function applyRule(type: string, rule: RuleDescriptor, value: unknown): string | null {
|
|
switch (rule.kind) {
|
|
case "min":
|
|
if (type === "string")
|
|
return String(value).length < rule.n
|
|
? (rule.message ?? `Must be at least ${rule.n} characters`)
|
|
: null;
|
|
return (value as number) < rule.n ? (rule.message ?? `Must be at least ${rule.n}`) : null;
|
|
case "max":
|
|
if (type === "string")
|
|
return String(value).length > rule.n
|
|
? (rule.message ?? `Must be at most ${rule.n} characters`)
|
|
: null;
|
|
return (value as number) > rule.n ? (rule.message ?? `Must be at most ${rule.n}`) : null;
|
|
case "length":
|
|
return String(value).length !== rule.n
|
|
? (rule.message ?? `Must be exactly ${rule.n} characters`)
|
|
: null;
|
|
case "email":
|
|
return EMAIL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid email");
|
|
case "url":
|
|
return URL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid URL");
|
|
case "uuid":
|
|
return UUID_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid UUID");
|
|
case "date":
|
|
return Number.isNaN(Date.parse(String(value)))
|
|
? (rule.message ?? "Must be a valid date")
|
|
: null;
|
|
case "oneOf":
|
|
return rule.values.includes(value as string | number)
|
|
? null
|
|
: (rule.message ?? `Must be one of: ${rule.values.join(", ")}`);
|
|
case "pattern":
|
|
try {
|
|
return new RegExp(rule.source, rule.flags ?? "").test(String(value))
|
|
? null
|
|
: (rule.message ?? "Invalid format");
|
|
} catch {
|
|
return null;
|
|
}
|
|
case "integer":
|
|
return Number.isInteger(value) ? null : (rule.message ?? "Must be a whole number");
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Coerce + validate one field against its descriptor. */
|
|
export function checkField(
|
|
desc: FieldDescriptor,
|
|
raw: unknown,
|
|
): { value: unknown; error: string | null } {
|
|
if (desc.type === "unknown") {
|
|
const empty = raw === undefined || raw === null || raw === "";
|
|
return {
|
|
value: raw,
|
|
error: empty && !desc.optional ? desc.requiredMessage || "Required" : null,
|
|
};
|
|
}
|
|
|
|
if (desc.type === "boolean") {
|
|
const value = raw === true || raw === "true" || raw === "on";
|
|
if (!desc.optional && !value) {
|
|
return { value, error: desc.requiredMessage || "Required" };
|
|
}
|
|
return { value, error: null };
|
|
}
|
|
|
|
const pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
|
const empty = pre === undefined || pre === null || pre === "";
|
|
if (empty) {
|
|
return {
|
|
value: undefined,
|
|
error: desc.optional ? null : desc.requiredMessage || "Required",
|
|
};
|
|
}
|
|
|
|
let value: unknown;
|
|
if (desc.type === "number") {
|
|
value = Number(pre);
|
|
if (Number.isNaN(value)) return { value, error: "Must be a number" };
|
|
} else {
|
|
value = String(pre);
|
|
}
|
|
for (const rule of desc.rules) {
|
|
const error = applyRule(desc.type, rule, value);
|
|
if (error) return { value, error };
|
|
}
|
|
return { value, error: null };
|
|
}
|
|
|
|
// --- Fluent builder --------------------------------------------------------
|
|
|
|
/** A server-only refinement (a predicate that can't be serialized to the client). */
|
|
type Refinement = { fn: (value: unknown) => boolean | string; message?: string };
|
|
|
|
export abstract class FieldSchema {
|
|
abstract readonly type: "string" | "number" | "boolean" | "unknown";
|
|
protected _optional = false;
|
|
protected _requiredMessage?: string;
|
|
protected _label?: string;
|
|
protected _default?: unknown;
|
|
protected rules: RuleDescriptor[] = [];
|
|
protected refinements: Refinement[] = [];
|
|
|
|
optional(): this {
|
|
this._optional = true;
|
|
return this;
|
|
}
|
|
/** Require a non-empty value and optionally replace the default message. */
|
|
required(message = "Required"): this {
|
|
this._optional = false;
|
|
this._requiredMessage = message;
|
|
return this;
|
|
}
|
|
label(label: string): this {
|
|
this._label = label;
|
|
return this;
|
|
}
|
|
/** Value used when the field is absent (implies optional). */
|
|
default(value: unknown): this {
|
|
this._default = value;
|
|
this._optional = true;
|
|
return this;
|
|
}
|
|
min(n: number, message?: string): this {
|
|
this.rules.push({ kind: "min", n, message });
|
|
return this;
|
|
}
|
|
max(n: number, message?: string): this {
|
|
this.rules.push({ kind: "max", n, message });
|
|
return this;
|
|
}
|
|
/**
|
|
* Custom SERVER-side validation. `fn` returns true (ok), false (use `message`),
|
|
* or a string (that error). Not mirrored to the client validator.
|
|
*/
|
|
refine(fn: (value: unknown) => boolean | string, message?: string): this {
|
|
this.refinements.push({ fn, message });
|
|
return this;
|
|
}
|
|
getDefault(): unknown {
|
|
return this._default;
|
|
}
|
|
runRefinements(value: unknown): string | null {
|
|
for (const r of this.refinements) {
|
|
const result = r.fn(value);
|
|
if (result === false) return r.message ?? "Invalid value";
|
|
if (typeof result === "string") return result;
|
|
}
|
|
return null;
|
|
}
|
|
describe(): FieldDescriptor {
|
|
return {
|
|
type: this.type,
|
|
optional: this._optional || undefined,
|
|
requiredMessage: this._requiredMessage,
|
|
label: this._label,
|
|
rules: this.rules,
|
|
};
|
|
}
|
|
}
|
|
|
|
export class StringSchema extends FieldSchema {
|
|
readonly type = "string" as const;
|
|
private _trim = false;
|
|
email(message?: string): this {
|
|
this.rules.push({ kind: "email", message });
|
|
return this;
|
|
}
|
|
url(message?: string): this {
|
|
this.rules.push({ kind: "url", message });
|
|
return this;
|
|
}
|
|
uuid(message?: string): this {
|
|
this.rules.push({ kind: "uuid", message });
|
|
return this;
|
|
}
|
|
date(message?: string): this {
|
|
this.rules.push({ kind: "date", message });
|
|
return this;
|
|
}
|
|
length(n: number, message?: string): this {
|
|
this.rules.push({ kind: "length", n, message });
|
|
return this;
|
|
}
|
|
oneOf(values: string[], message?: string): this {
|
|
this.rules.push({ kind: "oneOf", values, message });
|
|
return this;
|
|
}
|
|
trim(): this {
|
|
this._trim = true;
|
|
return this;
|
|
}
|
|
pattern(re: RegExp, message?: string): this {
|
|
this.rules.push({ kind: "pattern", source: re.source, flags: re.flags, message });
|
|
return this;
|
|
}
|
|
describe(): FieldDescriptor {
|
|
return { ...super.describe(), trim: this._trim || undefined };
|
|
}
|
|
}
|
|
|
|
export class NumberSchema extends FieldSchema {
|
|
readonly type = "number" as const;
|
|
integer(message?: string): this {
|
|
this.rules.push({ kind: "integer", message });
|
|
return this;
|
|
}
|
|
positive(message?: string): this {
|
|
this.rules.push({ kind: "min", n: Number.MIN_VALUE, message: message ?? "Must be positive" });
|
|
return this;
|
|
}
|
|
oneOf(values: number[], message?: string): this {
|
|
this.rules.push({ kind: "oneOf", values, message });
|
|
return this;
|
|
}
|
|
}
|
|
|
|
export class BooleanSchema extends FieldSchema {
|
|
readonly type = "boolean" as const;
|
|
}
|
|
|
|
export class UnknownSchema extends FieldSchema {
|
|
readonly type = "unknown" as const;
|
|
}
|
|
|
|
export type AnyFieldSchema = StringSchema | NumberSchema | BooleanSchema | UnknownSchema;
|
|
|
|
export class ObjectSchema {
|
|
constructor(private readonly fields: Record<string, FieldSchema>) {}
|
|
|
|
/** Return a defensive copy of the schema fields. */
|
|
getFields(): Readonly<Record<string, FieldSchema>> {
|
|
return { ...this.fields };
|
|
}
|
|
|
|
/** Create a new schema with fields added or replaced. The original is unchanged. */
|
|
extend(fields: Record<string, FieldSchema>): ObjectSchema {
|
|
return new ObjectSchema({ ...this.fields, ...fields });
|
|
}
|
|
|
|
/** Create a new schema containing fields from both schemas. */
|
|
merge(schema: ObjectSchema): ObjectSchema {
|
|
return new ObjectSchema({ ...this.fields, ...schema.getFields() });
|
|
}
|
|
|
|
/** Validate an input object; returns coerced values + per-field errors. */
|
|
parse(input: unknown): ParseResult {
|
|
const source = (input ?? {}) as Record<string, unknown>;
|
|
const value: Record<string, unknown> = {};
|
|
const errors: Record<string, string> = {};
|
|
for (const [name, field] of Object.entries(this.fields)) {
|
|
const { value: coerced, error } = checkField(field.describe(), source[name]);
|
|
if (error) {
|
|
errors[name] = error;
|
|
continue;
|
|
}
|
|
let finalValue = coerced;
|
|
if (finalValue === undefined) {
|
|
const fallback = field.getDefault();
|
|
if (fallback !== undefined) finalValue = fallback;
|
|
}
|
|
if (finalValue !== undefined) {
|
|
const refineError = field.runRefinements(finalValue);
|
|
if (refineError) {
|
|
errors[name] = refineError;
|
|
continue;
|
|
}
|
|
value[name] = finalValue;
|
|
}
|
|
}
|
|
return { ok: Object.keys(errors).length === 0, value, errors };
|
|
}
|
|
|
|
describe(): SchemaDescriptor {
|
|
const fields: Record<string, FieldDescriptor> = {};
|
|
for (const [name, field] of Object.entries(this.fields)) fields[name] = field.describe();
|
|
return { type: "object", fields };
|
|
}
|
|
}
|
|
|
|
/** The fluent schema builder. */
|
|
export const v = {
|
|
string: () => new StringSchema(),
|
|
number: () => new NumberSchema(),
|
|
boolean: () => new BooleanSchema(),
|
|
unknown: () => new UnknownSchema(),
|
|
object: (fields: Record<string, FieldSchema>) => new ObjectSchema(fields),
|
|
};
|
|
|
|
// --- Environment configuration --------------------------------------------
|
|
|
|
/** Read the ambient environment (Bun.env, falling back to process.env). */
|
|
function readEnv(): Record<string, string | undefined> {
|
|
const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
|
|
if (bun?.env) return bun.env;
|
|
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
|
|
return proc?.env ?? {};
|
|
}
|
|
|
|
/**
|
|
* Validate environment variables against a schema at startup. Values are read
|
|
* from `Bun.env` / `process.env` by default and coerced by the schema (so
|
|
* `PORT` becomes a number, `DEBUG` a boolean). On any problem it throws ONE
|
|
* readable error listing every offending variable, so misconfiguration fails
|
|
* fast with an actionable message instead of surfacing deep inside the app.
|
|
*
|
|
* export const env = parseEnv(v.object({
|
|
* DATABASE_URL: v.string().min(1),
|
|
* PORT: v.number(),
|
|
* }));
|
|
*/
|
|
export function parseEnv<T = Record<string, unknown>>(
|
|
schema: ObjectSchema,
|
|
source: Record<string, string | undefined> = readEnv(),
|
|
): T {
|
|
const result = schema.parse(source);
|
|
if (!result.ok) {
|
|
const lines = Object.entries(result.errors).map(([name, message]) => ` • ${name}: ${message}`);
|
|
throw new Error(`Invalid environment variables:\n${lines.join("\n")}`);
|
|
}
|
|
return result.value as T;
|
|
}
|
|
|
|
// --- API helpers -----------------------------------------------------------
|
|
|
|
/** A 400 response carrying field errors, for API routes. */
|
|
export function invalid(errors: Record<string, string>): Response {
|
|
return Response.json({ ok: false, errors }, { status: 400 });
|
|
}
|
|
|
|
/**
|
|
* Parse a request's JSON body against a schema. On failure returns
|
|
* `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`.
|
|
*/
|
|
export async function parseBody<T = Record<string, unknown>>(
|
|
schema: ObjectSchema,
|
|
req: Request,
|
|
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
|
|
const body = await readBody(req);
|
|
const result = schema.parse(body);
|
|
if (!result.ok) return { ok: false, response: invalid(result.errors) };
|
|
return { ok: true, value: result.value as T };
|
|
}
|
|
|
|
/** Read a request body as an object from JSON, form-urlencoded, or multipart. */
|
|
async function readBody(req: Request): Promise<Record<string, unknown>> {
|
|
const contentType = req.headers.get("content-type") ?? "";
|
|
try {
|
|
if (contentType.includes("application/json")) {
|
|
return (await req.json()) as Record<string, unknown>;
|
|
}
|
|
if (
|
|
contentType.includes("application/x-www-form-urlencoded") ||
|
|
contentType.includes("multipart/form-data")
|
|
) {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, value] of await req.formData()) out[key] = value;
|
|
return out;
|
|
}
|
|
// Best effort: try JSON, else treat as empty.
|
|
return (await req.json()) as Record<string, unknown>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export { renderSchemasScript, VALIDATE_RUNTIME } from "./runtime.ts";
|
|
export {
|
|
AsyncObjectSchema,
|
|
asyncSchema,
|
|
parseBodyAsync,
|
|
schemaToOpenApi,
|
|
mergeValidationResults,
|
|
} from "./advanced.ts";
|
|
export type { AsyncValidationContext, AsyncRefinement, OpenApiSchema } from "./advanced.ts";
|