From 297d3bfe77fb04afbf7f3685fb9441c1d4e94ee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 20:35:26 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat!:=20v2=20=E2=80=94=20stron?= =?UTF-8?q?gly=20typed=20decorators=20on=20the=20TC39=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: cereale moves from legacy `experimentalDecorators` to TC39 standard decorators, which is what makes validation rules type-checked against the fields they are attached to. class User { @IsString() name!: string; // fine @IsString() age!: number; // Type 'number' is not assignable to 'string' } Legacy decorators receive (target: any, key: string) and lose the field type entirely, so this was impossible in v1. Standard decorators receive ClassFieldDecoratorContext, which carries it. Rules now checked: scalar rules against scalar fields; { each: true } against arrays, in both directions; @JsonType against the field's class; @JsonSerialize/@JsonDeserialize against the field's type; @IsIn and @IsEnum against the field's value type. 17 tests invoke the real compiler to assert the wrong code stays rejected — a guarantee nobody checks is one that quietly stops holding. Positioning follows the capability: validated domain objects, not validated data. The README now leads with the Zod comparison. Cereale does not infer your type from a schema — you still write the field type and the rule — but it guarantees the two cannot disagree, which is what class-validator never offered. Removed - metadata-storage.ts and its WeakMap singleton. Metadata lives on context.metadata now, which also removes the dual ESM/CJS double-singleton hazard. Inheritance merging becomes structural rather than reconstructed on every read, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot reoccur by construction. - registerDecorator, replaced by defineRule(Class, 'field', constraint). Unchanged: the engine, options, naming strategies, access control, error helpers, the sync API, and the performance work. 193 tests pass. Toolchain note: standard decorators are transformed by tsc and esbuild, but not yet by oxc. The library builds with tsc and consumers on esbuild/Vite are fine; Vitest 4 uses oxc, so the test runner needs an esbuild transform plugin. This is recorded in vitest.config.ts and the README, and is the reason 1.x should stay available for oxc-based toolchains. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK --- CHANGELOG.md | 66 +- README.md | 173 +++-- docs/cereale.js | 4 +- package.json | 12 +- src/decorators.test.ts | 39 +- src/decorators.ts | 1530 +++++++++++++-------------------------- src/example.ts | 31 +- src/hardening.test.ts | 23 +- src/index.test.ts | 14 +- src/index.ts | 1 + src/interfaces.ts | 49 +- src/metadata-storage.ts | 147 ---- src/metadata.ts | 174 +++++ src/regressions.test.ts | 6 +- src/sync.test.ts | 4 +- src/type-safety.test.ts | 175 +++++ src/utils.ts | 176 ++--- src/validators.test.ts | 27 +- tsconfig.json | 6 +- vitest.config.ts | 37 +- 20 files changed, 1265 insertions(+), 1429 deletions(-) delete mode 100644 src/metadata-storage.ts create mode 100644 src/metadata.ts create mode 100644 src/type-safety.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9af3d6..a7610e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,71 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.0.0] - 2026-08-04 + +**Breaking.** Cereale moves to TC39 standard decorators, which is what makes validation rules +type-checked against the fields they are attached to. + +### The headline + +A rule that does not fit its field is now a compile error: + +```ts +class User { + @IsString() name!: string; // fine + @IsString() age!: number; // Type 'number' is not assignable to type 'string' +} +``` + +Legacy decorators receive `(target: any, key: string)` and lose the field's type entirely, so +this was impossible in v1. Standard decorators receive `ClassFieldDecoratorContext`, +which carries it. Checked rules include: + +- scalar rules against scalar fields (`@Min` on a string is rejected) +- `{ each: true }` against arrays (`@IsString({ each: true })` demands a `string[]`, and a bare + `@IsString()` on a `string[]` is rejected) +- `@JsonType(() => Address)` against the field's class +- `@JsonSerialize` / `@JsonDeserialize` against the field's type +- `@IsIn([...])` and `@IsEnum(E)` against the field's value type + +17 tests invoke the real compiler to assert these stay rejected. + +### Migration + +- Remove `"experimentalDecorators": true`; add `"ESNext.Decorators"` to `lib`. +- `registerDecorator({ target, propertyName, validator })` is replaced by + `defineRule(Class, 'field', constraint)`. +- Decorators cannot be applied to `abstract` fields. Declare the field concretely in the base. +- Field types may need tightening where a rule narrows them: `@IsIn(['a','b']) x!: string` + becomes `x!: 'a' | 'b'`. +- `@JsonPolymorphic` takes its base type explicitly to check subtypes: + `@JsonPolymorphic('type', [...])`. +- `@ValidateIf` takes the class as a type argument: `@ValidateIf(m => ...)`. + +Everything else — the engine, options, naming strategies, access control, error helpers, the +sync API — is unchanged. + +### Removed + +- `metadata-storage.ts` and its WeakMap singleton. Metadata now lives on `context.metadata`, + the language's own mechanism, which also removes the dual ESM/CJS double-singleton hazard. +- `registerDecorator`, replaced by `defineRule`. + +### Fixed + +- Inheritance merging is now structural rather than reconstructed: `context.metadata` inherits + through the prototype chain, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot + reoccur by construction. Identical inherited rules are still collapsed so re-stating a rule + on an override does not double-report. + +### Toolchain + +Standard decorators are transformed by `tsc` and by esbuild; **oxc does not implement them +yet**. The library builds with `tsc` and consumers bundling with esbuild or Vite are fine, but +the test runner (Vitest 4, which uses oxc) needs an esbuild transform plugin — see +`vitest.config.ts`. Projects on an oxc-based toolchain should stay on 1.x for now. + +## [Unreleased] (released as 1.0.0) ### Added diff --git a/README.md b/README.md index 189efbd..92f126a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,58 @@ # Cereale -Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support. +**Validated domain objects, not validated data.** + +Cereale maps JSON onto your own classes and gives you back real instances — with your methods, +your inheritance, your `instanceof` checks — and type-checks the validation rules against the +fields they are attached to. Zero runtime dependencies. + +```typescript +class User { + @JsonProperty('display_name') + @IsString() @MinLength(2) + displayName!: string; + + @IsInt() @Min(0) + age!: number; + + @IsString() + age2!: number; // ← compile error: Type 'number' is not assignable to type 'string' + + greet() { return `Hi ${this.displayName}`; } +} + +const user = fromJsonSync(User, body); // a real User +user.greet(); // your methods are still there +``` + +## Why not Zod? + +Zod is excellent, and if a plain validated object is what you want, use it. The difference is +what you get back: + +| | Zod | Cereale | +| --- | --- | --- | +| Result of parsing | an anonymous object matching a schema | an instance of **your class** | +| Methods, getters, inheritance | none — data only | preserved | +| Where the type comes from | inferred from the schema | your class declaration | +| Rules checked against the type | not applicable — schema *is* the type | **yes, at compile time** | +| Bidirectional mapping (renaming both ways) | not the focus | first-class | + +Cereale does not infer your type from a schema, so you still write the field type and the rule. +What it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not +compile. That is the guarantee class-validator has never offered. + +Reach for Cereale when your domain model is already a class — a NestJS provider, a TypeORM +entity, anything with behaviour attached. Reach for Zod when you just want the data. ## Features -- **Spring-like Decorators:** Familiar `@JsonProperty`, `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`. -- **Field-name Mapping:** Map `first_name` to `firstName` per property or with a naming strategy. -- **Access Control:** Keep passwords out of responses and server-owned ids out of requests. -- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects. -- **Polymorphism Support:** Native handling of polymorphic types via discriminators. -- **Integrated Validation:** 50+ validation decorators, applied during mapping or on demand. -- **Type Safety:** Fully written in TypeScript for excellent developer experience. -- **Zero Dependencies:** Extremely lightweight and fast. +- **Strongly typed decorators:** a rule that does not fit its field is a compile error. +- **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes. +- **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions. +- **Access control:** keep passwords out of responses and server-owned ids out of requests. +- **Sync and async:** every entry point has a synchronous twin. +- **Zero dependencies**, ESM + CJS, Node 20+. ## Installation @@ -19,109 +60,92 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators npm install cereale ``` -Enable `experimentalDecorators` in your `tsconfig.json`: +Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` flag: ```json { "compilerOptions": { - "experimentalDecorators": true, - "target": "ES2022" + "target": "ES2022", + "lib": ["ESNext", "ESNext.Decorators"] } } ``` -Cereale stores its own metadata, so `reflect-metadata` is not required and -`emitDecoratorMetadata` is not read. Requires Node 20 or later. +Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and +`emitDecoratorMetadata` is not read. + +> **Toolchain note.** Standard decorators are transformed by `tsc` and by esbuild (so Vite +> works). They are **not** yet transformed by oxc; if your toolchain uses it, decorator syntax +> will fail to parse. v1.x, which uses legacy decorators, remains available for those setups. ## Quick Start -### 1. Define your Models +### 1. Define your model ```typescript import { - IsString, - IsDate, - ValidateNested, - JsonSerialize, - JsonDeserialize, - JsonPolymorphic, - JsonSerializer, - JsonDeserializer + IsString, IsDate, IsInt, Min, ValidateNested, JsonType, + JsonProperty, JsonWriteOnly, JsonPolymorphic, } from 'cereale'; -// Custom Date Serializer -class DateSerializer implements JsonSerializer { - serialize(value: Date): string { - return value.toISOString().split('T')[0]!; - } -} +class Address { + @IsString() street!: string; + @IsString() city!: string; -class DateDeserializer implements JsonDeserializer { - deserialize(value: string): Date { - return new Date(value); - } + format() { return `${this.street}, ${this.city}`; } } abstract class Media { - @IsString() - abstract type: string; - - @IsString() - title: string; + // Standard decorators cannot decorate an `abstract` member, so declare it concretely. + @IsString() type: string = ''; + @IsString() title: string = ''; } class Book extends Media { - type = 'book'; + @IsString() override type = 'book'; + @IsString() author!: string; - @IsString() - author: string; - - @JsonSerialize(DateSerializer) - @JsonDeserialize(DateDeserializer) + @JsonProperty('published_at') @IsDate() - publishedAt: Date; + publishedAt!: Date; } class Library { - @IsString() - name: string; + @IsString() name!: string; + + @ValidateNested() @JsonType(() => Address) + address!: Address; // the class must match the field @ValidateNested({ each: true }) - @JsonPolymorphic('type', [ - { value: Book, name: 'book' } - ]) - items: Media[]; + @JsonPolymorphic('type', [{ value: Book, name: 'book' }]) + items!: Media[]; } ``` -Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s -`@IsString() title` as well as its own rules. +Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s rules as +well as its own, and re-stating a rule on an override does not report it twice. -### 2. Map JSON with Validation +### 2. Map JSON, synchronously or not ```typescript -import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale'; +import { fromJsonSync, toJsonSync, JsonValidationError, flattenErrors } from 'cereale'; -async function main() { - const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}'; - - try { - // Deserialize JSON to Class Instance - const library = await fromJson(Library, json); - console.log(library.name); // "Central Library" - console.log(library.items[0] instanceof Book); // true - - // Serialize Class Instance back to JSON - console.log(await toJson(library)); - } catch (error) { - if (error instanceof JsonValidationError) { - console.error(flattenErrors(error.errors)); - // { "items[0].title": ["title must be a string"] } - } +try { + const library = fromJsonSync(Library, json); + library.address.format(); // your method, on a real Address + library.items[0] instanceof Book; // true + console.log(toJsonSync(library)); +} catch (error) { + if (error instanceof JsonValidationError) { + console.error(flattenErrors(error.errors)); + // { "items[0].title": ["title must be a string"] } } } ``` +Every function has an async form too (`fromJson`, `toJson`, …) for when a serializer, +deserializer or validator of yours returns a Promise. + ### 3. Modern Web Frameworks (Request Integration) Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper. @@ -401,6 +425,13 @@ dominant cost. ## Notes and Limitations +- **Rules are checked, types are not inferred.** You write both the field type and the rule; + cereale guarantees they agree. If you want the type derived from a schema, that is Zod's + model, not this one. +- **`abstract` fields cannot be decorated.** Standard decorators do not apply to abstract + members. Declare the field concretely in the base class instead. +- **oxc does not transform standard decorators yet.** `tsc` and esbuild do. + - **Circular references** are rejected during serialization with a `JsonMappingError`. Break the cycle with `@JsonIgnore()` on the back-reference. - **`validate()` on a plain object** returns no errors: rules live on the class, so validate diff --git a/docs/cereale.js b/docs/cereale.js index 367c829..fcc7f59 100644 --- a/docs/cereale.js +++ b/docs/cereale.js @@ -1,2 +1,2 @@ -"use strict";var Cereale=(()=>{var U=Object.defineProperty;var An=Object.getOwnPropertyDescriptor;var Cn=Object.getOwnPropertyNames;var In=Object.prototype.hasOwnProperty;var Vn=(n,t)=>{for(var e in t)U(n,e,{get:t[e],enumerable:!0})},vn=(n,t,e,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of Cn(t))!In.call(n,i)&&i!==e&&U(n,i,{get:()=>t[i],enumerable:!(a=An(t,i))||a.enumerable});return n};var En=n=>vn(U({},"__esModule",{value:!0}),n);var ue={};Vn(ue,{Allow:()=>Wt,ArrayContains:()=>jt,ArrayMaxSize:()=>ot,ArrayMinSize:()=>st,ArrayNotContains:()=>qt,ArrayNotEmpty:()=>lt,ArrayUnique:()=>Ft,Contains:()=>$t,Email:()=>et,EndsWith:()=>It,Equals:()=>dt,IsAlpha:()=>Ot,IsAlphanumeric:()=>bt,IsArray:()=>rt,IsBigInt:()=>zt,IsBoolean:()=>qn,IsDate:()=>ft,IsDateString:()=>Tt,IsDefined:()=>Gn,IsDivisibleBy:()=>kt,IsEmpty:()=>mt,IsEnum:()=>pt,IsHexColor:()=>Pt,IsIP:()=>Dt,IsIn:()=>ut,IsInstance:()=>yt,IsInt:()=>Wn,IsJSON:()=>Nt,IsLatitude:()=>Rt,IsLongitude:()=>Lt,IsLowercase:()=>St,IsNotEmpty:()=>Yn,IsNotIn:()=>ct,IsNumber:()=>Zn,IsNumberString:()=>wt,IsObject:()=>Bn,IsOptional:()=>Fn,IsPort:()=>Jt,IsSemVer:()=>Mt,IsString:()=>jn,IsUUID:()=>Et,IsUppercase:()=>xt,IsUrl:()=>at,JsonAlias:()=>Dn,JsonDeserialize:()=>zn,JsonIgnore:()=>kn,JsonMapper:()=>q,JsonMappingError:()=>b,JsonPolymorphic:()=>_n,JsonProperty:()=>Pn,JsonReadOnly:()=>Jn,JsonSerialize:()=>Ln,JsonType:()=>Un,JsonValidationError:()=>A,JsonWriteOnly:()=>Rn,Length:()=>ht,METADATA_KEYS:()=>g,Matches:()=>it,Max:()=>Xn,MaxDate:()=>_t,MaxLength:()=>tt,Min:()=>Hn,MinDate:()=>Ut,MinLength:()=>nt,Negative:()=>Kn,NotContains:()=>At,NotEquals:()=>gt,Positive:()=>Qn,REDACTED:()=>un,StartsWith:()=>Ct,Validate:()=>Gt,ValidateIf:()=>Zt,ValidateNested:()=>Bt,collectErrorMessages:()=>Xt,configure:()=>Nn,flattenErrors:()=>j,formatErrors:()=>Ht,fromJson:()=>Sn,fromJsonArray:()=>xn,fromJsonArraySync:()=>le,fromJsonSync:()=>oe,fromRequest:()=>$n,getConfig:()=>Tn,registerDecorator:()=>Yt,resetConfig:()=>Mn,resolveNamingStrategy:()=>M,resolveOptions:()=>S,toInstance:()=>T,toInstanceArray:()=>H,toInstanceArraySync:()=>wn,toInstanceSync:()=>Y,toJson:()=>On,toJsonSync:()=>se,toPlain:()=>G,toPlainSync:()=>hn,validate:()=>N,validateOrReject:()=>yn,validateOrRejectSync:()=>re,validateSync:()=>R});function v(n){return n.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g,"$1 $2").replace(/[_\-\s]+/g," ").trim().split(" ").filter(Boolean).map(t=>t.toLowerCase())}var K=n=>n&&n.charAt(0).toUpperCase()+n.slice(1),_={identity:n=>n,camelCase:n=>{let t=v(n);return t.length===0?n:t[0]+t.slice(1).map(K).join("")},PascalCase:n=>v(n).map(K).join("")||n,snake_case:n=>v(n).join("_")||n,SCREAMING_SNAKE_CASE:n=>v(n).join("_").toUpperCase()||n,"kebab-case":n=>v(n).join("-")||n};function M(n){if(!n)return _.identity;if(typeof n=="function")return n;let t=_[n];if(!t)throw new Error(`Unknown naming strategy ${JSON.stringify(n)}. Use one of: ${Object.keys(_).join(", ")}, or pass your own function.`);return t}var nn={namingStrategy:"identity",unknownKeys:"allow",validate:!0,maxDepth:64},$={...nn};function Nn(n){$={...$,...n}}function Tn(){return{...$}}function Mn(){$={...nn}}function S(n){return n?{namingStrategy:n.namingStrategy??$.namingStrategy,unknownKeys:n.unknownKeys??$.unknownKeys,validate:n.validate??$.validate,maxDepth:n.maxDepth??$.maxDepth}:$}var F=class n{static instance;_version=0;get version(){return this._version}properties=new WeakMap;propertyMetadata=new WeakMap;classMetadata=new WeakMap;constructor(){}static getInstance(){return n.instance||(n.instance=new n),n.instance}defineMetadata(t,e,a,i){if(this._version++,i){let r=this.propertyMetadata.get(a);r||(r=new Map,this.propertyMetadata.set(a,r));let o=r.get(i);o||(o=new Map,r.set(i,o)),o.set(t,e)}else{let r=this.classMetadata.get(a);r||(r=new Map,this.classMetadata.set(a,r)),r.set(t,e)}}getMetadata(t,e,a){let i=e;for(;i;){let r=this.getOwnMetadata(t,i,a);if(r!==void 0)return r;i=Object.getPrototypeOf(i)}}getMetadataChain(t,e,a){let i=[],r=e;for(;r;){let o=this.getOwnMetadata(t,r,a);o!==void 0&&i.unshift(o),r=Object.getPrototypeOf(r)}return i}getOwnMetadata(t,e,a){return a?this.propertyMetadata.get(e)?.get(a)?.get(t):this.classMetadata.get(e)?.get(t)}registerProperty(t,e){this._version++;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(r=>e.add(r)),a=Object.getPrototypeOf(a)}return Array.from(e)}},f=F.getInstance();var g={PROPERTIES:"cereale:properties",TYPE:"cereale:type",VALIDATION:"cereale:validation",SERIALIZER:"cereale:serializer",DESERIALIZER:"cereale:deserializer",POLYMORPHIC:"cereale:polymorphic",IS_OPTIONAL:"cereale:optional",NESTED:"cereale:nested",NAME:"cereale:name",ALIASES:"cereale:aliases",ACCESS:"cereale:access",CONDITION:"cereale:condition"};function O(n,t){f.registerProperty(n,t)}function s(n,t,e,a){O(n,t),a&&(a.each&&(e.each=!0),a.message&&(e.message=a.message,e.hasCustomMessage=!0));let i=f.getOwnMetadata(g.VALIDATION,n,t)||[];i.push(e),f.defineMetadata(g.VALIDATION,i,n,t)}function Pn(n){return(t,e)=>{O(t,e),f.defineMetadata(g.NAME,n,t,e)}}function Dn(...n){return(t,e)=>{O(t,e);let a=f.getOwnMetadata(g.ALIASES,t,e)||[];f.defineMetadata(g.ALIASES,[...a,...n],t,e)}}function kn(){return(n,t)=>{O(n,t),f.defineMetadata(g.ACCESS,"none",n,t)}}function Jn(){return(n,t)=>{O(n,t),f.defineMetadata(g.ACCESS,"readonly",n,t)}}function Rn(){return(n,t)=>{O(n,t),f.defineMetadata(g.ACCESS,"writeonly",n,t)}}function Ln(n){return(t,e)=>{O(t,e),f.defineMetadata(g.SERIALIZER,n,t,e)}}function zn(n){return(t,e)=>{O(t,e),f.defineMetadata(g.DESERIALIZER,n,t,e)}}function Un(n){return(t,e)=>{O(t,e),f.defineMetadata(g.TYPE,n,t,e)}}function _n(n,t,e){return(a,i)=>{O(a,i),f.defineMetadata(g.POLYMORPHIC,{discriminator:n,subTypes:t,onUnknown:e?.onUnknown??"keep",fallback:e?.fallback},a,i)}}function Fn(){return(n,t)=>{O(n,t),f.defineMetadata(g.IS_OPTIONAL,!0,n,t)}}function jn(n){return(t,e)=>{s(t,e,{name:"isString",validate:a=>typeof a=="string",message:`${e} must be a string`},n)}}function qn(n){return(t,e)=>{s(t,e,{name:"isBoolean",validate:a=>typeof a=="boolean",message:`${e} must be a boolean`},n)}}function Zn(n){return(t,e)=>{s(t,e,{name:"isNumber",validate:a=>typeof a=="number"&&!isNaN(a),message:`${e} must be a number`},n)}}function Wn(n){return(t,e)=>{s(t,e,{name:"isInt",validate:a=>Number.isInteger(a),message:`${e} must be an integer`},n)}}function Bn(n){return(t,e)=>{s(t,e,{name:"isObject",validate:a=>typeof a=="object"&&a!==null&&!Array.isArray(a),message:`${e} must be an object`},n)}}function Gn(n){return(t,e)=>{s(t,e,{name:"isDefined",validate:a=>a!=null,message:`${e} should not be null or undefined`},n)}}function Yn(n){return(t,e)=>{s(t,e,{name:"isNotEmpty",validate:a=>a!=null&&a!=="",message:`${e} should not be empty`},n)}}function Hn(n,t){return(e,a)=>{s(e,a,{name:"min",validate:i=>typeof i=="number"&&i>=n,message:`${a} must be at least ${n}`,constraints:[n]},t)}}function Xn(n,t){return(e,a)=>{s(e,a,{name:"max",validate:i=>typeof i=="number"&&i<=n,message:`${a} must be at most ${n}`,constraints:[n]},t)}}function Qn(n){return(t,e)=>{s(t,e,{name:"positive",validate:a=>typeof a=="number"&&a>0,message:`${e} must be positive`},n)}}function Kn(n){return(t,e)=>{s(t,e,{name:"negative",validate:a=>typeof a=="number"&&a<0,message:`${e} must be negative`},n)}}function nt(n,t){return(e,a)=>{s(e,a,{name:"minLength",validate:i=>typeof i=="string"&&i.length>=n,message:`${a} must be longer than or equal to ${n} characters`,constraints:[n]},t)}}function tt(n,t){return(e,a)=>{s(e,a,{name:"maxLength",validate:i=>typeof i=="string"&&i.length<=n,message:`${a} must be shorter than or equal to ${n} characters`,constraints:[n]},t)}}function et(n){let t=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return(e,a)=>{s(e,a,{name:"isEmail",validate:i=>typeof i=="string"&&t.test(i),message:`${a} must be a valid email`},n)}}function at(n){return(t,e)=>{s(t,e,{name:"isUrl",validate:a=>{try{return new URL(a),!0}catch{return!1}},message:`${e} must be a valid URL`},n)}}function it(n,t){let e=n.flags.includes("g")||n.flags.includes("y")?new RegExp(n.source,n.flags.replace(/[gy]/g,"")):n;return(a,i)=>{s(a,i,{name:"matches",validate:r=>typeof r=="string"&&e.test(r),message:`${i} must match ${n} regular expression`,constraints:[n]},t)}}function rt(n){return(t,e)=>{s(t,e,{name:"isArray",validate:a=>Array.isArray(a),message:`${e} must be an array`},n)}}function st(n,t){return(e,a)=>{s(e,a,{name:"arrayMinSize",validate:i=>Array.isArray(i)&&i.length>=n,message:`${a} must contain at least ${n} elements`,constraints:[n]},t)}}function ot(n,t){return(e,a)=>{s(e,a,{name:"arrayMaxSize",validate:i=>Array.isArray(i)&&i.length<=n,message:`${a} must contain at most ${n} elements`,constraints:[n]},t)}}function lt(n){return(t,e)=>{s(t,e,{name:"arrayNotEmpty",validate:a=>Array.isArray(a)&&a.length>0,message:`${e} should not be empty`},n)}}function ut(n,t){return(e,a)=>{s(e,a,{name:"isIn",validate:i=>n.includes(i),message:`${a} must be one of the following values: ${n.join(", ")}`,constraints:[n]},t)}}function ct(n,t){return(e,a)=>{s(e,a,{name:"isNotIn",validate:i=>!n.includes(i),message:`${a} must not be one of the following values: ${n.join(", ")}`,constraints:[n]},t)}}function ft(n){return(t,e)=>{s(t,e,{name:"isDate",validate:a=>a instanceof Date&&!isNaN(a.getTime()),message:`${e} must be a valid Date object`},n)}}function dt(n,t){return(e,a)=>{s(e,a,{name:"equals",validate:i=>i===n,message:`${a} must be equal to ${JSON.stringify(n)}`,constraints:[n]},t)}}function gt(n,t){return(e,a)=>{s(e,a,{name:"notEquals",validate:i=>i!==n,message:`${a} must not be equal to ${JSON.stringify(n)}`,constraints:[n]},t)}}function mt(n){return(t,e)=>{s(t,e,{name:"isEmpty",validate:a=>a==null||a===""?!0:Array.isArray(a)?a.length===0:typeof a=="object"?Object.keys(a).length===0:!1,message:`${e} must be empty`},n)}}function pt(n,t){let e=Object.keys(n).filter(a=>typeof n[n[a]]!="number").map(a=>n[a]);return(a,i)=>{s(a,i,{name:"isEnum",validate:r=>e.includes(r),message:`${i} must be one of the following values: ${e.join(", ")}`,constraints:[e]},t)}}function yt(n,t){return(e,a)=>{s(e,a,{name:"isInstance",validate:i=>i instanceof n,message:`${a} must be an instance of ${n.name}`,constraints:[n]},t)}}function ht(n,t,e){return(a,i)=>{s(a,i,{name:"length",validate:r=>typeof r=="string"&&r.length>=n&&(t===void 0||r.length<=t),message:t===void 0?`${i} must be at least ${n} characters`:`${i} must be between ${n} and ${t} characters`,constraints:t===void 0?[n]:[n,t]},e)}}function D(n,t,e){return a=>(i,r)=>{s(i,r,{name:n,validate:o=>typeof o=="string"&&t.test(o),message:e(r)},a)}}var Ot=D("isAlpha",/^[A-Za-z]+$/,n=>`${n} must contain only letters`),bt=D("isAlphanumeric",/^[A-Za-z0-9]+$/,n=>`${n} must contain only letters and numbers`);function wt(n){return(t,e)=>{s(t,e,{name:"isNumberString",validate:a=>typeof a=="string"&&a.trim()!==""&&Number.isFinite(Number(a)),message:`${e} must be a number string`},n)}}function St(n){return(t,e)=>{s(t,e,{name:"isLowercase",validate:a=>typeof a=="string"&&a===a.toLowerCase(),message:`${e} must be lowercase`},n)}}function xt(n){return(t,e)=>{s(t,e,{name:"isUppercase",validate:a=>typeof a=="string"&&a===a.toUpperCase(),message:`${e} must be uppercase`},n)}}function $t(n,t){return(e,a)=>{s(e,a,{name:"contains",validate:i=>typeof i=="string"&&i.includes(n),message:`${a} must contain ${JSON.stringify(n)}`,constraints:[n]},t)}}function At(n,t){return(e,a)=>{s(e,a,{name:"notContains",validate:i=>typeof i=="string"&&!i.includes(n),message:`${a} must not contain ${JSON.stringify(n)}`,constraints:[n]},t)}}function Ct(n,t){return(e,a)=>{s(e,a,{name:"startsWith",validate:i=>typeof i=="string"&&i.startsWith(n),message:`${a} must start with ${JSON.stringify(n)}`,constraints:[n]},t)}}function It(n,t){return(e,a)=>{s(e,a,{name:"endsWith",validate:i=>typeof i=="string"&&i.endsWith(n),message:`${a} must end with ${JSON.stringify(n)}`,constraints:[n]},t)}}var Vt="00000000-0000-0000-0000-000000000000",vt="ffffffff-ffff-ffff-ffff-ffffffffffff";function Et(n,t){let e=n?new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${n}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,"i"):/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return(a,i)=>{s(a,i,{name:"isUuid",validate:r=>typeof r!="string"?!1:!n&&(r.toLowerCase()===Vt||r.toLowerCase()===vt)?!0:e.test(r),message:`${i} must be a valid UUID${n?` (version ${n})`:""}`,...n?{constraints:[n]}:{}},t)}}function Nt(n){return(t,e)=>{s(t,e,{name:"isJson",validate:a=>{if(typeof a!="string")return!1;try{return JSON.parse(a),!0}catch{return!1}},message:`${e} must be a JSON string`},n)}}function Tt(n){return(t,e)=>{s(t,e,{name:"isDateString",validate:a=>typeof a=="string"&&!isNaN(Date.parse(a)),message:`${e} must be a valid ISO 8601 date string`},n)}}var Mt=D("isSemVer",/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,n=>`${n} must be a valid semantic version`),Pt=D("isHexColor",/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i,n=>`${n} must be a hex color`),tn=/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;function Dt(n,t){let e=a=>{try{return new URL(`http://[${a}]`).hostname===`[${a.toLowerCase()}]`||/^[0-9a-f:.]+$/i.test(a)&&a.includes(":")}catch{return!1}};return(a,i)=>{s(a,i,{name:"isIp",validate:r=>typeof r!="string"?!1:n===4?tn.test(r):n===6?e(r):tn.test(r)||e(r),message:`${i} must be a valid IP${n?`v${n}`:""} address`,...n?{constraints:[n]}:{}},t)}}function kt(n,t){return(e,a)=>{s(e,a,{name:"isDivisibleBy",validate:i=>typeof i=="number"&&Number.isFinite(i)&&n!==0&&i%n===0,message:`${a} must be divisible by ${n}`,constraints:[n]},t)}}function Jt(n){return(t,e)=>{s(t,e,{name:"isPort",validate:a=>{let i=typeof a=="string"&&a.trim()!==""?Number(a):a;return typeof i=="number"&&Number.isInteger(i)&&i>=0&&i<=65535},message:`${e} must be a valid port number`},n)}}function Rt(n){return(t,e)=>{s(t,e,{name:"isLatitude",validate:a=>typeof a=="number"&&Number.isFinite(a)&&a>=-90&&a<=90,message:`${e} must be a latitude between -90 and 90`},n)}}function Lt(n){return(t,e)=>{s(t,e,{name:"isLongitude",validate:a=>typeof a=="number"&&Number.isFinite(a)&&a>=-180&&a<=180,message:`${e} must be a longitude between -180 and 180`},n)}}function zt(n){return(t,e)=>{s(t,e,{name:"isBigInt",validate:a=>typeof a=="bigint",message:`${e} must be a bigint`},n)}}var P=n=>typeof n=="function"?n():n;function Ut(n,t){return(e,a)=>{s(e,a,{name:"minDate",validate:i=>i instanceof Date&&!isNaN(i.getTime())&&i.getTime()>=P(n).getTime(),message:i=>`${i.property} must not be earlier than ${P(n).toISOString()}`,constraints:[n]},t)}}function _t(n,t){return(e,a)=>{s(e,a,{name:"maxDate",validate:i=>i instanceof Date&&!isNaN(i.getTime())&&i.getTime()<=P(n).getTime(),message:i=>`${i.property} must not be later than ${P(n).toISOString()}`,constraints:[n]},t)}}function Ft(n,t){return(e,a)=>{s(e,a,{name:"arrayUnique",validate:i=>{if(!Array.isArray(i))return!1;let r=n?i.map(n):i;return new Set(r).size===r.length},message:`${a} must not contain duplicate values`},t)}}function jt(n,t){return(e,a)=>{s(e,a,{name:"arrayContains",validate:i=>Array.isArray(i)&&n.every(r=>i.includes(r)),message:`${a} must contain the following values: ${n.join(", ")}`,constraints:[n]},t)}}function qt(n,t){return(e,a)=>{s(e,a,{name:"arrayNotContains",validate:i=>Array.isArray(i)&&n.every(r=>!i.includes(r)),message:`${a} must not contain any of the following values: ${n.join(", ")}`,constraints:[n]},t)}}function Zt(n){return(t,e)=>{O(t,e),f.defineMetadata(g.CONDITION,n,t,e)}}function Wt(){return(n,t)=>{O(n,t)}}function Bt(n){return(t,e)=>{O(t,e),f.defineMetadata(g.NESTED,!0,t,e),n?.each&&s(t,e,{name:"nestedEach",validate:a=>Array.isArray(a),message:`${e} must be an array`})}}function Gt(n,t,e){return(a,i)=>{let r=[],o;if(Array.isArray(t)?(r=t,o=e):typeof t=="object"&&(o=t),typeof n=="function"&&!n.prototype?.validate)s(a,i,{name:"custom",validate:n,message:u=>`${u.property} is invalid`,constraints:r},o);else{let u=new n;s(a,i,{name:n.name,validate:(c,l)=>u.validate(c,l),message:c=>u.defaultMessage?u.defaultMessage(c):`${c.property} is invalid`,constraints:r},o)}}}function Yt(n){let{name:t,target:e,propertyName:a,options:i,constraints:r,validator:o}=n,u;if(typeof o=="function"&&!o.prototype?.validate)u={name:t,validate:o,message:c=>`${c.property} is invalid`,...r?{constraints:r}:{}};else{let c=typeof o=="function"?new o:o;u={name:t,validate:(l,d)=>c.validate(l,d),message:l=>c.defaultMessage?c.defaultMessage(l):`${l.property} is invalid`,...r?{constraints:r}:{}}}s(e.prototype,a,u,i)}function j(n){let t={},e=(a,i)=>{for(let r of a){let o=r.property.startsWith("[")?`${i}${r.property}`:i?`${i}.${r.property}`:r.property,u=Object.values(r.constraints);u.length>0&&(t[o]??=[]).push(...u),r.children?.length&&e(r.children,o)}};return e(n,""),t}function Ht(n){let t=j(n);return Object.entries(t).flatMap(([e,a])=>a.map(i=>`${e}: ${i}`)).join(` -`)}function Xt(n){return Object.values(j(n)).flat()}var A=class extends Error{constructor(e,a){super(e);this.errors=a;this.name="JsonValidationError"}toString(){return`${this.message}: ${JSON.stringify(this.errors,null,2)}`}},b=class extends Error{constructor(t){super(t),this.name="JsonMappingError"}},Qt=new Set(["__proto__","constructor","prototype"]),un="[redacted]";function cn(n){return Object.getPrototypeOf(n)??void 0}function Z(n,t){return(n?f.getMetadata(g.ACCESS,n,t):void 0)??"readwrite"}function fn(n,t,e){return(n?f.getMetadata(g.NAME,n,t):void 0)??e(t)}var en=new WeakMap;function Kt(n,t,e){if(!n)return{name:e.naming(t),skip:!1};let a=en.get(n);(!a||a.version!==f.version)&&(a={version:f.version,byStrategy:new Map},en.set(n,a));let i=a.byStrategy.get(e.namingKey);i||(i=new Map,a.byStrategy.set(e.namingKey,i));let r=i.get(t);if(!r){let o=Z(n,t),u=f.getMetadata(g.SERIALIZER,n,t);r={name:fn(n,t,e.naming),skip:o==="none"||o==="writeonly",...u?{serializer:u}:{}},i.set(t,r)}return r}var an=new WeakMap;function ne(n,t){let e=an.get(n);(!e||e.version!==f.version)&&(e={version:f.version,byStrategy:new Map},an.set(n,e));let a=e.byStrategy.get(t.namingKey);if(a)return a;let i=new Map,r=new Set,o=new Map,u=(l,d)=>{let m=i.get(l);if(m&&m!==d)throw new b(`Properties "${m}" and "${d}" both map to the JSON name ${JSON.stringify(l)}. Give one of them a distinct @JsonProperty name.`);i.set(l,d)};for(let l of f.getProperties(n)){let d=[fn(n,l,t.naming),...f.getMetadata(g.ALIASES,n,l)||[]],m=Z(n,l);if(m==="none"||m==="readonly"){for(let h of d)r.add(h);continue}for(let h of d)u(h,l);let w=f.getMetadata(g.DESERIALIZER,n,l),y=f.getMetadata(g.POLYMORPHIC,n,l),p=f.getMetadata(g.TYPE,n,l);(w||y||p)&&o.set(l,{...w?{deserializer:w}:{},...y?{polymorphic:y}:{},...p?{typeFn:p}:{}})}let c={accept:i,blocked:r,props:o};return e.byStrategy.set(t.namingKey,c),c}function E(n){return n!==null&&typeof n=="object"&&typeof n.then=="function"}async function W(n){for(;n.length>0;){let t=n.splice(0,n.length);await Promise.all(t)}}function B(n,t,e){if(n.length!==0){for(let a of n)a.catch(()=>{});throw n.length=0,new b(`${t} requires every serializer, deserializer and validator to be synchronous, but one returned a Promise. Use ${e} instead, or make the hook synchronous.`)}}function k(n,t,e,a,i){if(n==null||typeof n!="object")return n;if(a>e.maxDepth)throw new b(`Maximum nesting depth of ${e.maxDepth} exceeded while serializing. Raise it with the maxDepth option if this structure is legitimate.`);if(n instanceof Date)return n.toISOString();if(t.has(n))throw new b("Circular reference detected during serialization. Break the cycle with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for that property.");t.add(n);try{if(Array.isArray(n)){let u=[];for(let c of n)u.push(k(c,t,e,a+1,i));return u}let r=cn(n),o={};for(let u of Object.keys(n)){let c=Kt(r,u,e);if(c.skip)continue;let l=n[u];if(c.serializer&&l!==null&&l!==void 0){let d=dn(c.serializer).serialize(l);if(E(d)){let m=c.name;o[m]=void 0,i.push(d.then(w=>{o[m]=w}))}else o[c.name]=d}else o[c.name]=k(l,t,e,a+1,i)}return o}finally{t.delete(n)}}function V(n,t,e,a,i){if(t==null)return t;if(a>e.maxDepth)throw new b(`Maximum nesting depth of ${e.maxDepth} exceeded while deserializing. Raise it with the maxDepth option if this structure is legitimate.`);if(Array.isArray(t))return t.map(c=>V(n,c,e,a+1,i));if(typeof t!="object")return t;let r=new n,o=n.prototype,u=ne(o,e);for(let c of Object.keys(t)){if(Qt.has(c)||u.blocked.has(c))continue;let l=u.accept.get(c);if(l===void 0){if(e.unknownKeys==="strip")continue;if(e.unknownKeys==="error")throw new b(`Unknown property ${JSON.stringify(c)} for ${n.name}. Allowed: ${[...u.accept.keys()].map(p=>JSON.stringify(p)).join(", ")||"(none declared)"}.`);r[c]=t[c];continue}let d=t[c],m=u.props.get(l);if(m?.deserializer){let p=dn(m.deserializer).deserialize(d);if(E(p)){let h=l;r[h]=void 0,i.push(p.then(C=>{r[h]=C}))}else r[l]=p;continue}let w=m?.polymorphic;if(w&&d!==null&&d!==void 0){let{discriminator:p,subTypes:h,onUnknown:C,fallback:I}=w,X=x=>{if(x==null||typeof x!="object")return x;let Q=h.find(z=>x[p]===z.name);if(Q)return V(Q.value,x,e,a+1,i);if(I)return V(I,x,e,a+1,i);if(C==="error")throw new b(`Unknown discriminator value ${JSON.stringify(x[p])} for property "${l}". Known values: ${h.map(z=>JSON.stringify(z.name)).join(", ")}.`);return x};r[l]=Array.isArray(d)?d.map(X):X(d);continue}let y=m?.typeFn;if(y&&d!==null&&d!==void 0){let p=y();r[l]=V(p,d,e,a+1,i);continue}r[l]=d}return r}function te(n,t){let e=f.getMetadataChain(g.VALIDATION,n,t),a=[],i=new Set;for(let r of e)for(let o of r){if(typeof o.message=="string"){let u=`${o.name}|${String(o.constraints)}|${o.message}`;if(i.has(u))continue;i.add(u)}a.push(o)}return a}var rn=new WeakMap;function ee(n){let t=rn.get(n);if(t&&t.version===f.version)return t.plan;let e=[];for(let a of f.getProperties(n)){let i=f.getMetadata(g.CONDITION,n,a),r=Z(n,a);e.push({key:a,constraints:te(n,a),isOptional:!!f.getMetadata(g.IS_OPTIONAL,n,a),isNested:!!f.getMetadata(g.NESTED,n,a),redact:r==="writeonly"||r==="none",...i?{condition:i}:{}})}return rn.set(n,{version:f.version,plan:e}),e}var sn=new WeakMap;function dn(n){let t=sn.get(n);return t||(t=new n,sn.set(n,t)),t}function on(n,t,e){if(!(t in n)){n[t]=e;return}let a=2;for(;`${t}_${a}`in n;)a++;n[`${t}_${a}`]=e}function ln(n,t,e){let a=typeof n.message=="function"?n.message(t):n.message;return n.each&&!n.hasCustomMessage&&(a=e>=0?`each element in ${a} (failed at index ${e})`:`each element in ${a}`),a}function ae(n,t,e){for(let a=0;a0?e.children=a:delete e.children,(Object.keys(e.constraints).length>0||e.children)&&t.push(e)}return t}function J(n,t,e,a,i){let r=[];if(n==null||typeof n!="object")return r;if(e>a)throw new b(`Maximum nesting depth of ${a} exceeded while validating. Raise it with the maxDepth option if this structure is legitimate.`);if(t.has(n))return r;t.add(n);try{if(Array.isArray(n)){for(let u=0;u0&&r.push({property:`[${u}]`,value:n[u],constraints:{},children:c})}return r}let o=cn(n);if(!o)return r;for(let u of ee(o)){let c=u.key,l=n[c];if(u.condition&&!u.condition(n)||u.isOptional&&l==null)continue;let d={property:c,value:u.redact?un:l,constraints:{}},m={value:l,object:n,property:c,constraints:[]},w=!1;for(let y of u.constraints){m.constraints=y.constraints||[];let p=y.each&&Array.isArray(l)?ae(y,l,m):(()=>{let h=y.validate(l,m);return E(h)?h.then(C=>({ok:C,index:-1})):{ok:h,index:-1}})();if(E(p)){w=!0;let h={value:l,object:n,property:c,constraints:y.constraints||[]};i.push(p.then(({ok:C,index:I})=>{C||(I>=0&&(h.value=l[I]),on(d.constraints,y.name,ln(y,h,I)))}));continue}if(!p.ok){p.index>=0&&(m.value=l[p.index]);let h=ln(y,m,p.index);m.value=l,on(d.constraints,y.name,h)}}if(u.isNested&&l!==null&&l!==void 0){let y=J(l,t,e+1,a,i);y.length>0&&(d.children=y)}(w||Object.keys(d.constraints).length>0||d.children)&&r.push(d)}return r}finally{t.delete(n)}}function mn(n){let t=S(n);return{naming:M(t.namingStrategy),namingKey:t.namingStrategy,maxDepth:t.maxDepth}}function pn(n){let t=S(n);return{naming:M(t.namingStrategy),namingKey:t.namingStrategy,unknownKeys:t.unknownKeys,maxDepth:t.maxDepth}}async function N(n,t){let e=[],a=J(n,new Set,0,S(t).maxDepth,e);return e.length===0?a:(await W(e),gn(a))}function R(n,t){let e=[],a=J(n,new Set,0,S(t).maxDepth,e);return B(e,"validateSync()","validate()"),a}async function yn(n,t){let e=await N(n,t);if(e.length>0)throw new A("Validation failed",e)}function re(n,t){let e=R(n,t);if(e.length>0)throw new A("Validation failed",e)}async function G(n,t){if(n==null)return n;if(S(t).validate){let i=await N(n,t);if(i.length>0)throw new A("Validation failed during serialization",i)}let e=[],a=k(n,new Set,mn(t),0,e);return await W(e),a}function hn(n,t){if(n==null)return n;if(S(t).validate){let i=R(n,t);if(i.length>0)throw new A("Validation failed during serialization",i)}let e=[],a=k(n,new Set,mn(t),0,e);return B(e,"toPlainSync()","toPlain()"),a}async function On(n,t){return JSON.stringify(await G(n,t))}function se(n,t){return JSON.stringify(hn(n,t))}async function T(n,t,e){let a=[],i=V(n,t,pn(e),0,a);if(await W(a),S(e).validate){let r=await N(i,e);if(r.length>0)throw new A("Validation failed during deserialization",r)}return i}function Y(n,t,e){let a=[],i=V(n,t,pn(e),0,a);if(B(a,"toInstanceSync()","toInstance()"),S(e).validate){let r=R(i,e);if(r.length>0)throw new A("Validation failed during deserialization",r)}return i}function bn(n,t){if(!Array.isArray(t))throw new b(`Expected an array to map to ${n.name}[], received ${typeof t}.`)}async function H(n,t,e){return bn(n,t),await T(n,t,e)}function wn(n,t,e){return bn(n,t),Y(n,t,e)}async function Sn(n,t,e){return T(n,L(t),e)}function oe(n,t,e){return Y(n,L(t),e)}async function xn(n,t,e){return H(n,L(t),e)}function le(n,t,e){return wn(n,L(t),e)}function L(n){try{return JSON.parse(n)}catch(t){throw new b(`Input is not valid JSON: ${t instanceof Error?t.message:String(t)}`)}}async function $n(n,t,e){let a;try{a=await t.json()}catch(i){throw new b(`Request body is not valid JSON: ${i instanceof Error?i.message:String(i)}`)}return T(n,a,e)}var q=class{static toPlain=G;static toJson=On;static toInstance=T;static toInstanceArray=H;static fromJson=Sn;static fromJsonArray=xn;static fromRequest=$n;static validate=N;static validateOrReject=yn};return En(ue);})(); +"use strict";var Cereale=(()=>{var L=Object.defineProperty;var In=Object.getOwnPropertyDescriptor;var An=Object.getOwnPropertyNames;var Nn=Object.prototype.hasOwnProperty;var Mn=(n,e)=>{for(var t in e)L(n,t,{get:e[t],enumerable:!0})},Pn=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of An(e))!Nn.call(n,r)&&r!==t&&L(n,r,{get:()=>e[r],enumerable:!(o=In(e,r))||o.enumerable});return n};var Rn=n=>Pn(L({},"__esModule",{value:!0}),n);var gt={};Mn(gt,{Allow:()=>Yn,ArrayContains:()=>Ye,ArrayMaxSize:()=>He,ArrayMinSize:()=>Ge,ArrayNotContains:()=>Qe,ArrayNotEmpty:()=>Ze,ArrayUnique:()=>Xe,Contains:()=>Fe,Email:()=>Oe,EndsWith:()=>Ue,Equals:()=>Be,IsAlpha:()=>Ce,IsAlphanumeric:()=>Te,IsArray:()=>We,IsBigInt:()=>re,IsBoolean:()=>oe,IsDate:()=>ae,IsDateString:()=>Ee,IsDefined:()=>se,IsDivisibleBy:()=>me,IsEmpty:()=>ue,IsEnum:()=>Le,IsHexColor:()=>Ve,IsIP:()=>ve,IsIn:()=>_e,IsInstance:()=>qe,IsInt:()=>te,IsJSON:()=>Ie,IsLatitude:()=>ge,IsLongitude:()=>he,IsLowercase:()=>$e,IsNotEmpty:()=>le,IsNotIn:()=>Ke,IsNumber:()=>ee,IsNumberString:()=>Se,IsObject:()=>ie,IsOptional:()=>Hn,IsPort:()=>ye,IsSemVer:()=>De,IsString:()=>ne,IsUUID:()=>Re,IsUppercase:()=>ke,IsUrl:()=>Ae,JsonAlias:()=>jn,JsonDeserialize:()=>Wn,JsonIgnore:()=>_n,JsonMapper:()=>G,JsonMappingError:()=>b,JsonPolymorphic:()=>Gn,JsonProperty:()=>Bn,JsonReadOnly:()=>Kn,JsonSerialize:()=>qn,JsonType:()=>Zn,JsonValidationError:()=>C,JsonWriteOnly:()=>Ln,Length:()=>we,Matches:()=>Ne,Max:()=>de,MaxDate:()=>et,MaxLength:()=>xe,Min:()=>ce,MinDate:()=>nt,MinLength:()=>be,Negative:()=>fe,NotContains:()=>Je,NotEquals:()=>je,Positive:()=>pe,REDACTED:()=>yn,StartsWith:()=>ze,Validate:()=>tt,ValidateIf:()=>Xn,ValidateNested:()=>Qn,addConstraint:()=>E,collectErrorMessages:()=>rt,configure:()=>Jn,defineRule:()=>Fn,flattenErrors:()=>Z,formatErrors:()=>ot,fromJson:()=>kn,fromJsonArray:()=>Sn,fromJsonArraySync:()=>yt,fromJsonSync:()=>mt,fromRequest:()=>En,getConfig:()=>zn,modelOf:()=>R,modelOfInstance:()=>v,modelVersion:()=>T,propertyModel:()=>h,resetConfig:()=>Un,resolveNamingStrategy:()=>F,resolveOptions:()=>x,toInstance:()=>P,toInstanceArray:()=>nn,toInstanceArraySync:()=>$n,toInstanceSync:()=>Q,toJson:()=>Dn,toJsonSync:()=>ft,toPlain:()=>Y,toPlainSync:()=>Tn,validate:()=>M,validateOrReject:()=>Cn,validateOrRejectSync:()=>pt,validateSync:()=>j});Symbol.metadata??=Symbol.for("Symbol.metadata");var S=Symbol.for("cereale.model"),on=0;function T(){return on}function vn(n){if(!Object.hasOwn(n,S)){let e=n[S],t={};for(let[o,r]of Object.entries(e??{}))t[o]={...r,constraints:[...r.constraints]};n[S]=t}return n[S]}function h(n,e){on++;let t=vn(n);return t[e]??={constraints:[]}}function E(n,e,t,o){o?.each&&(t.each=!0),o?.message&&(t.message=o.message,t.hasCustomMessage=!0),h(n,e).constraints.push(t)}function R(n){return typeof n!="function"?{}:n[Symbol.metadata]?.[S]??{}}function v(n){let e=Object.getPrototypeOf(n);if(!e)return{};let t=Object.getOwnPropertyDescriptor(e,"constructor");return R(t?.value)}function Fn(n,e,t,o){let r=n;r[Symbol.metadata]??=Object.create(null),E(r[Symbol.metadata],e,t,o)}function I(n){return n.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g,"$1 $2").replace(/[_\-\s]+/g," ").trim().split(" ").filter(Boolean).map(e=>e.toLowerCase())}var rn=n=>n&&n.charAt(0).toUpperCase()+n.slice(1),q={identity:n=>n,camelCase:n=>{let e=I(n);return e.length===0?n:e[0]+e.slice(1).map(rn).join("")},PascalCase:n=>I(n).map(rn).join("")||n,snake_case:n=>I(n).join("_")||n,SCREAMING_SNAKE_CASE:n=>I(n).join("_").toUpperCase()||n,"kebab-case":n=>I(n).join("-")||n};function F(n){if(!n)return q.identity;if(typeof n=="function")return n;let e=q[n];if(!e)throw new Error(`Unknown naming strategy ${JSON.stringify(n)}. Use one of: ${Object.keys(q).join(", ")}, or pass your own function.`);return e}var an={namingStrategy:"identity",unknownKeys:"allow",validate:!0,maxDepth:64},O={...an};function Jn(n){O={...O,...n}}function zn(){return{...O}}function Un(){O={...an}}function x(n){return n?{namingStrategy:n.namingStrategy??O.namingStrategy,unknownKeys:n.unknownKeys??O.unknownKeys,validate:n.validate??O.validate,maxDepth:n.maxDepth??O.maxDepth}:O}function d(n,e){return((t,o)=>{let r=String(o.name);E(o.metadata,r,n(r),e)})}function p(n,e,t){return(o=>d(r=>({name:n,validate:e,message:t(r)}),o))}function A(n,e,t){return p(n,o=>typeof o=="string"&&e.test(o),t)}function Bn(n){return((e,t)=>{h(t.metadata,String(t.name)).name=n})}function jn(...n){return((e,t)=>{let o=h(t.metadata,String(t.name));o.aliases=[...o.aliases??[],...n]})}function W(n){return((e,t)=>{h(t.metadata,String(t.name)).access=n})}var _n=()=>W("none"),Kn=()=>W("readonly"),Ln=()=>W("writeonly");function qn(n){return((e,t)=>{h(t.metadata,String(t.name)).serializer=n})}function Wn(n){return((e,t)=>{h(t.metadata,String(t.name)).deserializer=n})}function Zn(n){return((e,t)=>{h(t.metadata,String(t.name)).type=n})}function Gn(n,e,t){return((o,r)=>{let a={discriminator:n,subTypes:e,onUnknown:t?.onUnknown??"keep",...t?.fallback?{fallback:t.fallback}:{}};h(r.metadata,String(r.name)).polymorphic=a})}function Hn(){return((n,e)=>{h(e.metadata,String(e.name)).optional=!0})}function Xn(n){return((e,t)=>{h(t.metadata,String(t.name)).condition=n})}function Yn(){return((n,e)=>{h(e.metadata,String(e.name))})}function Qn(n){return((e,t)=>{let o=String(t.name);h(t.metadata,o).nested=!0,n?.each&&E(t.metadata,o,{name:"nestedEach",validate:r=>Array.isArray(r),message:`${o} must be an array`})})}var ne=p("isString",n=>typeof n=="string",n=>`${n} must be a string`),ee=p("isNumber",n=>typeof n=="number"&&!isNaN(n),n=>`${n} must be a number`),te=p("isInt",n=>Number.isInteger(n),n=>`${n} must be an integer`),oe=p("isBoolean",n=>typeof n=="boolean",n=>`${n} must be a boolean`),re=p("isBigInt",n=>typeof n=="bigint",n=>`${n} must be a bigint`),ae=p("isDate",n=>n instanceof Date&&!isNaN(n.getTime()),n=>`${n} must be a valid Date object`),ie=p("isObject",n=>typeof n=="object"&&n!==null&&!Array.isArray(n),n=>`${n} must be an object`),se=p("isDefined",n=>n!=null,n=>`${n} should not be null or undefined`),le=p("isNotEmpty",n=>n!=null&&n!=="",n=>`${n} should not be empty`),ue=p("isEmpty",n=>n==null||n===""?!0:Array.isArray(n)?n.length===0:typeof n=="object"?Object.keys(n).length===0:!1,n=>`${n} must be empty`);function ce(n,e){return d(t=>({name:"min",validate:o=>typeof o=="number"&&o>=n,message:`${t} must be at least ${n}`,constraints:[n]}),e)}function de(n,e){return d(t=>({name:"max",validate:o=>typeof o=="number"&&o<=n,message:`${t} must be at most ${n}`,constraints:[n]}),e)}var pe=p("positive",n=>typeof n=="number"&&n>0,n=>`${n} must be positive`),fe=p("negative",n=>typeof n=="number"&&n<0,n=>`${n} must be negative`);function me(n,e){return d(t=>({name:"isDivisibleBy",validate:o=>typeof o=="number"&&Number.isFinite(o)&&n!==0&&o%n===0,message:`${t} must be divisible by ${n}`,constraints:[n]}),e)}var ye=p("isPort",n=>{let e=typeof n=="string"&&n.trim()!==""?Number(n):n;return typeof e=="number"&&Number.isInteger(e)&&e>=0&&e<=65535},n=>`${n} must be a valid port number`),ge=p("isLatitude",n=>typeof n=="number"&&Number.isFinite(n)&&n>=-90&&n<=90,n=>`${n} must be a latitude between -90 and 90`),he=p("isLongitude",n=>typeof n=="number"&&Number.isFinite(n)&&n>=-180&&n<=180,n=>`${n} must be a longitude between -180 and 180`);function be(n,e){return d(t=>({name:"minLength",validate:o=>typeof o=="string"&&o.length>=n,message:`${t} must be longer than or equal to ${n} characters`,constraints:[n]}),e)}function xe(n,e){return d(t=>({name:"maxLength",validate:o=>typeof o=="string"&&o.length<=n,message:`${t} must be shorter than or equal to ${n} characters`,constraints:[n]}),e)}function we(n,e,t){return d(o=>({name:"length",validate:r=>typeof r=="string"&&r.length>=n&&(e===void 0||r.length<=e),message:e===void 0?`${o} must be at least ${n} characters`:`${o} must be between ${n} and ${e} characters`,constraints:e===void 0?[n]:[n,e]}),t)}var Oe=A("isEmail",/^[^\s@]+@[^\s@]+\.[^\s@]+$/,n=>`${n} must be a valid email`),Ce=A("isAlpha",/^[A-Za-z]+$/,n=>`${n} must contain only letters`),Te=A("isAlphanumeric",/^[A-Za-z0-9]+$/,n=>`${n} must contain only letters and numbers`),De=A("isSemVer",/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,n=>`${n} must be a valid semantic version`),Ve=A("isHexColor",/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i,n=>`${n} must be a hex color`),$e=p("isLowercase",n=>typeof n=="string"&&n===n.toLowerCase(),n=>`${n} must be lowercase`),ke=p("isUppercase",n=>typeof n=="string"&&n===n.toUpperCase(),n=>`${n} must be uppercase`),Se=p("isNumberString",n=>typeof n=="string"&&n.trim()!==""&&Number.isFinite(Number(n)),n=>`${n} must be a number string`),Ee=p("isDateString",n=>typeof n=="string"&&!isNaN(Date.parse(n)),n=>`${n} must be a valid ISO 8601 date string`),Ie=p("isJson",n=>{if(typeof n!="string")return!1;try{return JSON.parse(n),!0}catch{return!1}},n=>`${n} must be a JSON string`),Ae=p("isUrl",n=>{try{return new URL(n),!0}catch{return!1}},n=>`${n} must be a valid URL`);function Ne(n,e){let t=n.flags.includes("g")||n.flags.includes("y")?new RegExp(n.source,n.flags.replace(/[gy]/g,"")):n;return d(o=>({name:"matches",validate:r=>typeof r=="string"&&t.test(r),message:`${o} must match ${n} regular expression`,constraints:[n]}),e)}var Me="00000000-0000-0000-0000-000000000000",Pe="ffffffff-ffff-ffff-ffff-ffffffffffff";function Re(n,e){let t=n?new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${n}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,"i"):/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return d(o=>({name:"isUuid",validate:r=>typeof r!="string"?!1:!n&&(r.toLowerCase()===Me||r.toLowerCase()===Pe)?!0:t.test(r),message:`${o} must be a valid UUID${n?` (version ${n})`:""}`,...n?{constraints:[n]}:{}}),e)}var sn=/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/,ln=n=>{try{return new URL(`http://[${n}]`).hostname===`[${n.toLowerCase()}]`||/^[0-9a-f:.]+$/i.test(n)&&n.includes(":")}catch{return!1}};function ve(n,e){return d(t=>({name:"isIp",validate:o=>typeof o!="string"?!1:n===4?sn.test(o):n===6?ln(o):sn.test(o)||ln(o),message:`${t} must be a valid IP${n?`v${n}`:""} address`,...n?{constraints:[n]}:{}}),e)}function z(n,e,t){function o(r,a){return d(i=>({name:n,validate:s=>typeof s=="string"&&e(s,r),message:t(i,r),constraints:[r]}),a)}return o}var Fe=z("contains",(n,e)=>n.includes(e),(n,e)=>`${n} must contain ${JSON.stringify(e)}`),Je=z("notContains",(n,e)=>!n.includes(e),(n,e)=>`${n} must not contain ${JSON.stringify(e)}`),ze=z("startsWith",(n,e)=>n.startsWith(e),(n,e)=>`${n} must start with ${JSON.stringify(e)}`),Ue=z("endsWith",(n,e)=>n.endsWith(e),(n,e)=>`${n} must end with ${JSON.stringify(e)}`);function Be(n,e){return d(t=>({name:"equals",validate:o=>o===n,message:`${t} must be equal to ${JSON.stringify(n)}`,constraints:[n]}),e)}function je(n,e){return d(t=>({name:"notEquals",validate:o=>o!==n,message:`${t} must not be equal to ${JSON.stringify(n)}`,constraints:[n]}),e)}function _e(n,e){return d(t=>({name:"isIn",validate:o=>n.includes(o),message:`${t} must be one of the following values: ${n.join(", ")}`,constraints:[n]}),e)}function Ke(n,e){return d(t=>({name:"isNotIn",validate:o=>!n.includes(o),message:`${t} must not be one of the following values: ${n.join(", ")}`,constraints:[n]}),e)}function Le(n,e){let t=Object.keys(n).filter(o=>typeof n[n[o]]!="number").map(o=>n[o]);return d(o=>({name:"isEnum",validate:r=>t.includes(r),message:`${o} must be one of the following values: ${t.join(", ")}`,constraints:[t]}),e)}function qe(n,e){return d(t=>({name:"isInstance",validate:o=>o instanceof n,message:`${t} must be an instance of ${n.name}`,constraints:[n]}),e)}function $(n,e,t,o){return r=>d(a=>({name:n,validate:i=>Array.isArray(i)&&e(i),message:t(a),...o?{constraints:o}:{}}),r)}var We=n=>d(e=>({name:"isArray",validate:t=>Array.isArray(t),message:`${e} must be an array`}),n),Ze=n=>$("arrayNotEmpty",e=>e.length>0,e=>`${e} should not be empty`)(n),Ge=(n,e)=>$("arrayMinSize",t=>t.length>=n,t=>`${t} must contain at least ${n} elements`,[n])(e),He=(n,e)=>$("arrayMaxSize",t=>t.length<=n,t=>`${t} must contain at most ${n} elements`,[n])(e);function Xe(n,e){return $("arrayUnique",t=>{let o=n?t.map(n):t;return new Set(o).size===o.length},t=>`${t} must not contain duplicate values`)(e)}function Ye(n,e){return $("arrayContains",t=>n.every(o=>t.includes(o)),t=>`${t} must contain the following values: ${n.join(", ")}`,[n])(e)}function Qe(n,e){return $("arrayNotContains",t=>n.every(o=>!t.includes(o)),t=>`${t} must not contain any of the following values: ${n.join(", ")}`,[n])(e)}var J=n=>typeof n=="function"?n():n;function nt(n,e){return d(()=>({name:"minDate",validate:t=>t instanceof Date&&!isNaN(t.getTime())&&t.getTime()>=J(n).getTime(),message:t=>`${t.property} must not be earlier than ${J(n).toISOString()}`,constraints:[n]}),e)}function et(n,e){return d(()=>({name:"maxDate",validate:t=>t instanceof Date&&!isNaN(t.getTime())&&t.getTime()<=J(n).getTime(),message:t=>`${t.property} must not be later than ${J(n).toISOString()}`,constraints:[n]}),e)}function tt(n,e,t){let o=Array.isArray(e)?e:[],r=Array.isArray(e)?t:e;if(typeof n=="function"&&!n.prototype?.validate){let s=n;return d(()=>({name:"custom",validate:(l,u)=>s(l,u),message:l=>`${l.property} is invalid`,constraints:o}),r)}let i=new n;return d(()=>({name:n.name,validate:(s,l)=>i.validate(s,l),message:s=>i.defaultMessage?i.defaultMessage(s):`${s.property} is invalid`,constraints:o}),r)}function Z(n){let e={},t=(o,r)=>{for(let a of o){let i=a.property.startsWith("[")?`${r}${a.property}`:r?`${r}.${a.property}`:a.property,s=Object.values(a.constraints);s.length>0&&(e[i]??=[]).push(...s),a.children?.length&&t(a.children,i)}};return t(n,""),e}function ot(n){let e=Z(n);return Object.entries(e).flatMap(([t,o])=>o.map(r=>`${t}: ${r}`)).join(` +`)}function rt(n){return Object.values(Z(n)).flat()}var C=class extends Error{constructor(t,o){super(t);this.errors=o;this.name="JsonValidationError"}toString(){return`${this.message}: ${JSON.stringify(this.errors,null,2)}`}},b=class extends Error{constructor(e){super(e),this.name="JsonMappingError"}},at=new Set(["__proto__","constructor","prototype"]),yn="[redacted]";function gn(n,e){return n[e]?.access??"readwrite"}function hn(n,e,t){return n[e]?.name??t(e)}var un=new WeakMap;function it(n,e,t){let o=un.get(n);(!o||o.version!==T())&&(o={version:T(),byStrategy:new Map},un.set(n,o));let r=o.byStrategy.get(t.namingKey);r||(r=new Map,o.byStrategy.set(t.namingKey,r));let a=r.get(e);if(!a){let i=gn(n,e),s=n[e]?.serializer;a={name:hn(n,e,t.naming),skip:i==="none"||i==="writeonly",...s?{serializer:s}:{}},r.set(e,a)}return a}var cn=new WeakMap;function st(n,e){let t=cn.get(n);(!t||t.version!==T())&&(t={version:T(),byStrategy:new Map},cn.set(n,t));let o=t.byStrategy.get(e.namingKey);if(o)return o;let r=new Map,a=new Set,i=new Map,s=(u,c)=>{let y=r.get(u);if(y&&y!==c)throw new b(`Properties "${y}" and "${c}" both map to the JSON name ${JSON.stringify(u)}. Give one of them a distinct @JsonProperty name.`);r.set(u,c)};for(let[u,c]of Object.entries(n)){let y=[hn(n,u,e.naming),...c.aliases??[]],m=gn(n,u);if(m==="none"||m==="readonly"){for(let f of y)a.add(f);continue}for(let f of y)s(f,u);(c.deserializer||c.polymorphic||c.type)&&i.set(u,{...c.deserializer?{deserializer:c.deserializer}:{},...c.polymorphic?{polymorphic:c.polymorphic}:{},...c.type?{typeFn:c.type}:{}})}let l={accept:r,blocked:a,props:i};return t.byStrategy.set(e.namingKey,l),l}function N(n){return n!==null&&typeof n=="object"&&typeof n.then=="function"}async function H(n){for(;n.length>0;){let e=n.splice(0,n.length);await Promise.all(e)}}function X(n,e,t){if(n.length!==0){for(let o of n)o.catch(()=>{});throw n.length=0,new b(`${e} requires every serializer, deserializer and validator to be synchronous, but one returned a Promise. Use ${t} instead, or make the hook synchronous.`)}}function U(n,e,t,o,r){if(n==null||typeof n!="object")return n;if(o>t.maxDepth)throw new b(`Maximum nesting depth of ${t.maxDepth} exceeded while serializing. Raise it with the maxDepth option if this structure is legitimate.`);if(n instanceof Date)return n.toISOString();if(e.has(n))throw new b("Circular reference detected during serialization. Break the cycle with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for that property.");e.add(n);try{if(Array.isArray(n)){let s=[];for(let l of n)s.push(U(l,e,t,o+1,r));return s}let a=v(n),i={};for(let s of Object.keys(n)){let l=it(a,s,t);if(l.skip)continue;let u=n[s];if(l.serializer&&u!==null&&u!==void 0){let c=bn(l.serializer).serialize(u);if(N(c)){let y=l.name;i[y]=void 0,r.push(c.then(m=>{i[y]=m}))}else i[l.name]=c}else i[l.name]=U(u,e,t,o+1,r)}return i}finally{e.delete(n)}}function k(n,e,t,o,r){if(e==null)return e;if(o>t.maxDepth)throw new b(`Maximum nesting depth of ${t.maxDepth} exceeded while deserializing. Raise it with the maxDepth option if this structure is legitimate.`);if(Array.isArray(e))return e.map(s=>k(n,s,t,o+1,r));if(typeof e!="object")return e;let a=new n,i=st(R(n),t);for(let s of Object.keys(e)){if(at.has(s)||i.blocked.has(s))continue;let l=i.accept.get(s);if(l===void 0){if(t.unknownKeys==="strip")continue;if(t.unknownKeys==="error")throw new b(`Unknown property ${JSON.stringify(s)} for ${n.name}. Allowed: ${[...i.accept.keys()].map(f=>JSON.stringify(f)).join(", ")||"(none declared)"}.`);a[s]=e[s];continue}let u=e[s],c=i.props.get(l);if(c?.deserializer){let f=bn(c.deserializer).deserialize(u);if(N(f)){let g=l;a[g]=void 0,r.push(f.then(D=>{a[g]=D}))}else a[l]=f;continue}let y=c?.polymorphic;if(y&&u!==null&&u!==void 0){let{discriminator:f,subTypes:g,onUnknown:D,fallback:V}=y,en=w=>{if(w==null||typeof w!="object")return w;let tn=g.find(K=>w[f]===K.name);if(tn)return k(tn.value,w,t,o+1,r);if(V)return k(V,w,t,o+1,r);if(D==="error")throw new b(`Unknown discriminator value ${JSON.stringify(w[f])} for property "${l}". Known values: ${g.map(K=>JSON.stringify(K.name)).join(", ")}.`);return w};a[l]=Array.isArray(u)?u.map(en):en(u);continue}let m=c?.typeFn;if(m&&u!==null&&u!==void 0){let f=m();a[l]=k(f,u,t,o+1,r);continue}a[l]=u}return a}var dn=new WeakMap;function lt(n){let e=[],t=new Set;for(let o of n){if(typeof o.message=="string"){let r=`${o.name}|${String(o.constraints)}|${o.message}|${o.each??!1}`;if(t.has(r))continue;t.add(r)}e.push(o)}return e}function ut(n){let e=dn.get(n);if(e&&e.version===T())return e.plan;let t=[];for(let[o,r]of Object.entries(n)){let a=r.access??"readwrite";t.push({key:o,constraints:lt(r.constraints),isOptional:!!r.optional,isNested:!!r.nested,redact:a==="writeonly"||a==="none",...r.condition?{condition:r.condition}:{}})}return dn.set(n,{version:T(),plan:t}),t}var pn=new WeakMap;function bn(n){let e=pn.get(n);return e||(e=new n,pn.set(n,e)),e}function fn(n,e,t){if(!(e in n)){n[e]=t;return}let o=2;for(;`${e}_${o}`in n;)o++;n[`${e}_${o}`]=t}function mn(n,e,t){let o=typeof n.message=="function"?n.message(e):n.message;return n.each&&!n.hasCustomMessage&&(o=t>=0?`each element in ${o} (failed at index ${t})`:`each element in ${o}`),o}function ct(n,e,t){for(let o=0;o0?t.children=o:delete t.children,(Object.keys(t.constraints).length>0||t.children)&&e.push(t)}return e}function B(n,e,t,o,r){let a=[];if(n==null||typeof n!="object")return a;if(t>o)throw new b(`Maximum nesting depth of ${o} exceeded while validating. Raise it with the maxDepth option if this structure is legitimate.`);if(e.has(n))return a;e.add(n);try{if(Array.isArray(n)){for(let i=0;i0&&a.push({property:`[${i}]`,value:n[i],constraints:{},children:s})}return a}for(let i of ut(v(n))){let s=i.key,l=n[s];if(i.condition&&!i.condition(n)||i.isOptional&&l==null)continue;let u={property:s,value:i.redact?yn:l,constraints:{}},c={value:l,object:n,property:s,constraints:[]},y=!1;for(let m of i.constraints){c.constraints=m.constraints||[];let f=m.each&&Array.isArray(l)?ct(m,l,c):(()=>{let g=m.validate(l,c);return N(g)?g.then(D=>({ok:D,index:-1})):{ok:g,index:-1}})();if(N(f)){y=!0;let g={value:l,object:n,property:s,constraints:m.constraints||[]};r.push(f.then(({ok:D,index:V})=>{D||(V>=0&&(g.value=l[V]),fn(u.constraints,m.name,mn(m,g,V)))}));continue}if(!f.ok){f.index>=0&&(c.value=l[f.index]);let g=mn(m,c,f.index);c.value=l,fn(u.constraints,m.name,g)}}if(i.isNested&&l!==null&&l!==void 0){let m=B(l,e,t+1,o,r);m.length>0&&(u.children=m)}(y||Object.keys(u.constraints).length>0||u.children)&&a.push(u)}return a}finally{e.delete(n)}}function wn(n){let e=x(n);return{naming:F(e.namingStrategy),namingKey:e.namingStrategy,maxDepth:e.maxDepth}}function On(n){let e=x(n);return{naming:F(e.namingStrategy),namingKey:e.namingStrategy,unknownKeys:e.unknownKeys,maxDepth:e.maxDepth}}async function M(n,e){let t=[],o=B(n,new Set,0,x(e).maxDepth,t);return t.length===0?o:(await H(t),xn(o))}function j(n,e){let t=[],o=B(n,new Set,0,x(e).maxDepth,t);return X(t,"validateSync()","validate()"),o}async function Cn(n,e){let t=await M(n,e);if(t.length>0)throw new C("Validation failed",t)}function pt(n,e){let t=j(n,e);if(t.length>0)throw new C("Validation failed",t)}async function Y(n,e){if(n==null)return n;if(x(e).validate){let r=await M(n,e);if(r.length>0)throw new C("Validation failed during serialization",r)}let t=[],o=U(n,new Set,wn(e),0,t);return await H(t),o}function Tn(n,e){if(n==null)return n;if(x(e).validate){let r=j(n,e);if(r.length>0)throw new C("Validation failed during serialization",r)}let t=[],o=U(n,new Set,wn(e),0,t);return X(t,"toPlainSync()","toPlain()"),o}async function Dn(n,e){return JSON.stringify(await Y(n,e))}function ft(n,e){return JSON.stringify(Tn(n,e))}async function P(n,e,t){let o=[],r=k(n,e,On(t),0,o);if(await H(o),x(t).validate){let a=await M(r,t);if(a.length>0)throw new C("Validation failed during deserialization",a)}return r}function Q(n,e,t){let o=[],r=k(n,e,On(t),0,o);if(X(o,"toInstanceSync()","toInstance()"),x(t).validate){let a=j(r,t);if(a.length>0)throw new C("Validation failed during deserialization",a)}return r}function Vn(n,e){if(!Array.isArray(e))throw new b(`Expected an array to map to ${n.name}[], received ${typeof e}.`)}async function nn(n,e,t){return Vn(n,e),await P(n,e,t)}function $n(n,e,t){return Vn(n,e),Q(n,e,t)}async function kn(n,e,t){return P(n,_(e),t)}function mt(n,e,t){return Q(n,_(e),t)}async function Sn(n,e,t){return nn(n,_(e),t)}function yt(n,e,t){return $n(n,_(e),t)}function _(n){try{return JSON.parse(n)}catch(e){throw new b(`Input is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}}async function En(n,e,t){let o;try{o=await e.json()}catch(r){throw new b(`Request body is not valid JSON: ${r instanceof Error?r.message:String(r)}`)}return P(n,o,t)}var G=class{static toPlain=Y;static toJson=Dn;static toInstance=P;static toInstanceArray=nn;static fromJson=kn;static fromJsonArray=Sn;static fromRequest=En;static validate=M;static validateOrReject=Cn};return Rn(gt);})(); diff --git a/package.json b/package.json index 59010f3..54e4ee3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cereale", - "version": "0.1.0", - "description": "Spring-like decorators for JSON mapping and validation in TypeScript", + "version": "2.0.0", + "description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data", "type": "module", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -39,11 +39,13 @@ }, "keywords": [ "json", - "mapping", "validation", "decorators", - "spring", - "typescript" + "typescript", + "zod-alternative", + "dto", + "serialization", + "class-validator" ], "author": "Avalon Vanguard", "license": "MIT", diff --git a/src/decorators.test.ts b/src/decorators.test.ts index 414abd6..848666b 100644 --- a/src/decorators.test.ts +++ b/src/decorators.test.ts @@ -13,7 +13,7 @@ import { ArrayMaxSize, IsNotIn, Validate, - registerDecorator, + defineRule, JsonType, JsonPolymorphic, JsonMapper, @@ -244,21 +244,14 @@ describe('Additional Decorators', () => { describe('registerDecorator', () => { it('should register a custom decorator with functional validator', async () => { - function IsEven() { - return function (object: any, propertyName: string) { - registerDecorator({ - name: 'isEven', - target: object.constructor, - propertyName: propertyName, - validator: (value: any) => typeof value === 'number' && value % 2 === 0, - }); - }; - } - class Test { - @IsEven() - val: number; + val: number = 0; } + defineRule(Test, 'val', { + name: 'isEven', + validate: (value: any) => typeof value === 'number' && value % 2 === 0, + message: 'val must be even', + }); const t = new Test(); t.val = 2; @@ -271,19 +264,15 @@ describe('Additional Decorators', () => { class MyValidator implements ValidatorConstraintInterface { validate(v: any) { return v === 'ok'; } } - function IsOk() { - return function (object: any, propertyName: string) { - registerDecorator({ - name: 'isOk', - target: object.constructor, - propertyName: propertyName, - validator: MyValidator, - }); - }; - } class Test { - @IsOk() val: string; + val: string = ''; } + const validator = new MyValidator(); + defineRule(Test, 'val', { + name: 'isOk', + validate: (v: any) => validator.validate(v), + message: 'val must be ok', + }); const t = new Test(); t.val = 'ok'; expect(await JsonMapper.validate(t)).toHaveLength(0); diff --git a/src/decorators.ts b/src/decorators.ts index 74a841c..11b5600 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -1,1146 +1,652 @@ -import { JsonSerializer, JsonDeserializer, ClassConstructor } from './interfaces.js'; -import { metadataStorage } from './metadata-storage.js'; +import type { + ClassConstructor, FieldDecorator, JsonDeserializer, JsonSerializer, +} from './interfaces.js'; +import { + addConstraint, propertyModel, + type EachValidationOptions, type PolymorphicInfo, type ValidationArguments, + type ValidationConstraint, type ValidationOptions, type ValidatorConstraintInterface, +} from './metadata.js'; -export const METADATA_KEYS = { - PROPERTIES: 'cereale:properties', - TYPE: 'cereale:type', - VALIDATION: 'cereale:validation', - SERIALIZER: 'cereale:serializer', - DESERIALIZER: 'cereale:deserializer', - POLYMORPHIC: 'cereale:polymorphic', - IS_OPTIONAL: 'cereale:optional', - NESTED: 'cereale:nested', - NAME: 'cereale:name', - ALIASES: 'cereale:aliases', - ACCESS: 'cereale:access', - CONDITION: 'cereale:condition', -}; +export type { + ValidationArguments, ValidationOptions, EachValidationOptions, + ValidationConstraint, ValidatorConstraintInterface, +} from './metadata.js'; +export type { PropertyAccess, PropertyModel, ClassModel } from './metadata.js'; +export { defineRule } from './metadata.js'; + +/** A rule applied to the field itself. */ +type One = FieldDecorator; +/** The same rule under `{ each: true }`, which moves it onto the elements of an array. */ +type Each = FieldDecorator; /** - * Which directions a property participates in. - * - * - `readwrite` (default): mapped both ways. - * - `readonly`: written to JSON, never populated from incoming JSON (server-assigned ids). - * - `writeonly`: populated from incoming JSON, never written back out (passwords). - * - `none`: ignored entirely. + * A rule that can be applied either directly to a field or, with `{ each: true }`, to the + * elements of an array field. The two overloads are what make `@IsString({ each: true })` + * demand a `string[]` while a bare `@IsString()` demands a `string`. */ -export type PropertyAccess = 'readwrite' | 'readonly' | 'writeonly' | 'none'; - -export interface ValidationArguments { - value: any; - object: any; - property: string; - constraints: any[]; +interface Rule { + (options: EachValidationOptions): Each; + (options?: ValidationOptions): One; } -export interface ValidationOptions { - each?: boolean; - message?: string | ((args: ValidationArguments) => string); +function decorate( + build: (property: string) => ValidationConstraint, + options?: ValidationOptions +): FieldDecorator { + return ((_target: undefined, context: ClassFieldDecoratorContext) => { + const property = String(context.name); + addConstraint(context.metadata, property, build(property), options); + }) as FieldDecorator; } -export type ValidationConstraint = { - name: string; - validate: (value: any, args: ValidationArguments) => boolean | Promise; - message: string | ((args: ValidationArguments) => string); - constraints?: any[]; - each?: boolean; - /** - * True when the message came from the user via `ValidationOptions.message`. - * The engine only decorates default messages with the "each element in ..." prefix; - * a message the user wrote is reported exactly as written. - */ - hasCustomMessage?: boolean; -}; - -export interface ValidatorConstraintInterface { - validate(value: any, args: ValidationArguments): boolean | Promise; - defaultMessage?(args: ValidationArguments): string; +/** Declares a rule that takes no arguments of its own. */ +function rule( + name: string, + check: (value: any, args: ValidationArguments) => boolean | Promise, + message: (property: string) => string +): Rule { + return ((options?: ValidationOptions) => + decorate(property => ({ name, validate: check, message: message(property) }), options)) as Rule; } +function pattern(name: string, regex: RegExp, message: (property: string) => string): Rule { + return rule(name, v => typeof v === 'string' && regex.test(v), message); +} + +// ============================================================================ +// Mapping +// ============================================================================ + /** - * 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; - constraint.hasCustomMessage = true; - } - } - - const constraints: ValidationConstraint[] = metadataStorage.getOwnMetadata(METADATA_KEYS.VALIDATION, target, propertyKey) || []; - constraints.push(constraint); - metadataStorage.defineMetadata(METADATA_KEYS.VALIDATION, constraints, target, propertyKey); -} - -// --- Mapping Decorators --- - -/** - * @JsonProperty(name: string) - * Maps this property to a different name in JSON, in both directions. + * Maps this field to a different name in JSON, in both directions. * * ```ts * class User { * @JsonProperty('first_name') - * firstName: string; // <-> {"first_name": "Ada"} + * firstName!: string; // <-> {"first_name": "Ada"} * } * ``` * - * An explicit name always wins over the active naming strategy. + * An explicit name always wins over the active naming strategy. Note that renaming stops the + * original name from being accepted on input — add `@JsonAlias` to keep older clients working. */ -export function JsonProperty(name: string) { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.NAME, name, target, propertyKey); - }; +export function JsonProperty(name: string): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)).name = name; + }) as FieldDecorator; } /** - * @JsonAlias(...names: string[]) - * Additional names accepted for this property when reading JSON. + * Additional names accepted for this field when reading JSON. * - * Aliases are input-only — output always uses the canonical name — which makes them the - * tool for accepting a renamed field from older clients without emitting it. - * - * ```ts - * class User { - * @JsonProperty('surname') - * @JsonAlias('last_name', 'lastName') - * surname: string; - * } - * ``` + * Aliases are input-only — output always uses the canonical name — which makes them the tool + * for accepting a renamed field from older clients without emitting it. */ -export function JsonAlias(...names: string[]) { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - const existing: string[] = metadataStorage.getOwnMetadata(METADATA_KEYS.ALIASES, target, propertyKey) || []; - metadataStorage.defineMetadata(METADATA_KEYS.ALIASES, [...existing, ...names], target, propertyKey); - }; +export function JsonAlias(...names: string[]): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + const model = propertyModel(context.metadata, String(context.name)); + model.aliases = [...(model.aliases ?? []), ...names]; + }) as FieldDecorator; } -/** - * @JsonIgnore() - * Excludes this property from mapping in both directions. - */ -export function JsonIgnore() { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'none', target, propertyKey); - }; +function access(value: 'none' | 'readonly' | 'writeonly'): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)).access = value; + }) as FieldDecorator; } +/** Excludes this field from mapping in both directions. */ +export const JsonIgnore = (): FieldDecorator => access('none'); + /** - * @JsonReadOnly() * Serialized to JSON, but never populated from incoming JSON. * * For server-owned fields — ids, timestamps — that a client must not be able to set. */ -export function JsonReadOnly() { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'readonly', target, propertyKey); - }; -} +export const JsonReadOnly = (): FieldDecorator => access('readonly'); /** - * @JsonWriteOnly() * Populated from incoming JSON, but never serialized back out. * - * For secrets — passwords, tokens — that you accept but must never echo. + * For secrets — passwords, tokens — that you accept but must never echo. Their values are + * also withheld from validation errors. */ -export function JsonWriteOnly() { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'writeonly', target, propertyKey); - }; +export const JsonWriteOnly = (): FieldDecorator => access('writeonly'); + +/** + * Custom serializer for this field. + * + * The serializer's input type must match the field: a `JsonSerializer` can only + * be attached to a `Date` field. Skipped when the value is `null`/`undefined`. + */ +export function JsonSerialize( + serializer: ClassConstructor> +): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)).serializer = serializer; + }) as FieldDecorator; } /** - * @JsonSerialize(serializer: ClassConstructor) - * Custom serializer decorator. + * Custom deserializer for this field. + * + * The deserializer's output type must match the field: a `JsonDeserializer` can + * only be attached to a `Date` field. */ -export function JsonSerialize(serializer: ClassConstructor) { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.SERIALIZER, serializer, target, propertyKey); - }; +export function JsonDeserialize( + deserializer: ClassConstructor> +): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)).deserializer = deserializer; + }) as FieldDecorator; } /** - * @JsonDeserialize(deserializer: ClassConstructor) - * Custom deserializer decorator. + * Declares the class a nested field maps to, so the mapper produces real instances. + * + * The referenced class must match the field's declared type — `@JsonType(() => Money)` on an + * `Address` field is a compile error. Applies element-wise to arrays. */ -export function JsonDeserialize(deserializer: ClassConstructor) { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.DESERIALIZER, deserializer, target, propertyKey); - }; +export function JsonType( + typeFunction: () => ClassConstructor +): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)).type = typeFunction; + }) as FieldDecorator; } -/** - * @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); - }; -} - -export interface PolymorphicOptions { +export interface PolymorphicOptions { /** * What to do when the discriminator value matches no registered subtype. * - `keep` (default): pass the raw value through untouched. - * - `error`: throw a {@link JsonMappingError} naming the unknown discriminator value. + * - `error`: throw a `JsonMappingError` naming the unknown discriminator value. */ onUnknown?: 'keep' | 'error'; /** Subtype to use when the discriminator matches nothing. Takes precedence over `onUnknown`. */ - fallback?: ClassConstructor; + fallback?: ClassConstructor; } /** - * @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor, name: string }[], options?: PolymorphicOptions) - * Defines polymorphic behavior for a property. + * Selects the concrete class for a field from a discriminator property in the JSON. + * + * Name the base type explicitly to have the subtypes checked against it: + * + * ```ts + * @JsonPolymorphic('type', [ + * { value: Book, name: 'book' }, + * { value: Movie, name: 'movie' }, + * ]) + * items!: Media[]; + * ``` + * + * `NoInfer` keeps `Base` from being inferred from the first subtype — otherwise a list of + * `[Book, Movie]` would fix `Base` to `Book` and then reject `Movie`. */ -export function JsonPolymorphic( +export function JsonPolymorphic( discriminator: string, - subTypes: { value: ClassConstructor, name: string }[], - options?: PolymorphicOptions -) { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata( - METADATA_KEYS.POLYMORPHIC, - { discriminator, subTypes, onUnknown: options?.onUnknown ?? 'keep', fallback: options?.fallback }, - target, - propertyKey - ); - }; + subTypes: { value: ClassConstructor>; name: string }[], + options?: PolymorphicOptions> +): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + const info: PolymorphicInfo = { + discriminator, + subTypes: subTypes as PolymorphicInfo['subTypes'], + onUnknown: options?.onUnknown ?? 'keep', + ...(options?.fallback ? { fallback: options.fallback as ClassConstructor } : {}), + }; + propertyModel(context.metadata, String(context.name)).polymorphic = info; + }) as FieldDecorator; } -// --- Validation Decorators --- +// ============================================================================ +// Control flow +// ============================================================================ -/** - * @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); - }; +/** Skips every other rule on this field when the value is `null` or `undefined`. */ +export function IsOptional(): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)).optional = true; + }) as FieldDecorator; } /** - * @IsString() + * Skips every rule on this field when the condition returns false. + * + * ```ts + * class Payment { + * @IsIn(['card', 'invoice']) method!: string; + * @ValidateIf(p => p.method === 'card') @IsString() cardNumber?: string; + * } + * ``` */ -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); - }; +export function ValidateIf(condition: (object: This) => boolean): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)).condition = condition as (o: any) => boolean; + }) as FieldDecorator; } /** - * @IsBoolean() + * Declares a field with no rules of its own. + * + * Useful with `unknownKeys: 'strip'` or `'error'`, where a field has to be declared to survive + * the payload even though nothing about its value needs checking. */ -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); - }; +export function Allow(): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + propertyModel(context.metadata, String(context.name)); + }) as FieldDecorator; } /** - * @IsNumber() + * Recursively validates the value of this field. + * + * `{ each: true }` additionally asserts that the value really is an array. */ -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); - }; +export function ValidateNested(options?: ValidationOptions): FieldDecorator { + return ((_t: undefined, context: ClassFieldDecoratorContext) => { + const property = String(context.name); + propertyModel(context.metadata, property).nested = true; + if (options?.each) { + addConstraint(context.metadata, property, { + name: 'nestedEach', + validate: v => Array.isArray(v), + message: `${property} must be an array`, + }); + } + }) as FieldDecorator; } -/** - * @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); - }; +// ============================================================================ +// Type rules +// ============================================================================ + +export const IsString: Rule = rule('isString', v => typeof v === 'string', p => `${p} must be a string`); +export const IsNumber: Rule = rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`); +export const IsInt: Rule = rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`); +export const IsBoolean: Rule = rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`); +export const IsBigInt: Rule = rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`); +export const IsDate: Rule = rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`); +export const IsObject: Rule = rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`); + +export const IsDefined: Rule = rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`); +export const IsNotEmpty: Rule = rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`); +export const IsEmpty: Rule = rule('isEmpty', v => { + if (v === null || v === undefined || v === '') return true; + if (Array.isArray(v)) return v.length === 0; + if (typeof v === 'object') return Object.keys(v).length === 0; + return false; +}, p => `${p} must be empty`); + +// ============================================================================ +// Numbers +// ============================================================================ + +export function Min(min: number, options: EachValidationOptions): Each; +export function Min(min: number, options?: ValidationOptions): One; +export function Min(min: number, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'min', validate: v => typeof v === 'number' && v >= min, + message: `${p} must be at least ${min}`, constraints: [min], + }), 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); - }; +export function Max(max: number, options: EachValidationOptions): Each; +export function Max(max: number, options?: ValidationOptions): One; +export function Max(max: number, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'max', validate: v => typeof v === 'number' && v <= max, + message: `${p} must be at most ${max}`, constraints: [max], + }), 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); - }; +export const Positive: Rule = rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`); +export const Negative: Rule = rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`); + +export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each; +export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One; +export function IsDivisibleBy(divisor: number, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'isDivisibleBy', + validate: v => typeof v === 'number' && Number.isFinite(v) && divisor !== 0 && v % divisor === 0, + message: `${p} must be divisible by ${divisor}`, constraints: [divisor], + }), 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); - }; +/** An integer in 0..65535. Accepts a number or a numeric string. */ +export const IsPort: Rule = rule('isPort', v => { + const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v; + return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535; +}, p => `${p} must be a valid port number`); + +export const IsLatitude: Rule = rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`); +export const IsLongitude: Rule = rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`); + +// ============================================================================ +// Strings +// ============================================================================ + +export function MinLength(min: number, options: EachValidationOptions): Each; +export function MinLength(min: number, options?: ValidationOptions): One; +export function MinLength(min: number, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'minLength', validate: v => typeof v === 'string' && v.length >= min, + message: `${p} must be longer than or equal to ${min} characters`, constraints: [min], + }), 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); - }; +export function MaxLength(max: number, options: EachValidationOptions): Each; +export function MaxLength(max: number, options?: ValidationOptions): One; +export function MaxLength(max: number, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'maxLength', validate: v => typeof v === 'string' && v.length <= max, + message: `${p} must be shorter than or equal to ${max} characters`, constraints: [max], + }), 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); - }; +export function Length(min: number, max?: number, options?: ValidationOptions): One; +export function Length(min: number, max: number | undefined, options: EachValidationOptions): Each; +export function Length(min: number, max?: number, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'length', + validate: v => typeof v === 'string' && v.length >= min && (max === undefined || v.length <= max), + message: max === undefined + ? `${p} must be at least ${min} characters` + : `${p} must be between ${min} and ${max} characters`, + constraints: max === undefined ? [min] : [min, 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); - }; -} +export const Email: Rule = pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`); +export const IsAlpha: Rule = pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`); +export const IsAlphanumeric: Rule = pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`); +export const IsSemVer: Rule = pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`); +export const IsHexColor: Rule = pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`); -/** - * @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); - }; -} +export const IsLowercase: Rule = rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`); +export const IsUppercase: Rule = rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`); +export const IsNumberString: Rule = rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`); +export const IsDateString: Rule = rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`); +export const IsJSON: Rule = rule('isJson', v => { + if (typeof v !== 'string') return false; + try { JSON.parse(v); return true; } catch { return false; } +}, p => `${p} must be a JSON string`); -/** - * @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); - }; -} +export const IsUrl: Rule = rule('isUrl', v => { + try { new URL(v as string); return true; } catch { return false; } +}, p => `${p} must be a valid URL`); -/** - * @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) { - // A `g` or `y` flag makes RegExp.prototype.test stateful: it advances lastIndex on a - // match and resumes from there on the next call, so validating the same value twice - // yields different answers. Validation must be a pure predicate, so drop those flags. - const stateless = pattern.flags.includes('g') || pattern.flags.includes('y') - ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '')) - : pattern; - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'matches', - validate: (v) => typeof v === 'string' && stateless.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); - }; -} - -// --- Equality and presence --- - -/** - * @Equals(comparison: any) - */ -export function Equals(comparison: any, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'equals', - validate: (v) => v === comparison, - message: `${propertyKey} must be equal to ${JSON.stringify(comparison)}`, - constraints: [comparison] - }, options); - }; -} - -/** - * @NotEquals(comparison: any) - */ -export function NotEquals(comparison: any, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'notEquals', - validate: (v) => v !== comparison, - message: `${propertyKey} must not be equal to ${JSON.stringify(comparison)}`, - constraints: [comparison] - }, options); - }; -} - -/** - * @IsEmpty() - * Passes for null, undefined, '', [] and {} — the mirror of `@IsNotEmpty`. - */ -export function IsEmpty(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isEmpty', - validate: (v) => { - if (v === null || v === undefined || v === '') return true; - if (Array.isArray(v)) return v.length === 0; - if (typeof v === 'object') return Object.keys(v).length === 0; - return false; - }, - message: `${propertyKey} must be empty` - }, options); - }; -} - -/** - * @IsEnum(entity: object) - * Checks the value is a member of a TypeScript enum (string or numeric). - */ -export function IsEnum(entity: Record, options?: ValidationOptions) { - // A numeric enum compiles to a two-way map ({ A: 0, '0': 'A' }), so the reverse-mapped - // names have to be filtered out or 'A' would validate as a legal value. - const values = Object.keys(entity) - .filter(key => typeof entity[entity[key]] !== 'number') - .map(key => entity[key]); - - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isEnum', - validate: (v) => values.includes(v), - message: `${propertyKey} must be one of the following values: ${values.join(', ')}`, - constraints: [values] - }, options); - }; -} - -/** - * @IsInstance(target: ClassConstructor) - */ -export function IsInstance(clazz: ClassConstructor, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isInstance', - validate: (v) => v instanceof clazz, - message: `${propertyKey} must be an instance of ${clazz.name}`, - constraints: [clazz] - }, options); - }; -} - -// --- Strings --- - -/** - * @Length(min: number, max?: number) - */ -export function Length(min: number, max?: number, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'length', - validate: (v) => typeof v === 'string' && v.length >= min && (max === undefined || v.length <= max), - message: max === undefined - ? `${propertyKey} must be at least ${min} characters` - : `${propertyKey} must be between ${min} and ${max} characters`, - constraints: max === undefined ? [min] : [min, max] - }, options); - }; -} - -function stringPattern(name: string, regex: RegExp, describe: (property: string) => string) { - return (options?: ValidationOptions) => (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name, - validate: (v) => typeof v === 'string' && regex.test(v), - message: describe(propertyKey) - }, options); - }; -} - -/** @IsAlpha() — letters only. */ -export const IsAlpha = stringPattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`); - -/** @IsAlphanumeric() — letters and digits only. */ -export const IsAlphanumeric = stringPattern( - 'isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers` -); - -/** @IsNumberString() — a string that parses as a finite number. */ -export function IsNumberString(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isNumberString', - validate: (v) => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), - message: `${propertyKey} must be a number string` - }, options); - }; -} - -/** @IsLowercase() */ -export function IsLowercase(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isLowercase', - validate: (v) => typeof v === 'string' && v === v.toLowerCase(), - message: `${propertyKey} must be lowercase` - }, options); - }; -} - -/** @IsUppercase() */ -export function IsUppercase(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isUppercase', - validate: (v) => typeof v === 'string' && v === v.toUpperCase(), - message: `${propertyKey} must be uppercase` - }, options); - }; -} - -/** @Contains(seed: string) */ -export function Contains(seed: string, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'contains', - validate: (v) => typeof v === 'string' && v.includes(seed), - message: `${propertyKey} must contain ${JSON.stringify(seed)}`, - constraints: [seed] - }, options); - }; -} - -/** @NotContains(seed: string) */ -export function NotContains(seed: string, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'notContains', - validate: (v) => typeof v === 'string' && !v.includes(seed), - message: `${propertyKey} must not contain ${JSON.stringify(seed)}`, - constraints: [seed] - }, options); - }; -} - -/** @StartsWith(prefix: string) */ -export function StartsWith(prefix: string, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'startsWith', - validate: (v) => typeof v === 'string' && v.startsWith(prefix), - message: `${propertyKey} must start with ${JSON.stringify(prefix)}`, - constraints: [prefix] - }, options); - }; -} - -/** @EndsWith(suffix: string) */ -export function EndsWith(suffix: string, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'endsWith', - validate: (v) => typeof v === 'string' && v.endsWith(suffix), - message: `${propertyKey} must end with ${JSON.stringify(suffix)}`, - constraints: [suffix] - }, options); - }; +export function Matches(regex: RegExp, options: EachValidationOptions): Each; +export function Matches(regex: RegExp, options?: ValidationOptions): One; +export function Matches(regex: RegExp, options?: ValidationOptions): unknown { + // A `g` or `y` flag makes RegExp.test stateful: it advances lastIndex on a match and + // resumes from there next time, so validating the same value twice gives different + // answers. Validation must be a pure predicate, so those flags are dropped. + const stateless = regex.flags.includes('g') || regex.flags.includes('y') + ? new RegExp(regex.source, regex.flags.replace(/[gy]/g, '')) + : regex; + return decorate(p => ({ + name: 'matches', validate: v => typeof v === 'string' && stateless.test(v), + message: `${p} must match ${regex} regular expression`, constraints: [regex], + }), options); } const NIL_UUID = '00000000-0000-0000-0000-000000000000'; const MAX_UUID = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; -/** - * @IsUUID(version?: 1|2|3|4|5|6|7|8) - * Without a version, accepts any RFC 9562 UUID plus the nil and max UUIDs. - */ -export function IsUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, options?: ValidationOptions) { - const pattern = version +export function IsUUID(version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | undefined, options: EachValidationOptions): Each; +export function IsUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, options?: ValidationOptions): One; +export function IsUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, options?: ValidationOptions): unknown { + const regex = version ? new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, 'i') : /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isUuid', - validate: (v) => { - if (typeof v !== 'string') return false; - if (!version && (v.toLowerCase() === NIL_UUID || v.toLowerCase() === MAX_UUID)) return true; - return pattern.test(v); - }, - message: `${propertyKey} must be a valid UUID${version ? ` (version ${version})` : ''}`, - ...(version ? { constraints: [version] } : {}) - }, options); - }; + return decorate(p => ({ + name: 'isUuid', + validate: v => { + if (typeof v !== 'string') return false; + if (!version && (v.toLowerCase() === NIL_UUID || v.toLowerCase() === MAX_UUID)) return true; + return regex.test(v); + }, + message: `${p} must be a valid UUID${version ? ` (version ${version})` : ''}`, + ...(version ? { constraints: [version] } : {}), + }), options); } -/** @IsJSON() — a string that JSON.parse accepts. */ -export function IsJSON(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isJson', - validate: (v) => { - if (typeof v !== 'string') return false; - try { - JSON.parse(v); - return true; - } catch { - return false; - } - }, - message: `${propertyKey} must be a JSON string` - }, options); - }; -} - -/** @IsDateString() — an ISO-8601 string that parses to a real date. */ -export function IsDateString(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isDateString', - validate: (v) => typeof v === 'string' && !isNaN(Date.parse(v)), - message: `${propertyKey} must be a valid ISO 8601 date string` - }, options); - }; -} - -/** @IsSemVer() */ -export const IsSemVer = stringPattern( - 'isSemVer', - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, - p => `${p} must be a valid semantic version` -); - -/** @IsHexColor() — #rgb, #rrggbb or #rrggbbaa. */ -export const IsHexColor = stringPattern( - 'isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color` -); - const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const isIPv6 = (value: string): boolean => { + try { + return new URL(`http://[${value}]`).hostname === `[${value.toLowerCase()}]` + || (/^[0-9a-f:.]+$/i.test(value) && value.includes(':')); + } catch { return false; } +}; + +export function IsIP(version: 4 | 6 | undefined, options: EachValidationOptions): Each; +export function IsIP(version?: 4 | 6, options?: ValidationOptions): One; +export function IsIP(version?: 4 | 6, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'isIp', + validate: v => { + if (typeof v !== 'string') return false; + if (version === 4) return IPV4.test(v); + if (version === 6) return isIPv6(v); + return IPV4.test(v) || isIPv6(v); + }, + message: `${p} must be a valid IP${version ? `v${version}` : ''} address`, + ...(version ? { constraints: [version] } : {}), + }), options); +} + +function affix(name: string, test: (value: string, seed: string) => boolean, describe: (p: string, seed: string) => string) { + function decorator(seed: string, options: EachValidationOptions): Each; + function decorator(seed: string, options?: ValidationOptions): One; + function decorator(seed: string, options?: ValidationOptions): unknown { + return decorate(p => ({ + name, validate: v => typeof v === 'string' && test(v, seed), + message: describe(p, seed), constraints: [seed], + }), options); + } + return decorator; +} + +export const Contains = affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`); +export const NotContains = affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`); +export const StartsWith = affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`); +export const EndsWith = affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`); + +// ============================================================================ +// Equality and membership +// ============================================================================ + +export function Equals(comparison: T, options: EachValidationOptions): Each; +export function Equals(comparison: T, options?: ValidationOptions): One; +export function Equals(comparison: T, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'equals', validate: v => v === comparison, + message: `${p} must be equal to ${JSON.stringify(comparison)}`, constraints: [comparison], + }), options); +} + +export function NotEquals(comparison: T, options: EachValidationOptions): Each; +export function NotEquals(comparison: T, options?: ValidationOptions): One; +export function NotEquals(comparison: T, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'notEquals', validate: v => v !== comparison, + message: `${p} must not be equal to ${JSON.stringify(comparison)}`, constraints: [comparison], + }), options); +} /** - * @IsIP(version?: 4 | 6) + * Restricts the field to one of the listed values. + * + * The field's type must accept those values, so `@IsIn(['a', 'b']) role!: number` is a + * compile error. */ -export function IsIP(version?: 4 | 6, options?: ValidationOptions) { - const isV6 = (v: string) => { - // Node's URL parser is the most reliable IPv6 validator available without a dependency. - try { - return new URL(`http://[${v}]`).hostname === `[${v.toLowerCase()}]` || /^[0-9a-f:.]+$/i.test(v) && v.includes(':'); - } catch { - return false; - } - }; - - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isIp', - validate: (v) => { - if (typeof v !== 'string') return false; - if (version === 4) return IPV4.test(v); - if (version === 6) return isV6(v); - return IPV4.test(v) || isV6(v); - }, - message: `${propertyKey} must be a valid IP${version ? `v${version}` : ''} address`, - ...(version ? { constraints: [version] } : {}) - }, options); - }; +export function IsIn(values: T, options: EachValidationOptions): Each; +export function IsIn(values: T, options?: ValidationOptions): One; +export function IsIn(values: T, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'isIn', validate: v => values.includes(v), + message: `${p} must be one of the following values: ${values.join(', ')}`, constraints: [values], + }), options); } -// --- Numbers --- - -/** @IsDivisibleBy(divisor: number) */ -export function IsDivisibleBy(divisor: number, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isDivisibleBy', - validate: (v) => typeof v === 'number' && Number.isFinite(v) && divisor !== 0 && v % divisor === 0, - message: `${propertyKey} must be divisible by ${divisor}`, - constraints: [divisor] - }, options); - }; +export function IsNotIn(values: T, options: EachValidationOptions): Each; +export function IsNotIn(values: T, options?: ValidationOptions): One; +export function IsNotIn(values: T, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'isNotIn', validate: v => !values.includes(v), + message: `${p} must not be one of the following values: ${values.join(', ')}`, constraints: [values], + }), options); } -/** @IsPort() — an integer in 0..65535, as a number or a numeric string. */ -export function IsPort(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isPort', - validate: (v) => { - const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v; - return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535; - }, - message: `${propertyKey} must be a valid port number` - }, options); - }; +/** + * Restricts the field to the members of a TypeScript enum. + * + * The field's type must be the enum, so `@IsEnum(Role) status!: number` is a compile error + * when `Role` is a string enum. + */ +export function IsEnum>(entity: E, options: EachValidationOptions): Each; +export function IsEnum>(entity: E, options?: ValidationOptions): One; +export function IsEnum>(entity: E, options?: ValidationOptions): unknown { + // A numeric enum compiles to a two-way map ({ A: 0, '0': 'A' }), so the reverse-mapped + // names must be filtered out or 'A' would validate as a legal value. + const values = Object.keys(entity) + .filter(key => typeof entity[entity[key] as unknown as string] !== 'number') + .map(key => entity[key]); + return decorate(p => ({ + name: 'isEnum', validate: v => values.includes(v as E[keyof E]), + message: `${p} must be one of the following values: ${values.join(', ')}`, constraints: [values], + }), options); } -/** @IsLatitude() */ -export function IsLatitude(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isLatitude', - validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, - message: `${propertyKey} must be a latitude between -90 and 90` - }, options); - }; +export function IsInstance(clazz: ClassConstructor, options: EachValidationOptions): Each; +export function IsInstance(clazz: ClassConstructor, options?: ValidationOptions): One; +export function IsInstance(clazz: ClassConstructor, options?: ValidationOptions): unknown { + return decorate(p => ({ + name: 'isInstance', validate: v => v instanceof clazz, + message: `${p} must be an instance of ${clazz.name}`, constraints: [clazz], + }), options); } -/** @IsLongitude() */ -export function IsLongitude(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isLongitude', - validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, - message: `${propertyKey} must be a longitude between -180 and 180` - }, options); - }; +// ============================================================================ +// Arrays +// ============================================================================ + +type ArrayRule = FieldDecorator; + +function arrayRule( + name: string, + check: (value: readonly unknown[]) => boolean, + message: (property: string) => string, + constraints?: any[] +): (options?: ValidationOptions) => ArrayRule { + return (options?: ValidationOptions) => decorate(p => ({ + name, validate: v => Array.isArray(v) && check(v), message: message(p), + ...(constraints ? { constraints } : {}), + }), options) as ArrayRule; } -/** @IsBigInt() */ -export function IsBigInt(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'isBigInt', - validate: (v) => typeof v === 'bigint', - message: `${propertyKey} must be a bigint` - }, options); - }; +export const IsArray = (options?: ValidationOptions): ArrayRule => + decorate(p => ({ name: 'isArray', validate: v => Array.isArray(v), message: `${p} must be an array` }), options) as ArrayRule; + +export const ArrayNotEmpty = (options?: ValidationOptions): ArrayRule => + arrayRule('arrayNotEmpty', v => v.length > 0, p => `${p} should not be empty`)(options); + +export const ArrayMinSize = (min: number, options?: ValidationOptions): ArrayRule => + arrayRule('arrayMinSize', v => v.length >= min, p => `${p} must contain at least ${min} elements`, [min])(options); + +export const ArrayMaxSize = (max: number, options?: ValidationOptions): ArrayRule => + arrayRule('arrayMaxSize', v => v.length <= max, p => `${p} must contain at most ${max} elements`, [max])(options); + +/** Pass an extractor to deduplicate objects by a key rather than by reference. */ +export function ArrayUnique( + identifier?: (item: T) => unknown, + options?: ValidationOptions +): FieldDecorator { + return arrayRule('arrayUnique', v => { + const keys = identifier ? (v as readonly T[]).map(identifier) : v; + return new Set(keys).size === keys.length; + }, p => `${p} must not contain duplicate values`)(options) as FieldDecorator; } -// --- Dates --- +export function ArrayContains(values: readonly T[], options?: ValidationOptions): FieldDecorator { + return arrayRule('arrayContains', v => values.every(value => v.includes(value)), + p => `${p} must contain the following values: ${values.join(', ')}`, [values])(options) as FieldDecorator; +} + +export function ArrayNotContains(values: readonly T[], options?: ValidationOptions): FieldDecorator { + return arrayRule('arrayNotContains', v => values.every(value => !v.includes(value)), + p => `${p} must not contain any of the following values: ${values.join(', ')}`, [values])(options) as FieldDecorator; +} + +// ============================================================================ +// Dates +// ============================================================================ type DateBound = Date | (() => Date); - const boundOf = (bound: DateBound): Date => (typeof bound === 'function' ? bound() : bound); /** - * @MinDate(date: Date | (() => Date)) * Accepts a thunk so a moving boundary — "not in the past" — is evaluated per validation * rather than frozen when the class was declared. */ -export function MinDate(min: DateBound, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'minDate', - validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() >= boundOf(min).getTime(), - message: (args) => `${args.property} must not be earlier than ${boundOf(min).toISOString()}`, - constraints: [min] - }, options); - }; +export function MinDate(min: DateBound, options: EachValidationOptions): Each; +export function MinDate(min: DateBound, options?: ValidationOptions): One; +export function MinDate(min: DateBound, options?: ValidationOptions): unknown { + return decorate(() => ({ + name: 'minDate', + validate: v => v instanceof Date && !isNaN(v.getTime()) && v.getTime() >= boundOf(min).getTime(), + message: args => `${args.property} must not be earlier than ${boundOf(min).toISOString()}`, + constraints: [min], + }), options); } +export function MaxDate(max: DateBound, options: EachValidationOptions): Each; +export function MaxDate(max: DateBound, options?: ValidationOptions): One; +export function MaxDate(max: DateBound, options?: ValidationOptions): unknown { + return decorate(() => ({ + name: 'maxDate', + validate: v => v instanceof Date && !isNaN(v.getTime()) && v.getTime() <= boundOf(max).getTime(), + message: args => `${args.property} must not be later than ${boundOf(max).toISOString()}`, + constraints: [max], + }), options); +} + +// ============================================================================ +// Custom rules +// ============================================================================ + /** - * @MaxDate(date: Date | (() => Date)) - */ -export function MaxDate(max: DateBound, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'maxDate', - validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() <= boundOf(max).getTime(), - message: (args) => `${args.property} must not be later than ${boundOf(max).toISOString()}`, - constraints: [max] - }, options); - }; -} - -// --- Arrays --- - -/** - * @ArrayUnique(identifier?: (item: any) => any) - * Pass an extractor to deduplicate objects by a key rather than by reference. - */ -export function ArrayUnique(identifier?: (item: any) => any, options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'arrayUnique', - validate: (v) => { - if (!Array.isArray(v)) return false; - const keys = identifier ? v.map(identifier) : v; - return new Set(keys).size === keys.length; - }, - message: `${propertyKey} must not contain duplicate values` - }, options); - }; -} - -/** @ArrayContains(values: any[]) — the array must contain every listed value. */ -export function ArrayContains(values: any[], options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'arrayContains', - validate: (v) => Array.isArray(v) && values.every(value => v.includes(value)), - message: `${propertyKey} must contain the following values: ${values.join(', ')}`, - constraints: [values] - }, options); - }; -} - -/** @ArrayNotContains(values: any[]) — the array must contain none of the listed values. */ -export function ArrayNotContains(values: any[], options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - addValidation(target, propertyKey, { - name: 'arrayNotContains', - validate: (v) => Array.isArray(v) && values.every(value => !v.includes(value)), - message: `${propertyKey} must not contain any of the following values: ${values.join(', ')}`, - constraints: [values] - }, options); - }; -} - -// --- Control flow --- - -/** - * @ValidateIf(condition: (object: any) => boolean) - * Skips every constraint on this property when the condition returns false. + * Applies a custom validator, as a class implementing {@link ValidatorConstraintInterface} or + * a plain predicate. * - * ```ts - * class Payment { - * @IsIn(['card', 'invoice']) - * method: string; - * - * @ValidateIf(o => o.method === 'card') - * @IsString() - * cardNumber?: string; - * } - * ``` + * Constrain the field type by annotating the predicate's parameter: + * `@Validate((v: string) => v.startsWith('x'))` will only attach to a `string` field. */ -export function ValidateIf(condition: (object: any) => boolean) { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.CONDITION, condition, target, propertyKey); - }; -} - -/** - * @Allow() - * Declares a property with no constraints of its own. - * - * Useful with `unknownKeys: 'strip'` or `'error'`, where a property has to be declared to - * survive the payload even though nothing about its value needs checking. - */ -export function Allow() { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - }; -} - -/** - * @ValidateNested(options?: ValidationOptions) - * Recursively validates the value of this property. - * - * `{ each: true }` documents that the property holds a collection; nested validation - * already recurses into arrays, but passing `each` additionally asserts that the value - * really is an array. - */ -export function ValidateNested(options?: ValidationOptions) { - return (target: any, propertyKey: string) => { - registerProperty(target, propertyKey); - // This is a marker for recursive validation - metadataStorage.defineMetadata(METADATA_KEYS.NESTED, true, target, propertyKey); - - if (options?.each) { - addValidation(target, propertyKey, { - name: 'nestedEach', - validate: (v) => Array.isArray(v), - message: `${propertyKey} must be an array` - }); - } - }; -} - -/** - * Custom validation decorator that uses a validator class or function. - */ -export function Validate( - validator: ClassConstructor | ((value: any, args: ValidationArguments) => boolean | Promise), +export function Validate( + validator: ClassConstructor | ((value: T, args: ValidationArguments) => boolean | Promise), constraintsOrOptions?: any[] | ValidationOptions, - options?: ValidationOptions -) { - return (target: any, propertyKey: string) => { - let constraints: any[] = []; - let validationOptions: ValidationOptions | undefined; + maybeOptions?: ValidationOptions +): FieldDecorator { + const constraints = Array.isArray(constraintsOrOptions) ? constraintsOrOptions : []; + const options = Array.isArray(constraintsOrOptions) ? maybeOptions : constraintsOrOptions; - if (Array.isArray(constraintsOrOptions)) { - constraints = constraintsOrOptions; - validationOptions = options; - } else if (typeof constraintsOrOptions === 'object') { - validationOptions = constraintsOrOptions; - } + const isPlainFunction = typeof validator === 'function' && !(validator as any).prototype?.validate; - if (typeof validator === 'function' && !validator.prototype?.validate) { - // Functional validator - addValidation(target, propertyKey, { - name: 'custom', - validate: validator as (value: any, args: ValidationArguments) => boolean, - 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: any; - 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, - 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 } : {}) - }; + if (isPlainFunction) { + const predicate = validator as (value: T, args: ValidationArguments) => boolean | Promise; + return decorate(() => ({ + name: 'custom', + validate: (v, a) => predicate(v as T, a), + message: args => `${args.property} is invalid`, + constraints, + }), options) as FieldDecorator; } - - addValidation(target.prototype, propertyName, validationConstraint, validationOptions); + + const instance = new (validator as ClassConstructor)(); + return decorate(() => ({ + name: (validator as ClassConstructor).name, + validate: (v, a) => instance.validate(v, a), + message: args => (instance.defaultMessage ? instance.defaultMessage(args) : `${args.property} is invalid`), + constraints, + }), options) as FieldDecorator; } diff --git a/src/example.ts b/src/example.ts index 6375e6f..be505cd 100644 --- a/src/example.ts +++ b/src/example.ts @@ -25,8 +25,7 @@ import { Validate, ValidatorConstraintInterface, ValidationArguments, - registerDecorator, - ValidationOptions + Matches, } from './index.js'; // --- Custom Validators --- @@ -42,17 +41,8 @@ class IsLongerThan implements ValidatorConstraintInterface { } } -function IsSlug(options?: ValidationOptions) { - return function (object: any, propertyName: string) { - registerDecorator({ - name: 'isSlug', - target: object.constructor, - propertyName: propertyName, - ...(options ? { options } : {}), - validator: (value: any) => typeof value === 'string' && /^[a-z0-9-]+$/.test(value) - }); - }; -} +/** A custom rule is just a decorator that composes an existing one. */ +const IsSlug = () => Matches(/^[a-z0-9-]+$/, { message: 'name must be a lowercase slug' }); // --- Custom Serializers --- @@ -76,12 +66,14 @@ enum Format { } abstract class Media { + // Standard decorators cannot be applied to an `abstract` member, so the discriminator is a + // concrete field the subclasses override. @IsString() - abstract type: string; + type: string = ''; // Declared once here. Subclasses inherit the rule without restating it. @IsString() - title: string; + title: string = ''; } class Book extends Media { @@ -111,7 +103,7 @@ class Movie extends Media { duration: number; // Only checked for films that claim to be part of a series. - @ValidateIf((movie: Movie) => movie.duration > 200) + @ValidateIf(movie => movie.duration > 200) @IsString() intermissionNote?: string; } @@ -122,7 +114,7 @@ class Library { id: string; @IsString() - @IsSlug({ message: 'name must be a lowercase slug' }) + @IsSlug() name: string; @JsonProperty('curator_email') @@ -136,11 +128,12 @@ class Library { @IsArray() @ValidateNested({ each: true }) - @JsonPolymorphic('type', [ + // Naming the base type has the subtype list checked against it. + @JsonPolymorphic('type', [ { value: Book, name: 'book' }, { value: Movie, name: 'movie' } ]) - items: Media[]; + items: Media[] = []; } // --- Execution --- diff --git a/src/hardening.test.ts b/src/hardening.test.ts index 3e4fc98..3e04f3e 100644 --- a/src/hardening.test.ts +++ b/src/hardening.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize, JsonSerializer, JsonDeserializer, JsonMappingError, - registerDecorator, validate, toInstance, toPlain, configure, resetConfig, + defineRule, validate, toInstance, toPlain, configure, resetConfig, } from './index.js'; afterEach(() => resetConfig()); @@ -20,11 +20,10 @@ describe('plan caching', () => { expect(await validate(before)).toEqual([]); // Register a rule after the plan has already been built and cached. - registerDecorator({ + defineRule(Late, 'value', { name: 'isEven', - target: Late, - propertyName: 'value', - validator: (v: any) => typeof v === 'number' && v % 2 === 0, + validate: (v: any) => typeof v === 'number' && v % 2 === 0, + message: 'value must be even', }); const after = new Late(); @@ -148,11 +147,11 @@ describe('each: true error reporting', () => { it('names the index of the element that failed', async () => { class Basket { @IsIn(['a', 'b'], { each: true }) - tags: string[]; + tags!: ('a' | 'b')[]; } const basket = new Basket(); - basket.tags = ['a', 'b', 'a', 'nope', 'b']; + basket.tags = ['a', 'b', 'a', 'nope' as 'a', 'b']; const errors = await validate(basket); expect(errors).toHaveLength(1); @@ -162,10 +161,10 @@ describe('each: true error reporting', () => { it('leaves a caller-supplied message untouched', async () => { class Basket { @IsIn(['a'], { each: true, message: 'bad tag' }) - tags: string[]; + tags!: 'a'[]; } const basket = new Basket(); - basket.tags = ['a', 'zzz']; + basket.tags = ['a', 'zzz' as 'a']; const errors = await validate(basket); expect(errors[0]!.constraints['isIn']).toBe('bad tag'); @@ -174,10 +173,10 @@ describe('each: true error reporting', () => { it('gives the failing element to a message function, not the whole array', async () => { class Basket { @IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` }) - tags: string[]; + tags!: 'a'[]; } const basket = new Basket(); - basket.tags = ['a', 'zzz']; + basket.tags = ['a', 'zzz' as 'a']; const errors = await validate(basket); expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"'); @@ -186,7 +185,7 @@ describe('each: true error reporting', () => { it('reports nothing when every element passes', async () => { class Basket { @IsIn(['a', 'b'], { each: true }) - tags: string[]; + tags!: ('a' | 'b')[]; } const basket = new Basket(); basket.tags = ['a', 'b']; diff --git a/src/index.test.ts b/src/index.test.ts index e33616d..79a1eab 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -41,16 +41,18 @@ class DateDeserializer implements JsonDeserializer { // --- Domain Models --- abstract class Media { + // Standard decorators cannot be applied to an `abstract` member, so the base declares a + // concrete field the subclasses override. @IsString() - abstract type: string; + type: string = ''; @IsString() - title: string; + title: string = ''; } class Book extends Media { @IsString() - type: string = 'book'; + override type: string = 'book'; @IsString() author: string; @@ -63,7 +65,7 @@ class Book extends Media { class Movie extends Media { @IsString() - type: string = 'movie'; + override type: string = 'movie'; @IsInt() @Min(1) @@ -162,7 +164,7 @@ describe('JsonMapper', () => { @ArrayNotEmpty() @IsIn(['admin', 'user', 'guest'], { each: true }) - roles: string[]; + roles!: ('admin' | 'user' | 'guest')[]; @IsUrl() @IsOptional() @@ -224,7 +226,7 @@ describe('JsonMapper', () => { user.username = 'johndoe'; user.email = 'john@example.com'; user.active = true; - user.roles = ['superadmin']; + user.roles = ['superadmin' as 'admin']; const errors = await JsonMapper.validate(user); expect(errors).toHaveLength(1); diff --git a/src/index.ts b/src/index.ts index d8c295f..79538e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './interfaces.js'; +export * from './metadata.js'; export * from './naming.js'; export * from './config.js'; export * from './decorators.js'; diff --git a/src/interfaces.ts b/src/interfaces.ts index 26edd75..747a8fa 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,13 +1,13 @@ /** * 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 or a promise resolving to it. */ @@ -16,14 +16,14 @@ export interface JsonSerializer { /** * 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 or a promise resolving to it. */ @@ -32,9 +32,48 @@ export interface JsonDeserializer { /** * Represents a class constructor function. - * + * * @template T - The type of the instance created by this constructor. */ export type ClassConstructor = { new (...args: any[]): T; }; + +/** + * A decorator that may only be applied to a field whose type is assignable to `Allowed`. + * + * This is what makes cereale's rules type-checked rather than merely declared. Standard + * decorators receive a `ClassFieldDecoratorContext` that carries the field's + * declared type, so applying `@IsString()` to a `number` field is a compile error rather + * than a runtime surprise: + * + * ```ts + * class User { + * @IsString() name!: string; // fine + * @IsString() age!: number; // Type 'number' is not assignable to type 'string' + * } + * ``` + * + * `null` and `undefined` are included in the `Allowed` union of every built-in rule so + * optional fields (`nickname?: string`) still accept the rule that describes them. + */ +export type FieldDecorator = ( + target: undefined, + context: ClassFieldDecoratorContext +) => void; + +/** A field holding a string, or nothing. */ +export type StringField = string | null | undefined; +/** A field holding a number, or nothing. */ +export type NumberField = number | null | undefined; +/** A field holding a boolean, or nothing. */ +export type BooleanField = boolean | null | undefined; +/** A field holding a bigint, or nothing. */ +export type BigIntField = bigint | null | undefined; +/** A field holding a Date, or nothing. */ +export type DateField = Date | null | undefined; +/** A field holding an array, or nothing. */ +export type ArrayField = readonly unknown[] | null | undefined; + +/** The element type of an array field, used by rules that run per element. */ +export type ElementOf = T extends readonly (infer E)[] ? E : never; diff --git a/src/metadata-storage.ts b/src/metadata-storage.ts deleted file mode 100644 index 800d7db..0000000 --- a/src/metadata-storage.ts +++ /dev/null @@ -1,147 +0,0 @@ -export class MetadataStorage { - private static instance: MetadataStorage; - - /** - * Bumped whenever any metadata is written. - * - * Decorators run at class-definition time, so in practice this stops changing once the - * application has loaded. Derived structures (see the validation plan cache in utils.ts) - * record the version they were built from and rebuild if it moves, which keeps caching - * safe even for metadata registered late through `registerDecorator`. - */ - private _version = 0; - - get version(): number { - return this._version; - } - - // 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) { - this._version++; - 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; - } - - /** - * Collects a metadata value from every level of the prototype chain that defines one. - * - * Unlike {@link getMetadata}, which stops at the first (most derived) match, this returns - * every value found, ordered from the BASE class down to the most derived one. It exists - * for metadata that must accumulate across an inheritance chain rather than be overridden — - * validation constraints in particular, where a subclass re-decorating an inherited property - * must add to the base class's rules instead of silently replacing them. - */ - getMetadataChain(key: string, target: any, propertyKey?: string): any[] { - const chain: any[] = []; - let current = target; - while (current) { - const value = this.getOwnMetadata(key, current, propertyKey); - if (value !== undefined) { - // Walking derived -> base, so prepend to end up base-first. - chain.unshift(value); - } - current = Object.getPrototypeOf(current); - } - return chain; - } - - /** - * 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) { - this._version++; - 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/metadata.ts b/src/metadata.ts new file mode 100644 index 0000000..8305909 --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,174 @@ +import type { ClassConstructor } from './interfaces.js'; + +// TypeScript's standard-decorator emit reads `Symbol.metadata`. Node does not define it yet, +// so it is installed here, before any decorated class in the consuming application is +// evaluated. `Symbol.for` keeps it identical across duplicate copies of the library, which +// the dual ESM/CJS build can otherwise produce. +((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= + Symbol.for('Symbol.metadata'); + +export interface ValidationArguments { + value: any; + object: any; + property: string; + constraints: any[]; +} + +export interface ValidationOptions { + /** Apply the rule to each element of an array rather than to the array itself. */ + each?: boolean; + /** Replaces the built-in message. Reported verbatim — the engine never decorates it. */ + message?: string | ((args: ValidationArguments) => string); +} + +/** Narrowed form used by the `each: true` decorator overloads. */ +export interface EachValidationOptions extends ValidationOptions { + each: true; +} + +export type ValidationConstraint = { + name: string; + validate: (value: any, args: ValidationArguments) => boolean | Promise; + message: string | ((args: ValidationArguments) => string); + constraints?: any[]; + each?: boolean; + /** + * True when the message came from the caller. The engine only decorates its own default + * wording with the "each element in ..." prefix. + */ + hasCustomMessage?: boolean; +}; + +export interface ValidatorConstraintInterface { + validate(value: any, args: ValidationArguments): boolean | Promise; + defaultMessage?(args: ValidationArguments): string; +} + +/** + * Which directions a property participates in. + * + * - `readwrite` (default): mapped both ways. + * - `readonly`: written to JSON, never populated from incoming JSON (server-assigned ids). + * - `writeonly`: populated from incoming JSON, never written back out (passwords). + * - `none`: ignored entirely. + */ +export type PropertyAccess = 'readwrite' | 'readonly' | 'writeonly' | 'none'; + +export interface PolymorphicInfo { + discriminator: string; + subTypes: { value: ClassConstructor; name: string }[]; + onUnknown: 'keep' | 'error'; + fallback?: ClassConstructor; +} + +/** Everything cereale knows about one field. */ +export interface PropertyModel { + constraints: ValidationConstraint[]; + optional?: boolean; + nested?: boolean; + condition?: (object: any) => boolean; + /** Explicit JSON name from `@JsonProperty`. */ + name?: string; + aliases?: string[]; + access?: PropertyAccess; + serializer?: ClassConstructor; + deserializer?: ClassConstructor; + type?: () => ClassConstructor; + polymorphic?: PolymorphicInfo; +} + +export type ClassModel = Record; + +const MODEL = Symbol.for('cereale.model'); + +/** + * Bumped whenever a model is written. Derived structures (the plans in engine.ts) record the + * version they were built from and rebuild if it moves, so programmatic registration after a + * class has already been used stays correct. + */ +let version = 0; + +export function modelVersion(): number { + return version; +} + +/** + * Returns the model owned by this class, creating it if necessary. + * + * `context.metadata` inherits from the base class's metadata through the prototype chain, so + * a subclass starts out seeing everything its base declared. Writing requires an own copy — + * otherwise a subclass would mutate its parent — and the inherited entries are deep-copied so + * that a subclass re-decorating an inherited field *adds to* the base's rules instead of + * replacing them. That inheritance-merging behaviour is structural here; the previous + * WeakMap-based storage had to reconstruct it by walking prototypes on every read. + */ +function ownModel(metadata: DecoratorMetadata): ClassModel { + if (!Object.hasOwn(metadata, MODEL)) { + const inherited = (metadata as Record)[MODEL]; + const own: ClassModel = {}; + for (const [key, property] of Object.entries(inherited ?? {})) { + own[key] = { ...property, constraints: [...property.constraints] }; + } + (metadata as Record)[MODEL] = own; + } + return (metadata as Record)[MODEL]!; +} + +/** Returns (creating if needed) the model entry for one field. */ +export function propertyModel(metadata: DecoratorMetadata, property: string): PropertyModel { + version++; + const model = ownModel(metadata); + return (model[property] ??= { constraints: [] }); +} + +/** Appends a validation rule to a field, honouring `each` and a caller-supplied message. */ +export function addConstraint( + metadata: DecoratorMetadata, + property: string, + constraint: ValidationConstraint, + options?: ValidationOptions +): void { + if (options?.each) constraint.each = true; + if (options?.message) { + constraint.message = options.message; + constraint.hasCustomMessage = true; + } + propertyModel(metadata, property).constraints.push(constraint); +} + +/** Reads the model declared on a class. Returns an empty model for undecorated classes. */ +export function modelOf(clazz: unknown): ClassModel { + if (typeof clazz !== 'function') return {}; + const metadata = (clazz as { [Symbol.metadata]?: DecoratorMetadata })[Symbol.metadata]; + return (metadata as Record | undefined)?.[MODEL] ?? {}; +} + +/** + * Reads the model that applies to an instance. + * + * Guarded rather than reading `obj.constructor` directly: null-prototype objects have no + * constructor, and an instance whose `constructor` property has been overwritten would lie. + */ +export function modelOfInstance(obj: object): ClassModel { + const prototype = Object.getPrototypeOf(obj); + if (!prototype) return {}; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor'); + return modelOf(descriptor?.value); +} + +/** + * Registers a rule on a class from outside a decorator. + * + * The escape hatch for rules that cannot be expressed at the declaration site — built from + * configuration, say. Prefer decorators, which are type-checked against the field. + */ +export function defineRule( + clazz: ClassConstructor, + property: keyof T & string, + constraint: ValidationConstraint, + options?: ValidationOptions +): void { + const holder = clazz as unknown as { [Symbol.metadata]?: DecoratorMetadata }; + holder[Symbol.metadata] ??= Object.create(null) as DecoratorMetadata; + addConstraint(holder[Symbol.metadata]!, property, constraint, options); +} diff --git a/src/regressions.test.ts b/src/regressions.test.ts index 4f4ba5a..e68b09f 100644 --- a/src/regressions.test.ts +++ b/src/regressions.test.ts @@ -21,7 +21,7 @@ describe('regressions', () => { } class Sub extends Base { @IsString() - declare name: string; + override name: string = ''; } const s = new Sub(); @@ -35,7 +35,7 @@ describe('regressions', () => { it('enforces base constraints that the subclass never restates', async () => { abstract class Media { @IsString() - title: string; + title: string = ''; } class Book extends Media { @IsString() @@ -57,7 +57,7 @@ describe('regressions', () => { } class Sub extends Base { @IsString() - declare type: string; + override type: string = ''; } const s = new Sub(); diff --git a/src/sync.test.ts b/src/sync.test.ts index 65864f3..f8f685a 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -383,14 +383,14 @@ describe('write-only redaction in validation errors', () => { it('does not redact a value that merely sits next to a secret', async () => { class Form { @IsIn(['a', 'b']) - choice: string; + choice!: 'a' | 'b'; @JsonWriteOnly() @IsString() token: string; } const form = new Form(); - form.choice = 'zzz'; + form.choice = 'zzz' as 'a'; form.token = 'secret-token'; const errors = await validate(form); diff --git a/src/type-safety.test.ts b/src/type-safety.test.ts new file mode 100644 index 0000000..cd6ba6d --- /dev/null +++ b/src/type-safety.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +/** + * The headline guarantee of v2 is that a rule cannot be attached to a field it does not fit. + * That is a *compile-time* claim, so asserting it needs the compiler: each case below is + * type-checked in isolation and must fail. + * + * These run the real `tsc`, so they are slower than the rest of the suite — but a guarantee + * nobody checks is a guarantee that quietly stops holding. + */ +const TSC = resolve('node_modules/.bin/tsc'); +const SRC = resolve('src/index.js').replace(/\.js$/, ''); + +function typeCheck(body: string): { ok: boolean; output: string } { + const dir = mkdtempSync(join(tmpdir(), 'cereale-types-')); + try { + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', + // DOM supplies URL/Request, which the library's own signatures reference. A real + // consumer has these from either DOM or @types/node. + lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true, + strictPropertyInitialization: false, noEmit: true, skipLibCheck: true, + }, + include: ['case.ts'], + })); + writeFileSync(join(dir, 'case.ts'), `import {\n IsString, IsInt, Min, MinLength, IsArray, ArrayMinSize, ArrayUnique,\n IsDate, MinDate, IsBoolean, IsIn, IsEnum, JsonType, JsonSerialize,\n JsonDeserialize, JsonSerializer, JsonDeserializer,\n} from ${JSON.stringify(SRC + '.js')};\n\n${body}\n`); + try { + execFileSync(process.execPath, [TSC, '-p', dir], { stdio: 'pipe' }); + return { ok: true, output: '' }; + } catch (error: any) { + return { ok: false, output: String(error.stdout ?? '') + String(error.stderr ?? '') }; + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const compiles = (body: string) => { + const result = typeCheck(body); + if (!result.ok) throw new Error(`expected this to compile but it did not:\n${result.output}`); +}; + +const rejects = (body: string) => { + const result = typeCheck(body); + expect(result.ok, 'expected a compile error, but it compiled').toBe(false); + return result.output; +}; + +describe('rules are checked against the field type', () => { + it('accepts rules that match the field', () => { + compiles(` + class Ok { + @IsString() @MinLength(2) name!: string; + @IsInt() @Min(0) age!: number; + @IsBoolean() active!: boolean; + @IsDate() @MinDate(new Date(0)) when!: Date; + @IsArray() @ArrayMinSize(1) tags!: string[]; + @IsString() nickname?: string; + @IsString() maybe!: string | null; + } + void Ok; + `); + }, 60_000); + + it('rejects a string rule on a number field', () => { + expect(rejects(`class Bad { @IsString() age!: number } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects a number rule on a string field', () => { + expect(rejects(`class Bad { @Min(0) label!: string } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects an array rule on a non-array field', () => { + expect(rejects(`class Bad { @ArrayMinSize(1) count!: number } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects a date rule on a string field', () => { + expect(rejects(`class Bad { @MinDate(new Date(0)) when!: string } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); +}); + +describe('each: true moves the rule onto the elements', () => { + it('accepts a matching array field', () => { + compiles(`class Ok { @IsString({ each: true }) tags!: string[] } void Ok;`); + }, 60_000); + + it('rejects each:true on a scalar field', () => { + expect(rejects(`class Bad { @IsString({ each: true }) tag!: string } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects a bare rule on an array field', () => { + expect(rejects(`class Bad { @IsString() tags!: string[] } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects an element-type mismatch', () => { + expect(rejects(`class Bad { @IsString({ each: true }) nums!: number[] } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); +}); + +describe('nested types and converters are checked', () => { + const shapes = ` + class Address { street!: string } + class Money { amount!: number } + `; + + it('accepts the matching class', () => { + compiles(`${shapes} + class Ok { + @JsonType(() => Address) ship!: Address; + @JsonType(() => Address) history!: Address[]; + } + void Ok;`); + }, 60_000); + + it('rejects an unrelated class', () => { + expect(rejects(`${shapes} + class Bad { @JsonType(() => Money) ship!: Address } + void Bad;`)).toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects a serializer whose input does not match the field', () => { + expect(rejects(` + class DateToString implements JsonSerializer { + serialize(v: Date) { return v.toISOString(); } + } + class Bad { @JsonSerialize(DateToString) name!: string } + void Bad;`)).toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects a deserializer whose output does not match the field', () => { + expect(rejects(` + class StringToDate implements JsonDeserializer { + deserialize(v: string) { return new Date(v); } + } + class Bad { @JsonDeserialize(StringToDate) name!: string } + void Bad;`)).toMatch(/not assignable|Unable to resolve/); + }, 60_000); +}); + +describe('membership rules narrow the field', () => { + it('accepts a field typed as the allowed union', () => { + compiles(`class Ok { @IsIn(['a', 'b']) choice!: 'a' | 'b' } void Ok;`); + }, 60_000); + + it('rejects a field that cannot hold the allowed values', () => { + expect(rejects(`class Bad { @IsIn(['a', 'b']) choice!: number } void Bad;`)) + .toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('rejects an enum rule on a mismatched field', () => { + expect(rejects(` + enum Role { Admin = 'admin' } + class Bad { @IsEnum(Role) role!: number } + void Bad;`)).toMatch(/not assignable|Unable to resolve/); + }, 60_000); + + it('accepts an enum rule on the enum field', () => { + compiles(` + enum Role { Admin = 'admin', User = 'user' } + class Ok { @IsEnum(Role) role!: Role } + void Ok;`); + }, 60_000); +}); diff --git a/src/utils.ts b/src/utils.ts index e68e099..7ee0557 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,9 @@ import { ClassConstructor } from './interfaces.js'; -import { METADATA_KEYS, PropertyAccess, ValidationConstraint, ValidationArguments } from './decorators.js'; -import { metadataStorage } from './metadata-storage.js'; +import { + modelOf, modelOfInstance, modelVersion, + type ClassModel, type PropertyAccess, type PropertyModel, + type ValidationArguments, type ValidationConstraint, +} from './metadata.js'; import { NamingStrategyFn, resolveNamingStrategy } from './naming.js'; import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js'; @@ -62,25 +65,13 @@ interface DeserializeContext { maxDepth: number; } -/** - * Resolves the metadata lookup target for a value. - * - * `Object.getPrototypeOf` rather than `obj.constructor.prototype`: the latter throws on - * null-prototype objects (which have no `constructor`) and lies for instances whose - * `constructor` property has been overwritten. - */ -function prototypeOf(obj: any): any { - return Object.getPrototypeOf(obj) ?? undefined; -} - -function accessOf(target: any, key: string): PropertyAccess { - return (target ? metadataStorage.getMetadata(METADATA_KEYS.ACCESS, target, key) : undefined) ?? 'readwrite'; +function accessOf(model: ClassModel, key: string): PropertyAccess { + return model[key]?.access ?? 'readwrite'; } /** The name this property takes in JSON: an explicit @JsonProperty, else the naming strategy. */ -function outboundName(target: any, key: string, naming: NamingStrategyFn): string { - const explicit = target ? metadataStorage.getMetadata(METADATA_KEYS.NAME, target, key) : undefined; - return explicit ?? naming(key); +function outboundName(model: ClassModel, key: string, naming: NamingStrategyFn): string { + return model[key]?.name ?? naming(key); } /** Per-property serialization facts, resolved once instead of per call. */ @@ -93,7 +84,7 @@ interface OutboundProperty { serializer?: any; } -const outboundCache = new WeakMap> }>(); +const outboundCache = new WeakMap> }>(); /** * Resolves how one property is written out, memoized per (prototype, naming strategy). @@ -101,16 +92,11 @@ const outboundCache = new WeakMap }>(); +const inboundCache = new WeakMap }>(); /** * Builds the JSON-name -> property-key lookup used when reading a payload. @@ -167,11 +153,11 @@ const inboundCache = new WeakMap, ctx: SerializeContext, depth: return out; } - const target = prototypeOf(obj); + const model = modelOfInstance(obj); const result: any = {}; for (const key of Object.keys(obj)) { - const property = outboundFor(target, key, ctx); + const property = outboundFor(model, key, ctx); if (property.skip) continue; const value = obj[key]; @@ -347,8 +327,7 @@ function deserialize(clazz: ClassConstructor, plain: any, ctx: Deserialize if (typeof plain !== 'object') return plain; const instance = new clazz(); - const target = clazz.prototype; - const inbound = inboundNameMap(target, ctx); + const inbound = inboundNameMap(modelOf(clazz), ctx); for (const incoming of Object.keys(plain)) { if (FORBIDDEN_KEYS.has(incoming)) continue; @@ -428,36 +407,6 @@ function deserialize(clazz: ClassConstructor, plain: any, ctx: Deserialize return instance; } -/** - * Collects the validation constraints that apply to a property, merged across the whole - * prototype chain. - * - * A subclass that re-decorates an inherited property registers its constraints against its - * own prototype. Reading only the nearest set would silently drop everything the base class - * declared, so the chain is flattened base-first. Constraints that are genuinely identical - * (same rule, same fixed message) are collapsed so that re-stating `@IsString()` on an - * override does not report the same failure twice; anything with a computed message — custom - * validators in particular — is always kept. - */ -function collectConstraints(target: any, key: string): ValidationConstraint[] { - const levels: ValidationConstraint[][] = metadataStorage.getMetadataChain(METADATA_KEYS.VALIDATION, target, key); - const merged: ValidationConstraint[] = []; - const seen = new Set(); - - for (const level of levels) { - for (const constraint of level) { - if (typeof constraint.message === 'string') { - const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}`; - if (seen.has(identity)) continue; - seen.add(identity); - } - merged.push(constraint); - } - } - - return merged; -} - /** * Everything the validator needs to know about one property, resolved once. */ @@ -476,33 +425,53 @@ interface CachedPlan { plan: PropertyPlan[]; } -// Resolving a class's validation rules means walking its prototype chain several times per -// property, per call — which profiling showed to be roughly half of all validation time, -// recomputing an answer that cannot change. The result is memoized per prototype and -// invalidated by MetadataStorage's version counter, so metadata registered late still works. -const planCache = new WeakMap(); +// Turning a class model into a per-property plan is cheap, but doing it on every call was +// measurably not: profiling showed roughly half of all validation time re-deriving answers +// that cannot change. Plans are memoized per model and invalidated by the model version. +const planCache = new WeakMap(); -function validationPlan(target: any): PropertyPlan[] { - const cached = planCache.get(target); - if (cached && cached.version === metadataStorage.version) { +/** + * Collapses rules that are genuinely identical. + * + * Inheritance is structural here: a subclass's model starts as a copy of its base's, so + * re-stating `@IsString()` on an override would otherwise report the same failure twice. + * Only rules with a fixed message are compared — anything with a computed message, custom + * validators in particular, is always kept, since two of them can differ while looking alike. + */ +function dedupe(constraints: ValidationConstraint[]): ValidationConstraint[] { + const kept: ValidationConstraint[] = []; + const seen = new Set(); + for (const constraint of constraints) { + if (typeof constraint.message === 'string') { + const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}|${constraint.each ?? false}`; + if (seen.has(identity)) continue; + seen.add(identity); + } + kept.push(constraint); + } + return kept; +} + +function validationPlan(model: ClassModel): PropertyPlan[] { + const cached = planCache.get(model); + if (cached && cached.version === modelVersion()) { return cached.plan; } const plan: PropertyPlan[] = []; - for (const key of metadataStorage.getProperties(target)) { - const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key); - const access = accessOf(target, key); + for (const [key, property] of Object.entries(model) as [string, PropertyModel][]) { + const access = property.access ?? 'readwrite'; plan.push({ key, - constraints: collectConstraints(target, key), - isOptional: !!metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key), - isNested: !!metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key), + constraints: dedupe(property.constraints), + isOptional: !!property.optional, + isNested: !!property.nested, redact: access === 'writeonly' || access === 'none', - ...(condition ? { condition } : {}), + ...(property.condition ? { condition: property.condition } : {}), }); } - planCache.set(target, { version: metadataStorage.version, plan }); + planCache.set(model, { version: modelVersion(), plan }); return plan; } @@ -644,10 +613,7 @@ function validateInternal(obj: any, ancestors: Set, depth: number, maxDepth return errors; } - const target = prototypeOf(obj); - if (!target) return errors; - - for (const property of validationPlan(target)) { + for (const property of validationPlan(modelOfInstance(obj))) { const key = property.key; const value = obj[key]; diff --git a/src/validators.test.ts b/src/validators.test.ts index d437b60..580b48a 100644 --- a/src/validators.test.ts +++ b/src/validators.test.ts @@ -11,12 +11,29 @@ import { validate, toInstance, } from './index.js'; -/** Builds a one-property class, assigns `value`, and returns the constraint keys that failed. */ -async function check(decorate: (target: any, key: string) => void, value: any): Promise { +/** + * Applies a decorator to a synthetic one-field class and reports which rules failed. + * + * Standard decorators are invoked as `(undefined, context)` rather than against a prototype, + * so the context is built by hand here. Only `name` and `metadata` are read by the library; + * the rest satisfies the shape. + */ +async function check(decorator: any, value: any): Promise { + const metadata = Object.create(null) as DecoratorMetadata; + decorator(undefined, { + kind: 'field', + name: 'val', + static: false, + private: false, + metadata, + access: { has: () => true, get: (o: any) => o.val, set: (o: any, v: any) => { o.val = v; } }, + addInitializer: () => undefined, + }); + class Subject { val: any; } - decorate(Subject.prototype, 'val'); + (Subject as any)[Symbol.metadata] = metadata; const subject = new Subject(); subject.val = value; @@ -232,9 +249,9 @@ describe('arrays', () => { describe('@ValidateIf', () => { class Payment { @IsIn(['card', 'invoice']) - method: string; + method!: 'card' | 'invoice'; - @ValidateIf(o => o.method === 'card') + @ValidateIf(o => o.method === 'card') @IsString() cardNumber?: string; } diff --git a/tsconfig.json b/tsconfig.json index ac24741..6d02793 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ // Environment Settings "module": "NodeNext", "target": "ES2025", - "lib": ["ESNext"], + "lib": ["ESNext", "ESNext.Decorators"], "types": ["node"], // Other Outputs @@ -33,7 +33,9 @@ "isolatedModules": true, "skipLibCheck": true, - "experimentalDecorators": true + // NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what + // gives ClassFieldDecoratorContext and therefore compile-time checking of + // rules against field types. The two decorator systems cannot coexist in one program. }, // NOTE: test files are deliberately included here so that `npm run type-check` // covers them. The two build configs exclude them (and the demo) from `dist`. diff --git a/vitest.config.ts b/vitest.config.ts index 767a332..451586f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,13 +1,36 @@ import { defineConfig } from 'vitest/config'; +import { transform } from 'esbuild'; + +/** + * Transpiles test sources with esbuild instead of oxc. + * + * Vitest 4 transforms with oxc, which does not yet implement the TC39 standard decorator + * transform — it leaves the syntax in place and Node then fails to parse it, reporting + * "0 test" rather than an error. esbuild and tsc both implement it, so the library's own + * build (`tsc`) and consumers bundling with esbuild or Vite are unaffected; only the test + * runner needs this. Remove it once oxc gains standard-decorator support. + */ +function standardDecorators() { + return { + name: 'cereale:standard-decorators', + enforce: 'pre' as const, + async transform(code: string, id: string) { + if (!/\.ts$/.test(id) || id.includes('node_modules')) return null; + const result = await transform(code, { + loader: 'ts', + target: 'es2022', + sourcefile: id, + sourcemap: true, + // Standard semantics, not the legacy ones: the library reads context.metadata. + tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } }, + }); + return { code: result.code, map: result.map }; + }, + }; +} export default defineConfig({ - // Vitest 4 transpiles with oxc, which does not read `experimentalDecorators` - // out of tsconfig.json for files the tsconfig does not `include`. Without this - // the decorator syntax in the test files fails to parse and every suite is - // silently reported as "0 test". - oxc: { - decorator: { legacy: true }, - }, + plugins: [standardDecorators()], test: { include: ['src/**/*.test.ts'], coverage: { From 305be7a16ffef2507ad7b742d8c4dbd065ae1616 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 07:20:41 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=92=20fix:=20resolve=20the=20metad?= =?UTF-8?q?ata=20symbol=20into=20a=20binding,=20not=20a=20per-use=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #4 flagged that the Symbol.metadata polyfill is a module-level side effect while package.json declares "sideEffects": false, so a bundler is permitted to drop the module. Checking it narrowed the concern and corrected half of it. The decorator transforms are not exposed: esbuild's helper is __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : Symbol.for("Symbol." + name) which already falls back. The exposure was in this library's own read path, which used `Symbol.metadata` directly. Had the symbol been absent, `clazz[undefined]` would read a property literally named "undefined", modelOf() would return an empty model, and every object would validate clean — silent success, the worst failure mode a validation library can have. The key is now resolved once into METADATA_KEY, with the same Symbol.for fallback the transforms use, and all reads and writes go through it. The global assignment stays for consumer emit that reads Symbol.metadata directly, and package.json now lists metadata.js under sideEffects so bundlers keep it. 193 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK --- package.json | 7 +++++-- src/metadata.ts | 32 ++++++++++++++++++++++---------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 54e4ee3..622a86b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cereale", "version": "2.0.0", - "description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data", + "description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data", "type": "module", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -13,7 +13,10 @@ "require": "./dist/cjs/index.js" } }, - "sideEffects": false, + "sideEffects": [ + "./dist/esm/metadata.js", + "./dist/cjs/metadata.js" + ], "files": [ "dist" ], diff --git a/src/metadata.ts b/src/metadata.ts index 8305909..d3f73b8 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,11 +1,23 @@ import type { ClassConstructor } from './interfaces.js'; -// TypeScript's standard-decorator emit reads `Symbol.metadata`. Node does not define it yet, -// so it is installed here, before any decorated class in the consuming application is -// evaluated. `Symbol.for` keeps it identical across duplicate copies of the library, which -// the dual ESM/CJS build can otherwise produce. -((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= - Symbol.for('Symbol.metadata'); +/** + * The key decorator metadata is stored under. + * + * Resolved into a binding rather than read as `Symbol.metadata` at each use. If the well-known + * symbol is absent, `Symbol.metadata` evaluates to `undefined` and `clazz[undefined]` quietly + * reads a property literally named "undefined" — `modelOf` would return an empty model and + * every object would validate clean. Silent success is the worst failure mode a validation + * library can have, so the fallback is baked into the value the code actually uses. + * + * `Symbol.for` matches what the decorator transforms emit (esbuild's `__knownSymbol` uses the + * same fallback), and keeps the key identical across duplicate copies of the library, which + * the dual ESM/CJS build can otherwise produce. + */ +const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata'); + +// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata` +// directly. package.json marks this module as having side effects so it survives bundling. +((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY; export interface ValidationArguments { value: any; @@ -139,7 +151,7 @@ export function addConstraint( /** Reads the model declared on a class. Returns an empty model for undecorated classes. */ export function modelOf(clazz: unknown): ClassModel { if (typeof clazz !== 'function') return {}; - const metadata = (clazz as { [Symbol.metadata]?: DecoratorMetadata })[Symbol.metadata]; + const metadata = (clazz as unknown as Record)[METADATA_KEY]; return (metadata as Record | undefined)?.[MODEL] ?? {}; } @@ -168,7 +180,7 @@ export function defineRule( constraint: ValidationConstraint, options?: ValidationOptions ): void { - const holder = clazz as unknown as { [Symbol.metadata]?: DecoratorMetadata }; - holder[Symbol.metadata] ??= Object.create(null) as DecoratorMetadata; - addConstraint(holder[Symbol.metadata]!, property, constraint, options); + const holder = clazz as unknown as Record; + holder[METADATA_KEY] ??= Object.create(null) as DecoratorMetadata; + addConstraint(holder[METADATA_KEY]!, property, constraint, options); }