✨ 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
+29
View File
@@ -0,0 +1,29 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type Check
run: npm run type-check
- name: Run Demo
run: npm run demo
+14 -18
View File
@@ -1,28 +1,24 @@
# Angular specific
/dist/
/out-tsc/
/tmp/
/coverage/
/e2e/test-output/
/.angular/
.angular/
# Node modules and dependency files # Node modules and dependency files
/node_modules/ /node_modules/
/package-lock.json /package-lock.json
/yarn.lock
# Environment files # Build outputs
/.env /dist/
# Angular CLI and build artefacts
/.angular-cli.json
/.ng/
# TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# Logs # Logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# OS and System files
.DS_Store
# IDE and Tooling
.idea/
.vscode/
.air/
.junie/
# Coverage
/coverage/
+59
View File
@@ -0,0 +1,59 @@
# Contributing to Optimus
Thank you for your interest in contributing to Optimus! We welcome all contributions, from bug reports to new features and documentation improvements.
## Development Setup
To get started with development, follow these steps:
1. **Clone the repository:**
```bash
git clone https://github.com/Avalon-Vanguard/optimus.git
cd optimus
```
2. **Install dependencies:**
```bash
npm install
```
3. **Run the demo:**
You can run the example script to see the library in action:
```bash
npm run demo
```
## Project Structure
- `src/`: Contains the source code.
- `decorators.ts`: Custom decorators (@JsonSerialize, @JsonDeserialize, etc.).
- `interfaces.ts`: Core interfaces for serializers and deserializers.
- `utils.ts`: `JsonMapper` utility for serialization and validation.
- `index.ts`: Public API entry point.
- `src/example.ts`: Demonstrates the usage of the library.
## Coding Guidelines
- **TypeScript:** This project is written in TypeScript. Ensure all new code is properly typed.
- **Documentation:** Use JSDoc for all public functions, classes, and interfaces.
- **Style:** Follow the existing coding style and formatting.
- **Internal Mapping Engine:** The project uses a custom, dependency-free engine for mapping and validation. Do not add external dependencies without project-wide discussion.
- **Validation:** Use internal validation decorators.
- **Transformation:** Leverage the internal `JsonMapper` utility.
## Pull Request Process
1. Create a new branch for your feature or bug fix: `git checkout -b feat/your-feature-name` or `fix/your-bug-fix`.
2. Make your changes and ensure the code compiles.
3. Add or update documentation as needed.
4. Run the tests to verify your changes: `npm test`.
5. Commit your changes with a descriptive message.
6. Push your branch to GitHub and open a Pull Request.
## Bug Reports and Feature Requests
Please use GitHub Issues to report bugs or suggest new features. Provide as much detail as possible, including steps to reproduce bugs and clear descriptions of feature requests.
---
By contributing to this project, you agree to abide by the terms of the ISC License.
+180 -1
View File
@@ -1 +1,180 @@
# optimus # Optimus
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.
## Features
- **Spring-like Decorators:** Familiar `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
- **Polymorphism Support:** Native handling of polymorphic types via discriminators.
- **Integrated Validation:** Automatically validates objects during serialization and deserialization.
- **Type Safety:** Fully written in TypeScript for excellent developer experience.
- **Zero Dependencies:** Extremely lightweight and fast.
## Installation
```bash
npm install optimus
```
Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your `tsconfig.json`:
```json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES6"
}
}
```
## Quick Start
### 1. Define your Models
Use decorators to define how your data should be transformed and validated.
```typescript
import {
IsString,
IsInt,
Min,
IsDate,
ValidateNested,
JsonSerialize,
JsonDeserialize,
JsonPolymorphic,
JsonSerializer,
JsonDeserializer
} from 'optimus';
// Custom Date Serializer
class DateSerializer implements JsonSerializer<Date, string> {
serialize(value: Date): string {
return value.toISOString().split('T')[0];
}
}
class DateDeserializer implements JsonDeserializer<string, Date> {
deserialize(value: string): Date {
return new Date(value);
}
}
abstract class Media {
@IsString()
abstract type: string;
@IsString()
title: string;
}
class Book extends Media {
type = 'book';
@IsString()
author: string;
@JsonSerialize(DateSerializer)
@JsonDeserialize(DateDeserializer)
@IsDate()
publishedAt: Date;
}
class Library {
@IsString()
name: string;
@ValidateNested({ each: true })
@JsonPolymorphic('type', [
{ value: Book, name: 'book' }
])
items: Media[];
}
```
### 2. Map JSON with Validation
Use `JsonMapper` to handle the conversion process.
```typescript
import { JsonMapper, JsonValidationError } from 'optimus';
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);
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);
console.log(outputJson);
} catch (error) {
if (error instanceof JsonValidationError) {
console.error("Validation failed:", error.errors);
}
}
}
```
## API Reference
### Decorators
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Specifies a custom serializer for a property.
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Specifies a custom deserializer for a property.
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations.
- `@JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])`: Configures polymorphic transformation based on a discriminator field.
#### Validation Decorators
Most validation decorators accept an optional `ValidationOptions` object:
- `each: boolean`: Apply validation to each element of an array.
- `message: string | ((args: ValidationArguments) => string)`: Custom error message.
| Decorator | Description |
| --- | --- |
| `@IsString()` | Checks if value is a string. |
| `@IsNumber()` | Checks if value is a number (and not NaN). |
| `@IsInt()` | Checks if value is an integer. |
| `@IsBoolean()` | Checks if value is a boolean. |
| `@IsObject()` | Checks if value is an object (not null/array). |
| `@IsDate()` | Checks if value is a valid Date object. |
| `@IsDefined()` | Checks if value is not null or undefined. |
| `@IsOptional()` | Skips other validations if value is null/undefined. |
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
| `@Min(value)` | Checks if number is >= value. |
| `@Max(value)` | Checks if number is <= value. |
| `@Positive()` | Checks if number is > 0. |
| `@Negative()` | Checks if number is < 0. |
| `@MinLength(len)` | Checks if string length is >= len. |
| `@MaxLength(len)` | Checks if string length is <= len. |
| `@Email()` | Checks if string is a valid email. |
| `@IsUrl()` | Checks if string is a valid URL. |
| `@Matches(regex)`| Checks if string matches a regular expression. |
| `@IsArray()` | Checks if value is an array. |
| `@ArrayNotEmpty()`| Checks if array is not empty. |
| `@ArrayMinSize(n)`| Checks if array has at least n elements. |
| `@ArrayMaxSize(n)`| Checks if array has at most n elements. |
| `@IsIn(values)` | Checks if value is in the allowed list. |
| `@IsNotIn(vals)` | Checks if value is NOT in the list. |
| `@ValidateNested()`| Recursively validates nested objects/arrays. |
### 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.
## Contributing
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
## License
Optimus is licensed under the [ISC License](LICENSE).
+289
View File
@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="en">
<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>
<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>
<style>
.CodeMirror {
height: 400px;
border-radius: 0.5rem;
font-size: 14px;
}
.gradient-text {
background: linear-gradient(to right, #6366f1, #a855f7, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>
</head>
<body class="bg-slate-50 text-slate-900 font-sans">
<nav class="bg-white border-b border-slate-200 sticky top-0 z-50">
<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>
</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>
</div>
</div>
</div>
</div>
</nav>
<main>
<!-- Hero Section -->
<section class="py-20 px-4">
<div class="max-w-4xl mx-auto text-center">
<h1 class="text-5xl md:text-6xl font-extrabold mb-6">
<span class="gradient-text">Spring-like</span> JSON Mapping <br>& Validation for TypeScript
</h1>
<p class="text-xl text-slate-600 mb-10">
A lightweight library with <span class="font-bold">ZERO external dependencies</span>.
Simplify your data layer with familiar decorators.
</p>
<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
</code>
</div>
</div>
</section>
<!-- 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>
<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">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">Blazing Fast</h3>
<p class="text-slate-600">Zero overhead. Only uses decorators and metadata to handle mapping and validation.</p>
</div>
<div class="p-6 rounded-xl bg-slate-50 border border-slate-100">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4 text-purple-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.040L3 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622l-.382-3.016z" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">Integrated Validation</h3>
<p class="text-slate-600">Validate while you map. Ensure your data is correct before it even hits your business logic.</p>
</div>
<div class="p-6 rounded-xl bg-slate-50 border border-slate-100">
<div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center mb-4 text-pink-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a2 2 0 00-1.96 1.414l-.477 2.387a2 2 0 00.547 1.022l1.428 1.428a2 2 0 002.828 0l1.428-1.428a2 2 0 000-2.828l-1.428-1.428z" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">Polymorphism</h3>
<p class="text-slate-600">Native support for complex hierarchies. Map to the right subclass automatically based on a discriminator.</p>
</div>
</div>
</div>
</section>
<!-- Playground -->
<section id="playground" class="py-20 bg-slate-900 text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row justify-between items-center mb-10 gap-4">
<div>
<h2 class="text-3xl font-bold">Interactive Playground</h2>
<p class="text-slate-400 mt-2">Edit the code below and see the result in real-time.</p>
</div>
<button id="run-btn" class="bg-indigo-600 hover:bg-indigo-500 px-8 py-3 rounded-lg font-bold transition flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Run Code
</button>
</div>
<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>
<textarea id="editor"></textarea>
</div>
<div class="flex flex-col">
<div class="bg-slate-800 rounded-t-lg px-4 py-2 text-sm font-mono text-slate-400 border-b border-slate-700">Output</div>
<pre id="output" class="bg-slate-950 p-6 rounded-b-lg font-mono text-sm overflow-auto flex-grow" style="height: 400px; color: #a9b7c6;"></pre>
</div>
</div>
</div>
</section>
<!-- Docs Summary -->
<section id="docs" 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">Available Decorators</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3>
<ul class="space-y-2 text-slate-600">
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonSerialize(cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonDeserialize(cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonType(() => cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonPolymorphic(field, types)</code></li>
</ul>
</div>
<div>
<h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3>
<ul class="space-y-2 text-slate-600">
<li><code class="text-sm bg-slate-100 p-1 rounded">@IsString()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsNumber()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@IsInt()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsBoolean()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@IsDate()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsObject()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@IsNotEmpty()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsDefined()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@Positive()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Negative()</code></li>
</ul>
</div>
<div>
<h3 class="font-bold text-lg mb-4 text-pink-600">Advanced Validation</h3>
<ul class="space-y-2 text-slate-600">
<li><code class="text-sm bg-slate-100 p-1 rounded">@Min(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Max(n)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinLength(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@MaxLength(n)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@Email()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsUrl()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@ValidateNested()</code></li>
</ul>
</div>
</div>
</div>
</section>
</main>
<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>
</div>
</footer>
<script>
const initialCode = `// 1. Define your model with decorators
class User {
@IsString()
@MinLength(3)
name;
@IsInt()
@Min(18)
age;
@Email()
email;
constructor(name, age, email) {
this.name = name;
this.age = age;
this.email = email;
}
}
async function demo() {
console.log("--- Validating valid user ---");
const user = new User("Alice", 25, "alice@example.com");
const json = await JsonMapper.toJson(user);
console.log("JSON Output:", json);
console.log("\\n--- Testing validation failure ---");
try {
const invalidJson = '{"name": "Bo", "age": 15, "email": "not-an-email"}';
await JsonMapper.fromJson(User, invalidJson);
} catch (error) {
console.log("Caught Error:", error.message);
console.log("Validation Errors:", JSON.stringify(error.errors, null, 2));
}
}
demo();`;
const editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
mode: 'javascript',
theme: 'dracula',
lineNumbers: true,
indentUnit: 2,
tabSize: 2,
});
editor.setValue(initialCode);
const outputElement = document.getElementById('output');
const runBtn = document.getElementById('run-btn');
// Custom console.log to show output in the pre element
const originalLog = console.log;
function logToOutput(...args) {
originalLog(...args);
const message = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
).join(' ');
outputElement.textContent += message + '\\n';
}
runBtn.addEventListener('click', async () => {
outputElement.textContent = '';
const code = editor.getValue();
try {
// Transpile TypeScript-like code to JS with decorators support
const transpiled = Babel.transform(code, {
presets: ['env', 'typescript'],
plugins: [
['proposal-decorators', { legacy: true }],
['proposal-class-properties', { loose: true }]
]
}).code;
// Create a function with the library symbols in scope
const {
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested,
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
Positive, Negative,
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
JsonValidationError
} = Optimus;
const run = new Function(
'console',
'IsString', 'IsInt', 'Min', 'Max', 'IsEmail', 'IsArray', 'IsDate', 'IsOptional', 'ValidateNested',
'IsBoolean', 'IsNumber', 'IsObject', 'IsDefined', 'IsNotEmpty', 'MinLength', 'MaxLength',
'Email', 'IsUrl', 'Matches', 'ArrayMinSize', 'ArrayMaxSize', 'ArrayNotEmpty', 'IsIn', 'IsNotIn',
'Positive', 'Negative',
'JsonSerialize', 'JsonDeserialize', 'JsonPolymorphic', 'JsonType', 'JsonMapper',
'JsonValidationError',
transpiled
);
await run(
{ log: logToOutput },
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested,
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
Positive, Negative,
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
JsonValidationError
);
} catch (err) {
outputElement.textContent += 'Error: ' + err.message + '\\n';
if (err.stack) {
// originalLog(err.stack);
}
}
});
</script>
</body>
</html>
+1
View File
File diff suppressed because one or more lines are too long
+41
View File
@@ -0,0 +1,41 @@
{
"name": "optimus",
"version": "1.0.0",
"description": "Spring-like decorators for JSON mapping and validation in TypeScript",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"demo": "ts-node src/example.ts",
"type-check": "tsc --noEmit",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Avalon-Vanguard/optimus.git"
},
"keywords": [
"json",
"mapping",
"validation",
"decorators",
"spring",
"typescript"
],
"author": "Avalon Vanguard",
"license": "MIT",
"bugs": {
"url": "https://github.com/Avalon-Vanguard/optimus/issues"
},
"homepage": "https://github.com/Avalon-Vanguard/optimus#readme",
"devDependencies": {
"@types/node": "^25.6.0",
"ts-node": "^10.9.2",
"typescript": "^6.0.2",
"vitest": "^4.1.4"
}
}
+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;
}
}
+40
View File
@@ -0,0 +1,40 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "src",
"outDir": "dist",
// Environment Settings
"module": "CommonJS",
"target": "ES2020",
"lib": ["ESNext"],
"types": ["node"],
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
"noImplicitReturns": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// Recommended Options
"strict": true,
"strictPropertyInitialization": false,
"isolatedModules": true,
"skipLibCheck": true,
"experimentalDecorators": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
}