✨ 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 b4648b6949
18 changed files with 909 additions and 280 deletions
+3
View File
@@ -22,3 +22,6 @@ yarn-error.log*
# Coverage
/coverage/
# ESLint
.eslintcache
+5 -5
View File
@@ -1,6 +1,6 @@
# Contributing to Optimus
# Contributing to Cereale
Thank you for your interest in contributing to Optimus! We welcome all contributions, from bug reports to new features and documentation improvements.
Thank you for your interest in contributing to Cereale! We welcome all contributions, from bug reports to new features and documentation improvements.
## Development Setup
@@ -8,8 +8,8 @@ To get started with development, follow these steps:
1. **Clone the repository:**
```bash
git clone https://github.com/Avalon-Vanguard/optimus.git
cd optimus
git clone https://github.com/Avalon-Vanguard/cereale.git
cd cereale
```
2. **Install dependencies:**
@@ -56,4 +56,4 @@ Please use GitHub Issues to report bugs or suggest new features. Provide as much
---
By contributing to this project, you agree to abide by the terms of the ISC License.
By contributing to this project, you agree to abide by the terms of the MIT License.
+66 -14
View File
@@ -1,6 +1,6 @@
# Optimus
# Cereale
Optimus is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support.
Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support.
## Features
@@ -14,7 +14,7 @@ Optimus is a lightweight TypeScript library that provides Spring-like decorators
## Installation
```bash
npm install optimus
npm install cereale
```
Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your `tsconfig.json`:
@@ -24,7 +24,7 @@ Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES6"
"target": "ES2025"
}
}
```
@@ -47,7 +47,7 @@ import {
JsonPolymorphic,
JsonSerializer,
JsonDeserializer
} from 'optimus';
} from 'cereale';
// Custom Date Serializer
class DateSerializer implements JsonSerializer<Date, string> {
@@ -96,22 +96,22 @@ class Library {
### 2. Map JSON with Validation
Use `JsonMapper` to handle the conversion process.
Use standalone utility functions to handle the conversion process directly.
```typescript
import { JsonMapper, JsonValidationError } from 'optimus';
import { fromJson, toJson, JsonValidationError } from 'cereale';
async function main() {
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
try {
// Deserialize JSON to Class Instance
const library = await JsonMapper.fromJson(Library, json);
const library = await fromJson(Library, json);
console.log(library.name); // "Central Library"
console.log(library.items[0] instanceof Book); // true
// Serialize Class Instance back to JSON
const outputJson = await JsonMapper.toJson(library);
const outputJson = await toJson(library);
console.log(outputJson);
} catch (error) {
if (error instanceof JsonValidationError) {
@@ -121,6 +121,20 @@ async function main() {
}
```
### 3. Modern Web Frameworks (Request Integration)
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
```typescript
import { fromRequest, toPlain } from 'cereale';
// Hono Example
app.post('/books', async (c) => {
const book = await fromRequest(Book, c.req.raw);
return c.json(await toPlain(book));
});
```
## API Reference
### Decorators
@@ -166,10 +180,48 @@ Most validation decorators accept an optional `ValidationOptions` object:
### Utilities
- `JsonMapper.toJson(obj: any, options?)`: Validates and serializes an instance to a JSON string.
- `JsonMapper.toPlain(obj: any, options?)`: Validates and transforms an instance to a plain object.
- `JsonMapper.fromJson(clazz: ClassConstructor, json: string, options?)`: Parses JSON and transforms it to a validated class instance.
- `JsonMapper.toInstance(clazz: ClassConstructor, plain: any, options?)`: Transforms a plain object to a validated class instance.
- `toJson(obj: any)`: Validates and serializes an instance to a JSON string (Returns `Promise<string>`).
- `toPlain(obj: any)`: Validates and transforms an instance to a plain object (Returns `Promise<any>`).
- `fromJson(clazz: ClassConstructor, json: string)`: Parses JSON and transforms it to a validated class instance (Returns `Promise<T>`).
- `toInstance(clazz: ClassConstructor, plain: any)`: Transforms a plain object to a validated class instance (Returns `Promise<T>`).
- `fromRequest(clazz: ClassConstructor, request: Request)`: Extracts JSON from a Fetch `Request` and transforms it to a validated instance (Returns `Promise<T>`).
- `validate(obj: any)`: Performs full validation on an object/instance (Returns `Promise<ValidationError[]>`).
## Framework Integrations
Cereale is designed to be compatible with all trending web frameworks.
### Hono / Next.js / Cloudflare Workers
Use `fromRequest` for seamless integration with the Fetch `Request` API.
### NestJS
You can use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`.
```typescript
import { toInstance } from 'cereale';
@Post()
async create(@Body() body: any) {
const user = await toInstance(User, body);
return this.userService.create(user);
}
```
### Express / Fastify
Easily integrate with traditional Node.js frameworks.
```typescript
import { toInstance, toPlain } from 'cereale';
app.post('/user', async (req, res) => {
try {
const user = await toInstance(User, req.body);
res.json(await toPlain(user));
} catch (err) {
res.status(400).json(err);
}
});
```
## Contributing
@@ -177,4 +229,4 @@ Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute t
## License
Optimus is licensed under the [ISC License](LICENSE).
Cereale is licensed under the [MIT License](LICENSE).
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Optimus - Spring-like JSON Mapping & Validation for TypeScript</title>
<title>Cereale - Spring-like JSON Mapping & Validation for TypeScript</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/javascript/javascript.min.js"></script>
<script src="optimus.js"></script>
<script src="cereale.js"></script>
<style>
.CodeMirror {
height: 400px;
@@ -29,14 +29,14 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<div class="flex items-center">
<span class="text-2xl font-bold gradient-text">Optimus</span>
<span class="text-2xl font-bold gradient-text">Cereale</span>
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="#features" class="text-slate-600 hover:text-indigo-600 px-3 py-2 font-medium">Features</a>
<a href="#playground" class="text-slate-600 hover:text-indigo-600 px-3 py-2 font-medium">Playground</a>
<a href="#docs" class="text-slate-600 hover:text-indigo-600 px-3 py-2 font-medium">Docs</a>
<a href="https://github.com/Avalon-Vanguard/optimus" class="bg-indigo-600 text-white px-4 py-2 rounded-md font-medium hover:bg-indigo-700 transition">GitHub</a>
<a href="https://github.com/Avalon-Vanguard/cereale" class="bg-indigo-600 text-white px-4 py-2 rounded-md font-medium hover:bg-indigo-700 transition">GitHub</a>
</div>
</div>
</div>
@@ -57,7 +57,7 @@
<div class="flex justify-center gap-4">
<a href="#playground" class="bg-indigo-600 text-white px-8 py-3 rounded-lg text-lg font-semibold hover:bg-indigo-700 shadow-lg shadow-indigo-200 transition">Try on the fly</a>
<code class="bg-slate-800 text-slate-100 px-6 py-3 rounded-lg text-lg font-mono flex items-center">
npm install optimus
npm install cereale
</code>
</div>
</div>
@@ -66,7 +66,7 @@
<!-- Features -->
<section id="features" class="py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center mb-16">Why Optimus?</h2>
<h2 class="text-3xl font-bold text-center mb-16">Why Cereale?</h2>
<div class="grid md:grid-cols-3 gap-8">
<div class="p-6 rounded-xl bg-slate-50 border border-slate-100">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4 text-indigo-600">
@@ -117,7 +117,7 @@
<div class="grid lg:grid-cols-2 gap-6">
<div>
<div class="bg-slate-800 rounded-t-lg px-4 py-2 text-sm font-mono text-slate-400 border-b border-slate-700">TypeScript / Optimus</div>
<div class="bg-slate-800 rounded-t-lg px-4 py-2 text-sm font-mono text-slate-400 border-b border-slate-700">TypeScript / Cereale</div>
<textarea id="editor"></textarea>
</div>
<div class="flex flex-col">
@@ -168,7 +168,7 @@
<footer class="bg-slate-50 border-t border-slate-200 py-12">
<div class="max-w-7xl mx-auto px-4 text-center text-slate-500">
<p>© 2026 Optimus Library. Licensed under MIT.</p>
<p>© 2026 Cereale Library. Licensed under MIT.</p>
</div>
</footer>
@@ -255,7 +255,7 @@ demo();`;
Positive, Negative,
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
JsonValidationError
} = Optimus;
} = Cereale;
const run = new Function(
'console',
+29
View File
@@ -0,0 +1,29 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'docs/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': 'warn',
},
},
{
files: ['**/*.test.ts', 'src/example.ts'],
rules: {
'no-console': 'off',
},
}
);
+27 -9
View File
@@ -1,22 +1,35 @@
{
"name": "optimus",
"version": "1.0.0",
"name": "cereale",
"version": "0.0.1",
"description": "Spring-like decorators for JSON mapping and validation in TypeScript",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
},
"sideEffects": false,
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"demo": "ts-node src/example.ts",
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Avalon-Vanguard/optimus.git"
"url": "git+https://github.com/Avalon-Vanguard/cereale.git"
},
"keywords": [
"json",
@@ -29,13 +42,18 @@
"author": "Avalon Vanguard",
"license": "MIT",
"bugs": {
"url": "https://github.com/Avalon-Vanguard/optimus/issues"
"url": "https://github.com/Avalon-Vanguard/cereale/issues"
},
"homepage": "https://github.com/Avalon-Vanguard/optimus#readme",
"homepage": "https://github.com/Avalon-Vanguard/cereale#readme",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.6.0",
"@vitest/coverage-v8": "^4.1.4",
"eslint": "^10.2.1",
"globals": "^17.5.0",
"ts-node": "^10.9.2",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vitest": "^4.1.4"
}
}
+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);
});
});
+105 -70
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,61 +20,15 @@ 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);
}
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 {
async function serialize(obj: any): Promise<any> {
if (obj === null || obj === undefined || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => this.serialize(item));
return Promise.all(obj.map(item => serialize(item)));
}
if (obj instanceof Date) {
@@ -83,10 +37,6 @@ export class JsonMapper {
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);
@@ -97,20 +47,21 @@ export class JsonMapper {
const serializerCls = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
if (serializerCls) {
const serializer = new serializerCls();
result[key] = serializer.serialize(value);
result[key] = await serializer.serialize(value);
} else {
result[key] = this.serialize(value);
result[key] = await serialize(value);
}
}
return result;
}
private static deserialize<T>(clazz: ClassConstructor<T>, plain: any): T {
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
if (plain === null || plain === undefined) return plain;
if (Array.isArray(plain)) {
return plain.map(item => this.deserialize(clazz, item)) as any;
const results = await Promise.all(plain.map(item => deserialize(clazz, item)));
return results as any;
}
const instance = new clazz();
@@ -118,13 +69,13 @@ export class JsonMapper {
// Copy all properties from plain to instance
for (const key of Object.keys(plain)) {
let value = plain[key];
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] = deserializer.deserialize(value);
instance[key as keyof T] = await deserializer.deserialize(value);
continue;
}
@@ -133,14 +84,14 @@ export class JsonMapper {
if (poly && value !== null && value !== undefined) {
const { discriminator, subTypes } = poly;
if (Array.isArray(value)) {
instance[key as keyof T] = value.map(item => {
instance[key as keyof T] = await Promise.all(value.map(async item => {
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
return subTypeInfo ? this.deserialize(subTypeInfo.value, item) : item;
}) as any;
return subTypeInfo ? 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);
instance[key as keyof T] = await deserialize(subTypeInfo.value, value);
continue;
}
}
@@ -151,7 +102,7 @@ export class JsonMapper {
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);
instance[key as keyof T] = await deserialize(type, value);
continue;
}
@@ -161,13 +112,20 @@ export class JsonMapper {
return instance;
}
static async validate(obj: any): Promise<ValidationError[]> {
// --- Public API Functions ---
/**
* 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;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const childErrors = await this.validate(obj[i]);
const childErrors = await validate(obj[i]);
if (childErrors.length > 0) {
errors.push({
property: `[${i}]`,
@@ -238,9 +196,9 @@ export class JsonMapper {
}
// Recursive validation
const isNested = metadataStorage.getMetadata('optimus:nested', target, key);
const isNested = metadataStorage.getMetadata('cereale:nested', target, key);
if (isNested && value !== null && value !== undefined) {
const nestedErrors = await this.validate(value);
const nestedErrors = await validate(value);
if (nestedErrors.length > 0) {
propertyErrors.children = nestedErrors;
}
@@ -253,4 +211,81 @@ export class JsonMapper {
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;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Bundler",
"outDir": "dist/cjs",
"declaration": true
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"outDir": "dist/esm",
"declaration": true
}
}
+3 -3
View File
@@ -6,8 +6,8 @@
"outDir": "dist",
// Environment Settings
"module": "CommonJS",
"target": "ES2020",
"module": "NodeNext",
"target": "ES2025",
"lib": ["ESNext"],
"types": ["node"],
@@ -36,5 +36,5 @@
"experimentalDecorators": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}