ffffng/server/shared/validation/validator.ts

182 lines
4.1 KiB
TypeScript

import { parseToInteger } from "../utils/numbers";
import {
isBoolean,
isNumber,
isObject,
isOptional,
isRegExp,
isString,
toIsArray,
} from "../types";
export interface Constraint {
type: string;
default?: unknown;
optional?: boolean;
allowed?: string[];
min?: number;
max?: number;
regex?: RegExp;
}
export type Constraints = { [key: string]: Constraint };
export type NestedConstraints = {
[key: string]: Constraint | Constraints | NestedConstraints;
};
export type Values = { [key: string]: unknown };
export function isConstraint(arg: unknown): arg is Constraint {
if (!isObject(arg)) {
return false;
}
const constraint = arg as Constraint;
return (
isString(constraint.type) &&
// default?: any
isOptional(constraint.optional, isBoolean) &&
isOptional(constraint.allowed, toIsArray(isString)) &&
isOptional(constraint.min, isNumber) &&
isOptional(constraint.max, isNumber) &&
isOptional(constraint.regex, isRegExp)
);
}
export function isConstraints(
constraints: unknown
): constraints is Constraints {
if (!isObject(constraints)) {
return false;
}
return Object.entries(constraints).every(
([key, constraint]) => isString(key) && isConstraint(constraint)
);
}
// TODO: sanitize input for further processing as specified by constraints (correct types, trimming, etc.)
function isValidBoolean(value: unknown): boolean {
return isBoolean(value) || value === "true" || value === "false";
}
function isValidNumber(constraint: Constraint, value: unknown): boolean {
if (isString(value)) {
value = parseToInteger(value);
}
if (!isNumber(value)) {
return false;
}
if (isNaN(value) || !isFinite(value)) {
return false;
}
if (isNumber(constraint.min) && value < constraint.min) {
return false;
}
// noinspection RedundantIfStatementJS
if (isNumber(constraint.max) && value > constraint.max) {
return false;
}
return true;
}
function isValidEnum(constraint: Constraint, value: unknown): boolean {
if (!isString(value)) {
return false;
}
const allowed = constraint.allowed || [];
return allowed.indexOf(value) >= 0;
}
function isValidString(constraint: Constraint, value: unknown): boolean {
if (!constraint.regex) {
throw new Error(
"String constraints must have regex set: " + constraint
);
}
if (!isString(value)) {
return false;
}
const trimmed = value.trim();
return (
(trimmed === "" && constraint.optional) ||
constraint.regex.test(trimmed)
);
}
function isValid(
constraint: Constraint,
acceptUndefined: boolean,
value: unknown
): boolean {
if (value === undefined) {
return acceptUndefined || constraint.optional === true;
}
switch (constraint.type) {
case "boolean":
return isValidBoolean(value);
case "number":
return isValidNumber(constraint, value);
case "enum":
return isValidEnum(constraint, value);
case "string":
return isValidString(constraint, value);
}
return false;
}
function areValid(
constraints: Constraints,
acceptUndefined: boolean,
values: Values
): boolean {
const fields = new Set(Object.keys(constraints));
for (const field of fields) {
if (!isValid(constraints[field], acceptUndefined, values[field])) {
return false;
}
}
for (const field of Object.keys(values)) {
if (!fields.has(field)) {
return false;
}
}
return true;
}
export function forConstraint(
constraint: Constraint,
acceptUndefined: boolean
): (value: unknown) => boolean {
return (value: unknown): boolean =>
isValid(constraint, acceptUndefined, value);
}
export function forConstraints(
constraints: Constraints,
acceptUndefined: boolean
): (values: Values) => boolean {
return (values: Values): boolean =>
areValid(constraints, acceptUndefined, values);
}