✨ feat: implement core Optimus library for JSON mapping and validation
- 🎨 add Spring-like decorators (@JsonSerialize, @JsonDeserialize, etc.) - ⚙️ implement JsonMapper and metadata storage for transformations - 🧪 add comprehensive test suite using Vitest - 📝 add README, CONTRIBUTING, and API documentation - 👷 setup GitHub Actions CI workflow - 🔧 configure TypeScript and project settings
This commit is contained in:
@@ -0,0 +1,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
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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>
|
||||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './interfaces';
|
||||||
|
export * from './decorators';
|
||||||
|
export * from './utils';
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user