diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7387375 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0383c3a..a98d6e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,24 @@ -# Angular specific -/dist/ -/out-tsc/ -/tmp/ -/coverage/ -/e2e/test-output/ -/.angular/ -.angular/ - # Node modules and dependency files /node_modules/ /package-lock.json -/yarn.lock -# Environment files -/.env - -# Angular CLI and build artefacts -/.angular-cli.json -/.ng/ - -# TypeScript cache +# Build outputs +/dist/ *.tsbuildinfo # Logs npm-debug.log* yarn-debug.log* yarn-error.log* + +# OS and System files +.DS_Store + +# IDE and Tooling +.idea/ +.vscode/ +.air/ +.junie/ + +# Coverage +/coverage/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f8996a7 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 078714e..53df51e 100644 --- a/README.md +++ b/README.md @@ -1 +1,180 @@ -# optimus \ No newline at end of file +# 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 { + serialize(value: Date): string { + return value.toISOString().split('T')[0]; + } +} + +class DateDeserializer implements JsonDeserializer { + 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)`: Specifies a custom serializer for a property. +- `@JsonDeserialize(deserializer: ClassConstructor)`: Specifies a custom deserializer for a property. +- `@JsonType(typeFunction: () => ClassConstructor)`: Explicitly sets the type for nested transformations. +- `@JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor, 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). \ No newline at end of file diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..0c315f6 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,289 @@ + + + + + + Optimus - Spring-like JSON Mapping & Validation for TypeScript + + + + + + + + + + + + +
+ +
+
+

+ Spring-like JSON Mapping
& Validation for TypeScript +

+

+ A lightweight library with ZERO external dependencies. + Simplify your data layer with familiar decorators. +

+
+ Try on the fly + + npm install optimus + +
+
+
+ + +
+
+

Why Optimus?

+
+
+
+ + + +
+

Blazing Fast

+

Zero overhead. Only uses decorators and metadata to handle mapping and validation.

+
+
+
+ + + +
+

Integrated Validation

+

Validate while you map. Ensure your data is correct before it even hits your business logic.

+
+
+
+ + + +
+

Polymorphism

+

Native support for complex hierarchies. Map to the right subclass automatically based on a discriminator.

+
+
+
+
+ + +
+
+
+
+

Interactive Playground

+

Edit the code below and see the result in real-time.

+
+ +
+ +
+
+
TypeScript / Optimus
+ +
+
+
Output
+

+                    
+
+
+
+ + +
+
+

Available Decorators

+
+
+

Mapping

+
    +
  • @JsonSerialize(cls)
  • +
  • @JsonDeserialize(cls)
  • +
  • @JsonType(() => cls)
  • +
  • @JsonPolymorphic(field, types)
  • +
+
+
+

Basic Validation

+
    +
  • @IsString(), @IsNumber()
  • +
  • @IsInt(), @IsBoolean()
  • +
  • @IsDate(), @IsObject()
  • +
  • @IsNotEmpty(), @IsDefined()
  • +
  • @Positive(), @Negative()
  • +
+
+
+

Advanced Validation

+
    +
  • @Min(n), @Max(n)
  • +
  • @MinLength(n), @MaxLength(n)
  • +
  • @Email(), @IsUrl()
  • +
  • @ValidateNested()
  • +
+
+
+
+
+
+ +
+
+

© 2026 Optimus Library. Licensed under MIT.

