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..622a86b 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 \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" ], @@ -39,11 +42,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..d3f73b8 --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,186 @@ +import type { ClassConstructor } from './interfaces.js'; + +/** + * 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; + 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 unknown as Record)[METADATA_KEY]; + 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 Record; + holder[METADATA_KEY] ??= Object.create(null) as DecoratorMetadata; + addConstraint(holder[METADATA_KEY]!, 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: {