✨ 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);
|
||||
}
|
||||
Reference in New Issue
Block a user