✨ feat: add a synchronous API and redact write-only values from errors
Synchronous API --------------- Nothing on the default path is genuinely asynchronous - only a serializer, deserializer or validator the caller supplies can be - so requiring `await` everywhere taxed the common case. Rather than duplicating the traversal into a second sync copy (the traversal is exactly where the eight defects fixed in 0.1.0 lived, and two copies would drift), the engines are now written synchronously and anything a hook makes asynchronous is recorded and reconciled once at the end. A `*Sync` call that encounters a Promise raises a JsonMappingError naming the async alternative instead of returning a half-built object. Adds validateSync, validateOrRejectSync, toPlainSync, toJsonSync, toInstanceSync, toInstanceArraySync, fromJsonSync, fromJsonArraySync. fromRequest has no synchronous form, since reading a request body is inherently async. Removing the per-property await also sped up the async path substantially. With the plan caching from the previous release, against JSON.parse + JSON.stringify (5.8 us) as a fixed reference: validate (50 orders) 221.6 us -> 17.8 us 12.4x validate (10 orders) 47.8 us -> 4.5 us 10.6x toPlain (50 orders) 294.4 us -> 36.0 us 8.2x toInstance (50 orders) 255.1 us -> 31.7 us 8.0x toInstance (single) 19.8 us -> 5.2 us 3.8x Write-only redaction -------------------- A @JsonWriteOnly password that failed @MinLength put the rejected password into ValidationError.value, and from there into any log that recorded the error. Values of properties that never leave the process - @JsonWriteOnly and @JsonIgnore - are now replaced with the exported REDACTED placeholder. The property name and failure message are unchanged, so the error stays actionable. Tests ----- 176 tests, up from 150. The new suite covers the sync family, its refusal of async hooks (including that refusing does not leave an unhandled rejection), and async hooks through the async API - serializers, deserializers, validators, and async validators under each: true, which the suite had never exercised. That last group caught a regression this change introduced: with an async serializer the deferred write appended its key after the synchronous ones, changing property order in the output. The slot is now claimed before deferring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
+40
-17
@@ -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
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user