Merge pull request #2 from avalon-vanguard/claude/consolidation-perf-reliability
Memoize per-class plans (3-4.5x faster), add depth guard and per-index each reporting Profiling showed roughly half of all validation time re-deriving answers that cannot change - collectConstraints 22%, getOwnMetadata 12%, getMetadataChain 9%, getProperties 4%, getMetadata 3%, plus 8% GC - while the constraint predicates themselves accounted for under 1%. Decorator metadata is fixed once classes are declared, so the validation, serialization and deserialization plans are now memoized per prototype, along with serializer/deserializer instances that were previously constructed for every property of every object. A version counter on MetadataStorage invalidates the caches when metadata is written, so registerDecorator after first use still takes effect. 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 toPlain (50 orders) 294.4 us -> 95.3 us 3.1x Also bounds recursion with a maxDepth option (default 64) across all three engines, closing a stack-exhaustion vector on hostile payloads, and makes each: true failures name the element that failed. 136 -> 150 tests, no breaking changes, green on Node 20, 22 and 24.
This commit is contained in:
@@ -5,6 +5,49 @@ All notable changes to this project are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
Profiling the validator showed roughly **half of all validation time** was spent re-deriving
|
||||||
|
answers that cannot change: `collectConstraints` (22%), `getOwnMetadata` (12%),
|
||||||
|
`getMetadataChain` (9%), `getProperties` (4%) and `getMetadata` (3%), plus 8% garbage
|
||||||
|
collection from the allocation churn. The constraint predicates themselves accounted for
|
||||||
|
under 1%.
|
||||||
|
|
||||||
|
Decorator metadata is fixed once classes are declared, so the derived structures are now
|
||||||
|
memoized per prototype — the validation plan, the serialization plan, the deserialization
|
||||||
|
plan, and serializer/deserializer instances (previously constructed fresh for every property
|
||||||
|
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:
|
||||||
|
|
||||||
|
| Operation | Before | After | 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.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `each: true` failures now report which element failed — `"... (failed at index 3)"`. A bad
|
||||||
|
entry in a 200-item array previously produced a message that could not locate it. A message
|
||||||
|
function now receives the failing element as `args.value` rather than the whole array;
|
||||||
|
caller-supplied string messages are still reported verbatim.
|
||||||
|
|
||||||
## [0.1.0] - 2026-08-03
|
## [0.1.0] - 2026-08-03
|
||||||
|
|
||||||
The first release with a working test suite. Everything below the "Fixed" heading was
|
The first release with a working test suite. Everything below the "Fixed" heading was
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ defaults for the whole application. Per-call options win.
|
|||||||
| `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. |
|
| `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. |
|
||||||
| `namingStrategy` | strategy name or function | `identity` | JSON naming convention for properties without `@JsonProperty`. |
|
| `namingStrategy` | strategy name or function | `identity` | JSON naming convention for properties without `@JsonProperty`. |
|
||||||
| `unknownKeys` | `allow` \| `strip` \| `error` | `allow` | What to do with incoming keys matching no declared property. |
|
| `unknownKeys` | `allow` \| `strip` \| `error` | `allow` | What to do with incoming keys matching no declared property. |
|
||||||
|
| `maxDepth` | `number` | `64` | Nesting depth before a `JsonMappingError` is raised, bounding hostile payloads. |
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// lenient parse: build the instance, inspect the damage yourself
|
// lenient parse: build the instance, inspect the damage yourself
|
||||||
@@ -293,7 +294,7 @@ Write your own with `registerDecorator({ name, target, propertyName, validator }
|
|||||||
- `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
|
- `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
|
||||||
- `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
|
- `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
|
||||||
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
|
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
|
||||||
- `validate(obj)`: Full validation, returning `Promise<ValidationError[]>`.
|
- `validate(obj, options?)`: Full validation, returning `Promise<ValidationError[]>`.
|
||||||
- `validateOrReject(obj)`: As above, but throws `JsonValidationError`.
|
- `validateOrReject(obj)`: As above, but throws `JsonValidationError`.
|
||||||
- `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults.
|
- `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults.
|
||||||
|
|
||||||
@@ -350,6 +351,26 @@ app.post('/user', async (req, res) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
If you validate at the edge and map internally afterwards, `{ validate: false }` skips the
|
||||||
|
dominant cost.
|
||||||
|
|
||||||
## Notes and Limitations
|
## Notes and Limitations
|
||||||
|
|
||||||
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
+11
-1
@@ -27,15 +27,24 @@ export interface TransformOptions {
|
|||||||
|
|
||||||
/** What to do with incoming keys that match no declared property. Deserialization only. */
|
/** What to do with incoming keys that match no declared property. Deserialization only. */
|
||||||
unknownKeys?: UnknownKeyPolicy;
|
unknownKeys?: UnknownKeyPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum nesting depth before a {@link JsonMappingError} is raised. Defaults to 64.
|
||||||
|
*
|
||||||
|
* All three engines recurse, so a hostile payload nested thousands of levels deep would
|
||||||
|
* otherwise exhaust the call stack. Raise it if you legitimately model deep trees.
|
||||||
|
*/
|
||||||
|
maxDepth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options that can be set once for the whole application via {@link configure}. */
|
/** Options that can be set once for the whole application via {@link configure}. */
|
||||||
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate'>;
|
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate' | 'maxDepth'>;
|
||||||
|
|
||||||
const DEFAULTS: Required<GlobalOptions> = {
|
const DEFAULTS: Required<GlobalOptions> = {
|
||||||
namingStrategy: 'identity',
|
namingStrategy: 'identity',
|
||||||
unknownKeys: 'allow',
|
unknownKeys: 'allow',
|
||||||
validate: true,
|
validate: true,
|
||||||
|
maxDepth: 64,
|
||||||
};
|
};
|
||||||
|
|
||||||
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
|
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
|
||||||
@@ -71,5 +80,6 @@ export function resolveOptions(options?: TransformOptions): Required<GlobalOptio
|
|||||||
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
|
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
|
||||||
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
|
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
|
||||||
validate: options.validate ?? globalOptions.validate,
|
validate: options.validate ?? globalOptions.validate,
|
||||||
|
maxDepth: options.maxDepth ?? globalOptions.maxDepth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
||||||
|
JsonSerializer, JsonDeserializer, JsonMappingError,
|
||||||
|
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
afterEach(() => resetConfig());
|
||||||
|
|
||||||
|
describe('plan caching', () => {
|
||||||
|
// The validation plan for a class is memoized. It must not go stale when metadata is
|
||||||
|
// registered after the class has already been validated once.
|
||||||
|
it('picks up a decorator registered after the first validation', async () => {
|
||||||
|
class Late {
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = new Late();
|
||||||
|
before.value = 'anything';
|
||||||
|
expect(await validate(before)).toEqual([]);
|
||||||
|
|
||||||
|
// Register a rule after the plan has already been built and cached.
|
||||||
|
registerDecorator({
|
||||||
|
name: 'isEven',
|
||||||
|
target: Late,
|
||||||
|
propertyName: 'value',
|
||||||
|
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = new Late();
|
||||||
|
after.value = 'anything';
|
||||||
|
expect(await validate(after)).toHaveLength(1);
|
||||||
|
|
||||||
|
after.value = 4;
|
||||||
|
expect(await validate(after)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps per-class plans separate', async () => {
|
||||||
|
class A {
|
||||||
|
@IsString()
|
||||||
|
v: any;
|
||||||
|
}
|
||||||
|
class B {
|
||||||
|
@IsInt()
|
||||||
|
v: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = new A();
|
||||||
|
a.v = 'text';
|
||||||
|
const b = new B();
|
||||||
|
b.v = 'text';
|
||||||
|
|
||||||
|
expect(await validate(a)).toEqual([]);
|
||||||
|
expect(await validate(b)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses one serializer instance rather than constructing per property', async () => {
|
||||||
|
let constructed = 0;
|
||||||
|
class Counting implements JsonSerializer<string, string> {
|
||||||
|
constructor() { constructed++; }
|
||||||
|
serialize(value: string): string { return value.toUpperCase(); }
|
||||||
|
}
|
||||||
|
class Doc {
|
||||||
|
@JsonSerialize(Counting)
|
||||||
|
a: string;
|
||||||
|
|
||||||
|
@JsonSerialize(Counting)
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.a = 'x';
|
||||||
|
doc.b = 'y';
|
||||||
|
|
||||||
|
await toPlain(doc);
|
||||||
|
await toPlain(doc);
|
||||||
|
await toPlain(doc);
|
||||||
|
|
||||||
|
expect(await toPlain(doc)).toEqual({ a: 'X', b: 'Y' });
|
||||||
|
expect(constructed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still honours a deserializer after caching', async () => {
|
||||||
|
class ToDate implements JsonDeserializer<string, Date> {
|
||||||
|
deserialize(value: string): Date { return new Date(value); }
|
||||||
|
}
|
||||||
|
class Event {
|
||||||
|
@JsonDeserialize(ToDate)
|
||||||
|
at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const e = await toInstance(Event, { at: '2026-01-01T00:00:00Z' });
|
||||||
|
expect(e.at).toBeInstanceOf(Date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maxDepth guard', () => {
|
||||||
|
const nest = (depth: number): any => {
|
||||||
|
let node: any = { value: 'leaf' };
|
||||||
|
for (let i = 0; i < depth; i++) node = { child: node };
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Node {
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Node)
|
||||||
|
child?: Node;
|
||||||
|
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects a payload nested past the limit instead of exhausting the stack', async () => {
|
||||||
|
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||||
|
.rejects.toThrow(JsonMappingError);
|
||||||
|
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts nesting within the limit', async () => {
|
||||||
|
const parsed = await toInstance(Node, nest(10), { validate: false });
|
||||||
|
expect(parsed).toBeInstanceOf(Node);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is configurable per call and globally', async () => {
|
||||||
|
await expect(toInstance(Node, nest(10), { validate: false, maxDepth: 3 }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth of 3/);
|
||||||
|
|
||||||
|
configure({ maxDepth: 2 });
|
||||||
|
await expect(toInstance(Node, nest(10), { validate: false }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth of 2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards serialization too', async () => {
|
||||||
|
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||||
|
await expect(toPlain(deep, { validate: false, maxDepth: 5 }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards validation too', async () => {
|
||||||
|
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||||
|
await expect(validate(deep, { maxDepth: 5 })).rejects.toThrow(/Maximum nesting depth/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('each: true error reporting', () => {
|
||||||
|
it('names the index of the element that failed', async () => {
|
||||||
|
class Basket {
|
||||||
|
@IsIn(['a', 'b'], { each: true })
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
|
||||||
|
|
||||||
|
const errors = await validate(basket);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.constraints['isIn']).toContain('failed at index 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a caller-supplied message untouched', async () => {
|
||||||
|
class Basket {
|
||||||
|
@IsIn(['a'], { each: true, message: 'bad tag' })
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'zzz'];
|
||||||
|
|
||||||
|
const errors = await validate(basket);
|
||||||
|
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the failing element to a message function, not the whole array', async () => {
|
||||||
|
class Basket {
|
||||||
|
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'zzz'];
|
||||||
|
|
||||||
|
const errors = await validate(basket);
|
||||||
|
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing when every element passes', async () => {
|
||||||
|
class Basket {
|
||||||
|
@IsIn(['a', 'b'], { each: true })
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'b'];
|
||||||
|
expect(await validate(basket)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate() accepts options', () => {
|
||||||
|
it('threads maxDepth through nested validation', async () => {
|
||||||
|
class Item {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
@IsString()
|
||||||
|
ref: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Item)
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = new Item();
|
||||||
|
bad.qty = -1;
|
||||||
|
const order = new Order();
|
||||||
|
order.ref = 'r';
|
||||||
|
order.items = [bad];
|
||||||
|
|
||||||
|
// Deep enough to be fine at the default, so behaviour is unchanged.
|
||||||
|
expect(await validate(order)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-1
@@ -275,7 +275,12 @@ describe('configure()', () => {
|
|||||||
it('resetConfig() restores the defaults', async () => {
|
it('resetConfig() restores the defaults', async () => {
|
||||||
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
|
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
|
||||||
resetConfig();
|
resetConfig();
|
||||||
expect(getConfig()).toEqual({ namingStrategy: 'identity', unknownKeys: 'allow', validate: true });
|
expect(getConfig()).toEqual({
|
||||||
|
namingStrategy: 'identity',
|
||||||
|
unknownKeys: 'allow',
|
||||||
|
validate: true,
|
||||||
|
maxDepth: 64,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,20 @@
|
|||||||
export class MetadataStorage {
|
export class MetadataStorage {
|
||||||
private static instance: MetadataStorage;
|
private static instance: MetadataStorage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped whenever any metadata is written.
|
||||||
|
*
|
||||||
|
* Decorators run at class-definition time, so in practice this stops changing once the
|
||||||
|
* application has loaded. Derived structures (see the validation plan cache in utils.ts)
|
||||||
|
* record the version they were built from and rebuild if it moves, which keeps caching
|
||||||
|
* safe even for metadata registered late through `registerDecorator`.
|
||||||
|
*/
|
||||||
|
private _version = 0;
|
||||||
|
|
||||||
|
get version(): number {
|
||||||
|
return this._version;
|
||||||
|
}
|
||||||
|
|
||||||
// Maps a prototype to its property names
|
// Maps a prototype to its property names
|
||||||
private properties = new WeakMap<any, string[]>();
|
private properties = new WeakMap<any, string[]>();
|
||||||
|
|
||||||
@@ -24,6 +38,7 @@ export class MetadataStorage {
|
|||||||
* Defines metadata for a specific property on a target.
|
* Defines metadata for a specific property on a target.
|
||||||
*/
|
*/
|
||||||
defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
|
defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
|
||||||
|
this._version++;
|
||||||
if (propertyKey) {
|
if (propertyKey) {
|
||||||
let targetMap = this.propertyMetadata.get(target);
|
let targetMap = this.propertyMetadata.get(target);
|
||||||
if (!targetMap) {
|
if (!targetMap) {
|
||||||
@@ -101,6 +116,7 @@ export class MetadataStorage {
|
|||||||
* Registers a property for a target.
|
* Registers a property for a target.
|
||||||
*/
|
*/
|
||||||
registerProperty(target: any, propertyKey: string) {
|
registerProperty(target: any, propertyKey: string) {
|
||||||
|
this._version++;
|
||||||
let props = this.properties.get(target);
|
let props = this.properties.get(target);
|
||||||
if (!props) {
|
if (!props) {
|
||||||
props = [];
|
props = [];
|
||||||
|
|||||||
+222
-66
@@ -45,12 +45,15 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|||||||
|
|
||||||
interface SerializeContext {
|
interface SerializeContext {
|
||||||
naming: NamingStrategyFn;
|
naming: NamingStrategyFn;
|
||||||
|
namingKey: unknown;
|
||||||
|
maxDepth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeserializeContext {
|
interface DeserializeContext {
|
||||||
naming: NamingStrategyFn;
|
naming: NamingStrategyFn;
|
||||||
namingKey: unknown;
|
namingKey: unknown;
|
||||||
unknownKeys: UnknownKeyPolicy;
|
unknownKeys: UnknownKeyPolicy;
|
||||||
|
maxDepth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,9 +77,69 @@ function outboundName(target: any, key: string, naming: NamingStrategyFn): strin
|
|||||||
return explicit ?? naming(key);
|
return explicit ?? naming(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-property serialization facts, resolved once instead of per call. */
|
||||||
|
interface OutboundProperty {
|
||||||
|
/** The name to write in the output. */
|
||||||
|
name: string;
|
||||||
|
/** True for @JsonIgnore / @JsonWriteOnly — omitted from output. */
|
||||||
|
skip: boolean;
|
||||||
|
/** The @JsonSerialize class, if any. */
|
||||||
|
serializer?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
||||||
|
*
|
||||||
|
* Serialization walks the runtime keys of each object, so undeclared properties turn up here
|
||||||
|
* too; they memoize just as well, since the naming strategy is deterministic.
|
||||||
|
*/
|
||||||
|
function outboundFor(target: any, key: string, ctx: SerializeContext): OutboundProperty {
|
||||||
|
if (!target) {
|
||||||
|
// Null-prototype object: nothing is declared, so there is nothing to cache against.
|
||||||
|
return { name: ctx.naming(key), skip: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = outboundCache.get(target);
|
||||||
|
if (!entry || entry.version !== metadataStorage.version) {
|
||||||
|
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
||||||
|
outboundCache.set(target, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let byKey = entry.byStrategy.get(ctx.namingKey);
|
||||||
|
if (!byKey) {
|
||||||
|
byKey = new Map();
|
||||||
|
entry.byStrategy.set(ctx.namingKey, byKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = byKey.get(key);
|
||||||
|
if (!resolved) {
|
||||||
|
const access = accessOf(target, key);
|
||||||
|
const serializer = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
|
||||||
|
resolved = {
|
||||||
|
name: outboundName(target, key, ctx.naming),
|
||||||
|
// `writeonly` is accepted on input but must never be echoed back out.
|
||||||
|
skip: access === 'none' || access === 'writeonly',
|
||||||
|
...(serializer ? { serializer } : {}),
|
||||||
|
};
|
||||||
|
byKey.set(key, resolved);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-property deserialization facts, resolved once instead of per call. */
|
||||||
|
interface InboundProperty {
|
||||||
|
deserializer?: any;
|
||||||
|
polymorphic?: any;
|
||||||
|
typeFn?: () => ClassConstructor<any>;
|
||||||
|
}
|
||||||
|
|
||||||
interface InboundNames {
|
interface InboundNames {
|
||||||
/** JSON name -> property key, for properties this payload is allowed to set. */
|
/** JSON name -> property key, for properties this payload is allowed to set. */
|
||||||
accept: Map<string, string>;
|
accept: Map<string, string>;
|
||||||
|
/** property key -> the conversion metadata that applies to it. */
|
||||||
|
props: Map<string, InboundProperty>;
|
||||||
/**
|
/**
|
||||||
* JSON names that belong to a declared property the payload may NOT set
|
* JSON names that belong to a declared property the payload may NOT set
|
||||||
* (`@JsonIgnore` / `@JsonReadOnly`). They are dropped rather than treated as unknown
|
* (`@JsonIgnore` / `@JsonReadOnly`). They are dropped rather than treated as unknown
|
||||||
@@ -88,7 +151,7 @@ interface InboundNames {
|
|||||||
|
|
||||||
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
||||||
// declared, so they are cached per (prototype, naming strategy).
|
// declared, so they are cached per (prototype, naming strategy).
|
||||||
const inboundCache = new WeakMap<object, Map<unknown, InboundNames>>();
|
const inboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
||||||
@@ -99,16 +162,17 @@ const inboundCache = new WeakMap<object, Map<unknown, InboundNames>>();
|
|||||||
* keep it working for older clients.
|
* keep it working for older clients.
|
||||||
*/
|
*/
|
||||||
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
||||||
let byStrategy = inboundCache.get(target);
|
let entry = inboundCache.get(target);
|
||||||
if (!byStrategy) {
|
if (!entry || entry.version !== metadataStorage.version) {
|
||||||
byStrategy = new Map();
|
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
||||||
inboundCache.set(target, byStrategy);
|
inboundCache.set(target, entry);
|
||||||
}
|
}
|
||||||
const cached = byStrategy.get(ctx.namingKey);
|
const cached = entry.byStrategy.get(ctx.namingKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const accept = new Map<string, string>();
|
const accept = new Map<string, string>();
|
||||||
const blocked = new Set<string>();
|
const blocked = new Set<string>();
|
||||||
|
const props = new Map<string, InboundProperty>();
|
||||||
|
|
||||||
const claim = (external: string, key: string) => {
|
const claim = (external: string, key: string) => {
|
||||||
const owner = accept.get(external);
|
const owner = accept.get(external);
|
||||||
@@ -134,18 +198,36 @@ function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const name of names) claim(name, key);
|
for (const name of names) claim(name, key);
|
||||||
|
|
||||||
|
const deserializer = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
||||||
|
const polymorphic = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
|
||||||
|
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
||||||
|
if (deserializer || polymorphic || typeFn) {
|
||||||
|
props.set(key, {
|
||||||
|
...(deserializer ? { deserializer } : {}),
|
||||||
|
...(polymorphic ? { polymorphic } : {}),
|
||||||
|
...(typeFn ? { typeFn } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = { accept, blocked };
|
const result = { accept, blocked, props };
|
||||||
byStrategy.set(ctx.namingKey, result);
|
entry.byStrategy.set(ctx.namingKey, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext): Promise<any> {
|
async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number): Promise<any> {
|
||||||
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (depth > ctx.maxDepth) {
|
||||||
|
throw new JsonMappingError(
|
||||||
|
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing. ` +
|
||||||
|
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (obj instanceof Date) {
|
if (obj instanceof Date) {
|
||||||
return obj.toISOString();
|
return obj.toISOString();
|
||||||
}
|
}
|
||||||
@@ -162,7 +244,7 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
|||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
const out: any[] = [];
|
const out: any[] = [];
|
||||||
for (const item of obj) {
|
for (const item of obj) {
|
||||||
out.push(await serialize(item, ancestors, ctx));
|
out.push(await serialize(item, ancestors, ctx, depth + 1));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -171,21 +253,17 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
|||||||
|
|
||||||
const result: any = {};
|
const result: any = {};
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
const access = accessOf(target, key);
|
const property = outboundFor(target, key, ctx);
|
||||||
// `writeonly` is accepted on input but must never be echoed back out.
|
if (property.skip) continue;
|
||||||
if (access === 'none' || access === 'writeonly') continue;
|
|
||||||
|
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
const name = outboundName(target, key, ctx.naming);
|
|
||||||
|
|
||||||
// Custom serializers only see real values. Handing a serializer `undefined` for a
|
// 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.
|
// property that was simply never set turns an optional field into a crash.
|
||||||
const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
|
if (property.serializer && value !== null && value !== undefined) {
|
||||||
if (serializerCls && value !== null && value !== undefined) {
|
result[property.name] = await converterFor(property.serializer).serialize(value);
|
||||||
const serializer = new serializerCls();
|
|
||||||
result[name] = await serializer.serialize(value);
|
|
||||||
} else {
|
} else {
|
||||||
result[name] = await serialize(value, ancestors, ctx);
|
result[property.name] = await serialize(value, ancestors, ctx, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +275,18 @@ async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext): Promise<T> {
|
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext, depth: number): Promise<T> {
|
||||||
if (plain === null || plain === undefined) return plain;
|
if (plain === null || plain === undefined) return plain;
|
||||||
|
|
||||||
|
if (depth > ctx.maxDepth) {
|
||||||
|
throw new JsonMappingError(
|
||||||
|
`Maximum nesting depth of ${ctx.maxDepth} exceeded while deserializing. ` +
|
||||||
|
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(plain)) {
|
if (Array.isArray(plain)) {
|
||||||
const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx)));
|
const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx, depth + 1)));
|
||||||
return results as any;
|
return results as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,24 +320,24 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
|
|||||||
|
|
||||||
const value = plain[incoming];
|
const value = plain[incoming];
|
||||||
|
|
||||||
|
const property = inbound.props.get(key);
|
||||||
|
|
||||||
// Custom Deserializer
|
// Custom Deserializer
|
||||||
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
if (property?.deserializer) {
|
||||||
if (deserializerCls) {
|
instance[key as keyof T] = await converterFor(property.deserializer).deserialize(value);
|
||||||
const deserializer = new deserializerCls();
|
|
||||||
instance[key as keyof T] = await deserializer.deserialize(value);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polymorphic
|
// Polymorphic
|
||||||
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
|
const poly = property?.polymorphic;
|
||||||
if (poly && value !== null && value !== undefined) {
|
if (poly && value !== null && value !== undefined) {
|
||||||
const { discriminator, subTypes, onUnknown, fallback } = poly;
|
const { discriminator, subTypes, onUnknown, fallback } = poly;
|
||||||
|
|
||||||
const resolve = async (item: any): Promise<any> => {
|
const resolve = async (item: any): Promise<any> => {
|
||||||
if (item === null || item === undefined || typeof item !== 'object') return item;
|
if (item === null || item === undefined || typeof item !== 'object') return item;
|
||||||
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
|
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
|
||||||
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx);
|
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx, depth + 1);
|
||||||
if (fallback) return deserialize(fallback, item, ctx);
|
if (fallback) return deserialize(fallback, item, ctx, depth + 1);
|
||||||
if (onUnknown === 'error') {
|
if (onUnknown === 'error') {
|
||||||
throw new JsonMappingError(
|
throw new JsonMappingError(
|
||||||
`Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
|
`Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
|
||||||
@@ -270,10 +355,10 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nested Type
|
// Nested Type
|
||||||
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
const typeFn = property?.typeFn;
|
||||||
if (typeFn && value !== null && value !== undefined) {
|
if (typeFn && value !== null && value !== undefined) {
|
||||||
const type = typeFn();
|
const type = typeFn();
|
||||||
instance[key as keyof T] = await deserialize(type, value, ctx);
|
instance[key as keyof T] = await deserialize(type, value, ctx, depth + 1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +398,63 @@ function collectConstraints(target: any, key: string): ValidationConstraint[] {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the validator needs to know about one property, resolved once.
|
||||||
|
*/
|
||||||
|
interface PropertyPlan {
|
||||||
|
key: string;
|
||||||
|
constraints: ValidationConstraint[];
|
||||||
|
isOptional: boolean;
|
||||||
|
isNested: boolean;
|
||||||
|
condition?: (object: any) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedPlan {
|
||||||
|
version: number;
|
||||||
|
plan: PropertyPlan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolving a class's validation rules means walking its prototype chain several times per
|
||||||
|
// property, per call — which profiling showed to be roughly half of all validation time,
|
||||||
|
// recomputing an answer that cannot change. The result is memoized per prototype and
|
||||||
|
// invalidated by MetadataStorage's version counter, so metadata registered late still works.
|
||||||
|
const planCache = new WeakMap<object, CachedPlan>();
|
||||||
|
|
||||||
|
function validationPlan(target: any): PropertyPlan[] {
|
||||||
|
const cached = planCache.get(target);
|
||||||
|
if (cached && cached.version === metadataStorage.version) {
|
||||||
|
return cached.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan: PropertyPlan[] = [];
|
||||||
|
for (const key of metadataStorage.getProperties(target)) {
|
||||||
|
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, 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),
|
||||||
|
...(condition ? { condition } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
planCache.set(target, { version: metadataStorage.version, plan });
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serializers and deserializers are stateless by contract, so one instance per class is
|
||||||
|
// enough. Constructing a fresh one for every property of every object was pure waste.
|
||||||
|
const converterCache = new WeakMap<object, any>();
|
||||||
|
|
||||||
|
function converterFor(clazz: any): any {
|
||||||
|
let instance = converterCache.get(clazz);
|
||||||
|
if (!instance) {
|
||||||
|
instance = new clazz();
|
||||||
|
converterCache.set(clazz, instance);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records a failure without letting a later constraint overwrite an earlier one that happens
|
* Records a failure without letting a later constraint overwrite an earlier one that happens
|
||||||
* to share a name (two `@Min` rules, or a rule inherited and re-declared).
|
* to share a name (two `@Min` rules, or a rule inherited and re-declared).
|
||||||
@@ -327,10 +469,17 @@ function recordFailure(constraints: { [key: string]: string }, name: string, mes
|
|||||||
constraints[`${name}_${suffix}`] = message;
|
constraints[`${name}_${suffix}`] = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateInternal(obj: any, ancestors: Set<any>): Promise<ValidationError[]> {
|
async function validateInternal(obj: any, ancestors: Set<any>, depth: number, maxDepth: number): Promise<ValidationError[]> {
|
||||||
const errors: ValidationError[] = [];
|
const errors: ValidationError[] = [];
|
||||||
if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
|
if (obj === null || obj === undefined || typeof obj !== 'object') return errors;
|
||||||
|
|
||||||
|
if (depth > maxDepth) {
|
||||||
|
throw new JsonMappingError(
|
||||||
|
`Maximum nesting depth of ${maxDepth} exceeded while validating. ` +
|
||||||
|
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A cycle has already been validated further up the stack; re-entering it would never
|
// A cycle has already been validated further up the stack; re-entering it would never
|
||||||
// terminate. Diamonds are still validated on each distinct path.
|
// terminate. Diamonds are still validated on each distinct path.
|
||||||
if (ancestors.has(obj)) return errors;
|
if (ancestors.has(obj)) return errors;
|
||||||
@@ -339,7 +488,7 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
|||||||
try {
|
try {
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
for (let i = 0; i < obj.length; i++) {
|
for (let i = 0; i < obj.length; i++) {
|
||||||
const childErrors = await validateInternal(obj[i], ancestors);
|
const childErrors = await validateInternal(obj[i], ancestors, depth + 1, maxDepth);
|
||||||
if (childErrors.length > 0) {
|
if (childErrors.length > 0) {
|
||||||
errors.push({
|
errors.push({
|
||||||
property: `[${i}]`,
|
property: `[${i}]`,
|
||||||
@@ -355,32 +504,26 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
|||||||
const target = prototypeOf(obj);
|
const target = prototypeOf(obj);
|
||||||
if (!target) return errors;
|
if (!target) return errors;
|
||||||
|
|
||||||
const properties: string[] = metadataStorage.getProperties(target);
|
for (const property of validationPlan(target)) {
|
||||||
|
const key = property.key;
|
||||||
for (const key of properties) {
|
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
|
|
||||||
|
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
|
||||||
|
if (property.condition && !property.condition(obj)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle IsOptional
|
||||||
|
if (property.isOptional && (value === null || value === undefined)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const propertyErrors: ValidationError = {
|
const propertyErrors: ValidationError = {
|
||||||
property: key,
|
property: key,
|
||||||
value: value,
|
value: value,
|
||||||
constraints: {}
|
constraints: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
|
|
||||||
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
|
|
||||||
if (condition && !condition(obj)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle IsOptional
|
|
||||||
const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key);
|
|
||||||
const isNullOrUndefined = value === null || value === undefined;
|
|
||||||
|
|
||||||
if (isOptional && isNullOrUndefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check validation constraints
|
|
||||||
const constraints = collectConstraints(target, key);
|
|
||||||
const validationArgs: ValidationArguments = {
|
const validationArgs: ValidationArguments = {
|
||||||
value: value,
|
value: value,
|
||||||
object: obj,
|
object: obj,
|
||||||
@@ -388,32 +531,41 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
|||||||
constraints: []
|
constraints: []
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const constraint of constraints) {
|
for (const constraint of property.constraints) {
|
||||||
validationArgs.constraints = constraint.constraints || [];
|
validationArgs.constraints = constraint.constraints || [];
|
||||||
|
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
|
let failedIndex = -1;
|
||||||
if (constraint.each && Array.isArray(value)) {
|
if (constraint.each && Array.isArray(value)) {
|
||||||
for (const item of value) {
|
// Report which element failed. Previously the index was discarded, so a bad entry
|
||||||
const itemArgs = { ...validationArgs, value: item };
|
// in a 200-item array produced a message that could not locate it.
|
||||||
if (!(await constraint.validate(item, itemArgs))) {
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
validationArgs.value = value[i];
|
||||||
|
if (!(await constraint.validate(value[i], validationArgs))) {
|
||||||
isValid = false;
|
isValid = false;
|
||||||
|
failedIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
validationArgs.value = value;
|
||||||
} else {
|
} else {
|
||||||
isValid = await constraint.validate(value, validationArgs);
|
isValid = await constraint.validate(value, validationArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
if (failedIndex >= 0) validationArgs.value = value[failedIndex];
|
||||||
let message = typeof constraint.message === 'function'
|
let message = typeof constraint.message === 'function'
|
||||||
? constraint.message(validationArgs)
|
? constraint.message(validationArgs)
|
||||||
: constraint.message;
|
: constraint.message;
|
||||||
|
validationArgs.value = value;
|
||||||
|
|
||||||
// Only decorate the library's own default wording. A message the caller wrote
|
// Only decorate the library's own default wording. A message the caller wrote
|
||||||
// is reported verbatim — prefixing it produced sentences like
|
// is reported verbatim — prefixing it produced sentences like
|
||||||
// "each element in tags must all be strings".
|
// "each element in tags must all be strings".
|
||||||
if (constraint.each && !constraint.hasCustomMessage) {
|
if (constraint.each && !constraint.hasCustomMessage) {
|
||||||
message = `each element in ${message}`;
|
message = failedIndex >= 0
|
||||||
|
? `each element in ${message} (failed at index ${failedIndex})`
|
||||||
|
: `each element in ${message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
recordFailure(propertyErrors.constraints, constraint.name, message);
|
recordFailure(propertyErrors.constraints, constraint.name, message);
|
||||||
@@ -421,9 +573,8 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recursive validation
|
// Recursive validation
|
||||||
const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key);
|
if (property.isNested && value !== null && value !== undefined) {
|
||||||
if (isNested && value !== null && value !== undefined) {
|
const nestedErrors = await validateInternal(value, ancestors, depth + 1, maxDepth);
|
||||||
const nestedErrors = await validateInternal(value, ancestors);
|
|
||||||
if (nestedErrors.length > 0) {
|
if (nestedErrors.length > 0) {
|
||||||
propertyErrors.children = nestedErrors;
|
propertyErrors.children = nestedErrors;
|
||||||
}
|
}
|
||||||
@@ -442,7 +593,11 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
|||||||
|
|
||||||
function serializeContext(options?: TransformOptions): SerializeContext {
|
function serializeContext(options?: TransformOptions): SerializeContext {
|
||||||
const resolved = resolveOptions(options);
|
const resolved = resolveOptions(options);
|
||||||
return { naming: resolveNamingStrategy(resolved.namingStrategy) };
|
return {
|
||||||
|
naming: resolveNamingStrategy(resolved.namingStrategy),
|
||||||
|
namingKey: resolved.namingStrategy,
|
||||||
|
maxDepth: resolved.maxDepth,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function deserializeContext(options?: TransformOptions): DeserializeContext {
|
function deserializeContext(options?: TransformOptions): DeserializeContext {
|
||||||
@@ -451,6 +606,7 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
|
|||||||
naming: resolveNamingStrategy(resolved.namingStrategy),
|
naming: resolveNamingStrategy(resolved.namingStrategy),
|
||||||
namingKey: resolved.namingStrategy,
|
namingKey: resolved.namingStrategy,
|
||||||
unknownKeys: resolved.unknownKeys,
|
unknownKeys: resolved.unknownKeys,
|
||||||
|
maxDepth: resolved.maxDepth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,8 +617,8 @@ function deserializeContext(options?: TransformOptions): DeserializeContext {
|
|||||||
* @param obj The object to validate
|
* @param obj The object to validate
|
||||||
* @returns Array of validation errors
|
* @returns Array of validation errors
|
||||||
*/
|
*/
|
||||||
export async function validate(obj: any): Promise<ValidationError[]> {
|
export async function validate(obj: any, options?: TransformOptions): Promise<ValidationError[]> {
|
||||||
return validateInternal(obj, new Set());
|
return validateInternal(obj, new Set(), 0, resolveOptions(options).maxDepth);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -492,13 +648,13 @@ export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<an
|
|||||||
if (obj === null || obj === undefined) return obj;
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
|
||||||
if (resolveOptions(options).validate) {
|
if (resolveOptions(options).validate) {
|
||||||
const errors = await validate(obj);
|
const errors = await validate(obj, options);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
throw new JsonValidationError('Validation failed during serialization', errors);
|
throw new JsonValidationError('Validation failed during serialization', errors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return serialize(obj, new Set(), serializeContext(options));
|
return serialize(obj, new Set(), serializeContext(options), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -524,10 +680,10 @@ export async function toJson<T>(obj: T, options?: TransformOptions): Promise<str
|
|||||||
* @returns Class instance
|
* @returns Class instance
|
||||||
*/
|
*/
|
||||||
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
|
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
|
||||||
const instance = await deserialize(clazz, plain, deserializeContext(options));
|
const instance = await deserialize(clazz, plain, deserializeContext(options), 0);
|
||||||
|
|
||||||
if (resolveOptions(options).validate) {
|
if (resolveOptions(options).validate) {
|
||||||
const errors = await validate(instance);
|
const errors = await validate(instance, options);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
throw new JsonValidationError('Validation failed during deserialization', errors);
|
throw new JsonValidationError('Validation failed during deserialization', errors);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user