From 6d30182ca853d88f5695f13769e09328408eb89c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:56:05 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20a=20synchronous=20API?= =?UTF-8?q?=20and=20redact=20write-only=20values=20from=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronous API --------------- Nothing on the default path is genuinely asynchronous - only a serializer, deserializer or validator the caller supplies can be - so requiring `await` everywhere taxed the common case. Rather than duplicating the traversal into a second sync copy (the traversal is exactly where the eight defects fixed in 0.1.0 lived, and two copies would drift), the engines are now written synchronously and anything a hook makes asynchronous is recorded and reconciled once at the end. A `*Sync` call that encounters a Promise raises a JsonMappingError naming the async alternative instead of returning a half-built object. Adds validateSync, validateOrRejectSync, toPlainSync, toJsonSync, toInstanceSync, toInstanceArraySync, fromJsonSync, fromJsonArraySync. fromRequest has no synchronous form, since reading a request body is inherently async. Removing the per-property await also sped up the async path substantially. With the plan caching from the previous release, against JSON.parse + JSON.stringify (5.8 us) as a fixed reference: 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 (single) 19.8 us -> 5.2 us 3.8x Write-only redaction -------------------- A @JsonWriteOnly password that failed @MinLength put the rejected password into ValidationError.value, and from there into any log that recorded the error. Values of properties that never leave the process - @JsonWriteOnly and @JsonIgnore - are now replaced with the exported REDACTED placeholder. The property name and failure message are unchanged, so the error stays actionable. Tests ----- 176 tests, up from 150. The new suite covers the sync family, its refusal of async hooks (including that refusing does not leave an unhandled rejection), and async hooks through the async API - serializers, deserializers, validators, and async validators under each: true, which the suite had never exercised. That last group caught a regression this change introduced: with an async serializer the deferred write appended its key after the synchronous ones, changing property order in the output. The slot is now claimed before deferring. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK --- CHANGELOG.md | 57 +++++-- README.md | 42 ++++- docs/cereale.js | 4 +- src/sync.test.ts | 400 +++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 373 ++++++++++++++++++++++++++++++++++++------- 5 files changed, 791 insertions(+), 85 deletions(-) create mode 100644 src/sync.test.ts 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