✨ feat: add 30 validation decorators, @ValidateIf and @Allow
Rounds out the validator set with the rules an application actually reaches for,
all following the existing decorator style and honouring each/message options.
- equality and presence: @Equals, @NotEquals, @IsEmpty, @IsEnum, @IsInstance
- strings: @Length, @IsAlpha, @IsAlphanumeric, @IsNumberString, @IsLowercase,
@IsUppercase, @Contains, @NotContains, @StartsWith, @EndsWith
- formats: @IsUUID, @IsJSON, @IsDateString, @IsSemVer, @IsHexColor, @IsIP
- numbers: @IsDivisibleBy, @IsPort, @IsLatitude, @IsLongitude, @IsBigInt
- dates: @MinDate, @MaxDate
- arrays: @ArrayUnique, @ArrayContains, @ArrayNotContains
@ValidateIf(o => ...) makes a property's rules conditional on the rest of the
object, and @Allow() declares a property that needs no rules of its own so it
survives unknownKeys: 'strip'.
Two details worth noting. @IsEnum filters the reverse mapping a numeric enum
compiles to, so 'Low' is not accepted as a value of enum { Low, High }.
@MinDate/@MaxDate accept a thunk, so "not in the past" is evaluated per
validation instead of being frozen when the class was declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -13,6 +13,7 @@ export const METADATA_KEYS = {
|
|||||||
NAME: 'cereale:name',
|
NAME: 'cereale:name',
|
||||||
ALIASES: 'cereale:aliases',
|
ALIASES: 'cereale:aliases',
|
||||||
ACCESS: 'cereale:access',
|
ACCESS: 'cereale:access',
|
||||||
|
CONDITION: 'cereale:condition',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -565,6 +566,483 @@ export function IsDate(options?: ValidationOptions) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Equality and presence ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Equals(comparison: any)
|
||||||
|
*/
|
||||||
|
export function Equals(comparison: any, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'equals',
|
||||||
|
validate: (v) => v === comparison,
|
||||||
|
message: `${propertyKey} must be equal to ${JSON.stringify(comparison)}`,
|
||||||
|
constraints: [comparison]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NotEquals(comparison: any)
|
||||||
|
*/
|
||||||
|
export function NotEquals(comparison: any, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'notEquals',
|
||||||
|
validate: (v) => v !== comparison,
|
||||||
|
message: `${propertyKey} must not be equal to ${JSON.stringify(comparison)}`,
|
||||||
|
constraints: [comparison]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @IsEmpty()
|
||||||
|
* Passes for null, undefined, '', [] and {} — the mirror of `@IsNotEmpty`.
|
||||||
|
*/
|
||||||
|
export function IsEmpty(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isEmpty',
|
||||||
|
validate: (v) => {
|
||||||
|
if (v === null || v === undefined || v === '') return true;
|
||||||
|
if (Array.isArray(v)) return v.length === 0;
|
||||||
|
if (typeof v === 'object') return Object.keys(v).length === 0;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
message: `${propertyKey} must be empty`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @IsEnum(entity: object)
|
||||||
|
* Checks the value is a member of a TypeScript enum (string or numeric).
|
||||||
|
*/
|
||||||
|
export function IsEnum(entity: Record<string, any>, options?: ValidationOptions) {
|
||||||
|
// A numeric enum compiles to a two-way map ({ A: 0, '0': 'A' }), so the reverse-mapped
|
||||||
|
// names have to be filtered out or 'A' would validate as a legal value.
|
||||||
|
const values = Object.keys(entity)
|
||||||
|
.filter(key => typeof entity[entity[key]] !== 'number')
|
||||||
|
.map(key => entity[key]);
|
||||||
|
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isEnum',
|
||||||
|
validate: (v) => values.includes(v),
|
||||||
|
message: `${propertyKey} must be one of the following values: ${values.join(', ')}`,
|
||||||
|
constraints: [values]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @IsInstance(target: ClassConstructor)
|
||||||
|
*/
|
||||||
|
export function IsInstance(clazz: ClassConstructor<any>, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isInstance',
|
||||||
|
validate: (v) => v instanceof clazz,
|
||||||
|
message: `${propertyKey} must be an instance of ${clazz.name}`,
|
||||||
|
constraints: [clazz]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Strings ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Length(min: number, max?: number)
|
||||||
|
*/
|
||||||
|
export function Length(min: number, max?: number, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'length',
|
||||||
|
validate: (v) => typeof v === 'string' && v.length >= min && (max === undefined || v.length <= max),
|
||||||
|
message: max === undefined
|
||||||
|
? `${propertyKey} must be at least ${min} characters`
|
||||||
|
: `${propertyKey} must be between ${min} and ${max} characters`,
|
||||||
|
constraints: max === undefined ? [min] : [min, max]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringPattern(name: string, regex: RegExp, describe: (property: string) => string) {
|
||||||
|
return (options?: ValidationOptions) => (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name,
|
||||||
|
validate: (v) => typeof v === 'string' && regex.test(v),
|
||||||
|
message: describe(propertyKey)
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsAlpha() — letters only. */
|
||||||
|
export const IsAlpha = stringPattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
|
||||||
|
|
||||||
|
/** @IsAlphanumeric() — letters and digits only. */
|
||||||
|
export const IsAlphanumeric = stringPattern(
|
||||||
|
'isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @IsNumberString() — a string that parses as a finite number. */
|
||||||
|
export function IsNumberString(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isNumberString',
|
||||||
|
validate: (v) => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)),
|
||||||
|
message: `${propertyKey} must be a number string`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsLowercase() */
|
||||||
|
export function IsLowercase(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isLowercase',
|
||||||
|
validate: (v) => typeof v === 'string' && v === v.toLowerCase(),
|
||||||
|
message: `${propertyKey} must be lowercase`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsUppercase() */
|
||||||
|
export function IsUppercase(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isUppercase',
|
||||||
|
validate: (v) => typeof v === 'string' && v === v.toUpperCase(),
|
||||||
|
message: `${propertyKey} must be uppercase`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @Contains(seed: string) */
|
||||||
|
export function Contains(seed: string, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'contains',
|
||||||
|
validate: (v) => typeof v === 'string' && v.includes(seed),
|
||||||
|
message: `${propertyKey} must contain ${JSON.stringify(seed)}`,
|
||||||
|
constraints: [seed]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @NotContains(seed: string) */
|
||||||
|
export function NotContains(seed: string, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'notContains',
|
||||||
|
validate: (v) => typeof v === 'string' && !v.includes(seed),
|
||||||
|
message: `${propertyKey} must not contain ${JSON.stringify(seed)}`,
|
||||||
|
constraints: [seed]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @StartsWith(prefix: string) */
|
||||||
|
export function StartsWith(prefix: string, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'startsWith',
|
||||||
|
validate: (v) => typeof v === 'string' && v.startsWith(prefix),
|
||||||
|
message: `${propertyKey} must start with ${JSON.stringify(prefix)}`,
|
||||||
|
constraints: [prefix]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @EndsWith(suffix: string) */
|
||||||
|
export function EndsWith(suffix: string, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'endsWith',
|
||||||
|
validate: (v) => typeof v === 'string' && v.endsWith(suffix),
|
||||||
|
message: `${propertyKey} must end with ${JSON.stringify(suffix)}`,
|
||||||
|
constraints: [suffix]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
|
||||||
|
const MAX_UUID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @IsUUID(version?: 1|2|3|4|5|6|7|8)
|
||||||
|
* Without a version, accepts any RFC 9562 UUID plus the nil and max UUIDs.
|
||||||
|
*/
|
||||||
|
export function IsUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, options?: ValidationOptions) {
|
||||||
|
const pattern = version
|
||||||
|
? new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, 'i')
|
||||||
|
: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isUuid',
|
||||||
|
validate: (v) => {
|
||||||
|
if (typeof v !== 'string') return false;
|
||||||
|
if (!version && (v.toLowerCase() === NIL_UUID || v.toLowerCase() === MAX_UUID)) return true;
|
||||||
|
return pattern.test(v);
|
||||||
|
},
|
||||||
|
message: `${propertyKey} must be a valid UUID${version ? ` (version ${version})` : ''}`,
|
||||||
|
...(version ? { constraints: [version] } : {})
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsJSON() — a string that JSON.parse accepts. */
|
||||||
|
export function IsJSON(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isJson',
|
||||||
|
validate: (v) => {
|
||||||
|
if (typeof v !== 'string') return false;
|
||||||
|
try {
|
||||||
|
JSON.parse(v);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: `${propertyKey} must be a JSON string`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsDateString() — an ISO-8601 string that parses to a real date. */
|
||||||
|
export function IsDateString(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isDateString',
|
||||||
|
validate: (v) => typeof v === 'string' && !isNaN(Date.parse(v)),
|
||||||
|
message: `${propertyKey} must be a valid ISO 8601 date string`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsSemVer() */
|
||||||
|
export const IsSemVer = stringPattern(
|
||||||
|
'isSemVer',
|
||||||
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
|
||||||
|
p => `${p} must be a valid semantic version`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @IsHexColor() — #rgb, #rrggbb or #rrggbbaa. */
|
||||||
|
export const IsHexColor = stringPattern(
|
||||||
|
'isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`
|
||||||
|
);
|
||||||
|
|
||||||
|
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @IsIP(version?: 4 | 6)
|
||||||
|
*/
|
||||||
|
export function IsIP(version?: 4 | 6, options?: ValidationOptions) {
|
||||||
|
const isV6 = (v: string) => {
|
||||||
|
// Node's URL parser is the most reliable IPv6 validator available without a dependency.
|
||||||
|
try {
|
||||||
|
return new URL(`http://[${v}]`).hostname === `[${v.toLowerCase()}]` || /^[0-9a-f:.]+$/i.test(v) && v.includes(':');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isIp',
|
||||||
|
validate: (v) => {
|
||||||
|
if (typeof v !== 'string') return false;
|
||||||
|
if (version === 4) return IPV4.test(v);
|
||||||
|
if (version === 6) return isV6(v);
|
||||||
|
return IPV4.test(v) || isV6(v);
|
||||||
|
},
|
||||||
|
message: `${propertyKey} must be a valid IP${version ? `v${version}` : ''} address`,
|
||||||
|
...(version ? { constraints: [version] } : {})
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Numbers ---
|
||||||
|
|
||||||
|
/** @IsDivisibleBy(divisor: number) */
|
||||||
|
export function IsDivisibleBy(divisor: number, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isDivisibleBy',
|
||||||
|
validate: (v) => typeof v === 'number' && Number.isFinite(v) && divisor !== 0 && v % divisor === 0,
|
||||||
|
message: `${propertyKey} must be divisible by ${divisor}`,
|
||||||
|
constraints: [divisor]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsPort() — an integer in 0..65535, as a number or a numeric string. */
|
||||||
|
export function IsPort(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isPort',
|
||||||
|
validate: (v) => {
|
||||||
|
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
|
||||||
|
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
|
||||||
|
},
|
||||||
|
message: `${propertyKey} must be a valid port number`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsLatitude() */
|
||||||
|
export function IsLatitude(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isLatitude',
|
||||||
|
validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90,
|
||||||
|
message: `${propertyKey} must be a latitude between -90 and 90`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsLongitude() */
|
||||||
|
export function IsLongitude(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isLongitude',
|
||||||
|
validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180,
|
||||||
|
message: `${propertyKey} must be a longitude between -180 and 180`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @IsBigInt() */
|
||||||
|
export function IsBigInt(options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'isBigInt',
|
||||||
|
validate: (v) => typeof v === 'bigint',
|
||||||
|
message: `${propertyKey} must be a bigint`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dates ---
|
||||||
|
|
||||||
|
type DateBound = Date | (() => Date);
|
||||||
|
|
||||||
|
const boundOf = (bound: DateBound): Date => (typeof bound === 'function' ? bound() : bound);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @MinDate(date: Date | (() => Date))
|
||||||
|
* Accepts a thunk so a moving boundary — "not in the past" — is evaluated per validation
|
||||||
|
* rather than frozen when the class was declared.
|
||||||
|
*/
|
||||||
|
export function MinDate(min: DateBound, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'minDate',
|
||||||
|
validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() >= boundOf(min).getTime(),
|
||||||
|
message: (args) => `${args.property} must not be earlier than ${boundOf(min).toISOString()}`,
|
||||||
|
constraints: [min]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @MaxDate(date: Date | (() => Date))
|
||||||
|
*/
|
||||||
|
export function MaxDate(max: DateBound, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'maxDate',
|
||||||
|
validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() <= boundOf(max).getTime(),
|
||||||
|
message: (args) => `${args.property} must not be later than ${boundOf(max).toISOString()}`,
|
||||||
|
constraints: [max]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Arrays ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ArrayUnique(identifier?: (item: any) => any)
|
||||||
|
* Pass an extractor to deduplicate objects by a key rather than by reference.
|
||||||
|
*/
|
||||||
|
export function ArrayUnique(identifier?: (item: any) => any, options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'arrayUnique',
|
||||||
|
validate: (v) => {
|
||||||
|
if (!Array.isArray(v)) return false;
|
||||||
|
const keys = identifier ? v.map(identifier) : v;
|
||||||
|
return new Set(keys).size === keys.length;
|
||||||
|
},
|
||||||
|
message: `${propertyKey} must not contain duplicate values`
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @ArrayContains(values: any[]) — the array must contain every listed value. */
|
||||||
|
export function ArrayContains(values: any[], options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'arrayContains',
|
||||||
|
validate: (v) => Array.isArray(v) && values.every(value => v.includes(value)),
|
||||||
|
message: `${propertyKey} must contain the following values: ${values.join(', ')}`,
|
||||||
|
constraints: [values]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @ArrayNotContains(values: any[]) — the array must contain none of the listed values. */
|
||||||
|
export function ArrayNotContains(values: any[], options?: ValidationOptions) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
addValidation(target, propertyKey, {
|
||||||
|
name: 'arrayNotContains',
|
||||||
|
validate: (v) => Array.isArray(v) && values.every(value => !v.includes(value)),
|
||||||
|
message: `${propertyKey} must not contain any of the following values: ${values.join(', ')}`,
|
||||||
|
constraints: [values]
|
||||||
|
}, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Control flow ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ValidateIf(condition: (object: any) => boolean)
|
||||||
|
* Skips every constraint on this property when the condition returns false.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* class Payment {
|
||||||
|
* @IsIn(['card', 'invoice'])
|
||||||
|
* method: string;
|
||||||
|
*
|
||||||
|
* @ValidateIf(o => o.method === 'card')
|
||||||
|
* @IsString()
|
||||||
|
* cardNumber?: string;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ValidateIf(condition: (object: any) => boolean) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
registerProperty(target, propertyKey);
|
||||||
|
metadataStorage.defineMetadata(METADATA_KEYS.CONDITION, condition, target, propertyKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Allow()
|
||||||
|
* Declares a property with no constraints of its own.
|
||||||
|
*
|
||||||
|
* Useful with `unknownKeys: 'strip'` or `'error'`, where a property has to be declared to
|
||||||
|
* survive the payload even though nothing about its value needs checking.
|
||||||
|
*/
|
||||||
|
export function Allow() {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
registerProperty(target, propertyKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ValidateNested(options?: ValidationOptions)
|
* @ValidateNested(options?: ValidationOptions)
|
||||||
* Recursively validates the value of this property.
|
* Recursively validates the value of this property.
|
||||||
|
|||||||
@@ -365,6 +365,12 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
|||||||
constraints: {}
|
constraints: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
|
||||||
|
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
|
||||||
|
if (condition && !condition(obj)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle IsOptional
|
// Handle IsOptional
|
||||||
const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key);
|
const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key);
|
||||||
const isNullOrUndefined = value === null || value === undefined;
|
const isNullOrUndefined = value === null || value === undefined;
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
Equals, NotEquals, IsEmpty, IsEnum, IsInstance,
|
||||||
|
Length, IsAlpha, IsAlphanumeric, IsNumberString, IsLowercase, IsUppercase,
|
||||||
|
Contains, NotContains, StartsWith, EndsWith,
|
||||||
|
IsUUID, IsJSON, IsDateString, IsSemVer, IsHexColor, IsIP,
|
||||||
|
IsDivisibleBy, IsPort, IsLatitude, IsLongitude, IsBigInt,
|
||||||
|
MinDate, MaxDate,
|
||||||
|
ArrayUnique, ArrayContains, ArrayNotContains,
|
||||||
|
ValidateIf, Allow, IsString, IsIn, IsOptional,
|
||||||
|
validate, toInstance,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
/** Builds a one-property class, assigns `value`, and returns the constraint keys that failed. */
|
||||||
|
async function check(decorate: (target: any, key: string) => void, value: any): Promise<string[]> {
|
||||||
|
class Subject {
|
||||||
|
val: any;
|
||||||
|
}
|
||||||
|
decorate(Subject.prototype, 'val');
|
||||||
|
|
||||||
|
const subject = new Subject();
|
||||||
|
subject.val = value;
|
||||||
|
|
||||||
|
const errors = await validate(subject);
|
||||||
|
return errors.length ? Object.keys(errors[0]!.constraints) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const passes = async (decorator: any, value: any) => expect(await check(decorator, value)).toEqual([]);
|
||||||
|
const fails = async (decorator: any, value: any) => expect((await check(decorator, value)).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
describe('equality and presence', () => {
|
||||||
|
it('@Equals / @NotEquals', async () => {
|
||||||
|
await passes(Equals('x'), 'x');
|
||||||
|
await fails(Equals('x'), 'y');
|
||||||
|
await passes(NotEquals('x'), 'y');
|
||||||
|
await fails(NotEquals('x'), 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsEmpty', async () => {
|
||||||
|
for (const empty of [null, undefined, '', [], {}]) await passes(IsEmpty(), empty);
|
||||||
|
for (const filled of ['a', [1], { a: 1 }, 0]) await fails(IsEmpty(), filled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsInstance', async () => {
|
||||||
|
class Thing {}
|
||||||
|
await passes(IsInstance(Thing), new Thing());
|
||||||
|
await fails(IsInstance(Thing), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@IsEnum', () => {
|
||||||
|
enum StringRole { Admin = 'admin', User = 'user' }
|
||||||
|
enum NumericLevel { Low, High }
|
||||||
|
|
||||||
|
it('accepts members of a string enum', async () => {
|
||||||
|
await passes(IsEnum(StringRole), 'admin');
|
||||||
|
await fails(IsEnum(StringRole), 'root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts members of a numeric enum without accepting its reverse-mapped names', async () => {
|
||||||
|
await passes(IsEnum(NumericLevel), 0);
|
||||||
|
await passes(IsEnum(NumericLevel), 1);
|
||||||
|
await fails(IsEnum(NumericLevel), 2);
|
||||||
|
// 'Low' is the reverse mapping, not a legal value
|
||||||
|
await fails(IsEnum(NumericLevel), 'Low');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('strings', () => {
|
||||||
|
it('@Length with and without a maximum', async () => {
|
||||||
|
await passes(Length(2), 'ab');
|
||||||
|
await fails(Length(3), 'ab');
|
||||||
|
await passes(Length(2, 4), 'abc');
|
||||||
|
await fails(Length(2, 4), 'abcde');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsAlpha / @IsAlphanumeric', async () => {
|
||||||
|
await passes(IsAlpha(), 'abcDEF');
|
||||||
|
await fails(IsAlpha(), 'abc1');
|
||||||
|
await passes(IsAlphanumeric(), 'abc123');
|
||||||
|
await fails(IsAlphanumeric(), 'abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsNumberString', async () => {
|
||||||
|
await passes(IsNumberString(), '42');
|
||||||
|
await passes(IsNumberString(), '-1.5');
|
||||||
|
await fails(IsNumberString(), 'abc');
|
||||||
|
await fails(IsNumberString(), '');
|
||||||
|
await fails(IsNumberString(), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsLowercase / @IsUppercase', async () => {
|
||||||
|
await passes(IsLowercase(), 'abc');
|
||||||
|
await fails(IsLowercase(), 'Abc');
|
||||||
|
await passes(IsUppercase(), 'ABC');
|
||||||
|
await fails(IsUppercase(), 'Abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@Contains / @NotContains / @StartsWith / @EndsWith', async () => {
|
||||||
|
await passes(Contains('ell'), 'hello');
|
||||||
|
await fails(Contains('xyz'), 'hello');
|
||||||
|
await passes(NotContains('xyz'), 'hello');
|
||||||
|
await fails(NotContains('ell'), 'hello');
|
||||||
|
await passes(StartsWith('he'), 'hello');
|
||||||
|
await fails(StartsWith('lo'), 'hello');
|
||||||
|
await passes(EndsWith('lo'), 'hello');
|
||||||
|
await fails(EndsWith('he'), 'hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@IsUUID', () => {
|
||||||
|
const v4 = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
|
||||||
|
|
||||||
|
it('accepts any version when unversioned', async () => {
|
||||||
|
await passes(IsUUID(), v4);
|
||||||
|
await passes(IsUUID(), '00000000-0000-0000-0000-000000000000'); // nil
|
||||||
|
await fails(IsUUID(), 'not-a-uuid');
|
||||||
|
await fails(IsUUID(), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces a requested version', async () => {
|
||||||
|
await passes(IsUUID(4), v4);
|
||||||
|
await fails(IsUUID(1), v4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formats', () => {
|
||||||
|
it('@IsJSON', async () => {
|
||||||
|
await passes(IsJSON(), '{"a":1}');
|
||||||
|
await passes(IsJSON(), '[1,2]');
|
||||||
|
await fails(IsJSON(), '{a:1}');
|
||||||
|
await fails(IsJSON(), { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsDateString', async () => {
|
||||||
|
await passes(IsDateString(), '2026-08-03T00:00:00Z');
|
||||||
|
await fails(IsDateString(), 'not a date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsSemVer', async () => {
|
||||||
|
await passes(IsSemVer(), '1.2.3');
|
||||||
|
await passes(IsSemVer(), '1.0.0-alpha.1+build.5');
|
||||||
|
await fails(IsSemVer(), '1.2');
|
||||||
|
await fails(IsSemVer(), 'v1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsHexColor', async () => {
|
||||||
|
await passes(IsHexColor(), '#fff');
|
||||||
|
await passes(IsHexColor(), '#A1B2C3');
|
||||||
|
await passes(IsHexColor(), '#A1B2C3FF');
|
||||||
|
await fails(IsHexColor(), 'fff');
|
||||||
|
await fails(IsHexColor(), '#ggg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsIP', async () => {
|
||||||
|
await passes(IsIP(4), '192.168.0.1');
|
||||||
|
await fails(IsIP(4), '256.0.0.1');
|
||||||
|
await fails(IsIP(4), '::1');
|
||||||
|
await passes(IsIP(6), '::1');
|
||||||
|
await passes(IsIP(), '10.0.0.1');
|
||||||
|
await fails(IsIP(), 'nope');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('numbers', () => {
|
||||||
|
it('@IsDivisibleBy', async () => {
|
||||||
|
await passes(IsDivisibleBy(5), 10);
|
||||||
|
await fails(IsDivisibleBy(5), 11);
|
||||||
|
await fails(IsDivisibleBy(5), '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsPort', async () => {
|
||||||
|
await passes(IsPort(), 8080);
|
||||||
|
await passes(IsPort(), '443');
|
||||||
|
await fails(IsPort(), 70000);
|
||||||
|
await fails(IsPort(), -1);
|
||||||
|
await fails(IsPort(), 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsLatitude / @IsLongitude', async () => {
|
||||||
|
await passes(IsLatitude(), 48.85);
|
||||||
|
await fails(IsLatitude(), 91);
|
||||||
|
await passes(IsLongitude(), 2.35);
|
||||||
|
await fails(IsLongitude(), 181);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsBigInt', async () => {
|
||||||
|
await passes(IsBigInt(), 10n);
|
||||||
|
await fails(IsBigInt(), 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dates', () => {
|
||||||
|
it('@MinDate / @MaxDate with a fixed bound', async () => {
|
||||||
|
const bound = new Date('2026-01-01T00:00:00Z');
|
||||||
|
await passes(MinDate(bound), new Date('2026-06-01T00:00:00Z'));
|
||||||
|
await fails(MinDate(bound), new Date('2025-06-01T00:00:00Z'));
|
||||||
|
await passes(MaxDate(bound), new Date('2025-06-01T00:00:00Z'));
|
||||||
|
await fails(MaxDate(bound), new Date('2026-06-01T00:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@MinDate accepts a thunk so the bound moves', async () => {
|
||||||
|
await passes(MinDate(() => new Date(Date.now() - 1000)), new Date());
|
||||||
|
await fails(MinDate(() => new Date(Date.now() + 60_000)), new Date());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-date', async () => {
|
||||||
|
await fails(MinDate(new Date(0)), '2026-01-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arrays', () => {
|
||||||
|
it('@ArrayUnique by value', async () => {
|
||||||
|
await passes(ArrayUnique(), [1, 2, 3]);
|
||||||
|
await fails(ArrayUnique(), [1, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@ArrayUnique by extracted key', async () => {
|
||||||
|
const byId = (item: any) => item.id;
|
||||||
|
await passes(ArrayUnique(byId), [{ id: 1 }, { id: 2 }]);
|
||||||
|
await fails(ArrayUnique(byId), [{ id: 1 }, { id: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@ArrayContains / @ArrayNotContains', async () => {
|
||||||
|
await passes(ArrayContains(['a']), ['a', 'b']);
|
||||||
|
await fails(ArrayContains(['c']), ['a', 'b']);
|
||||||
|
await passes(ArrayNotContains(['c']), ['a', 'b']);
|
||||||
|
await fails(ArrayNotContains(['a']), ['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@ValidateIf', () => {
|
||||||
|
class Payment {
|
||||||
|
@IsIn(['card', 'invoice'])
|
||||||
|
method: string;
|
||||||
|
|
||||||
|
@ValidateIf(o => o.method === 'card')
|
||||||
|
@IsString()
|
||||||
|
cardNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('skips the constraint when the condition is false', async () => {
|
||||||
|
const p = new Payment();
|
||||||
|
p.method = 'invoice';
|
||||||
|
expect(await validate(p)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the constraint when the condition is true', async () => {
|
||||||
|
const p = new Payment();
|
||||||
|
p.method = 'card';
|
||||||
|
|
||||||
|
const errors = await validate(p);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.property).toBe('cardNumber');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the condition is true and the value is valid', async () => {
|
||||||
|
const p = new Payment();
|
||||||
|
p.method = 'card';
|
||||||
|
p.cardNumber = '4111111111111111';
|
||||||
|
expect(await validate(p)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@Allow', () => {
|
||||||
|
it('declares a property so strict unknown-key policies keep it', async () => {
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Allow()
|
||||||
|
metadata: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = await toInstance(
|
||||||
|
Dto,
|
||||||
|
{ name: 'x', metadata: { anything: true }, stray: 1 },
|
||||||
|
{ unknownKeys: 'strip' }
|
||||||
|
);
|
||||||
|
expect(d.metadata).toEqual({ anything: true });
|
||||||
|
expect((d as any).stray).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('new validators cooperate with existing options', () => {
|
||||||
|
it('honours each: true', async () => {
|
||||||
|
class T {
|
||||||
|
@IsUUID(4, { each: true })
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21'];
|
||||||
|
expect(await validate(t)).toEqual([]);
|
||||||
|
|
||||||
|
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21', 'nope'];
|
||||||
|
expect(await validate(t)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours @IsOptional', async () => {
|
||||||
|
class T {
|
||||||
|
@IsOptional()
|
||||||
|
@IsSemVer()
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
expect(await validate(t)).toEqual([]);
|
||||||
|
|
||||||
|
t.version = 'bad';
|
||||||
|
expect(await validate(t)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a custom message', async () => {
|
||||||
|
class T {
|
||||||
|
@IsPort({ message: 'give me a real port' })
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.port = -1;
|
||||||
|
|
||||||
|
const errors = await validate(t);
|
||||||
|
expect(errors[0]!.constraints['isPort']).toBe('give me a real port');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user