diff --git a/CHANGELOG.md b/CHANGELOG.md index d56cd40..a9af3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +**Synchronous API.** `validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`, +`toInstanceSync`, `toInstanceArraySync`, `fromJsonSync` and `fromJsonArraySync`. Nothing on +the default path is genuinely asynchronous — only a user-supplied serializer, deserializer or +validator can be — so requiring `await` everywhere was a tax on the common case. + +The engines are now written synchronously, and anything a hook makes asynchronous is recorded +and reconciled once at the end. There is no second copy of the traversal logic to keep in +step, and the async entry points stop paying for a microtask per property. If a hook does +return a Promise, the `*Sync` call raises a `JsonMappingError` naming the async alternative +rather than silently returning a half-built object. + +`fromRequest` has no synchronous counterpart, because reading a request body is inherently +asynchronous. + +- `maxDepth` option (default 64) on every mapping function and on `configure()`. All three + engines recurse, so a hostile payload nested thousands of levels deep could exhaust the + call stack; it now raises a `JsonMappingError`. Cycles were already handled, but legitimate + deep nesting was not bounded. +- `validate(obj, options?)` accepts options, so `maxDepth` applies to standalone validation. +- `REDACTED` export, the placeholder substituted for withheld values. + +### Security + +- **Validation errors no longer carry the value of a property that is never serialized.** + A `@JsonWriteOnly` password that failed `@MinLength` put the rejected password into + `ValidationError.value`, and from there into any log that recorded the error. Values for + `@JsonWriteOnly` and `@JsonIgnore` properties are replaced with `REDACTED`; the property + name and the failure message are unchanged, so the error is still actionable. + ### Performance Profiling the validator showed roughly **half of all validation time** was spent re-deriving @@ -21,25 +52,17 @@ plan, and serializer/deserializer instances (previously constructed fresh for ev of every object). `MetadataStorage` carries a version counter that invalidates every cache if metadata is registered late, so `registerDecorator` after first use still works. -Measured on a customer record with nested address and orders, against `JSON.parse` + -`JSON.stringify` as a fixed reference point: +Together with the synchronous core, measured on a customer record with a nested address and +orders, against `JSON.parse` + `JSON.stringify` (5.8 us) as a fixed reference point: -| Operation | Before | After | Speedup | +| Operation | 0.1.0 | Now | Speedup | | --- | --- | --- | --- | -| `validate` (50 orders) | 221.6 us | 49.6 us | 4.5x | -| `validate` (10 orders) | 47.8 us | 12.9 us | 3.7x | -| `toInstance` (50 orders) | 255.1 us | 74.0 us | 3.4x | -| `toInstance` (10 orders) | 64.6 us | 19.0 us | 3.4x | -| `toPlain` (50 orders) | 294.4 us | 95.3 us | 3.1x | -| `toInstance` (single) | 19.8 us | 8.4 us | 2.4x | - -### Added - -- `maxDepth` option (default 64) on every mapping function and on `configure()`. All three - engines recurse, so a hostile payload nested thousands of levels deep could exhaust the - call stack; it now raises a `JsonMappingError`. Cycles were already handled, but legitimate - deep nesting was not bounded. -- `validate(obj, options?)` accepts options, so `maxDepth` applies to standalone validation. +| `validate` (50 orders) | 221.6 us | 17.8 us | 12.4x | +| `validate` (10 orders) | 47.8 us | 4.5 us | 10.6x | +| `toPlain` (50 orders) | 294.4 us | 36.0 us | 8.2x | +| `toInstance` (50 orders) | 255.1 us | 31.7 us | 8.0x | +| `toInstance` (10 orders) | 64.6 us | 8.2 us | 7.9x | +| `toInstance` (single) | 19.8 us | 5.2 us | 3.8x | ### Changed diff --git a/README.md b/README.md index f3dd8f5..189efbd 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,26 @@ const problems = flattenErrors(await validate(draft)); const order = await fromJson(Order, body, { unknownKeys: 'error' }); ``` +## Synchronous API + +Nothing on the default path is genuinely asynchronous — only a serializer, deserializer or +validator you supply can be — so every mapping function has a synchronous twin. + +```typescript +import { fromJsonSync, toJsonSync, validateSync } from 'cereale'; + +const user = fromJsonSync(User, body); // no await +const errors = validateSync(user); +const payload = toJsonSync(user); +``` + +`validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`, `toInstanceSync`, +`toInstanceArraySync`, `fromJsonSync`, `fromJsonArraySync`. + +If one of your hooks does return a Promise, the synchronous call raises a `JsonMappingError` +naming the async function to use instead, rather than handing back a half-built object. +`fromRequest` has no synchronous form, since reading a request body is inherently async. + ## API Reference ### Mapping Decorators @@ -295,7 +315,10 @@ Write your own with `registerDecorator({ name, target, propertyName, validator } - `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise`). - `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise`). - `validate(obj, options?)`: Full validation, returning `Promise`. -- `validateOrReject(obj)`: As above, but throws `JsonValidationError`. +- `validateOrReject(obj, options?)`: As above, but throws `JsonValidationError`. +- Synchronous twins of all of the above except `fromRequest`: `toPlainSync`, `toJsonSync`, + `fromJsonSync`, `fromJsonArraySync`, `toInstanceSync`, `toInstanceArraySync`, + `validateSync`, `validateOrRejectSync`. - `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults. ### Error Handling @@ -311,6 +334,10 @@ formatErrors(errors); // "items[0].qty: qty must be at least 1" collectErrorMessages(errors); // ["qty must be at least 1"] ``` +Values of properties that never leave the process — `@JsonWriteOnly` and `@JsonIgnore` — are +replaced with `REDACTED` in `ValidationError.value`, so a rejected password does not travel +into your logs inside an error object. The property name and message are unaffected. + `JsonMappingError` is raised when a value cannot be mapped at all — a body that is not JSON, a circular reference, an unknown discriminator under `{ onUnknown: 'error' }` — as distinct from mapping fine and failing validation. @@ -356,17 +383,18 @@ app.post('/user', async (req, res) => { Decorator metadata is fixed once your classes are declared, so cereale resolves each class's validation, serialization and deserialization plans once and memoizes them per prototype. A version counter invalidates the caches if metadata is registered late, so `registerDecorator` -after first use still behaves correctly. +after first use still behaves correctly. The engines are synchronous internally, so the async +entry points do not pay for a microtask per property. Indicative throughput for a customer record with a nested address and 10 orders, measured -against `JSON.parse` + `JSON.stringify` (5.9 us) on the same machine: +against `JSON.parse` + `JSON.stringify` (5.8 us) on the same machine: | Operation | Time | | --- | --- | -| `toInstance` (deserialize + validate) | ~19 us | -| `toInstance` with `{ validate: false }` | ~6 us | -| `validate` on an existing instance | ~13 us | -| `toPlain` (validate + serialize) | ~23 us | +| `toInstance` (deserialize + validate) | ~8 us | +| `toInstance` with `{ validate: false }` | ~3 us | +| `validate` on an existing instance | ~4.5 us | +| `toPlain` (validate + serialize) | ~12 us | If you validate at the edge and map internally afterwards, `{ validate: false }` skips the dominant cost. diff --git a/docs/cereale.js b/docs/cereale.js index 13e6de6..367c829 100644 --- a/docs/cereale.js +++ b/docs/cereale.js @@ -1,2 +1,2 @@ -"use strict";var Cereale=(()=>{var k=Object.defineProperty;var an=Object.getOwnPropertyDescriptor;var sn=Object.getOwnPropertyNames;var rn=Object.prototype.hasOwnProperty;var on=(n,t)=>{for(var a in t)k(n,a,{get:t[a],enumerable:!0})},ln=(n,t,a,e)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of sn(t))!rn.call(n,i)&&i!==a&&k(n,i,{get:()=>t[i],enumerable:!(e=an(t,i))||e.enumerable});return n};var un=n=>ln(k({},"__esModule",{value:!0}),n);var Lt={};on(Lt,{Allow:()=>xt,ArrayContains:()=>At,ArrayMaxSize:()=>_n,ArrayMinSize:()=>zn,ArrayNotContains:()=>It,ArrayNotEmpty:()=>qn,ArrayUnique:()=>wt,Contains:()=>at,Email:()=>Jn,EndsWith:()=>rt,Equals:()=>Bn,IsAlpha:()=>Qn,IsAlphanumeric:()=>Kn,IsArray:()=>Un,IsBigInt:()=>bt,IsBoolean:()=>In,IsDate:()=>jn,IsDateString:()=>ft,IsDefined:()=>Nn,IsDivisibleBy:()=>pt,IsEmpty:()=>Gn,IsEnum:()=>Yn,IsHexColor:()=>dt,IsIP:()=>mt,IsIn:()=>Fn,IsInstance:()=>Hn,IsInt:()=>xn,IsJSON:()=>ct,IsLatitude:()=>Ot,IsLongitude:()=>ht,IsLowercase:()=>tt,IsNotEmpty:()=>En,IsNotIn:()=>Zn,IsNumber:()=>Cn,IsNumberString:()=>nt,IsObject:()=>Vn,IsOptional:()=>wn,IsPort:()=>yt,IsSemVer:()=>gt,IsString:()=>An,IsUUID:()=>ut,IsUppercase:()=>et,IsUrl:()=>Rn,JsonAlias:()=>mn,JsonDeserialize:()=>bn,JsonIgnore:()=>pn,JsonMapper:()=>z,JsonMappingError:()=>h,JsonPolymorphic:()=>Sn,JsonProperty:()=>dn,JsonReadOnly:()=>yn,JsonSerialize:()=>hn,JsonType:()=>$n,JsonValidationError:()=>I,JsonWriteOnly:()=>On,Length:()=>Xn,METADATA_KEYS:()=>f,Matches:()=>Ln,Max:()=>Tn,MaxDate:()=>St,MaxLength:()=>Dn,Min:()=>Mn,MinDate:()=>$t,MinLength:()=>kn,Negative:()=>vn,NotContains:()=>it,NotEquals:()=>Wn,Positive:()=>Pn,StartsWith:()=>st,Validate:()=>Nt,ValidateIf:()=>Ct,ValidateNested:()=>Vt,collectErrorMessages:()=>Tt,configure:()=>cn,flattenErrors:()=>R,formatErrors:()=>Mt,fromJson:()=>K,fromJsonArray:()=>nn,fromRequest:()=>en,getConfig:()=>fn,registerDecorator:()=>Et,resetConfig:()=>gn,resolveNamingStrategy:()=>E,resolveOptions:()=>A,toInstance:()=>N,toInstanceArray:()=>q,toJson:()=>Q,toPlain:()=>_,validate:()=>V,validateOrReject:()=>X});function C(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 Z=n=>n&&n.charAt(0).toUpperCase()+n.slice(1),D={identity:n=>n,camelCase:n=>{let t=C(n);return t.length===0?n:t[0]+t.slice(1).map(Z).join("")},PascalCase:n=>C(n).map(Z).join("")||n,snake_case:n=>C(n).join("_")||n,SCREAMING_SNAKE_CASE:n=>C(n).join("_").toUpperCase()||n,"kebab-case":n=>C(n).join("-")||n};function E(n){if(!n)return D.identity;if(typeof n=="function")return n;let t=D[n];if(!t)throw new Error(`Unknown naming strategy ${JSON.stringify(n)}. Use one of: ${Object.keys(D).join(", ")}, or pass your own function.`);return t}var j={namingStrategy:"identity",unknownKeys:"allow",validate:!0},S={...j};function cn(n){S={...S,...n}}function fn(){return{...S}}function gn(){S={...j}}function A(n){return n?{namingStrategy:n.namingStrategy??S.namingStrategy,unknownKeys:n.unknownKeys??S.unknownKeys,validate:n.validate??S.validate}:S}var J=class n{static instance;properties=new WeakMap;propertyMetadata=new WeakMap;classMetadata=new WeakMap;constructor(){}static getInstance(){return n.instance||(n.instance=new n),n.instance}defineMetadata(t,a,e,i){if(i){let s=this.propertyMetadata.get(e);s||(s=new Map,this.propertyMetadata.set(e,s));let r=s.get(i);r||(r=new Map,s.set(i,r)),r.set(t,a)}else{let s=this.classMetadata.get(e);s||(s=new Map,this.classMetadata.set(e,s)),s.set(t,a)}}getMetadata(t,a,e){let i=a;for(;i;){let s=this.getOwnMetadata(t,i,e);if(s!==void 0)return s;i=Object.getPrototypeOf(i)}}getMetadataChain(t,a,e){let i=[],s=a;for(;s;){let r=this.getOwnMetadata(t,s,e);r!==void 0&&i.unshift(r),s=Object.getPrototypeOf(s)}return i}getOwnMetadata(t,a,e){return e?this.propertyMetadata.get(a)?.get(e)?.get(t):this.classMetadata.get(a)?.get(t)}registerProperty(t,a){let e=this.properties.get(t);e||(e=[],this.properties.set(t,e)),e.includes(a)||e.push(a)}getProperties(t){let a=new Set,e=t;for(;e;){let i=this.properties.get(e);i&&i.forEach(s=>a.add(s)),e=Object.getPrototypeOf(e)}return Array.from(a)}},c=J.getInstance();var f={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 p(n,t){c.registerProperty(n,t)}function o(n,t,a,e){p(n,t),e&&(e.each&&(a.each=!0),e.message&&(a.message=e.message,a.hasCustomMessage=!0));let i=c.getOwnMetadata(f.VALIDATION,n,t)||[];i.push(a),c.defineMetadata(f.VALIDATION,i,n,t)}function dn(n){return(t,a)=>{p(t,a),c.defineMetadata(f.NAME,n,t,a)}}function mn(...n){return(t,a)=>{p(t,a);let e=c.getOwnMetadata(f.ALIASES,t,a)||[];c.defineMetadata(f.ALIASES,[...e,...n],t,a)}}function pn(){return(n,t)=>{p(n,t),c.defineMetadata(f.ACCESS,"none",n,t)}}function yn(){return(n,t)=>{p(n,t),c.defineMetadata(f.ACCESS,"readonly",n,t)}}function On(){return(n,t)=>{p(n,t),c.defineMetadata(f.ACCESS,"writeonly",n,t)}}function hn(n){return(t,a)=>{p(t,a),c.defineMetadata(f.SERIALIZER,n,t,a)}}function bn(n){return(t,a)=>{p(t,a),c.defineMetadata(f.DESERIALIZER,n,t,a)}}function $n(n){return(t,a)=>{p(t,a),c.defineMetadata(f.TYPE,n,t,a)}}function Sn(n,t,a){return(e,i)=>{p(e,i),c.defineMetadata(f.POLYMORPHIC,{discriminator:n,subTypes:t,onUnknown:a?.onUnknown??"keep",fallback:a?.fallback},e,i)}}function wn(){return(n,t)=>{p(n,t),c.defineMetadata(f.IS_OPTIONAL,!0,n,t)}}function An(n){return(t,a)=>{o(t,a,{name:"isString",validate:e=>typeof e=="string",message:`${a} must be a string`},n)}}function In(n){return(t,a)=>{o(t,a,{name:"isBoolean",validate:e=>typeof e=="boolean",message:`${a} must be a boolean`},n)}}function Cn(n){return(t,a)=>{o(t,a,{name:"isNumber",validate:e=>typeof e=="number"&&!isNaN(e),message:`${a} must be a number`},n)}}function xn(n){return(t,a)=>{o(t,a,{name:"isInt",validate:e=>Number.isInteger(e),message:`${a} must be an integer`},n)}}function Vn(n){return(t,a)=>{o(t,a,{name:"isObject",validate:e=>typeof e=="object"&&e!==null&&!Array.isArray(e),message:`${a} must be an object`},n)}}function Nn(n){return(t,a)=>{o(t,a,{name:"isDefined",validate:e=>e!=null,message:`${a} should not be null or undefined`},n)}}function En(n){return(t,a)=>{o(t,a,{name:"isNotEmpty",validate:e=>e!=null&&e!=="",message:`${a} should not be empty`},n)}}function Mn(n,t){return(a,e)=>{o(a,e,{name:"min",validate:i=>typeof i=="number"&&i>=n,message:`${e} must be at least ${n}`,constraints:[n]},t)}}function Tn(n,t){return(a,e)=>{o(a,e,{name:"max",validate:i=>typeof i=="number"&&i<=n,message:`${e} must be at most ${n}`,constraints:[n]},t)}}function Pn(n){return(t,a)=>{o(t,a,{name:"positive",validate:e=>typeof e=="number"&&e>0,message:`${a} must be positive`},n)}}function vn(n){return(t,a)=>{o(t,a,{name:"negative",validate:e=>typeof e=="number"&&e<0,message:`${a} must be negative`},n)}}function kn(n,t){return(a,e)=>{o(a,e,{name:"minLength",validate:i=>typeof i=="string"&&i.length>=n,message:`${e} must be longer than or equal to ${n} characters`,constraints:[n]},t)}}function Dn(n,t){return(a,e)=>{o(a,e,{name:"maxLength",validate:i=>typeof i=="string"&&i.length<=n,message:`${e} must be shorter than or equal to ${n} characters`,constraints:[n]},t)}}function Jn(n){let t=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return(a,e)=>{o(a,e,{name:"isEmail",validate:i=>typeof i=="string"&&t.test(i),message:`${e} must be a valid email`},n)}}function Rn(n){return(t,a)=>{o(t,a,{name:"isUrl",validate:e=>{try{return new URL(e),!0}catch{return!1}},message:`${a} must be a valid URL`},n)}}function Ln(n,t){let a=n.flags.includes("g")||n.flags.includes("y")?new RegExp(n.source,n.flags.replace(/[gy]/g,"")):n;return(e,i)=>{o(e,i,{name:"matches",validate:s=>typeof s=="string"&&a.test(s),message:`${i} must match ${n} regular expression`,constraints:[n]},t)}}function Un(n){return(t,a)=>{o(t,a,{name:"isArray",validate:e=>Array.isArray(e),message:`${a} must be an array`},n)}}function zn(n,t){return(a,e)=>{o(a,e,{name:"arrayMinSize",validate:i=>Array.isArray(i)&&i.length>=n,message:`${e} must contain at least ${n} elements`,constraints:[n]},t)}}function _n(n,t){return(a,e)=>{o(a,e,{name:"arrayMaxSize",validate:i=>Array.isArray(i)&&i.length<=n,message:`${e} must contain at most ${n} elements`,constraints:[n]},t)}}function qn(n){return(t,a)=>{o(t,a,{name:"arrayNotEmpty",validate:e=>Array.isArray(e)&&e.length>0,message:`${a} should not be empty`},n)}}function Fn(n,t){return(a,e)=>{o(a,e,{name:"isIn",validate:i=>n.includes(i),message:`${e} must be one of the following values: ${n.join(", ")}`,constraints:[n]},t)}}function Zn(n,t){return(a,e)=>{o(a,e,{name:"isNotIn",validate:i=>!n.includes(i),message:`${e} must not be one of the following values: ${n.join(", ")}`,constraints:[n]},t)}}function jn(n){return(t,a)=>{o(t,a,{name:"isDate",validate:e=>e instanceof Date&&!isNaN(e.getTime()),message:`${a} must be a valid Date object`},n)}}function Bn(n,t){return(a,e)=>{o(a,e,{name:"equals",validate:i=>i===n,message:`${e} must be equal to ${JSON.stringify(n)}`,constraints:[n]},t)}}function Wn(n,t){return(a,e)=>{o(a,e,{name:"notEquals",validate:i=>i!==n,message:`${e} must not be equal to ${JSON.stringify(n)}`,constraints:[n]},t)}}function Gn(n){return(t,a)=>{o(t,a,{name:"isEmpty",validate:e=>e==null||e===""?!0:Array.isArray(e)?e.length===0:typeof e=="object"?Object.keys(e).length===0:!1,message:`${a} must be empty`},n)}}function Yn(n,t){let a=Object.keys(n).filter(e=>typeof n[n[e]]!="number").map(e=>n[e]);return(e,i)=>{o(e,i,{name:"isEnum",validate:s=>a.includes(s),message:`${i} must be one of the following values: ${a.join(", ")}`,constraints:[a]},t)}}function Hn(n,t){return(a,e)=>{o(a,e,{name:"isInstance",validate:i=>i instanceof n,message:`${e} must be an instance of ${n.name}`,constraints:[n]},t)}}function Xn(n,t,a){return(e,i)=>{o(e,i,{name:"length",validate:s=>typeof s=="string"&&s.length>=n&&(t===void 0||s.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]},a)}}function T(n,t,a){return e=>(i,s)=>{o(i,s,{name:n,validate:r=>typeof r=="string"&&t.test(r),message:a(s)},e)}}var Qn=T("isAlpha",/^[A-Za-z]+$/,n=>`${n} must contain only letters`),Kn=T("isAlphanumeric",/^[A-Za-z0-9]+$/,n=>`${n} must contain only letters and numbers`);function nt(n){return(t,a)=>{o(t,a,{name:"isNumberString",validate:e=>typeof e=="string"&&e.trim()!==""&&Number.isFinite(Number(e)),message:`${a} must be a number string`},n)}}function tt(n){return(t,a)=>{o(t,a,{name:"isLowercase",validate:e=>typeof e=="string"&&e===e.toLowerCase(),message:`${a} must be lowercase`},n)}}function et(n){return(t,a)=>{o(t,a,{name:"isUppercase",validate:e=>typeof e=="string"&&e===e.toUpperCase(),message:`${a} must be uppercase`},n)}}function at(n,t){return(a,e)=>{o(a,e,{name:"contains",validate:i=>typeof i=="string"&&i.includes(n),message:`${e} must contain ${JSON.stringify(n)}`,constraints:[n]},t)}}function it(n,t){return(a,e)=>{o(a,e,{name:"notContains",validate:i=>typeof i=="string"&&!i.includes(n),message:`${e} must not contain ${JSON.stringify(n)}`,constraints:[n]},t)}}function st(n,t){return(a,e)=>{o(a,e,{name:"startsWith",validate:i=>typeof i=="string"&&i.startsWith(n),message:`${e} must start with ${JSON.stringify(n)}`,constraints:[n]},t)}}function rt(n,t){return(a,e)=>{o(a,e,{name:"endsWith",validate:i=>typeof i=="string"&&i.endsWith(n),message:`${e} must end with ${JSON.stringify(n)}`,constraints:[n]},t)}}var ot="00000000-0000-0000-0000-000000000000",lt="ffffffff-ffff-ffff-ffff-ffffffffffff";function ut(n,t){let a=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(e,i)=>{o(e,i,{name:"isUuid",validate:s=>typeof s!="string"?!1:!n&&(s.toLowerCase()===ot||s.toLowerCase()===lt)?!0:a.test(s),message:`${i} must be a valid UUID${n?` (version ${n})`:""}`,...n?{constraints:[n]}:{}},t)}}function ct(n){return(t,a)=>{o(t,a,{name:"isJson",validate:e=>{if(typeof e!="string")return!1;try{return JSON.parse(e),!0}catch{return!1}},message:`${a} must be a JSON string`},n)}}function ft(n){return(t,a)=>{o(t,a,{name:"isDateString",validate:e=>typeof e=="string"&&!isNaN(Date.parse(e)),message:`${a} must be a valid ISO 8601 date string`},n)}}var gt=T("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`),dt=T("isHexColor",/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i,n=>`${n} must be a hex color`),B=/^(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 mt(n,t){let a=e=>{try{return new URL(`http://[${e}]`).hostname===`[${e.toLowerCase()}]`||/^[0-9a-f:.]+$/i.test(e)&&e.includes(":")}catch{return!1}};return(e,i)=>{o(e,i,{name:"isIp",validate:s=>typeof s!="string"?!1:n===4?B.test(s):n===6?a(s):B.test(s)||a(s),message:`${i} must be a valid IP${n?`v${n}`:""} address`,...n?{constraints:[n]}:{}},t)}}function pt(n,t){return(a,e)=>{o(a,e,{name:"isDivisibleBy",validate:i=>typeof i=="number"&&Number.isFinite(i)&&n!==0&&i%n===0,message:`${e} must be divisible by ${n}`,constraints:[n]},t)}}function yt(n){return(t,a)=>{o(t,a,{name:"isPort",validate:e=>{let i=typeof e=="string"&&e.trim()!==""?Number(e):e;return typeof i=="number"&&Number.isInteger(i)&&i>=0&&i<=65535},message:`${a} must be a valid port number`},n)}}function Ot(n){return(t,a)=>{o(t,a,{name:"isLatitude",validate:e=>typeof e=="number"&&Number.isFinite(e)&&e>=-90&&e<=90,message:`${a} must be a latitude between -90 and 90`},n)}}function ht(n){return(t,a)=>{o(t,a,{name:"isLongitude",validate:e=>typeof e=="number"&&Number.isFinite(e)&&e>=-180&&e<=180,message:`${a} must be a longitude between -180 and 180`},n)}}function bt(n){return(t,a)=>{o(t,a,{name:"isBigInt",validate:e=>typeof e=="bigint",message:`${a} must be a bigint`},n)}}var M=n=>typeof n=="function"?n():n;function $t(n,t){return(a,e)=>{o(a,e,{name:"minDate",validate:i=>i instanceof Date&&!isNaN(i.getTime())&&i.getTime()>=M(n).getTime(),message:i=>`${i.property} must not be earlier than ${M(n).toISOString()}`,constraints:[n]},t)}}function St(n,t){return(a,e)=>{o(a,e,{name:"maxDate",validate:i=>i instanceof Date&&!isNaN(i.getTime())&&i.getTime()<=M(n).getTime(),message:i=>`${i.property} must not be later than ${M(n).toISOString()}`,constraints:[n]},t)}}function wt(n,t){return(a,e)=>{o(a,e,{name:"arrayUnique",validate:i=>{if(!Array.isArray(i))return!1;let s=n?i.map(n):i;return new Set(s).size===s.length},message:`${e} must not contain duplicate values`},t)}}function At(n,t){return(a,e)=>{o(a,e,{name:"arrayContains",validate:i=>Array.isArray(i)&&n.every(s=>i.includes(s)),message:`${e} must contain the following values: ${n.join(", ")}`,constraints:[n]},t)}}function It(n,t){return(a,e)=>{o(a,e,{name:"arrayNotContains",validate:i=>Array.isArray(i)&&n.every(s=>!i.includes(s)),message:`${e} must not contain any of the following values: ${n.join(", ")}`,constraints:[n]},t)}}function Ct(n){return(t,a)=>{p(t,a),c.defineMetadata(f.CONDITION,n,t,a)}}function xt(){return(n,t)=>{p(n,t)}}function Vt(n){return(t,a)=>{p(t,a),c.defineMetadata(f.NESTED,!0,t,a),n?.each&&o(t,a,{name:"nestedEach",validate:e=>Array.isArray(e),message:`${a} must be an array`})}}function Nt(n,t,a){return(e,i)=>{let s=[],r;if(Array.isArray(t)?(s=t,r=a):typeof t=="object"&&(r=t),typeof n=="function"&&!n.prototype?.validate)o(e,i,{name:"custom",validate:n,message:l=>`${l.property} is invalid`,constraints:s},r);else{let l=new n;o(e,i,{name:n.name,validate:(u,g)=>l.validate(u,g),message:u=>l.defaultMessage?l.defaultMessage(u):`${u.property} is invalid`,constraints:s},r)}}}function Et(n){let{name:t,target:a,propertyName:e,options:i,constraints:s,validator:r}=n,l;if(typeof r=="function"&&!r.prototype?.validate)l={name:t,validate:r,message:u=>`${u.property} is invalid`,...s?{constraints:s}:{}};else{let u=typeof r=="function"?new r:r;l={name:t,validate:(g,d)=>u.validate(g,d),message:g=>u.defaultMessage?u.defaultMessage(g):`${g.property} is invalid`,...s?{constraints:s}:{}}}o(a.prototype,e,l,i)}function R(n){let t={},a=(e,i)=>{for(let s of e){let r=s.property.startsWith("[")?`${i}${s.property}`:i?`${i}.${s.property}`:s.property,l=Object.values(s.constraints);l.length>0&&(t[r]??=[]).push(...l),s.children?.length&&a(s.children,r)}};return a(n,""),t}function Mt(n){let t=R(n);return Object.entries(t).flatMap(([a,e])=>e.map(i=>`${a}: ${i}`)).join(` -`)}function Tt(n){return Object.values(R(n)).flat()}var I=class extends Error{constructor(a,e){super(a);this.errors=e;this.name="JsonValidationError"}toString(){return`${this.message}: ${JSON.stringify(this.errors,null,2)}`}},h=class extends Error{constructor(t){super(t),this.name="JsonMappingError"}},Pt=new Set(["__proto__","constructor","prototype"]);function G(n){return Object.getPrototypeOf(n)??void 0}function Y(n,t){return(n?c.getMetadata(f.ACCESS,n,t):void 0)??"readwrite"}function H(n,t,a){return(n?c.getMetadata(f.NAME,n,t):void 0)??a(t)}var W=new WeakMap;function vt(n,t){let a=W.get(n);a||(a=new Map,W.set(n,a));let e=a.get(t.namingKey);if(e)return e;let i=new Map,s=new Set,r=(u,g)=>{let d=i.get(u);if(d&&d!==g)throw new h(`Properties "${d}" and "${g}" both map to the JSON name ${JSON.stringify(u)}. Give one of them a distinct @JsonProperty name.`);i.set(u,g)};for(let u of c.getProperties(n)){let g=[H(n,u,t.naming),...c.getMetadata(f.ALIASES,n,u)||[]],d=Y(n,u);if(d==="none"||d==="readonly"){for(let $ of g)s.add($);continue}for(let $ of g)r($,u)}let l={accept:i,blocked:s};return a.set(t.namingKey,l),l}async function L(n,t,a){if(n==null||typeof n!="object")return n;if(n instanceof Date)return n.toISOString();if(t.has(n))throw new h("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 s=[];for(let r of n)s.push(await L(r,t,a));return s}let e=G(n),i={};for(let s of Object.keys(n)){let r=Y(e,s);if(r==="none"||r==="writeonly")continue;let l=n[s],u=H(e,s,a.naming),g=e?c.getMetadata(f.SERIALIZER,e,s):void 0;if(g&&l!==null&&l!==void 0){let d=new g;i[u]=await d.serialize(l)}else i[u]=await L(l,t,a)}return i}finally{t.delete(n)}}async function x(n,t,a){if(t==null)return t;if(Array.isArray(t))return await Promise.all(t.map(l=>x(n,l,a)));if(typeof t!="object")return t;let e=new n,i=n.prototype,s=vt(i,a);for(let r of Object.keys(t)){if(Pt.has(r)||s.blocked.has(r))continue;let l=s.accept.get(r);if(l===void 0){if(a.unknownKeys==="strip")continue;if(a.unknownKeys==="error")throw new h(`Unknown property ${JSON.stringify(r)} for ${n.name}. Allowed: ${[...s.accept.keys()].map(y=>JSON.stringify(y)).join(", ")||"(none declared)"}.`);e[r]=t[r];continue}let u=t[r],g=c.getMetadata(f.DESERIALIZER,i,l);if(g){let y=new g;e[l]=await y.deserialize(u);continue}let d=c.getMetadata(f.POLYMORPHIC,i,l);if(d&&u!==null&&u!==void 0){let{discriminator:y,subTypes:P,onUnknown:m,fallback:w}=d,b=async O=>{if(O==null||typeof O!="object")return O;let F=P.find(v=>O[y]===v.name);if(F)return x(F.value,O,a);if(w)return x(w,O,a);if(m==="error")throw new h(`Unknown discriminator value ${JSON.stringify(O[y])} for property "${l}". Known values: ${P.map(v=>JSON.stringify(v.name)).join(", ")}.`);return O};e[l]=Array.isArray(u)?await Promise.all(u.map(b)):await b(u);continue}let $=c.getMetadata(f.TYPE,i,l);if($&&u!==null&&u!==void 0){let y=$();e[l]=await x(y,u,a);continue}e[l]=u}return e}function kt(n,t){let a=c.getMetadataChain(f.VALIDATION,n,t),e=[],i=new Set;for(let s of a)for(let r of s){if(typeof r.message=="string"){let l=`${r.name}|${String(r.constraints)}|${r.message}`;if(i.has(l))continue;i.add(l)}e.push(r)}return e}function Dt(n,t,a){if(!(t in n)){n[t]=a;return}let e=2;for(;`${t}_${e}`in n;)e++;n[`${t}_${e}`]=a}async function U(n,t){let a=[];if(n==null||typeof n!="object"||t.has(n))return a;t.add(n);try{if(Array.isArray(n)){for(let s=0;s0&&a.push({property:`[${s}]`,value:n[s],constraints:{},children:r})}return a}let e=G(n);if(!e)return a;let i=c.getProperties(e);for(let s of i){let r=n[s],l={property:s,value:r,constraints:{}},u=c.getMetadata(f.CONDITION,e,s);if(u&&!u(n))continue;let g=c.getMetadata(f.IS_OPTIONAL,e,s),d=r==null;if(g&&d)continue;let $=kt(e,s),y={value:r,object:n,property:s,constraints:[]};for(let m of $){y.constraints=m.constraints||[];let w=!0;if(m.each&&Array.isArray(r))for(let b of r){let O={...y,value:b};if(!await m.validate(b,O)){w=!1;break}}else w=await m.validate(r,y);if(!w){let b=typeof m.message=="function"?m.message(y):m.message;m.each&&!m.hasCustomMessage&&(b=`each element in ${b}`),Dt(l.constraints,m.name,b)}}if(c.getMetadata(f.NESTED,e,s)&&r!==null&&r!==void 0){let m=await U(r,t);m.length>0&&(l.children=m)}(Object.keys(l.constraints).length>0||l.children)&&a.push(l)}return a}finally{t.delete(n)}}function Jt(n){let t=A(n);return{naming:E(t.namingStrategy)}}function Rt(n){let t=A(n);return{naming:E(t.namingStrategy),namingKey:t.namingStrategy,unknownKeys:t.unknownKeys}}async function V(n){return U(n,new Set)}async function X(n){let t=await V(n);if(t.length>0)throw new I("Validation failed",t)}async function _(n,t){if(n==null)return n;if(A(t).validate){let a=await V(n);if(a.length>0)throw new I("Validation failed during serialization",a)}return L(n,new Set,Jt(t))}async function Q(n,t){let a=await _(n,t);return JSON.stringify(a)}async function N(n,t,a){let e=await x(n,t,Rt(a));if(A(a).validate){let i=await V(e);if(i.length>0)throw new I("Validation failed during deserialization",i)}return e}async function q(n,t,a){if(!Array.isArray(t))throw new h(`Expected an array to map to ${n.name}[], received ${typeof t}.`);return await N(n,t,a)}async function K(n,t,a){return N(n,tn(t),a)}async function nn(n,t,a){return q(n,tn(t),a)}function tn(n){try{return JSON.parse(n)}catch(t){throw new h(`Input is not valid JSON: ${t instanceof Error?t.message:String(t)}`)}}async function en(n,t,a){let e;try{e=await t.json()}catch(i){throw new h(`Request body is not valid JSON: ${i instanceof Error?i.message:String(i)}`)}return N(n,e,a)}var z=class{static toPlain=_;static toJson=Q;static toInstance=N;static toInstanceArray=q;static fromJson=K;static fromJsonArray=nn;static fromRequest=en;static validate=V;static validateOrReject=X};return un(Lt);})(); +"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);})(); diff --git a/src/sync.test.ts b/src/sync.test.ts new file mode 100644 index 0000000..65864f3 --- /dev/null +++ b/src/sync.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect } from 'vitest'; +import { + IsString, IsInt, Min, MinLength, IsIn, ValidateNested, JsonType, JsonProperty, + JsonSerialize, JsonDeserialize, JsonSerializer, JsonDeserializer, + JsonIgnore, JsonWriteOnly, Validate, JsonMappingError, JsonValidationError, REDACTED, + validate, validateSync, validateOrReject, validateOrRejectSync, + toPlain, toPlainSync, toJson, toJsonSync, + toInstance, toInstanceSync, toInstanceArray, toInstanceArraySync, + fromJsonSync, fromJsonArraySync, + flattenErrors, +} from './index.js'; + +class Upper implements JsonSerializer { + serialize(value: string): string { return value.toUpperCase(); } +} +class Lower implements JsonDeserializer { + deserialize(value: string): string { return value.toLowerCase(); } +} + +class User { + @JsonProperty('display_name') + @IsString() + @MinLength(2) + displayName: string; + + @IsInt() + @Min(0) + age: number; +} + +describe('synchronous API', () => { + it('toInstanceSync / fromJsonSync map and validate without a Promise', () => { + const user = toInstanceSync(User, { display_name: 'Ada', age: 36 }); + expect(user).toBeInstanceOf(User); + expect(user.displayName).toBe('Ada'); + + const parsed = fromJsonSync(User, '{"display_name":"Ada","age":36}'); + expect(parsed.displayName).toBe('Ada'); + }); + + it('toPlainSync / toJsonSync round-trip', () => { + const user = new User(); + user.displayName = 'Ada'; + user.age = 36; + + expect(toPlainSync(user)).toEqual({ display_name: 'Ada', age: 36 }); + expect(toJsonSync(user)).toBe('{"display_name":"Ada","age":36}'); + }); + + it('validateSync returns the same errors as validate', async () => { + const user = new User(); + user.displayName = 'A'; + user.age = -1; + + const sync = validateSync(user); + const async = await validate(user); + expect(flattenErrors(sync)).toEqual(flattenErrors(async)); + expect(Object.keys(flattenErrors(sync))).toEqual(['displayName', 'age']); + }); + + it('throws JsonValidationError on invalid input, like the async form', () => { + expect(() => toInstanceSync(User, { display_name: 'A', age: 5 })).toThrow(JsonValidationError); + expect(() => validateOrRejectSync(Object.assign(new User(), { displayName: 'A', age: 1 }))) + .toThrow(JsonValidationError); + }); + + it('honours options', () => { + const lenient = toInstanceSync(User, { display_name: 'A', age: -1 }, { validate: false }); + expect(lenient.displayName).toBe('A'); + + expect(() => toInstanceSync(User, { display_name: 'Ada', age: 1, stray: 1 }, { unknownKeys: 'error' })) + .toThrow(/Unknown property "stray"/); + }); + + it('array entry points work synchronously', () => { + class Item { + @IsString() + name: string; + } + expect(toInstanceArraySync(Item, [{ name: 'a' }])[0]!.name).toBe('a'); + expect(fromJsonArraySync(Item, '[{"name":"b"}]')[0]!.name).toBe('b'); + expect(() => toInstanceArraySync(Item, {} as any)).toThrow(JsonMappingError); + }); + + it('runs synchronous custom serializers and deserializers', () => { + class Doc { + @JsonSerialize(Upper) + @JsonDeserialize(Lower) + code: string; + } + const doc = toInstanceSync(Doc, { code: 'ABC' }, { validate: false }); + expect(doc.code).toBe('abc'); + expect(toPlainSync(doc)).toEqual({ code: 'ABC' }); + }); + + it('handles nesting, cycles and depth the same way', () => { + class Child { @IsString() name: string; } + class Parent { + @ValidateNested() + @JsonType(() => Child) + child: Child; + } + const parent = toInstanceSync(Parent, { child: { name: 'x' } }); + expect(parent.child).toBeInstanceOf(Child); + + const cyclic: any = new Parent(); + cyclic.child = cyclic; + expect(() => toPlainSync(cyclic, { validate: false })).toThrow(/Circular reference/); + }); +}); + +describe('synchronous API refuses asynchronous hooks', () => { + class SlowSerializer implements JsonSerializer { + async serialize(value: string): Promise { return value.toUpperCase(); } + } + class SlowDeserializer implements JsonDeserializer { + async deserialize(value: string): Promise { return value.toLowerCase(); } + } + + it('reports a clear error for an async serializer and names the async alternative', () => { + class Doc { + @JsonSerialize(SlowSerializer) + code: string; + } + const doc = new Doc(); + doc.code = 'abc'; + + expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError); + expect(() => toPlainSync(doc, { validate: false })).toThrow(/toPlainSync\(\) requires every/); + expect(() => toPlainSync(doc, { validate: false })).toThrow(/Use toPlain\(\) instead/); + }); + + it('reports a clear error for an async deserializer', () => { + class Doc { + @JsonDeserialize(SlowDeserializer) + code: string; + } + expect(() => toInstanceSync(Doc, { code: 'ABC' }, { validate: false })) + .toThrow(/toInstanceSync\(\) requires every/); + }); + + it('reports a clear error for an async validator', () => { + class Doc { + @Validate(async (v: any) => v === 'ok') + code: string; + } + const doc = new Doc(); + doc.code = 'ok'; + expect(() => validateSync(doc)).toThrow(/validateSync\(\) requires every/); + }); + + it('does not leave an unhandled rejection behind when it refuses', async () => { + class Exploding implements JsonSerializer { + serialize(): Promise { return Promise.reject(new Error('boom')); } + } + class Doc { + @JsonSerialize(Exploding) + code: string; + } + const doc = new Doc(); + doc.code = 'x'; + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError); + await new Promise(resolve => setTimeout(resolve, 20)); + } finally { + process.off('unhandledRejection', onUnhandled); + } + expect(unhandled).toEqual([]); + }); +}); + +describe('the async API still supports asynchronous hooks', () => { + it('awaits an async serializer', async () => { + class Slow implements JsonSerializer { + async serialize(value: string): Promise { + await new Promise(resolve => setTimeout(resolve, 1)); + return value.toUpperCase(); + } + } + class Doc { + @JsonSerialize(Slow) + code: string; + + @IsString() + other: string; + } + const doc = new Doc(); + doc.code = 'abc'; + doc.other = 'kept'; + + await expect(toPlain(doc)).resolves.toEqual({ code: 'ABC', other: 'kept' }); + await expect(toJson(doc)).resolves.toBe('{"code":"ABC","other":"kept"}'); + }); + + it('awaits an async deserializer, including inside a nested type', async () => { + class Slow implements JsonDeserializer { + async deserialize(value: string): Promise { + await new Promise(resolve => setTimeout(resolve, 1)); + return new Date(value); + } + } + class Child { + @JsonDeserialize(Slow) + at: Date; + } + class Parent { + @JsonType(() => Child) + child: Child; + } + + const parent = await toInstance(Parent, { child: { at: '2026-01-01T00:00:00Z' } }, { validate: false }); + expect(parent.child.at).toBeInstanceOf(Date); + expect(parent.child.at.getUTCFullYear()).toBe(2026); + }); + + it('awaits an async deserializer inside an array', async () => { + class Slow implements JsonDeserializer { + async deserialize(value: string): Promise { return value.toUpperCase(); } + } + class Row { + @JsonDeserialize(Slow) + code: string; + } + const rows = await toInstanceArray(Row, [{ code: 'a' }, { code: 'b' }], { validate: false }); + expect(rows.map(r => r.code)).toEqual(['A', 'B']); + }); + + it('awaits an async validator and reports its failure', async () => { + class Doc { + @Validate(async (v: any) => { + await new Promise(resolve => setTimeout(resolve, 1)); + return v === 'ok'; + }, { message: 'must be ok' }) + code: string; + } + const doc = new Doc(); + + doc.code = 'ok'; + await expect(validate(doc)).resolves.toEqual([]); + + doc.code = 'wrong'; + const errors = await validate(doc); + expect(errors).toHaveLength(1); + expect(errors[0]!.constraints['custom']).toBe('must be ok'); + }); + + it('awaits an async validator under each: true and keeps the index', async () => { + class Doc { + @Validate(async (v: any) => v === 'ok', { each: true }) + codes: string[]; + } + const doc = new Doc(); + + doc.codes = ['ok', 'ok']; + await expect(validate(doc)).resolves.toEqual([]); + + doc.codes = ['ok', 'ok', 'bad']; + const errors = await validate(doc); + expect(errors).toHaveLength(1); + expect(errors[0]!.constraints['custom']).toContain('failed at index 2'); + }); + + it('mixes sync and async validators on one object without losing either failure', async () => { + class Doc { + @IsString() + name: any; + + @Validate(async (v: any) => v > 0, { message: 'must be positive' }) + amount: number; + } + const doc = new Doc(); + doc.name = 123; + doc.amount = -5; + + const flat = flattenErrors(await validate(doc)); + expect(flat['name']).toEqual(['name must be a string']); + expect(flat['amount']).toEqual(['must be positive']); + }); + + it('prunes provisional entries for async validators that pass', async () => { + class Doc { + @Validate(async () => true) + a: string; + + @Validate(async () => true) + b: string; + } + await expect(validate(new Doc())).resolves.toEqual([]); + }); + + it('validateOrReject still rejects on an async failure', async () => { + class Doc { + @Validate(async () => false) + code: string; + } + await expect(validateOrReject(new Doc())).rejects.toThrow(JsonValidationError); + }); +}); + +describe('write-only redaction in validation errors', () => { + it('redacts a @JsonWriteOnly value but keeps the failure message', async () => { + class Credentials { + @IsString() + email: string; + + @JsonWriteOnly() + @IsString() + @MinLength(12) + password: string; + } + + const creds = new Credentials(); + creds.email = 'ada@example.com'; + creds.password = 'hunter2'; + + const errors = await validate(creds); + const failure = errors.find(e => e.property === 'password')!; + + expect(failure.value).toBe(REDACTED); + expect(failure.constraints['minLength']).toContain('12'); + expect(JSON.stringify(errors)).not.toContain('hunter2'); + }); + + it('redacts @JsonIgnore values too', async () => { + class Record { + @JsonIgnore() + @IsString() + internalSecret: any; + } + const record = new Record(); + record.internalSecret = 999; + + const errors = await validate(record); + expect(errors[0]!.value).toBe(REDACTED); + expect(JSON.stringify(errors)).not.toContain('999'); + }); + + it('leaves ordinary property values in place', async () => { + class Doc { + @IsString() + name: any; + } + const doc = new Doc(); + doc.name = 42; + + const errors = await validate(doc); + expect(errors[0]!.value).toBe(42); + }); + + it('keeps the secret out of a thrown JsonValidationError', async () => { + class SignUp { + @JsonWriteOnly() + @IsString() + @MinLength(12) + password: string; + } + + await expect(toInstance(SignUp, { password: 'short' })).rejects.toThrow(JsonValidationError); + try { + await toInstance(SignUp, { password: 'short' }); + } catch (error) { + expect(String((error as JsonValidationError).toString())).not.toContain('short'); + } + }); + + it('redacts in the synchronous path as well', () => { + class Credentials { + @JsonWriteOnly() + @IsString() + @MinLength(12) + password: string; + } + const creds = new Credentials(); + creds.password = 'hunter2'; + + expect(validateSync(creds)[0]!.value).toBe(REDACTED); + }); + + it('does not redact a value that merely sits next to a secret', async () => { + class Form { + @IsIn(['a', 'b']) + choice: string; + + @JsonWriteOnly() + @IsString() + token: string; + } + const form = new Form(); + form.choice = 'zzz'; + form.token = 'secret-token'; + + const errors = await validate(form); + expect(errors.find(e => e.property === 'choice')!.value).toBe('zzz'); + expect(JSON.stringify(errors)).not.toContain('secret-token'); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 2485ae7..e68e099 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -41,6 +41,12 @@ export class JsonMappingError extends Error { */ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +/** + * Stands in for the value of a property that is never serialized, so that a failing password + * does not travel inside a ValidationError into whatever logs the caller writes. + */ +export const REDACTED = '[redacted]'; + // --- Internal Engine --- interface SerializeContext { @@ -216,7 +222,47 @@ function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames { return result; } -async function serialize(obj: any, ancestors: Set, ctx: SerializeContext, depth: number): Promise { + +/** + * The engines below are written synchronously. Anything a user hook makes asynchronous — a + * serializer, deserializer or validator that returns a Promise — is recorded here instead of + * being awaited inline, and reconciled once at the end. + * + * This buys two things. The `*Sync` entry points can simply refuse to continue if the list is + * non-empty, without a second copy of the traversal logic to keep in step. And the async entry + * points stop paying for a microtask per property on the overwhelmingly common path where no + * hook is actually asynchronous. + */ +type Deferred = Promise[]; + +function isThenable(value: any): value is Promise { + return value !== null && typeof value === 'object' && typeof value.then === 'function'; +} + +/** Settles any deferred work recorded during a traversal. */ +async function settle(deferred: Deferred): Promise { + while (deferred.length > 0) { + // A hook may itself queue more work (a serializer returning nested async values). + const batch = deferred.splice(0, deferred.length); + await Promise.all(batch); + } +} + +/** + * Rejects a synchronous call that turned out to need asynchronous work. + */ +function refuseAsync(deferred: Deferred, operation: string, asyncName: string): void { + if (deferred.length === 0) return; + // Nothing will await these now; swallow rejections so they do not surface as unhandled. + for (const promise of deferred) promise.catch(() => undefined); + deferred.length = 0; + throw new JsonMappingError( + `${operation} requires every serializer, deserializer and validator to be synchronous, ` + + `but one returned a Promise. Use ${asyncName} instead, or make the hook synchronous.` + ); +} + +function serialize(obj: any, ancestors: Set, ctx: SerializeContext, depth: number, deferred: Deferred): any { if (obj === null || obj === undefined || typeof obj !== 'object') { return obj; } @@ -244,7 +290,7 @@ async function serialize(obj: any, ancestors: Set, ctx: SerializeContext, d if (Array.isArray(obj)) { const out: any[] = []; for (const item of obj) { - out.push(await serialize(item, ancestors, ctx, depth + 1)); + out.push(serialize(item, ancestors, ctx, depth + 1, deferred)); } return out; } @@ -261,9 +307,18 @@ async function serialize(obj: any, ancestors: Set, ctx: SerializeContext, d // Custom serializers only see real values. Handing a serializer `undefined` for a // property that was simply never set turns an optional field into a crash. if (property.serializer && value !== null && value !== undefined) { - result[property.name] = await converterFor(property.serializer).serialize(value); + const produced = converterFor(property.serializer).serialize(value); + if (isThenable(produced)) { + const slot = property.name; + // Claim the key now so the deferred write lands in declaration order rather than + // being appended after every synchronous property. + result[slot] = undefined; + deferred.push(produced.then((settled: any) => { result[slot] = settled; })); + } else { + result[property.name] = produced; + } } else { - result[property.name] = await serialize(value, ancestors, ctx, depth + 1); + result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred); } } @@ -275,7 +330,7 @@ async function serialize(obj: any, ancestors: Set, ctx: SerializeContext, d } } -async function deserialize(clazz: ClassConstructor, plain: any, ctx: DeserializeContext, depth: number): Promise { +function deserialize(clazz: ClassConstructor, plain: any, ctx: DeserializeContext, depth: number, deferred: Deferred): T { if (plain === null || plain === undefined) return plain; if (depth > ctx.maxDepth) { @@ -286,8 +341,7 @@ async function deserialize(clazz: ClassConstructor, plain: any, ctx: Deser } if (Array.isArray(plain)) { - const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx, depth + 1))); - return results as any; + return plain.map(item => deserialize(clazz, item, ctx, depth + 1, deferred)) as any; } if (typeof plain !== 'object') return plain; @@ -324,7 +378,15 @@ async function deserialize(clazz: ClassConstructor, plain: any, ctx: Deser // Custom Deserializer if (property?.deserializer) { - instance[key as keyof T] = await converterFor(property.deserializer).deserialize(value); + const produced = converterFor(property.deserializer).deserialize(value); + if (isThenable(produced)) { + const slot = key as keyof T; + // Claim the key now so property order matches the synchronous path. + instance[slot] = undefined as any; + deferred.push(produced.then((settled: any) => { instance[slot] = settled; })); + } else { + instance[key as keyof T] = produced; + } continue; } @@ -333,11 +395,11 @@ async function deserialize(clazz: ClassConstructor, plain: any, ctx: Deser if (poly && value !== null && value !== undefined) { const { discriminator, subTypes, onUnknown, fallback } = poly; - const resolve = async (item: any): Promise => { + const resolve = (item: any): any => { if (item === null || item === undefined || typeof item !== 'object') return item; const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name); - if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx, depth + 1); - if (fallback) return deserialize(fallback, item, ctx, depth + 1); + if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx, depth + 1, deferred); + if (fallback) return deserialize(fallback, item, ctx, depth + 1, deferred); if (onUnknown === 'error') { throw new JsonMappingError( `Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` + @@ -348,9 +410,7 @@ async function deserialize(clazz: ClassConstructor, plain: any, ctx: Deser return item; }; - instance[key as keyof T] = Array.isArray(value) - ? (await Promise.all(value.map(resolve))) as any - : await resolve(value); + instance[key as keyof T] = Array.isArray(value) ? value.map(resolve) as any : resolve(value); continue; } @@ -358,7 +418,7 @@ async function deserialize(clazz: ClassConstructor, plain: any, ctx: Deser const typeFn = property?.typeFn; if (typeFn && value !== null && value !== undefined) { const type = typeFn(); - instance[key as keyof T] = await deserialize(type, value, ctx, depth + 1); + instance[key as keyof T] = deserialize(type, value, ctx, depth + 1, deferred); continue; } @@ -406,6 +466,8 @@ interface PropertyPlan { constraints: ValidationConstraint[]; isOptional: boolean; isNested: boolean; + /** True for properties that never leave the process (@JsonWriteOnly / @JsonIgnore). */ + redact: boolean; condition?: (object: any) => boolean; } @@ -429,11 +491,13 @@ function validationPlan(target: any): PropertyPlan[] { const plan: PropertyPlan[] = []; for (const key of metadataStorage.getProperties(target)) { const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key); + const access = accessOf(target, key); plan.push({ key, constraints: collectConstraints(target, key), isOptional: !!metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key), isNested: !!metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key), + redact: access === 'writeonly' || access === 'none', ...(condition ? { condition } : {}), }); } @@ -469,7 +533,86 @@ function recordFailure(constraints: { [key: string]: string }, name: string, mes constraints[`${name}_${suffix}`] = message; } -async function validateInternal(obj: any, ancestors: Set, depth: number, maxDepth: number): Promise { + +interface EachOutcome { + ok: boolean; + /** Index of the element that failed, or -1. */ + index: number; +} + +/** Builds the reported message, applying the `each` decoration only to library defaults. */ +function messageFor(constraint: ValidationConstraint, args: ValidationArguments, failedIndex: number): string { + let message = typeof constraint.message === 'function' ? constraint.message(args) : constraint.message; + if (constraint.each && !constraint.hasCustomMessage) { + message = failedIndex >= 0 + ? `each element in ${message} (failed at index ${failedIndex})` + : `each element in ${message}`; + } + return message; +} + +/** + * Runs an `each: true` constraint over an array, staying synchronous until a validator + * actually returns a Promise and only then continuing asynchronously. + */ +function evaluateEach(constraint: ValidationConstraint, items: any[], args: ValidationArguments): EachOutcome | Promise { + for (let i = 0; i < items.length; i++) { + args.value = items[i]; + const result = constraint.validate(items[i], args); + if (isThenable(result)) { + const base = { object: args.object, property: args.property, constraints: args.constraints }; + const tail = finishEach(constraint, items, i, result, base); + args.value = items; + return tail; + } + if (!result) { + args.value = items; + return { ok: false, index: i }; + } + } + args.value = items; + return { ok: true, index: -1 }; +} + +async function finishEach( + constraint: ValidationConstraint, + items: any[], + startIndex: number, + firstResult: Promise, + base: Omit +): Promise { + if (!(await firstResult)) return { ok: false, index: startIndex }; + for (let i = startIndex + 1; i < items.length; i++) { + if (!(await constraint.validate(items[i], { ...base, value: items[i] }))) { + return { ok: false, index: i }; + } + } + return { ok: true, index: -1 }; +} + +/** + * Drops entries that ended up with nothing to report. + * + * With an asynchronous validator the verdict is not known while the tree is being built, so + * candidate entries are created up front and pruned once everything has settled. + */ +function pruneErrors(errors: ValidationError[]): ValidationError[] { + const kept: ValidationError[] = []; + for (const error of errors) { + const children = error.children ? pruneErrors(error.children) : undefined; + if (children && children.length > 0) { + error.children = children; + } else { + delete error.children; + } + if (Object.keys(error.constraints).length > 0 || error.children) { + kept.push(error); + } + } + return kept; +} + +function validateInternal(obj: any, ancestors: Set, depth: number, maxDepth: number, deferred: Deferred): ValidationError[] { const errors: ValidationError[] = []; if (obj === null || obj === undefined || typeof obj !== 'object') return errors; @@ -488,7 +631,7 @@ async function validateInternal(obj: any, ancestors: Set, depth: number, ma try { if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { - const childErrors = await validateInternal(obj[i], ancestors, depth + 1, maxDepth); + const childErrors = validateInternal(obj[i], ancestors, depth + 1, maxDepth, deferred); if (childErrors.length > 0) { errors.push({ property: `[${i}]`, @@ -520,7 +663,9 @@ async function validateInternal(obj: any, ancestors: Set, depth: number, ma const propertyErrors: ValidationError = { property: key, - value: value, + // A property that never leaves the process — a @JsonWriteOnly password, say — must + // not have its value copied into an error object that is about to be logged. + value: property.redact ? REDACTED : value, constraints: {} }; @@ -531,56 +676,59 @@ async function validateInternal(obj: any, ancestors: Set, depth: number, ma constraints: [] }; + let awaited = false; + for (const constraint of property.constraints) { validationArgs.constraints = constraint.constraints || []; - let isValid = true; - let failedIndex = -1; - if (constraint.each && Array.isArray(value)) { + const outcome: EachOutcome | Promise = constraint.each && Array.isArray(value) // Report which element failed. Previously the index was discarded, so a bad entry // in a 200-item array produced a message that could not locate it. - for (let i = 0; i < value.length; i++) { - validationArgs.value = value[i]; - if (!(await constraint.validate(value[i], validationArgs))) { - isValid = false; - failedIndex = i; - break; - } - } - validationArgs.value = value; - } else { - isValid = await constraint.validate(value, validationArgs); + ? evaluateEach(constraint, value, validationArgs) + : (() => { + const result = constraint.validate(value, validationArgs); + return isThenable(result) + ? result.then((ok: boolean) => ({ ok, index: -1 })) + : { ok: result as boolean, index: -1 }; + })(); + + if (isThenable(outcome)) { + awaited = true; + // The shared args object is reused as the loop advances, so snapshot what the + // message will need before handing control back. + const snapshot: ValidationArguments = { + value: value, + object: obj, + property: key, + constraints: constraint.constraints || [] + }; + deferred.push(outcome.then(({ ok, index }: EachOutcome) => { + if (ok) return; + if (index >= 0) snapshot.value = value[index]; + recordFailure(propertyErrors.constraints, constraint.name, messageFor(constraint, snapshot, index)); + })); + continue; } - if (!isValid) { - if (failedIndex >= 0) validationArgs.value = value[failedIndex]; - let message = typeof constraint.message === 'function' - ? constraint.message(validationArgs) - : constraint.message; + if (!outcome.ok) { + if (outcome.index >= 0) validationArgs.value = value[outcome.index]; + const message = messageFor(constraint, validationArgs, outcome.index); validationArgs.value = value; - - // Only decorate the library's own default wording. A message the caller wrote - // is reported verbatim — prefixing it produced sentences like - // "each element in tags must all be strings". - if (constraint.each && !constraint.hasCustomMessage) { - message = failedIndex >= 0 - ? `each element in ${message} (failed at index ${failedIndex})` - : `each element in ${message}`; - } - recordFailure(propertyErrors.constraints, constraint.name, message); } } // Recursive validation if (property.isNested && value !== null && value !== undefined) { - const nestedErrors = await validateInternal(value, ancestors, depth + 1, maxDepth); + const nestedErrors = validateInternal(value, ancestors, depth + 1, maxDepth, deferred); if (nestedErrors.length > 0) { propertyErrors.children = nestedErrors; } } - if (Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) { + // `awaited` entries are kept provisionally: their verdict is not known yet, and + // pruneErrors() drops the ones that turn out to be clean. + if (awaited || Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) { errors.push(propertyErrors); } } @@ -614,11 +762,29 @@ function deserializeContext(options?: TransformOptions): DeserializeContext { /** * Validates a class instance or object against its decorators. + * * @param obj The object to validate + * @param options Per-call options (currently `maxDepth`) * @returns Array of validation errors */ export async function validate(obj: any, options?: TransformOptions): Promise { - return validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth); + const deferred: Deferred = []; + const errors = validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth, deferred); + if (deferred.length === 0) return errors; + await settle(deferred); + return pruneErrors(errors); +} + +/** + * Synchronous {@link validate}. + * + * @throws JsonMappingError if any validator returns a Promise. + */ +export function validateSync(obj: any, options?: TransformOptions): ValidationError[] { + const deferred: Deferred = []; + const errors = validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth, deferred); + refuseAsync(deferred, 'validateSync()', 'validate()'); + return errors; } /** @@ -627,8 +793,16 @@ export async function validate(obj: any, options?: TransformOptions): Promise { - const errors = await validate(obj); +export async function validateOrReject(obj: any, options?: TransformOptions): Promise { + const errors = await validate(obj, options); + if (errors.length > 0) { + throw new JsonValidationError('Validation failed', errors); + } +} + +/** Synchronous {@link validateOrReject}. */ +export function validateOrRejectSync(obj: any, options?: TransformOptions): void { + const errors = validateSync(obj, options); if (errors.length > 0) { throw new JsonValidationError('Validation failed', errors); } @@ -654,7 +828,31 @@ export async function toPlain(obj: T, options?: TransformOptions): Promise(obj: T, options?: TransformOptions): any { + if (obj === null || obj === undefined) return obj; + + if (resolveOptions(options).validate) { + const errors = validateSync(obj, options); + if (errors.length > 0) { + throw new JsonValidationError('Validation failed during serialization', errors); + } + } + + const deferred: Deferred = []; + const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred); + refuseAsync(deferred, 'toPlainSync()', 'toPlain()'); + return plain; } /** @@ -664,8 +862,12 @@ export async function toPlain(obj: T, options?: TransformOptions): Promise(obj: T, options?: TransformOptions): Promise { - const plain = await toPlain(obj, options); - return JSON.stringify(plain); + return JSON.stringify(await toPlain(obj, options)); +} + +/** Synchronous {@link toJson}. */ +export function toJsonSync(obj: T, options?: TransformOptions): string { + return JSON.stringify(toPlainSync(obj, options)); } /** @@ -680,7 +882,9 @@ export async function toJson(obj: T, options?: TransformOptions): Promise(clazz: ClassConstructor, plain: any, options?: TransformOptions): Promise { - const instance = await deserialize(clazz, plain, deserializeContext(options), 0); + const deferred: Deferred = []; + const instance = deserialize(clazz, plain, deserializeContext(options), 0, deferred); + await settle(deferred); if (resolveOptions(options).validate) { const errors = await validate(instance, options); @@ -692,6 +896,32 @@ export async function toInstance(clazz: ClassConstructor, plain: any, opti return instance; } +/** + * Synchronous {@link toInstance}. + * + * @throws JsonMappingError if any deserializer or validator returns a Promise. + */ +export function toInstanceSync(clazz: ClassConstructor, plain: any, options?: TransformOptions): T { + const deferred: Deferred = []; + const instance = deserialize(clazz, plain, deserializeContext(options), 0, deferred); + refuseAsync(deferred, 'toInstanceSync()', 'toInstance()'); + + if (resolveOptions(options).validate) { + const errors = validateSync(instance, options); + if (errors.length > 0) { + throw new JsonValidationError('Validation failed during deserialization', errors); + } + } + + return instance; +} + +function requireArray(clazz: ClassConstructor, plain: any): void { + if (!Array.isArray(plain)) { + throw new JsonMappingError(`Expected an array to map to ${clazz.name}[], received ${typeof plain}.`); + } +} + /** * Converts an array of plain objects to an array of class instances. * @@ -708,12 +938,20 @@ export async function toInstanceArray( plain: any[], options?: TransformOptions ): Promise { - if (!Array.isArray(plain)) { - throw new JsonMappingError(`Expected an array to map to ${clazz.name}[], received ${typeof plain}.`); - } + requireArray(clazz, plain); return (await toInstance(clazz, plain, options)) as unknown as T[]; } +/** Synchronous {@link toInstanceArray}. */ +export function toInstanceArraySync( + clazz: ClassConstructor, + plain: any[], + options?: TransformOptions +): T[] { + requireArray(clazz, plain); + return toInstanceSync(clazz, plain, options) as unknown as T[]; +} + /** * Parses a JSON string to a class instance. * @param clazz The class constructor @@ -725,6 +963,11 @@ export async function fromJson(clazz: ClassConstructor, json: string, opti return toInstance(clazz, parseJson(json), options); } +/** Synchronous {@link fromJson}. */ +export function fromJsonSync(clazz: ClassConstructor, json: string, options?: TransformOptions): T { + return toInstanceSync(clazz, parseJson(json), options); +} + /** * Parses a JSON string containing an array into class instances. * @param clazz The class constructor @@ -740,6 +983,15 @@ export async function fromJsonArray( return toInstanceArray(clazz, parseJson(json), options); } +/** Synchronous {@link fromJsonArray}. */ +export function fromJsonArraySync( + clazz: ClassConstructor, + json: string, + options?: TransformOptions +): T[] { + return toInstanceArraySync(clazz, parseJson(json), options); +} + function parseJson(json: string): any { try { return JSON.parse(json); @@ -753,6 +1005,9 @@ function parseJson(json: string): any { /** * Helper for Fetch-based frameworks (Next.js, Hono, etc.) * Extracts JSON from a Request and transforms it to a class instance. + * + * There is no synchronous counterpart: reading a Request body is inherently asynchronous. + * * @param clazz The class constructor * @param request Web Request object * @param options Per-call transform options