Merge pull request #3 from avalon-vanguard/claude/sync-api-and-redaction

Synchronous API and write-only redaction

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, which would drift from the original, the engines are now
written synchronously and anything a hook makes asynchronous is recorded and
reconciled once at the end. A *Sync call that meets a Promise raises a
JsonMappingError naming the async alternative.

Adds validateSync, validateOrRejectSync, toPlainSync, toJsonSync, toInstanceSync,
toInstanceArraySync, fromJsonSync and 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. Combined with the
plan caching from #2, 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

A @JsonWriteOnly password that failed @MinLength put the rejected password into
ValidationError.value, and from there into any log recording the error - with a
plausible route to an HTTP response, since the README recommends flattening those
errors into a 400 body. Values of properties that never leave the process are now
replaced with the exported REDACTED placeholder; property name and failure
message are unchanged.

176 tests, up from 150, adding coverage of async hooks through the async API that
the suite had never exercised. That caught a regression this change introduced,
where an async serializer's deferred write changed property order in the output.

No breaking changes. Green on Node 20, 22 and 24.
This commit is contained in:
Senrokai
2026-08-04 19:54:15 +02:00
committed by GitHub
5 changed files with 791 additions and 85 deletions
+40 -17
View File
@@ -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
+35 -7
View File
@@ -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<T[]>`).
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
- `validate(obj, options?)`: Full validation, returning `Promise<ValidationError[]>`.
- `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.
+2 -2
View File
File diff suppressed because one or more lines are too long
+400
View File
@@ -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<string, string> {
serialize(value: string): string { return value.toUpperCase(); }
}
class Lower implements JsonDeserializer<string, string> {
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<string, string> {
async serialize(value: string): Promise<string> { return value.toUpperCase(); }
}
class SlowDeserializer implements JsonDeserializer<string, string> {
async deserialize(value: string): Promise<string> { 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<string, string> {
serialize(): Promise<string> { 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<string, string> {
async serialize(value: string): Promise<string> {
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<string, Date> {
async deserialize(value: string): Promise<Date> {
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<string, string> {
async deserialize(value: string): Promise<string> { 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');
});
});
+314 -59
View File
@@ -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<any>, ctx: SerializeContext, depth: number): Promise<any> {
/**
* 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<unknown>[];
function isThenable(value: any): value is Promise<any> {
return value !== null && typeof value === 'object' && typeof value.then === 'function';
}
/** Settles any deferred work recorded during a traversal. */
async function settle(deferred: Deferred): Promise<void> {
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<any>, 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<any>, 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<any>, 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<any>, ctx: SerializeContext, d
}
}
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext, depth: number): Promise<T> {
function deserialize<T>(clazz: ClassConstructor<T>, 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<T>(clazz: ClassConstructor<T>, 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<T>(clazz: ClassConstructor<T>, 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<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deser
if (poly && value !== null && value !== undefined) {
const { discriminator, subTypes, onUnknown, fallback } = poly;
const resolve = async (item: any): Promise<any> => {
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<T>(clazz: ClassConstructor<T>, 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<T>(clazz: ClassConstructor<T>, 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<any>, depth: number, maxDepth: number): Promise<ValidationError[]> {
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<EachOutcome> {
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<boolean>,
base: Omit<ValidationArguments, 'value'>
): Promise<EachOutcome> {
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<any>, 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<any>, 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<any>, 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<any>, 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<EachOutcome> = 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<ValidationError[]> {
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<Va
* The counterpart to {@link validate} for callers who want an exception rather than an
* array they have to remember to check.
*/
export async function validateOrReject(obj: any): Promise<void> {
const errors = await validate(obj);
export async function validateOrReject(obj: any, options?: TransformOptions): Promise<void> {
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<T>(obj: T, options?: TransformOptions): Promise<an
}
}
return serialize(obj, new Set(), serializeContext(options), 0);
const deferred: Deferred = [];
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
await settle(deferred);
return plain;
}
/**
* Synchronous {@link toPlain}.
*
* @throws JsonMappingError if any serializer or validator returns a Promise.
*/
export function toPlainSync<T>(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<T>(obj: T, options?: TransformOptions): Promise<an
* @returns JSON string
*/
export async function toJson<T>(obj: T, options?: TransformOptions): Promise<string> {
const plain = await toPlain(obj, options);
return JSON.stringify(plain);
return JSON.stringify(await toPlain(obj, options));
}
/** Synchronous {@link toJson}. */
export function toJsonSync<T>(obj: T, options?: TransformOptions): string {
return JSON.stringify(toPlainSync(obj, options));
}
/**
@@ -680,7 +882,9 @@ export async function toJson<T>(obj: T, options?: TransformOptions): Promise<str
* @returns Class instance
*/
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
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<T>(clazz: ClassConstructor<T>, plain: any, opti
return instance;
}
/**
* Synchronous {@link toInstance}.
*
* @throws JsonMappingError if any deserializer or validator returns a Promise.
*/
export function toInstanceSync<T>(clazz: ClassConstructor<T>, 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<T>(clazz: ClassConstructor<T>, 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<T>(
plain: any[],
options?: TransformOptions
): Promise<T[]> {
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<T>(
clazz: ClassConstructor<T>,
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<T>(clazz: ClassConstructor<T>, json: string, opti
return toInstance(clazz, parseJson(json), options);
}
/** Synchronous {@link fromJson}. */
export function fromJsonSync<T>(clazz: ClassConstructor<T>, 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<T>(
return toInstanceArray(clazz, parseJson(json), options);
}
/** Synchronous {@link fromJsonArray}. */
export function fromJsonArraySync<T>(
clazz: ClassConstructor<T>,
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