✨ 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:
Enzo Marioni
2026-04-11 16:58:12 +02:00
parent 45e790a7d0
commit 7fe7e8c7c3
15 changed files with 2026 additions and 19 deletions
+528
View File
@@ -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
View File
@@ -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();
+262
View File
@@ -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');
});
});
});
+3
View File
@@ -0,0 +1,3 @@
export * from './interfaces';
export * from './decorators';
export * from './utils';
+40
View File
@@ -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;
};
+108
View File
@@ -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
View File
@@ -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;
}
}