✨ feat: implement core Cereale 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-18 19:33:19 +02:00
parent 7fe7e8c7c3
commit fa75147e4e
18 changed files with 909 additions and 280 deletions
+382
View File
@@ -0,0 +1,382 @@
import { describe, it, expect } from 'vitest';
import {
IsNumber,
IsObject,
IsDefined,
IsNotEmpty,
Positive,
Negative,
IsUrl,
Max,
Matches,
ArrayMinSize,
ArrayMaxSize,
IsNotIn,
Validate,
registerDecorator,
JsonType,
JsonPolymorphic,
JsonMapper,
JsonValidationError,
ValidationArguments,
ValidatorConstraintInterface,
IsString
} from './index.js';
describe('Additional Decorators', () => {
describe('IsNumber', () => {
class Test {
@IsNumber()
val: any;
}
it('should validate numbers', async () => {
const t = new Test();
t.val = 123;
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = '123';
expect(await JsonMapper.validate(t)).toHaveLength(1);
t.val = NaN;
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('IsObject', () => {
class Test {
@IsObject()
val: any;
}
it('should validate objects', async () => {
const t = new Test();
t.val = { a: 1 };
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = 123;
expect(await JsonMapper.validate(t)).toHaveLength(1);
t.val = null;
expect(await JsonMapper.validate(t)).toHaveLength(1);
t.val = [];
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('IsDefined', () => {
class Test {
@IsDefined()
val: any;
}
it('should validate defined values', async () => {
const t = new Test();
t.val = 0;
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = null;
expect(await JsonMapper.validate(t)).toHaveLength(1);
t.val = undefined;
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('IsNotEmpty', () => {
class Test {
@IsNotEmpty()
val: any;
}
it('should validate non-empty values', async () => {
const t = new Test();
t.val = 'abc';
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = '';
expect(await JsonMapper.validate(t)).toHaveLength(1);
t.val = null;
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('Positive and Negative', () => {
class Test {
@Positive()
pos: number;
@Negative()
neg: number;
}
it('should validate positive and negative numbers', async () => {
const t = new Test();
t.pos = 5;
t.neg = -5;
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.pos = -1;
t.neg = 1;
expect(await JsonMapper.validate(t)).toHaveLength(2);
const errors = await JsonMapper.validate(t);
expect(errors.find(e => e.property === 'pos')).toBeDefined();
});
});
describe('Matches', () => {
class Test {
@Matches(/^[a-z]+$/)
val: string;
@Max(10)
num: number;
}
it('should validate regex matches and Max', async () => {
const t = new Test();
t.val = 'abc';
t.num = 5;
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = '123';
t.num = 15;
expect(await JsonMapper.validate(t)).toHaveLength(2);
});
});
describe('IsUrl invalid', () => {
class Test {
@IsUrl()
url: string;
}
it('should fail on invalid URL', async () => {
const t = new Test();
t.url = 'not-a-url';
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('JsonValidationError and toPlain error', () => {
it('should have a working toString on JsonValidationError', () => {
const err = new JsonValidationError('fail', [{ property: 'x', value: 1, constraints: { c: 'm' } }]);
expect(err.toString()).toContain('fail');
expect(err.toString()).toContain('x');
});
it('should throw during serialization if invalid', async () => {
class Test {
@IsString()
val: any;
}
const t = new Test();
t.val = 123;
await expect(JsonMapper.toPlain(t)).rejects.toThrow(JsonValidationError);
});
});
describe('Array Size', () => {
class Test {
@ArrayMinSize(2)
@ArrayMaxSize(4)
vals: any[];
}
it('should validate array size', async () => {
const t = new Test();
t.vals = [1, 2];
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.vals = [1];
expect(await JsonMapper.validate(t)).toHaveLength(1);
t.vals = [1, 2, 3, 4, 5];
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('IsNotIn', () => {
class Test {
@IsNotIn(['black', 'white'])
color: string;
}
it('should validate value is NOT in list', async () => {
const t = new Test();
t.color = 'red';
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.color = 'black';
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('JsonType', () => {
class Child {
@IsString()
name: string;
}
class Parent {
@JsonType(() => Child)
child: Child;
}
it('should deserialize nested type using JsonType', async () => {
const plain = { child: { name: 'Junior' } };
const parent = await JsonMapper.toInstance(Parent, plain);
expect(parent.child).toBeInstanceOf(Child);
expect(parent.child.name).toBe('Junior');
});
});
describe('Custom Validate', () => {
it('should use functional validator', async () => {
class Test {
@Validate((v) => v === 'secret')
val: string;
}
const t = new Test();
t.val = 'secret';
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = 'wrong';
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
it('should use class-based validator', async () => {
class CustomValidator implements ValidatorConstraintInterface {
validate(value: any) {
return value === 'correct';
}
defaultMessage(args: ValidationArguments) {
return `${args.property} must be correct`;
}
}
class Test {
@Validate(CustomValidator)
val: string;
}
const t = new Test();
t.val = 'correct';
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = 'wrong';
const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1);
expect(errors[0].constraints['CustomValidator']).toBe('val must be correct');
});
});
describe('registerDecorator', () => {
it('should register a custom decorator with functional validator', async () => {
function IsEven() {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'isEven',
target: object.constructor,
propertyName: propertyName,
validator: (value: any) => typeof value === 'number' && value % 2 === 0,
});
};
}
class Test {
@IsEven()
val: number;
}
const t = new Test();
t.val = 2;
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = 3;
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
it('should register a custom decorator with class validator', async () => {
class MyValidator implements ValidatorConstraintInterface {
validate(v: any) { return v === 'ok'; }
}
function IsOk() {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'isOk',
target: object.constructor,
propertyName: propertyName,
validator: MyValidator,
});
};
}
class Test {
@IsOk() val: string;
}
const t = new Test();
t.val = 'ok';
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = 'not ok';
expect(await JsonMapper.validate(t)).toHaveLength(1);
});
});
describe('Validate with constraints and options', () => {
it('should handle constraints and options', async () => {
class Test {
@Validate((v, a) => v === a.constraints[0], [10], { message: 'must be ten' })
val: number;
}
const t = new Test();
t.val = 10;
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = 5;
const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be ten');
});
it('should handle options as second argument', async () => {
class Test {
@Validate((v) => v === 1, { message: 'must be one' })
val: number;
}
const t = new Test();
t.val = 1;
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.val = 2;
const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be one');
});
});
describe('JsonMapper extra cases', () => {
it('should serialize Date with default toISOString', async () => {
class Test {
date: Date;
}
const t = new Test();
t.date = new Date("2026-01-01T00:00:00Z");
const plain = await JsonMapper.toPlain(t);
expect(plain.date).toBe(t.date.toISOString());
});
it('should deserialize top-level array', async () => {
class Item {
@IsString()
name: string;
}
const json = '[{"name": "a"}, {"name": "b"}]';
const items = await JsonMapper.fromJson(Item, json);
expect(Array.isArray(items)).toBe(true);
expect(items[0]).toBeInstanceOf(Item);
expect(items[0].name).toBe('a');
});
it('should handle single polymorphic object', async () => {
abstract class Animal {
@IsString() type: string;
}
class Dog extends Animal {
type = 'dog';
@IsString() breed: string;
}
class Test {
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
pet: Animal;
}
const plain = { pet: { type: 'dog', breed: 'Labrador' } };
const t = await JsonMapper.toInstance(Test, plain);
expect(t.pet).toBeInstanceOf(Dog);
expect((t.pet as Dog).breed).toBe('Labrador');
});
});
describe('ValidationOptions each', () => {
class Test {
@IsString({ each: true })
tags: string[];
}
it('should validate each element in array', async () => {
const t = new Test();
t.tags = ['a', 'b'];
expect(await JsonMapper.validate(t)).toHaveLength(0);
t.tags = ['a', 1 as any];
const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1);
expect(errors[0].constraints['isString']).toContain('each element');
});
});
});
+13 -13
View File
@@ -1,14 +1,14 @@
import { JsonSerializer, JsonDeserializer, ClassConstructor } from './interfaces';
import { metadataStorage } from './metadata-storage';
import { JsonSerializer, JsonDeserializer, ClassConstructor } from './interfaces.js';
import { metadataStorage } from './metadata-storage.js';
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',
PROPERTIES: 'cereale:properties',
TYPE: 'cereale:type',
VALIDATION: 'cereale:validation',
SERIALIZER: 'cereale:serializer',
DESERIALIZER: 'cereale:deserializer',
POLYMORPHIC: 'cereale:polymorphic',
IS_OPTIONAL: 'cereale:optional',
};
export interface ValidationArguments {
@@ -445,7 +445,7 @@ 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);
metadataStorage.defineMetadata('cereale:nested', true, target, propertyKey);
};
}
@@ -472,7 +472,7 @@ export function Validate(
// Functional validator
addValidation(target, propertyKey, {
name: 'custom',
validate: validator as (value: any, args: ValidationArguments) => boolean | Promise<boolean>,
validate: validator as (value: any, args: ValidationArguments) => boolean,
message: (args) => `${args.property} is invalid`,
constraints
}, validationOptions);
@@ -494,7 +494,7 @@ export function Validate(
*/
export function registerDecorator(options: {
name: string;
target: Function;
target: any;
propertyName: string;
options?: ValidationOptions;
constraints?: any[];
@@ -507,7 +507,7 @@ export function registerDecorator(options: {
if (typeof validator === 'function' && !validator.prototype?.validate) {
validationConstraint = {
name,
validate: validator as (value: any, args: ValidationArguments) => boolean | Promise<boolean>,
validate: validator as (value: any, args: ValidationArguments) => boolean,
message: (args) => `${args.property} is invalid`,
...(constraints ? { constraints } : {})
};
+7 -6
View File
@@ -8,7 +8,8 @@ import {
JsonSerialize,
JsonDeserialize,
JsonPolymorphic,
JsonMapper,
toJson,
fromJson,
JsonSerializer,
JsonDeserializer,
Validate,
@@ -16,7 +17,7 @@ import {
ValidationArguments,
registerDecorator,
ValidationOptions
} from './index';
} from './index.js';
// --- Custom Validators ---
@@ -76,7 +77,7 @@ class Book extends Media {
@IsString()
@IsUsername({ message: 'Title must be a valid alphanumeric username' })
override title: string;
declare title: string;
@IsString()
@Validate(IsLongerThan, [5])
@@ -133,12 +134,12 @@ async function runExample() {
try {
// 2. Serialize to JSON
console.log("\n[1] Serializing Library to JSON...");
const json = await JsonMapper.toJson(library);
const json = await 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);
const deserializedLibrary = await fromJson(Library, json);
console.log("Deserialized Library Name:", deserializedLibrary.name);
console.log("Items count:", deserializedLibrary.items.length);
@@ -162,7 +163,7 @@ async function runExample() {
]
});
await JsonMapper.fromJson(Library, invalidJson);
await fromJson(Library, invalidJson);
} catch (error) {
if (error instanceof Error) {
console.log("Caught expected error:", error.message);
+1 -2
View File
@@ -4,7 +4,6 @@ import {
IsBoolean,
IsInt,
Min,
Max,
MinLength,
MaxLength,
Email,
@@ -22,7 +21,7 @@ import {
JsonSerializer,
JsonDeserializer,
JsonValidationError
} from './index';
} from './index.js';
// --- Custom Serializers ---
class DateSerializer implements JsonSerializer<Date, string> {
+3 -3
View File
@@ -1,3 +1,3 @@
export * from './interfaces';
export * from './decorators';
export * from './utils';
export * from './interfaces.js';
export * from './decorators.js';
export * from './utils.js';
+4 -4
View File
@@ -9,9 +9,9 @@ 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.
* @returns The serialized value or a promise resolving to it.
*/
serialize(value: T): R;
serialize(value: T): R | Promise<R>;
}
/**
@@ -25,9 +25,9 @@ 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.
* @returns The deserialized value or a promise resolving to it.
*/
deserialize(value: T): R;
deserialize(value: T): R | Promise<R>;
}
/**
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import {
toPlain,
toInstance,
toJson,
fromJson,
fromRequest,
validate,
IsString,
IsNumber,
JsonValidationError
} from './index.js';
describe('Standalone Utility Functions', () => {
class User {
@IsString()
name: string;
@IsNumber()
age: number;
}
it('should validate an object directly', async () => {
const user = new User();
user.name = 'John';
user.age = 30;
const errors = await validate(user);
expect(errors).toHaveLength(0);
user.age = '30' as any;
const errors2 = await validate(user);
expect(errors2).toHaveLength(1);
expect(errors2[0].property).toBe('age');
});
it('should transform to plain object directly', async () => {
const user = new User();
user.name = 'John';
user.age = 30;
const plain = await toPlain(user);
expect(plain).toEqual({ name: 'John', age: 30 });
expect(plain).not.toBeInstanceOf(User);
});
it('should throw JsonValidationError in toPlain if invalid', async () => {
const user = new User();
user.name = 'John';
user.age = '30' as any;
await expect(toPlain(user)).rejects.toThrow(JsonValidationError);
});
it('should transform to instance directly', async () => {
const plain = { name: 'John', age: 30 };
const user = await toInstance(User, plain);
expect(user).toBeInstanceOf(User);
expect(user.name).toBe('John');
expect(user.age).toBe(30);
});
it('should throw JsonValidationError in toInstance if invalid', async () => {
const plain = { name: 'John', age: '30' };
await expect(toInstance(User, plain)).rejects.toThrow(JsonValidationError);
});
it('should transform to JSON directly', async () => {
const user = new User();
user.name = 'John';
user.age = 30;
const json = await toJson(user);
expect(json).toBe('{"name":"John","age":30}');
});
it('should transform from JSON directly', async () => {
const json = '{"name":"John","age":30}';
const user = await fromJson(User, json);
expect(user).toBeInstanceOf(User);
expect(user.name).toBe('John');
expect(user.age).toBe(30);
});
it('should transform from Request directly', async () => {
const json = '{"name":"John","age":30}';
const request = new Request('https://example.com', {
method: 'POST',
body: json,
headers: { 'Content-Type': 'application/json' }
});
const user = await fromRequest(User, request);
expect(user).toBeInstanceOf(User);
expect(user.name).toBe('John');
expect(user.age).toBe(30);
});
});
+246 -211
View File
@@ -1,6 +1,6 @@
import { ClassConstructor } from './interfaces';
import { METADATA_KEYS, ValidationConstraint, ValidationArguments } from './decorators';
import { metadataStorage } from './metadata-storage';
import { ClassConstructor } from './interfaces.js';
import { METADATA_KEYS, ValidationConstraint, ValidationArguments } from './decorators.js';
import { metadataStorage } from './metadata-storage.js';
export interface ValidationError {
property: string;
@@ -20,237 +20,272 @@ export class JsonValidationError extends Error {
}
}
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);
}
// --- Internal Engine ---
return this.serialize(obj);
async function serialize(obj: any): Promise<any> {
if (obj === null || obj === undefined || typeof obj !== 'object') {
return 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);
if (Array.isArray(obj)) {
return Promise.all(obj.map(item => serialize(item)));
}
/**
* 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;
if (obj instanceof Date) {
return obj.toISOString();
}
/**
* 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);
const target = obj.constructor.prototype;
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] = await serializer.serialize(value);
} else {
result[key] = await serialize(value);
}
}
// --- Internal Engine ---
return result;
}
private static serialize(obj: any): any {
if (obj === null || obj === undefined || typeof obj !== 'object') {
return obj;
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
if (plain === null || plain === undefined) return plain;
if (Array.isArray(plain)) {
const results = await Promise.all(plain.map(item => deserialize(clazz, item)));
return results as any;
}
const instance = new clazz();
const target = clazz.prototype;
// Copy all properties from plain to instance
for (const key of Object.keys(plain)) {
const 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] = await deserializer.deserialize(value);
continue;
}
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);
// 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] = await Promise.all(value.map(async item => {
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
return subTypeInfo ? deserialize(subTypeInfo.value, item) : item;
})) as any;
} else {
result[key] = this.serialize(value);
const subTypeInfo = subTypes.find((s: any) => value[discriminator] === s.name);
if (subTypeInfo) {
instance[key as keyof T] = await deserialize(subTypeInfo.value, value);
continue;
}
}
continue;
}
return result;
// 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] = await deserialize(type, value);
continue;
}
instance[key as keyof T] = value;
}
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;
}
return instance;
}
const instance = new clazz();
const target = clazz.prototype;
// --- Public API Functions ---
// Copy all properties from plain to instance
for (const key of Object.keys(plain)) {
let value = plain[key];
/**
* Validates a class instance or object against its decorators.
* @param obj The object to validate
* @returns Array of validation errors
*/
export async function validate(obj: any): Promise<ValidationError[]> {
const errors: ValidationError[] = [];
if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
// 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);
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const childErrors = await 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('cereale:nested', target, key);
if (isNested && value !== null && value !== undefined) {
const nestedErrors = await validate(value);
if (nestedErrors.length > 0) {
propertyErrors.children = nestedErrors;
}
}
if (Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) {
errors.push(propertyErrors);
}
}
return errors;
}
/**
* Converts a class instance to a plain object with validation.
* @param obj The class instance to transform
* @returns Plain object
*/
export async function toPlain<T>(obj: T): Promise<any> {
if (obj === null || obj === undefined) return obj;
const errors = await validate(obj);
if (errors.length > 0) {
throw new JsonValidationError('Validation failed during serialization', errors);
}
return serialize(obj);
}
/**
* Converts a class instance to a JSON string with validation.
* @param obj The class instance to transform
* @returns JSON string
*/
export async function toJson<T>(obj: T): Promise<string> {
const plain = await toPlain(obj);
return JSON.stringify(plain);
}
/**
* Converts a plain object to a class instance with validation.
* @param clazz The class constructor
* @param plain The plain object to transform
* @returns Validated class instance
*/
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
const instance = await deserialize(clazz, plain);
const errors = await 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.
* @param clazz The class constructor
* @param json JSON string
* @returns Validated class instance
*/
export async function fromJson<T>(clazz: ClassConstructor<T>, json: string): Promise<T> {
const plain = JSON.parse(json);
return toInstance(clazz, plain);
}
/**
* Helper for Fetch-based frameworks (Next.js, Hono, etc.)
* Extracts JSON from a Request and transforms it to a validated instance.
* @param clazz The class constructor
* @param request Web Request object
* @returns Validated class instance
*/
export async function fromRequest<T>(clazz: ClassConstructor<T>, request: Request): Promise<T> {
const plain = await request.json();
return toInstance(clazz, plain);
}
/**
* @deprecated Use standalone functions like toPlain, toInstance, etc.
*/
export class JsonMapper {
static toPlain = toPlain;
static toJson = toJson;
static toInstance = toInstance;
static fromJson = fromJson;
static fromRequest = fromRequest;
static validate = validate;
}