+
+
+ + + + diff --git a/docs/optimus.js b/docs/optimus.js new file mode 100644 index 0000000..139fe69 --- /dev/null +++ b/docs/optimus.js @@ -0,0 +1 @@ +"use strict";var Optimus=(()=>{var A=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var T=Object.getOwnPropertyNames;var b=Object.prototype.hasOwnProperty;var $=(n,t)=>{for(var e in t)A(n,e,{get:t[e],enumerable:!0})},P=(n,t,e,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of T(t))!b.call(n,i)&&i!==e&&A(n,i,{get:()=>t[i],enumerable:!(a=O(t,i))||a.enumerable});return n};var S=n=>P(A({},"__esModule",{value:!0}),n);var it={};$(it,{ArrayMaxSize:()=>K,ArrayMinSize:()=>j,ArrayNotEmpty:()=>tt,Email:()=>_,IsArray:()=>X,IsBoolean:()=>R,IsDate:()=>at,IsDefined:()=>Y,IsIn:()=>et,IsInt:()=>k,IsNotEmpty:()=>Z,IsNotIn:()=>nt,IsNumber:()=>D,IsObject:()=>J,IsOptional:()=>z,IsString:()=>L,IsUrl:()=>G,JsonDeserialize:()=>w,JsonMapper:()=>x,JsonPolymorphic:()=>N,JsonSerialize:()=>V,JsonType:()=>C,JsonValidationError:()=>h,METADATA_KEYS:()=>u,Matches:()=>Q,Max:()=>H,MaxLength:()=>F,Min:()=>U,MinLength:()=>B,Negative:()=>q,Positive:()=>W,ValidateNested:()=>st});var M=class n{constructor(){this.properties=new WeakMap;this.propertyMetadata=new WeakMap;this.classMetadata=new WeakMap}static getInstance(){return n.instance||(n.instance=new n),n.instance}defineMetadata(t,e,a,i){if(i){let s=this.propertyMetadata.get(a);s||(s=new Map,this.propertyMetadata.set(a,s));let r=s.get(i);r||(r=new Map,s.set(i,r)),r.set(t,e)}else{let s=this.classMetadata.get(a);s||(s=new Map,this.classMetadata.set(a,s)),s.set(t,e)}}getMetadata(t,e,a){let i=e;for(;i;){let s=this.getOwnMetadata(t,i,a);if(s!==void 0)return s;i=Object.getPrototypeOf(i)}}getOwnMetadata(t,e,a){return a?this.propertyMetadata.get(e)?.get(a)?.get(t):this.classMetadata.get(e)?.get(t)}registerProperty(t,e){let a=this.properties.get(t);a||(a=[],this.properties.set(t,a)),a.includes(e)||a.push(e)}getProperties(t){let e=new Set,a=t;for(;a;){let i=this.properties.get(a);i&&i.forEach(s=>e.add(s)),a=Object.getPrototypeOf(a)}return Array.from(e)}},l=M.getInstance();var u={PROPERTIES:"optimus:properties",TYPE:"optimus:type",VALIDATION:"optimus:validation",SERIALIZER:"optimus:serializer",DESERIALIZER:"optimus:deserializer",POLYMORPHIC:"optimus:polymorphic",IS_OPTIONAL:"optimus:optional"};function f(n,t){l.registerProperty(n,t)}function o(n,t,e,a){f(n,t),a?.message&&(e.message=a.message);let i=l.getOwnMetadata(u.VALIDATION,n,t)||[];i.push(e),l.defineMetadata(u.VALIDATION,i,n,t)}function V(n){return(t,e)=>{f(t,e),l.defineMetadata(u.SERIALIZER,n,t,e)}}function w(n){return(t,e)=>{f(t,e),l.defineMetadata(u.DESERIALIZER,n,t,e)}}function C(n){return(t,e)=>{f(t,e),l.defineMetadata(u.TYPE,n,t,e)}}function N(n,t){return(e,a)=>{f(e,a),l.defineMetadata(u.POLYMORPHIC,{discriminator:n,subTypes:t},e,a)}}function z(){return(n,t)=>{f(n,t),l.defineMetadata(u.IS_OPTIONAL,!0,n,t)}}function L(){return(n,t)=>{o(n,t,{name:"isString",validate:e=>typeof e=="string",message:`${t} must be a string`})}}function R(){return(n,t)=>{o(n,t,{name:"isBoolean",validate:e=>typeof e=="boolean",message:`${t} must be a boolean`})}}function D(){return(n,t)=>{o(n,t,{name:"isNumber",validate:e=>typeof e=="number"&&!isNaN(e),message:`${t} must be a number`})}}function k(){return(n,t)=>{o(n,t,{name:"isInt",validate:e=>Number.isInteger(e),message:`${t} must be an integer`})}}function J(){return(n,t)=>{o(n,t,{name:"isObject",validate:e=>typeof e=="object"&&e!==null&&!Array.isArray(e),message:`${t} must be an object`})}}function Y(){return(n,t)=>{o(n,t,{name:"isDefined",validate:e=>e!=null,message:`${t} should not be null or undefined`})}}function Z(){return(n,t)=>{o(n,t,{name:"isNotEmpty",validate:e=>e!=null&&e!=="",message:`${t} should not be empty`})}}function U(n){return(t,e)=>{o(t,e,{name:"min",validate:a=>typeof a=="number"&&a>=n,message:`${e} must be at least ${n}`})}}function H(n){return(t,e)=>{o(t,e,{name:"max",validate:a=>typeof a=="number"&&a<=n,message:`${e} must be at most ${n}`})}}function W(){return(n,t)=>{o(n,t,{name:"positive",validate:e=>typeof e=="number"&&e>0,message:`${t} must be positive`})}}function q(){return(n,t)=>{o(n,t,{name:"negative",validate:e=>typeof e=="number"&&e<0,message:`${t} must be negative`})}}function B(n){return(t,e)=>{o(t,e,{name:"minLength",validate:a=>typeof a=="string"&&a.length>=n,message:`${e} must be longer than or equal to ${n} characters`})}}function F(n){return(t,e)=>{o(t,e,{name:"maxLength",validate:a=>typeof a=="string"&&a.length<=n,message:`${e} must be shorter than or equal to ${n} characters`})}}function _(){let n=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return(t,e)=>{o(t,e,{name:"isEmail",validate:a=>typeof a=="string"&&n.test(a),message:`${e} must be a valid email`})}}function G(){return(n,t)=>{o(n,t,{name:"isUrl",validate:e=>{try{return new URL(e),!0}catch{return!1}},message:`${t} must be a valid URL`})}}function Q(n){return(t,e)=>{o(t,e,{name:"matches",validate:a=>typeof a=="string"&&n.test(a),message:`${e} must match ${n} regular expression`})}}function X(){return(n,t)=>{o(n,t,{name:"isArray",validate:e=>Array.isArray(e),message:`${t} must be an array`})}}function j(n){return(t,e)=>{o(t,e,{name:"arrayMinSize",validate:a=>Array.isArray(a)&&a.length>=n,message:`${e} must contain at least ${n} elements`})}}function K(n){return(t,e)=>{o(t,e,{name:"arrayMaxSize",validate:a=>Array.isArray(a)&&a.length<=n,message:`${e} must contain at most ${n} elements`})}}function tt(){return(n,t)=>{o(n,t,{name:"arrayNotEmpty",validate:e=>Array.isArray(e)&&e.length>0,message:`${t} should not be empty`})}}function et(n){return(t,e)=>{o(t,e,{name:"isIn",validate:a=>n.includes(a),message:`${e} must be one of the following values: ${n.join(", ")}`})}}function nt(n){return(t,e)=>{o(t,e,{name:"isNotIn",validate:a=>!n.includes(a),message:`${e} must not be one of the following values: ${n.join(", ")}`})}}function at(){return(n,t)=>{o(n,t,{name:"isDate",validate:e=>e instanceof Date&&!isNaN(e.getTime()),message:`${t} must be a valid Date object`})}}function st(){return(n,t)=>{f(n,t),l.defineMetadata("optimus:nested",!0,n,t)}}var h=class extends Error{constructor(e,a){super(e);this.errors=a;this.name="JsonValidationError"}toString(){return`${this.message}: ${JSON.stringify(this.errors,null,2)}`}},x=class{static async toPlain(t){if(t==null)return t;let e=await this.validate(t);if(e.length>0)throw new h("Validation failed during serialization",e);return this.serialize(t)}static async toJson(t){let e=await this.toPlain(t);return JSON.stringify(e)}static async toInstance(t,e){let a=this.deserialize(t,e),i=await this.validate(a);if(i.length>0)throw new h("Validation failed during deserialization",i);return a}static async fromJson(t,e){let a=JSON.parse(e);return this.toInstance(t,a)}static serialize(t){if(t==null||typeof t!="object")return t;if(Array.isArray(t))return t.map(s=>this.serialize(s));if(t instanceof Date)return t.toISOString();let e=t.constructor.prototype,a={},i=Object.keys(t);for(let s of i){let r=t[s],g=l.getMetadata(u.SERIALIZER,e,s);if(g){let p=new g;a[s]=p.serialize(r)}else a[s]=this.serialize(r)}return a}static deserialize(t,e){if(e==null)return e;if(Array.isArray(e))return e.map(s=>this.deserialize(t,s));let a=new t,i=t.prototype;for(let s of Object.keys(e)){let r=e[s],g=l.getMetadata(u.DESERIALIZER,i,s);if(g){let d=new g;a[s]=d.deserialize(r);continue}let p=l.getMetadata(u.POLYMORPHIC,i,s);if(p&&r!==null&&r!==void 0){let{discriminator:d,subTypes:y}=p;if(Array.isArray(r))a[s]=r.map(m=>{let c=y.find(v=>m[d]===v.name);return c?this.deserialize(c.value,m):m});else{let m=y.find(c=>r[d]===c.name);if(m){a[s]=this.deserialize(m.value,r);continue}}continue}let I=l.getMetadata(u.TYPE,i,s);if(I&&r!==null&&r!==void 0){let d=I();a[s]=this.deserialize(d,r);continue}a[s]=r}return a}static async validate(t){let e=[];if(t==null||typeof t!="object")return e;if(Array.isArray(t)){for(let s=0;s0&&e.push({property:`[${s}]`,value:t[s],constraints:{},children:r})}return e}let a=Object.getPrototypeOf(t),i=l.getProperties(a);for(let s of i){let r=t[s],g={property:s,value:r,constraints:{}},p=l.getMetadata(u.IS_OPTIONAL,a,s),I=r==null;if(p&&I)continue;let d=l.getMetadata(u.VALIDATION,a,s)||[],y={value:r,object:t,property:s,constraints:[]};for(let c of d)if(y.constraints=c.constraints||[],!await c.validate(r,y)){let E=typeof c.message=="function"?c.message(y):c.message;g.constraints[c.name]=E}if(l.getMetadata("optimus:nested",a,s)&&r!==null&&r!==void 0){let c=await this.validate(r);c.length>0&&(g.children=c)}(Object.keys(g.constraints).length>0||g.children)&&e.push(g)}return e}};return S(it);})(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..6f9119d --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/decorators.ts b/src/decorators.ts new file mode 100644 index 0000000..b17a9c1 --- /dev/null +++ b/src/decorators.ts @@ -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; + message: string | ((args: ValidationArguments) => string); + constraints?: any[]; + each?: boolean; +}; + +export interface ValidatorConstraintInterface { + validate(value: any, args: ValidationArguments): boolean | Promise; + 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) + * Custom serializer decorator. + */ +export function JsonSerialize(serializer: ClassConstructor) { + return (target: any, propertyKey: string) => { + registerProperty(target, propertyKey); + metadataStorage.defineMetadata(METADATA_KEYS.SERIALIZER, serializer, target, propertyKey); + }; +} + +/** + * @JsonDeserialize(deserializer: ClassConstructor) + * Custom deserializer decorator. + */ +export function JsonDeserialize(deserializer: ClassConstructor) { + return (target: any, propertyKey: string) => { + registerProperty(target, propertyKey); + metadataStorage.defineMetadata(METADATA_KEYS.DESERIALIZER, deserializer, target, propertyKey); + }; +} + +/** + * @JsonType(typeFunction: () => ClassConstructor) + * Identifies the type of a property for nested object conversion. + */ +export function JsonType(typeFunction: () => ClassConstructor) { + return (target: any, propertyKey: string) => { + registerProperty(target, propertyKey); + metadataStorage.defineMetadata(METADATA_KEYS.TYPE, typeFunction, target, propertyKey); + }; +} + +/** + * @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor, name: string }[]) + * Defines polymorphic behavior for a property. + */ +export function JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor, 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 | ((value: any, args: ValidationArguments) => boolean | Promise), + 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, + message: (args) => `${args.property} is invalid`, + constraints + }, validationOptions); + } else { + // Class validator + const constraintInstance = new (validator as ClassConstructor)(); + 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 | ((value: any, args: ValidationArguments) => boolean | Promise); +}) { + 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, + message: (args) => `${args.property} is invalid`, + ...(constraints ? { constraints } : {}) + }; + } else { + const constraintInstance = typeof validator === 'function' + ? new (validator as ClassConstructor)() + : 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); +} diff --git a/src/example.ts b/src/example.ts new file mode 100644 index 0000000..9a132e3 --- /dev/null +++ b/src/example.ts @@ -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 { + serialize(value: Date): string { + if (value instanceof Date) { + return value.toISOString().split('T')[0] || ''; + } + return String(value); + } +} + +class DateDeserializer implements JsonDeserializer { + 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(); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..d84bdd1 --- /dev/null +++ b/src/index.test.ts @@ -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 { + serialize(value: Date): string { + if (value instanceof Date) { + return value.toISOString().split('T')[0] || ''; + } + return String(value); + } +} + +class DateDeserializer implements JsonDeserializer { + 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'); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..299bffa --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './interfaces'; +export * from './decorators'; +export * from './utils'; diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..cb21ab7 --- /dev/null +++ b/src/interfaces.ts @@ -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 { + /** + * 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 { + /** + * 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 = { + new (...args: any[]): T; +}; diff --git a/src/metadata-storage.ts b/src/metadata-storage.ts new file mode 100644 index 0000000..bb413db --- /dev/null +++ b/src/metadata-storage.ts @@ -0,0 +1,108 @@ +export class MetadataStorage { + private static instance: MetadataStorage; + + // Maps a prototype to its property names + private properties = new WeakMap(); + + // Maps a prototype and property name to its metadata + // Map>> + private propertyMetadata = new WeakMap>>(); + + // Maps a prototype to its class-level metadata + private classMetadata = new WeakMap>(); + + 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(); + 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(); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..6e645e0 --- /dev/null +++ b/src/utils.ts @@ -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(obj: T): Promise { + 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(obj: T): Promise { + const plain = await this.toPlain(obj); + return JSON.stringify(plain); + } + + /** + * Converts a plain object to a class instance with validation. + */ + static async toInstance(clazz: ClassConstructor, plain: any): Promise { + 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(clazz: ClassConstructor, json: string): Promise { + 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(clazz: ClassConstructor, 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 { + 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; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e65f026 --- /dev/null +++ b/tsconfig.json @@ -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"] +}