✨ feat: implement core Optimus library for JSON mapping and validation
- 🎨 add Spring-like decorators (@JsonSerialize, @JsonDeserialize, etc.) - ⚙️ implement JsonMapper and metadata storage for transformations - 🧪 add comprehensive test suite using Vitest - 📝 add README, CONTRIBUTING, and API documentation - 👷 setup GitHub Actions CI workflow - 🔧 configure TypeScript and project settings
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
import { JsonSerializer, JsonDeserializer, ClassConstructor } from './interfaces';
|
||||
import { metadataStorage } from './metadata-storage';
|
||||
|
||||
export const METADATA_KEYS = {
|
||||
PROPERTIES: 'optimus:properties',
|
||||
TYPE: 'optimus:type',
|
||||
VALIDATION: 'optimus:validation',
|
||||
SERIALIZER: 'optimus:serializer',
|
||||
DESERIALIZER: 'optimus:deserializer',
|
||||
POLYMORPHIC: 'optimus:polymorphic',
|
||||
IS_OPTIONAL: 'optimus:optional',
|
||||
};
|
||||
|
||||
export interface ValidationArguments {
|
||||
value: any;
|
||||
object: any;
|
||||
property: string;
|
||||
constraints: any[];
|
||||
}
|
||||
|
||||
export interface ValidationOptions {
|
||||
each?: boolean;
|
||||
message?: string | ((args: ValidationArguments) => string);
|
||||
}
|
||||
|
||||
export type ValidationConstraint = {
|
||||
name: string;
|
||||
validate: (value: any, args: ValidationArguments) => boolean | Promise<boolean>;
|
||||
message: string | ((args: ValidationArguments) => string);
|
||||
constraints?: any[];
|
||||
each?: boolean;
|
||||
};
|
||||
|
||||
export interface ValidatorConstraintInterface {
|
||||
validate(value: any, args: ValidationArguments): boolean | Promise<boolean>;
|
||||
defaultMessage?(args: ValidationArguments): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to register a property in metadata.
|
||||
*/
|
||||
function registerProperty(target: any, propertyKey: string) {
|
||||
metadataStorage.registerProperty(target, propertyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to add a validation constraint to a property.
|
||||
*/
|
||||
function addValidation(target: any, propertyKey: string, constraint: ValidationConstraint, options?: ValidationOptions) {
|
||||
registerProperty(target, propertyKey);
|
||||
|
||||
if (options) {
|
||||
if (options.each) {
|
||||
constraint.each = true;
|
||||
}
|
||||
if (options.message) {
|
||||
constraint.message = options.message;
|
||||
}
|
||||
}
|
||||
|
||||
const constraints: ValidationConstraint[] = metadataStorage.getOwnMetadata(METADATA_KEYS.VALIDATION, target, propertyKey) || [];
|
||||
constraints.push(constraint);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.VALIDATION, constraints, target, propertyKey);
|
||||
}
|
||||
|
||||
// --- Mapping Decorators ---
|
||||
|
||||
/**
|
||||
* @JsonSerialize(serializer: ClassConstructor<JsonSerializer>)
|
||||
* Custom serializer decorator.
|
||||
*/
|
||||
export function JsonSerialize(serializer: ClassConstructor<JsonSerializer>) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.SERIALIZER, serializer, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)
|
||||
* Custom deserializer decorator.
|
||||
*/
|
||||
export function JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.DESERIALIZER, deserializer, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonType(typeFunction: () => ClassConstructor<any>)
|
||||
* Identifies the type of a property for nested object conversion.
|
||||
*/
|
||||
export function JsonType(typeFunction: () => ClassConstructor<any>) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.TYPE, typeFunction, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])
|
||||
* Defines polymorphic behavior for a property.
|
||||
*/
|
||||
export function JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.POLYMORPHIC, { discriminator, subTypes }, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
// --- Validation Decorators ---
|
||||
|
||||
/**
|
||||
* @IsOptional()
|
||||
* Marks a property as optional, skipping other validation rules if it's null or undefined.
|
||||
*/
|
||||
export function IsOptional() {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.IS_OPTIONAL, true, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsString()
|
||||
*/
|
||||
export function IsString(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isString',
|
||||
validate: (v) => typeof v === 'string',
|
||||
message: `${propertyKey} must be a string`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsBoolean()
|
||||
*/
|
||||
export function IsBoolean(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isBoolean',
|
||||
validate: (v) => typeof v === 'boolean',
|
||||
message: `${propertyKey} must be a boolean`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsNumber()
|
||||
*/
|
||||
export function IsNumber(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isNumber',
|
||||
validate: (v) => typeof v === 'number' && !isNaN(v),
|
||||
message: `${propertyKey} must be a number`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsInt()
|
||||
*/
|
||||
export function IsInt(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isInt',
|
||||
validate: (v) => Number.isInteger(v),
|
||||
message: `${propertyKey} must be an integer`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsObject()
|
||||
*/
|
||||
export function IsObject(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isObject',
|
||||
validate: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
|
||||
message: `${propertyKey} must be an object`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsDefined()
|
||||
*/
|
||||
export function IsDefined(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isDefined',
|
||||
validate: (v) => v !== null && v !== undefined,
|
||||
message: `${propertyKey} should not be null or undefined`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsNotEmpty()
|
||||
*/
|
||||
export function IsNotEmpty(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isNotEmpty',
|
||||
validate: (v) => v !== null && v !== undefined && v !== '',
|
||||
message: `${propertyKey} should not be empty`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @Min(value: number)
|
||||
*/
|
||||
export function Min(min: number, options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'min',
|
||||
validate: (v) => typeof v === 'number' && v >= min,
|
||||
message: `${propertyKey} must be at least ${min}`,
|
||||
constraints: [min]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @Max(value: number)
|
||||
*/
|
||||
export function Max(max: number, options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'max',
|
||||
validate: (v) => typeof v === 'number' && v <= max,
|
||||
message: `${propertyKey} must be at most ${max}`,
|
||||
constraints: [max]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @Positive()
|
||||
*/
|
||||
export function Positive(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'positive',
|
||||
validate: (v) => typeof v === 'number' && v > 0,
|
||||
message: `${propertyKey} must be positive`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @Negative()
|
||||
*/
|
||||
export function Negative(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'negative',
|
||||
validate: (v) => typeof v === 'number' && v < 0,
|
||||
message: `${propertyKey} must be negative`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @MinLength(value: number)
|
||||
*/
|
||||
export function MinLength(min: number, options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'minLength',
|
||||
validate: (v) => typeof v === 'string' && v.length >= min,
|
||||
message: `${propertyKey} must be longer than or equal to ${min} characters`,
|
||||
constraints: [min]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @MaxLength(value: number)
|
||||
*/
|
||||
export function MaxLength(max: number, options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'maxLength',
|
||||
validate: (v) => typeof v === 'string' && v.length <= max,
|
||||
message: `${propertyKey} must be shorter than or equal to ${max} characters`,
|
||||
constraints: [max]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @Email()
|
||||
*/
|
||||
export function Email(options?: ValidationOptions) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isEmail',
|
||||
validate: (v) => typeof v === 'string' && emailRegex.test(v),
|
||||
message: `${propertyKey} must be a valid email`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsUrl()
|
||||
*/
|
||||
export function IsUrl(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isUrl',
|
||||
validate: (v) => {
|
||||
try {
|
||||
new URL(v);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
message: `${propertyKey} must be a valid URL`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @Matches(pattern: RegExp)
|
||||
*/
|
||||
export function Matches(pattern: RegExp, options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'matches',
|
||||
validate: (v) => typeof v === 'string' && pattern.test(v),
|
||||
message: `${propertyKey} must match ${pattern} regular expression`,
|
||||
constraints: [pattern]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsArray()
|
||||
*/
|
||||
export function IsArray(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isArray',
|
||||
validate: (v) => Array.isArray(v),
|
||||
message: `${propertyKey} must be an array`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @ArrayMinSize(value: number)
|
||||
*/
|
||||
export function ArrayMinSize(min: number, options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'arrayMinSize',
|
||||
validate: (v) => Array.isArray(v) && v.length >= min,
|
||||
message: `${propertyKey} must contain at least ${min} elements`,
|
||||
constraints: [min]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @ArrayMaxSize(value: number)
|
||||
*/
|
||||
export function ArrayMaxSize(max: number, options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'arrayMaxSize',
|
||||
validate: (v) => Array.isArray(v) && v.length <= max,
|
||||
message: `${propertyKey} must contain at most ${max} elements`,
|
||||
constraints: [max]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @ArrayNotEmpty()
|
||||
*/
|
||||
export function ArrayNotEmpty(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'arrayNotEmpty',
|
||||
validate: (v) => Array.isArray(v) && v.length > 0,
|
||||
message: `${propertyKey} should not be empty`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsIn(values: any[])
|
||||
*/
|
||||
export function IsIn(values: any[], options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isIn',
|
||||
validate: (v) => values.includes(v),
|
||||
message: `${propertyKey} must be one of the following values: ${values.join(', ')}`,
|
||||
constraints: [values]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsNotIn(values: any[])
|
||||
*/
|
||||
export function IsNotIn(values: any[], options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isNotIn',
|
||||
validate: (v) => !values.includes(v),
|
||||
message: `${propertyKey} must not be one of the following values: ${values.join(', ')}`,
|
||||
constraints: [values]
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @IsDate()
|
||||
*/
|
||||
export function IsDate(options?: ValidationOptions) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'isDate',
|
||||
validate: (v) => v instanceof Date && !isNaN(v.getTime()),
|
||||
message: `${propertyKey} must be a valid Date object`
|
||||
}, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @ValidateNested()
|
||||
*/
|
||||
export function ValidateNested() {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
// This is a marker for recursive validation
|
||||
metadataStorage.defineMetadata('optimus:nested', true, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation decorator that uses a validator class or function.
|
||||
*/
|
||||
export function Validate(
|
||||
validator: ClassConstructor<ValidatorConstraintInterface> | ((value: any, args: ValidationArguments) => boolean | Promise<boolean>),
|
||||
constraintsOrOptions?: any[] | ValidationOptions,
|
||||
options?: ValidationOptions
|
||||
) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
let constraints: any[] = [];
|
||||
let validationOptions: ValidationOptions | undefined;
|
||||
|
||||
if (Array.isArray(constraintsOrOptions)) {
|
||||
constraints = constraintsOrOptions;
|
||||
validationOptions = options;
|
||||
} else if (typeof constraintsOrOptions === 'object') {
|
||||
validationOptions = constraintsOrOptions;
|
||||
}
|
||||
|
||||
if (typeof validator === 'function' && !validator.prototype?.validate) {
|
||||
// Functional validator
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'custom',
|
||||
validate: validator as (value: any, args: ValidationArguments) => boolean | Promise<boolean>,
|
||||
message: (args) => `${args.property} is invalid`,
|
||||
constraints
|
||||
}, validationOptions);
|
||||
} else {
|
||||
// Class validator
|
||||
const constraintInstance = new (validator as ClassConstructor<ValidatorConstraintInterface>)();
|
||||
addValidation(target, propertyKey, {
|
||||
name: (validator as any).name,
|
||||
validate: (v, a) => constraintInstance.validate(v, a),
|
||||
message: (a) => constraintInstance.defaultMessage ? constraintInstance.defaultMessage(a) : `${a.property} is invalid`,
|
||||
constraints
|
||||
}, validationOptions);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to register a custom decorator.
|
||||
*/
|
||||
export function registerDecorator(options: {
|
||||
name: string;
|
||||
target: Function;
|
||||
propertyName: string;
|
||||
options?: ValidationOptions;
|
||||
constraints?: any[];
|
||||
validator: ValidatorConstraintInterface | ClassConstructor<ValidatorConstraintInterface> | ((value: any, args: ValidationArguments) => boolean | Promise<boolean>);
|
||||
}) {
|
||||
const { name, target, propertyName, options: validationOptions, constraints, validator } = options;
|
||||
|
||||
let validationConstraint: ValidationConstraint;
|
||||
|
||||
if (typeof validator === 'function' && !validator.prototype?.validate) {
|
||||
validationConstraint = {
|
||||
name,
|
||||
validate: validator as (value: any, args: ValidationArguments) => boolean | Promise<boolean>,
|
||||
message: (args) => `${args.property} is invalid`,
|
||||
...(constraints ? { constraints } : {})
|
||||
};
|
||||
} else {
|
||||
const constraintInstance = typeof validator === 'function'
|
||||
? new (validator as ClassConstructor<ValidatorConstraintInterface>)()
|
||||
: validator as ValidatorConstraintInterface;
|
||||
|
||||
validationConstraint = {
|
||||
name,
|
||||
validate: (v, a) => constraintInstance.validate(v, a),
|
||||
message: (a) => constraintInstance.defaultMessage ? constraintInstance.defaultMessage(a) : `${a.property} is invalid`,
|
||||
...(constraints ? { constraints } : {})
|
||||
};
|
||||
}
|
||||
|
||||
addValidation(target.prototype, propertyName, validationConstraint, validationOptions);
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
Min,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsDate,
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
JsonMapper,
|
||||
JsonSerializer,
|
||||
JsonDeserializer,
|
||||
Validate,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions
|
||||
} from './index';
|
||||
|
||||
// --- Custom Validators ---
|
||||
|
||||
class IsLongerThan implements ValidatorConstraintInterface {
|
||||
validate(value: any, args: ValidationArguments): boolean {
|
||||
const minLength = args.constraints[0];
|
||||
return typeof value === 'string' && value.length > minLength;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${args.property} must be longer than ${args.constraints[0]} characters (actual: ${args.value?.length})`;
|
||||
}
|
||||
}
|
||||
|
||||
function IsUsername(options?: ValidationOptions) {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isUsername',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
...(options ? { options } : {}),
|
||||
validator: (value: any) => typeof value === 'string' && /^[a-zA-Z0-9_]+$/.test(value)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// --- Custom Serializers ---
|
||||
|
||||
class DateSerializer implements JsonSerializer<Date, string> {
|
||||
serialize(value: Date): string {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().split('T')[0] || '';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
class DateDeserializer implements JsonDeserializer<string, Date> {
|
||||
deserialize(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Domain Models ---
|
||||
|
||||
abstract class Media {
|
||||
@IsString()
|
||||
abstract type: string;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
}
|
||||
|
||||
class Book extends Media {
|
||||
@IsString()
|
||||
override type: string = 'book';
|
||||
|
||||
@IsString()
|
||||
@IsUsername({ message: 'Title must be a valid alphanumeric username' })
|
||||
override title: string;
|
||||
|
||||
@IsString()
|
||||
@Validate(IsLongerThan, [5])
|
||||
author: string;
|
||||
|
||||
@JsonSerialize(DateSerializer)
|
||||
@JsonDeserialize(DateDeserializer)
|
||||
@IsDate()
|
||||
publishedAt: Date;
|
||||
}
|
||||
|
||||
class Movie extends Media {
|
||||
@IsString()
|
||||
type: string = 'movie';
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
duration: number;
|
||||
}
|
||||
|
||||
class Library {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested()
|
||||
@JsonPolymorphic('type', [
|
||||
{ value: Book, name: 'book' },
|
||||
{ value: Movie, name: 'movie' }
|
||||
])
|
||||
items: Media[];
|
||||
}
|
||||
|
||||
// --- Execution ---
|
||||
|
||||
async function runExample() {
|
||||
console.log("--- Starting Example ---");
|
||||
|
||||
// 1. Create a Library instance
|
||||
const library = new Library();
|
||||
library.name = "Central Library";
|
||||
|
||||
const book = new Book();
|
||||
book.title = "Gatsby";
|
||||
book.author = "Fitzgerald";
|
||||
book.publishedAt = new Date("1925-04-10");
|
||||
|
||||
const movie = new Movie();
|
||||
movie.title = "Inception";
|
||||
movie.duration = 148;
|
||||
|
||||
library.items = [book, movie];
|
||||
|
||||
try {
|
||||
// 2. Serialize to JSON
|
||||
console.log("\n[1] Serializing Library to JSON...");
|
||||
const json = await JsonMapper.toJson(library);
|
||||
console.log("JSON Output:", json);
|
||||
|
||||
// 3. Deserialize back to Instance
|
||||
console.log("\n[2] Deserializing JSON back to Library instance...");
|
||||
const deserializedLibrary = await JsonMapper.fromJson(Library, json);
|
||||
console.log("Deserialized Library Name:", deserializedLibrary.name);
|
||||
console.log("Items count:", deserializedLibrary.items.length);
|
||||
|
||||
// Check Polymorphism
|
||||
deserializedLibrary.items.forEach((item, index) => {
|
||||
console.log(`Item ${index} is instance of ${item.constructor.name}: ${item.title}`);
|
||||
if (item instanceof Book) {
|
||||
console.log(` > Book Author: ${item.author}`);
|
||||
console.log(` > Published At: ${item.publishedAt.toISOString()} (instanceof Date: ${item.publishedAt instanceof Date})`);
|
||||
} else if (item instanceof Movie) {
|
||||
console.log(` > Movie Duration: ${item.duration} mins`);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Test Validation Failure
|
||||
console.log("\n[3] Testing Validation Failure (Invalid Movie Duration)...");
|
||||
const invalidJson = JSON.stringify({
|
||||
name: "Invalid Library",
|
||||
items: [
|
||||
{ type: "movie", title: "Short Film", duration: -5 } // Invalid: duration < 1
|
||||
]
|
||||
});
|
||||
|
||||
await JsonMapper.fromJson(Library, invalidJson);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log("Caught expected error:", error.message);
|
||||
if ((error as any).errors) {
|
||||
console.log("Validation details:", JSON.stringify((error as any).errors, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runExample();
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Email,
|
||||
IsUrl,
|
||||
IsOptional,
|
||||
IsIn,
|
||||
ArrayNotEmpty,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsDate,
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
JsonMapper,
|
||||
JsonSerializer,
|
||||
JsonDeserializer,
|
||||
JsonValidationError
|
||||
} from './index';
|
||||
|
||||
// --- Custom Serializers ---
|
||||
class DateSerializer implements JsonSerializer<Date, string> {
|
||||
serialize(value: Date): string {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().split('T')[0] || '';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
class DateDeserializer implements JsonDeserializer<string, Date> {
|
||||
deserialize(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Domain Models ---
|
||||
abstract class Media {
|
||||
@IsString()
|
||||
abstract type: string;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
}
|
||||
|
||||
class Book extends Media {
|
||||
@IsString()
|
||||
type: string = 'book';
|
||||
|
||||
@IsString()
|
||||
author: string;
|
||||
|
||||
@JsonSerialize(DateSerializer)
|
||||
@JsonDeserialize(DateDeserializer)
|
||||
@IsDate()
|
||||
publishedAt: Date;
|
||||
}
|
||||
|
||||
class Movie extends Media {
|
||||
@IsString()
|
||||
type: string = 'movie';
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
duration: number;
|
||||
}
|
||||
|
||||
class Library {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested()
|
||||
@JsonPolymorphic('type', [
|
||||
{ value: Book, name: 'book' },
|
||||
{ value: Movie, name: 'movie' }
|
||||
])
|
||||
items: Media[];
|
||||
}
|
||||
|
||||
describe('JsonMapper', () => {
|
||||
it('should serialize a Library instance correctly', async () => {
|
||||
const library = new Library();
|
||||
library.name = "Central Library";
|
||||
|
||||
const book = new Book();
|
||||
book.title = "The Great Gatsby";
|
||||
book.author = "F. Scott Fitzgerald";
|
||||
book.publishedAt = new Date("1925-04-10");
|
||||
|
||||
library.items = [book];
|
||||
|
||||
const json = await JsonMapper.toJson(library);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.name).toBe("Central Library");
|
||||
expect(parsed.items[0].type).toBe("book");
|
||||
expect(parsed.items[0].publishedAt).toBe("1925-04-10");
|
||||
});
|
||||
|
||||
it('should deserialize a JSON string back to a Library instance', async () => {
|
||||
const json = JSON.stringify({
|
||||
name: "Central Library",
|
||||
items: [
|
||||
{
|
||||
type: "book",
|
||||
title: "The Great Gatsby",
|
||||
author: "F. Scott Fitzgerald",
|
||||
publishedAt: "1925-04-10"
|
||||
},
|
||||
{
|
||||
type: "movie",
|
||||
title: "Inception",
|
||||
duration: 148
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const library = await JsonMapper.fromJson(Library, json);
|
||||
|
||||
expect(library).toBeInstanceOf(Library);
|
||||
expect(library.items).toHaveLength(2);
|
||||
expect(library.items[0]).toBeInstanceOf(Book);
|
||||
expect(library.items[1]).toBeInstanceOf(Movie);
|
||||
expect((library.items[0] as Book).publishedAt).toBeInstanceOf(Date);
|
||||
expect((library.items[1] as Movie).duration).toBe(148);
|
||||
});
|
||||
|
||||
it('should throw JsonValidationError for invalid data', async () => {
|
||||
const invalidJson = JSON.stringify({
|
||||
name: "Invalid Library",
|
||||
items: [
|
||||
{ type: "movie", title: "Short Film", duration: -5 }
|
||||
]
|
||||
});
|
||||
|
||||
await expect(JsonMapper.fromJson(Library, invalidJson)).rejects.toThrow(JsonValidationError);
|
||||
});
|
||||
|
||||
describe('New Validation Decorators', () => {
|
||||
class User {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(10)
|
||||
username: string;
|
||||
|
||||
@Email()
|
||||
email: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(18)
|
||||
age?: number;
|
||||
|
||||
@IsBoolean()
|
||||
active: boolean;
|
||||
|
||||
@ArrayNotEmpty()
|
||||
@IsIn(['admin', 'user', 'guest'], { each: true })
|
||||
roles: string[];
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
website?: string;
|
||||
}
|
||||
|
||||
it('should validate a valid user', async () => {
|
||||
const user = new User();
|
||||
user.username = 'johndoe';
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['user'];
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate a valid user with optional fields', async () => {
|
||||
const user = new User();
|
||||
user.username = 'johndoe';
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['user'];
|
||||
user.age = 25;
|
||||
user.website = 'https://example.com';
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should fail on invalid username length', async () => {
|
||||
const user = new User();
|
||||
user.username = 'jo'; // too short
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['user'];
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('username');
|
||||
expect(errors[0].constraints).toHaveProperty('minLength');
|
||||
});
|
||||
|
||||
it('should fail on invalid email', async () => {
|
||||
const user = new User();
|
||||
user.username = 'johndoe';
|
||||
user.email = 'invalid-email';
|
||||
user.active = true;
|
||||
user.roles = ['user'];
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('email');
|
||||
expect(errors[0].constraints).toHaveProperty('isEmail');
|
||||
});
|
||||
|
||||
it('should fail on invalid role (IsIn)', async () => {
|
||||
const user = new User();
|
||||
user.username = 'johndoe';
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['superadmin'];
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('roles');
|
||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
||||
});
|
||||
|
||||
it('should skip validation for null optional field', async () => {
|
||||
const user = new User();
|
||||
user.username = 'johndoe';
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['user'];
|
||||
user.age = undefined; // optional
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should fail if optional field is provided but invalid', async () => {
|
||||
const user = new User();
|
||||
user.username = 'johndoe';
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['user'];
|
||||
user.age = 15; // too young (Min 18)
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('age');
|
||||
expect(errors[0].constraints).toHaveProperty('min');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './interfaces';
|
||||
export * from './decorators';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Interface for custom JSON serializers.
|
||||
*
|
||||
* @template T - The type of the value to serialize (usually a class instance or a specific field).
|
||||
* @template R - The type of the serialized value (usually a string, number, or plain object).
|
||||
*/
|
||||
export interface JsonSerializer<T = any, R = any> {
|
||||
/**
|
||||
* Serializes the value into a representation suitable for JSON output.
|
||||
*
|
||||
* @param value - The value to be serialized.
|
||||
* @returns The serialized value.
|
||||
*/
|
||||
serialize(value: T): R;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for custom JSON deserializers.
|
||||
*
|
||||
* @template T - The type of the value to deserialize (usually a string or plain object from JSON).
|
||||
* @template R - The type of the deserialized value (usually a class instance or a specific field).
|
||||
*/
|
||||
export interface JsonDeserializer<T = any, R = any> {
|
||||
/**
|
||||
* Deserializes the value from a JSON-like representation back to its original type.
|
||||
*
|
||||
* @param value - The value to be deserialized.
|
||||
* @returns The deserialized value.
|
||||
*/
|
||||
deserialize(value: T): R;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a class constructor function.
|
||||
*
|
||||
* @template T - The type of the instance created by this constructor.
|
||||
*/
|
||||
export type ClassConstructor<T> = {
|
||||
new (...args: any[]): T;
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
export class MetadataStorage {
|
||||
private static instance: MetadataStorage;
|
||||
|
||||
// Maps a prototype to its property names
|
||||
private properties = new WeakMap<any, string[]>();
|
||||
|
||||
// Maps a prototype and property name to its metadata
|
||||
// Map<Prototype, Map<PropertyKey, Map<MetadataKey, Value>>>
|
||||
private propertyMetadata = new WeakMap<any, Map<string, Map<string, any>>>();
|
||||
|
||||
// Maps a prototype to its class-level metadata
|
||||
private classMetadata = new WeakMap<any, Map<string, any>>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MetadataStorage {
|
||||
if (!MetadataStorage.instance) {
|
||||
MetadataStorage.instance = new MetadataStorage();
|
||||
}
|
||||
return MetadataStorage.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines metadata for a specific property on a target.
|
||||
*/
|
||||
defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
|
||||
if (propertyKey) {
|
||||
let targetMap = this.propertyMetadata.get(target);
|
||||
if (!targetMap) {
|
||||
targetMap = new Map();
|
||||
this.propertyMetadata.set(target, targetMap);
|
||||
}
|
||||
|
||||
let propertyMap = targetMap.get(propertyKey);
|
||||
if (!propertyMap) {
|
||||
propertyMap = new Map();
|
||||
targetMap.set(propertyKey, propertyMap);
|
||||
}
|
||||
|
||||
propertyMap.set(key, value);
|
||||
} else {
|
||||
let targetMap = this.classMetadata.get(target);
|
||||
if (!targetMap) {
|
||||
targetMap = new Map();
|
||||
this.classMetadata.set(target, targetMap);
|
||||
}
|
||||
targetMap.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets metadata for a specific property on a target, including from the prototype chain.
|
||||
*/
|
||||
getMetadata(key: string, target: any, propertyKey?: string): any {
|
||||
let current = target;
|
||||
while (current) {
|
||||
const value = this.getOwnMetadata(key, current, propertyKey);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets metadata defined directly on the target.
|
||||
*/
|
||||
getOwnMetadata(key: string, target: any, propertyKey?: string): any {
|
||||
if (propertyKey) {
|
||||
return this.propertyMetadata.get(target)?.get(propertyKey)?.get(key);
|
||||
} else {
|
||||
return this.classMetadata.get(target)?.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a property for a target.
|
||||
*/
|
||||
registerProperty(target: any, propertyKey: string) {
|
||||
let props = this.properties.get(target);
|
||||
if (!props) {
|
||||
props = [];
|
||||
this.properties.set(target, props);
|
||||
}
|
||||
if (!props.includes(propertyKey)) {
|
||||
props.push(propertyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all registered properties for a target, including from the prototype chain.
|
||||
*/
|
||||
getProperties(target: any): string[] {
|
||||
const allProps = new Set<string>();
|
||||
let current = target;
|
||||
while (current) {
|
||||
const props = this.properties.get(current);
|
||||
if (props) {
|
||||
props.forEach(p => allProps.add(p));
|
||||
}
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
return Array.from(allProps);
|
||||
}
|
||||
}
|
||||
|
||||
export const metadataStorage = MetadataStorage.getInstance();
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import { ClassConstructor } from './interfaces';
|
||||
import { METADATA_KEYS, ValidationConstraint, ValidationArguments } from './decorators';
|
||||
import { metadataStorage } from './metadata-storage';
|
||||
|
||||
export interface ValidationError {
|
||||
property: string;
|
||||
value: any;
|
||||
constraints: { [key: string]: string };
|
||||
children?: ValidationError[];
|
||||
}
|
||||
|
||||
export class JsonValidationError extends Error {
|
||||
constructor(message: string, public errors: ValidationError[]) {
|
||||
super(message);
|
||||
this.name = 'JsonValidationError';
|
||||
}
|
||||
|
||||
override toString() {
|
||||
return `${this.message}: ${JSON.stringify(this.errors, null, 2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonMapper {
|
||||
/**
|
||||
* Converts a class instance to a plain object with validation.
|
||||
*/
|
||||
static async toPlain<T>(obj: T): Promise<any> {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
|
||||
// Validate first
|
||||
const errors = await this.validate(obj);
|
||||
if (errors.length > 0) {
|
||||
throw new JsonValidationError('Validation failed during serialization', errors);
|
||||
}
|
||||
|
||||
return this.serialize(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a class instance to a JSON string with validation.
|
||||
*/
|
||||
static async toJson<T>(obj: T): Promise<string> {
|
||||
const plain = await this.toPlain(obj);
|
||||
return JSON.stringify(plain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plain object to a class instance with validation.
|
||||
*/
|
||||
static async toInstance<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
|
||||
const instance = this.deserialize(clazz, plain);
|
||||
|
||||
const errors = await this.validate(instance);
|
||||
if (errors.length > 0) {
|
||||
throw new JsonValidationError('Validation failed during deserialization', errors);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON string to a class instance with validation.
|
||||
*/
|
||||
static async fromJson<T>(clazz: ClassConstructor<T>, json: string): Promise<T> {
|
||||
const plain = JSON.parse(json);
|
||||
return this.toInstance(clazz, plain);
|
||||
}
|
||||
|
||||
// --- Internal Engine ---
|
||||
|
||||
private static serialize(obj: any): any {
|
||||
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => this.serialize(item));
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return obj.toISOString();
|
||||
}
|
||||
|
||||
const target = obj.constructor.prototype;
|
||||
|
||||
// If no properties are registered with decorators, we might want to serialize everything
|
||||
// But for a "lightweight lib" based on decorators, we only serialize registered properties?
|
||||
// Actually, usually we serialize everything and only apply special logic to registered ones.
|
||||
// Let's take all keys of the object.
|
||||
const result: any = {};
|
||||
const allKeys = Object.keys(obj);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const value = obj[key];
|
||||
|
||||
// Check for custom serializer
|
||||
const serializerCls = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
|
||||
if (serializerCls) {
|
||||
const serializer = new serializerCls();
|
||||
result[key] = serializer.serialize(value);
|
||||
} else {
|
||||
result[key] = this.serialize(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static deserialize<T>(clazz: ClassConstructor<T>, plain: any): T {
|
||||
if (plain === null || plain === undefined) return plain;
|
||||
|
||||
if (Array.isArray(plain)) {
|
||||
return plain.map(item => this.deserialize(clazz, item)) as any;
|
||||
}
|
||||
|
||||
const instance = new clazz();
|
||||
const target = clazz.prototype;
|
||||
|
||||
// Copy all properties from plain to instance
|
||||
for (const key of Object.keys(plain)) {
|
||||
let value = plain[key];
|
||||
|
||||
// Custom Deserializer
|
||||
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
||||
if (deserializerCls) {
|
||||
const deserializer = new deserializerCls();
|
||||
instance[key as keyof T] = deserializer.deserialize(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Polymorphic
|
||||
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
|
||||
if (poly && value !== null && value !== undefined) {
|
||||
const { discriminator, subTypes } = poly;
|
||||
if (Array.isArray(value)) {
|
||||
instance[key as keyof T] = value.map(item => {
|
||||
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
|
||||
return subTypeInfo ? this.deserialize(subTypeInfo.value, item) : item;
|
||||
}) as any;
|
||||
} else {
|
||||
const subTypeInfo = subTypes.find((s: any) => value[discriminator] === s.name);
|
||||
if (subTypeInfo) {
|
||||
instance[key as keyof T] = this.deserialize(subTypeInfo.value, value);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nested Type
|
||||
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
||||
if (typeFn && value !== null && value !== undefined) {
|
||||
const type = typeFn();
|
||||
instance[key as keyof T] = this.deserialize(type, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
instance[key as keyof T] = value;
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
static async validate(obj: any): Promise<ValidationError[]> {
|
||||
const errors: ValidationError[] = [];
|
||||
if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
const childErrors = await this.validate(obj[i]);
|
||||
if (childErrors.length > 0) {
|
||||
errors.push({
|
||||
property: `[${i}]`,
|
||||
value: obj[i],
|
||||
constraints: {},
|
||||
children: childErrors
|
||||
});
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
const target = Object.getPrototypeOf(obj);
|
||||
const properties: string[] = metadataStorage.getProperties(target);
|
||||
|
||||
for (const key of properties) {
|
||||
const value = obj[key];
|
||||
const propertyErrors: ValidationError = {
|
||||
property: key,
|
||||
value: value,
|
||||
constraints: {}
|
||||
};
|
||||
|
||||
// Handle IsOptional
|
||||
const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key);
|
||||
const isNullOrUndefined = value === null || value === undefined;
|
||||
|
||||
if (isOptional && isNullOrUndefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check validation constraints
|
||||
const constraints: ValidationConstraint[] = metadataStorage.getMetadata(METADATA_KEYS.VALIDATION, target, key) || [];
|
||||
const validationArgs: ValidationArguments = {
|
||||
value: value,
|
||||
object: obj,
|
||||
property: key,
|
||||
constraints: []
|
||||
};
|
||||
|
||||
for (const constraint of constraints) {
|
||||
validationArgs.constraints = constraint.constraints || [];
|
||||
|
||||
let isValid = true;
|
||||
if (constraint.each && Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const itemArgs = { ...validationArgs, value: item };
|
||||
if (!(await constraint.validate(item, itemArgs))) {
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isValid = await constraint.validate(value, validationArgs);
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
let message = typeof constraint.message === 'function'
|
||||
? constraint.message(validationArgs)
|
||||
: constraint.message;
|
||||
|
||||
if (constraint.each) {
|
||||
message = `each element in ${message}`;
|
||||
}
|
||||
|
||||
propertyErrors.constraints[constraint.name] = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursive validation
|
||||
const isNested = metadataStorage.getMetadata('optimus:nested', target, key);
|
||||
if (isNested && value !== null && value !== undefined) {
|
||||
const nestedErrors = await this.validate(value);
|
||||
if (nestedErrors.length > 0) {
|
||||
propertyErrors.children = nestedErrors;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) {
|
||||
errors.push(propertyErrors);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user