145 lines
3.9 KiB
TypeScript
145 lines
3.9 KiB
TypeScript
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<string, unknown>)
|
|
: {};
|
|
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<string, string | string[]> = {};
|
|
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<string, string> = {};
|
|
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<string, FieldRule>;
|
|
|
|
/** Result of a validation. */
|
|
export type ValidationResult<T> =
|
|
| { ok: true; value: T; errors: Record<string, string> }
|
|
| { ok: false; value: undefined; errors: Record<string, string> };
|
|
|
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
/** Validates the fields of a form. */
|
|
export function validate<T extends Record<string, unknown>>(
|
|
fields: FormFields,
|
|
schema: FormSchema,
|
|
): ValidationResult<T> {
|
|
const errors: Record<string, string> = {};
|
|
const value: Record<string, unknown> = {};
|
|
|
|
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)}`;
|
|
}
|