import type { FormFields } from "../contracts/typegen/http/request.ts"; /** Reads the fields of a parsed form body. */ export function toFormFields(body: unknown): FormFields { const source = body && typeof body === "object" && !Array.isArray(body) ? (body as Record) : {}; return { get(name: string) { const value = source[name]; if (value === undefined || value === null) return undefined; return Array.isArray(value) ? String(value[0]) : String(value); }, all() { const result: Record = {}; for (const [key, value] of Object.entries(source)) { result[key] = Array.isArray(value) ? value.map((entry) => String(entry)) : String(value); } return result; }, has(name: string) { return name in source; }, values() { const result: Record = {}; for (const [key, value] of Object.entries(source)) { result[key] = Array.isArray(value) ? String(value[0] ?? "") : String(value); } return result; }, }; } /** Rules of a single form field. */ export interface FieldRule { /** The field has to be filled in. */ required?: boolean; /** Type of the field. */ type?: "string" | "number" | "boolean" | "email"; /** Minimum value of a number field. */ min?: number; /** Maximum value of a number field. */ max?: number; /** Maximum length of a string field. */ maxLength?: number; /** Allowed values. */ enum?: string[]; /** Value used when the field is empty. */ default?: unknown; } export type FormSchema = Record; /** Result of a validation. */ export type ValidationResult = | { ok: true; value: T; errors: Record } | { ok: false; value: undefined; errors: Record }; const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** Validates the fields of a form. */ export function validate>( fields: FormFields, schema: FormSchema, ): ValidationResult { const errors: Record = {}; const value: Record = {}; for (const [name, rule] of Object.entries(schema)) { const raw = fields.get(name); const empty = raw === undefined || raw.trim().length === 0; if (empty) { if (rule.default !== undefined) { value[name] = rule.default; continue; } if (rule.required) { errors[name] = `${name} is required`; } continue; } let parsed: unknown = raw; if (rule.type === "number") { const numeric = Number(raw); if (Number.isNaN(numeric)) { errors[name] = `${name} must be a number`; continue; } if (rule.min !== undefined && numeric < rule.min) { errors[name] = `${name} must be at least ${rule.min}`; continue; } if (rule.max !== undefined && numeric > rule.max) { errors[name] = `${name} must be at most ${rule.max}`; continue; } parsed = numeric; } else if (rule.type === "boolean") { parsed = raw === "on" || raw === "true" || raw === "1"; } else if (rule.type === "email") { if (!EMAIL.test(raw)) { errors[name] = `${name} must be a valid email address`; continue; } } if ( rule.maxLength !== undefined && String(parsed).length > rule.maxLength ) { errors[name] = `${name} must be at most ${rule.maxLength} characters`; continue; } if (rule.enum && !rule.enum.includes(String(parsed))) { errors[name] = `${name} must be one of ${rule.enum.join(", ")}`; continue; } value[name] = parsed; } if (Object.keys(errors).length > 0) { return { ok: false, value: undefined, errors }; } return { ok: true, value: value as T, errors }; } /** * Builds the location of a redirect that carries a message. */ export function redirectWithMessage( location: string, message?: string, ): string { if (!message) return location; const separator = location.includes("?") ? "&" : "?"; return `${location}${separator}message=${encodeURIComponent(message)}`; }