An audit of the last commit found two real problems and several claims of mine that went further than the evidence. **cereale/min was not tree-shakable.** scripts/build-bundle.mjs used esbuild's `minify: true`, whose minifyWhitespace pass strips comments — /*#__PURE__*/ annotations included. The published entry point therefore reproduced exactly the bug the previous commit fixed: one decorator came out at 5,066 bytes with all 26 unrelated rule messages, against 1,837 from the per-module entry. Every source-level check stayed green, because they all bundled src/ and the annotations are stripped on the way into dist/. It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw and 9.6 KB gzipped against 26.0/8.7, so about a kilobyte over the wire for a file that behaves correctly however it is used. One decorator via cereale/min is now 1,996 bytes. The build asserts the annotation count survives, and treeshake.test.ts now bundles the published artifact as well as the source — the gap that let this through. **sideEffects was partly inert.** `./dist/cjs/metadata.js` could never match: the build writes dist/cjs/package.json, which becomes the nearest descriptor for everything beneath it, so bundlers read sideEffects from there. That file now carries its own declaration. `./src/metadata.ts` was missing while src/ is published, which declared the Symbol.metadata install droppable in the source tree. Five module-scope caches in utils.ts are annotated for the same reason as the rules. I checked the audit's third blocker — that the Symbol.metadata install is dropped by bundlers — and it is not. It survives every case where it is load-bearing (a decorator import, toPlainSync, modelOf, and a decorated model bundled with an app). It is dropped only when importing nothing but flattenErrors, which needs no metadata, so that is correct. Corrections to my own wording: - "all three bundlers land within 20 bytes" held only for the one-decorator row; larger imports differ by up to a few hundred bytes - "measured through three bundlers, and pinned by a test" read as though the test covered all three; it covers esbuild, on source and on dist - "every rule is a top-level call" — 30 of the 68 are - the docs page "loads nothing from the network" — it fetches its own vendored compiler, same-origin, on first Run. It has no third-party dependencies, which is the claim I should have made - cereale/min's size, everywhere it appears Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
1198 lines
42 KiB
TypeScript
1198 lines
42 KiB
TypeScript
import { ClassConstructor } from './interfaces.js';
|
|
import {
|
|
modelOf, modelOfInstance, modelVersion,
|
|
type ClassModel, type PropertyAccess, type PropertyModel,
|
|
type ValidationArguments, type ValidationConstraint,
|
|
} from './metadata.js';
|
|
import { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
|
|
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
|
|
|
|
export interface ValidationError {
|
|
property: string;
|
|
value: any;
|
|
constraints: { [key: string]: string };
|
|
children?: ValidationError[];
|
|
}
|
|
|
|
export class JsonValidationError extends Error {
|
|
constructor(message: string, public errors: ValidationError[]) {
|
|
super(message);
|
|
this.name = 'JsonValidationError';
|
|
}
|
|
|
|
override toString() {
|
|
return `${this.message}: ${JSON.stringify(this.errors, null, 2)}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown when a value cannot be mapped at all — as opposed to mapping fine but failing
|
|
* validation, which raises {@link JsonValidationError}.
|
|
*/
|
|
export class JsonMappingError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'JsonMappingError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Keys that must never be copied from untrusted input onto an instance. Assigning
|
|
* `__proto__` swaps an object's prototype, and `constructor` / `prototype` are the usual
|
|
* next steps in a pollution chain. This library exists to parse request bodies, so the
|
|
* transform layer drops them rather than trusting callers to sanitise first.
|
|
*/
|
|
const FORBIDDEN_KEYS = /*#__PURE__*/ 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]';
|
|
|
|
/**
|
|
* Anything that can hand back a parsed JSON body — a `Request`, a `Response`, or a test double.
|
|
*
|
|
* Declared structurally rather than as the global `Request`, which does not exist unless the
|
|
* consumer's `lib` includes DOM or their `types` includes node. Naming the global here put a
|
|
* `Cannot find name 'Request'` error inside cereale's own published `.d.ts`, in a project that
|
|
* may not call `fromRequest` at all and cannot fix it from the outside.
|
|
*/
|
|
export interface JsonBody {
|
|
json(): Promise<any>;
|
|
}
|
|
|
|
// --- Internal Engine ---
|
|
|
|
interface SerializeContext {
|
|
naming: NamingStrategyFn;
|
|
namingKey: unknown;
|
|
maxDepth: number;
|
|
}
|
|
|
|
interface DeserializeContext {
|
|
naming: NamingStrategyFn;
|
|
namingKey: unknown;
|
|
unknownKeys: UnknownKeyPolicy;
|
|
maxDepth: number;
|
|
}
|
|
|
|
function accessOf(model: ClassModel, key: string): PropertyAccess {
|
|
return model[key]?.access ?? 'readwrite';
|
|
}
|
|
|
|
/** The name this property takes in JSON: an explicit @JsonProperty, else the naming strategy. */
|
|
function outboundName(model: ClassModel, key: string, naming: NamingStrategyFn): string {
|
|
return model[key]?.name ?? naming(key);
|
|
}
|
|
|
|
/** Per-property serialization facts, resolved once instead of per call. */
|
|
interface OutboundProperty {
|
|
/** The name to write in the output. */
|
|
name: string;
|
|
/** True for @JsonIgnore / @JsonWriteOnly — omitted from output. */
|
|
skip: boolean;
|
|
/** The @JsonSerialize class, if any. */
|
|
serializer?: any;
|
|
}
|
|
|
|
const outboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
|
|
|
/**
|
|
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
|
*
|
|
* Serialization walks the runtime keys of each object, so undeclared properties turn up here
|
|
* too; they memoize just as well, since the naming strategy is deterministic.
|
|
*/
|
|
function outboundFor(model: ClassModel, key: string, ctx: SerializeContext): OutboundProperty {
|
|
let entry = outboundCache.get(model);
|
|
if (!entry || entry.version !== modelVersion()) {
|
|
entry = { version: modelVersion(), byStrategy: new Map() };
|
|
outboundCache.set(model, entry);
|
|
}
|
|
|
|
let byKey = entry.byStrategy.get(ctx.namingKey);
|
|
if (!byKey) {
|
|
byKey = new Map();
|
|
entry.byStrategy.set(ctx.namingKey, byKey);
|
|
}
|
|
|
|
let resolved = byKey.get(key);
|
|
if (!resolved) {
|
|
const access = accessOf(model, key);
|
|
const serializer = model[key]?.serializer;
|
|
resolved = {
|
|
name: outboundName(model, key, ctx.naming),
|
|
// `writeonly` is accepted on input but must never be echoed back out.
|
|
skip: access === 'none' || access === 'writeonly',
|
|
...(serializer ? { serializer } : {}),
|
|
};
|
|
byKey.set(key, resolved);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/** Per-property deserialization facts, resolved once instead of per call. */
|
|
interface InboundProperty {
|
|
deserializer?: any;
|
|
polymorphic?: any;
|
|
typeFn?: () => ClassConstructor<any>;
|
|
}
|
|
|
|
interface InboundNames {
|
|
/** JSON name -> property key, for properties this payload is allowed to set. */
|
|
accept: Map<string, string>;
|
|
/** property key -> the conversion metadata that applies to it. */
|
|
props: Map<string, InboundProperty>;
|
|
/**
|
|
* JSON names that belong to a declared property the payload may NOT set
|
|
* (`@JsonIgnore` / `@JsonReadOnly`), plus those properties' own keys. They are dropped
|
|
* rather than treated as unknown keys — otherwise the default `unknownKeys: 'allow'` policy
|
|
* would copy them straight back onto the instance and undo the protection.
|
|
*/
|
|
blocked: Set<string>;
|
|
/**
|
|
* Names that used to reach a declared property but no longer do: the property key of a
|
|
* field renamed by `@JsonProperty`, or its raw key under a naming strategy that renders it
|
|
* differently.
|
|
*
|
|
* These are kept apart from `blocked` because they mean something different. A blocked name
|
|
* is a deliberate refusal, so it is dropped in silence. A stale name is a mismatch between
|
|
* this class and whatever produced the payload, which a caller who asked for
|
|
* `unknownKeys: 'error'` wants to hear about — and can be told precisely, since we know
|
|
* which property it was reaching for and what that property is called now.
|
|
*/
|
|
stale: Map<string, { property: string; accepted: string }>;
|
|
}
|
|
|
|
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
|
// declared, so they are cached per (prototype, naming strategy).
|
|
const inboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
|
|
|
/**
|
|
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
|
*
|
|
* Only names the class actually declares are mapped: the `@JsonProperty` name (or the naming
|
|
* strategy's rendering of the property name) plus any `@JsonAlias`.
|
|
*
|
|
* A name that no longer reaches its property must not fall through to the unknown-key policy,
|
|
* because `allow` would then copy it onto the instance raw — landing a value on a declared
|
|
* property having skipped the `@JsonType` or `@JsonDeserialize` declared for it, so that
|
|
* `@ValidateNested` finds a plain object with no model and reports nothing. Renaming a
|
|
* property has to actually take effect. Those names go into `stale` (reported under `error`,
|
|
* dropped otherwise), and the keys of properties the payload may not set at all go into
|
|
* `blocked` (always dropped in silence).
|
|
*/
|
|
function inboundNameMap(model: ClassModel, ctx: DeserializeContext): InboundNames {
|
|
let entry = inboundCache.get(model);
|
|
if (!entry || entry.version !== modelVersion()) {
|
|
entry = { version: modelVersion(), byStrategy: new Map() };
|
|
inboundCache.set(model, entry);
|
|
}
|
|
const cached = entry.byStrategy.get(ctx.namingKey);
|
|
if (cached) return cached;
|
|
|
|
const accept = new Map<string, string>();
|
|
const blocked = new Set<string>();
|
|
const stale = new Map<string, { property: string; accepted: string }>();
|
|
const props = new Map<string, InboundProperty>();
|
|
|
|
// Whether a property key is also some property's accepted JSON name is only known once
|
|
// every property has been walked, so these are resolved after the loop.
|
|
const shadowed: string[] = [];
|
|
const staleCandidates: { property: string; accepted: string }[] = [];
|
|
|
|
const claim = (external: string, key: string) => {
|
|
const owner = accept.get(external);
|
|
if (owner && owner !== key) {
|
|
throw new JsonMappingError(
|
|
`Properties "${owner}" and "${key}" both map to the JSON name ${JSON.stringify(external)}. ` +
|
|
`Give one of them a distinct @JsonProperty name.`
|
|
);
|
|
}
|
|
accept.set(external, key);
|
|
};
|
|
|
|
for (const [key, property] of Object.entries(model)) {
|
|
const names = [outboundName(model, key, ctx.naming), ...(property.aliases ?? [])];
|
|
|
|
const access = accessOf(model, key);
|
|
if (access === 'none' || access === 'readonly') {
|
|
for (const name of names) blocked.add(name);
|
|
// A renamed read-only property would otherwise still be settable under its own key,
|
|
// which is the protection undone by a different route.
|
|
shadowed.push(key);
|
|
continue;
|
|
}
|
|
|
|
for (const name of names) claim(name, key);
|
|
if (!names.includes(key)) staleCandidates.push({ property: key, accepted: names[0]! });
|
|
|
|
if (property.deserializer || property.polymorphic || property.type) {
|
|
props.set(key, {
|
|
...(property.deserializer ? { deserializer: property.deserializer } : {}),
|
|
...(property.polymorphic ? { polymorphic: property.polymorphic } : {}),
|
|
...(property.type ? { typeFn: property.type } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
// A property key that another property legitimately answers to stays mapped; only keys that
|
|
// nothing accepts are refused.
|
|
for (const key of shadowed) {
|
|
if (!accept.has(key)) blocked.add(key);
|
|
}
|
|
for (const candidate of staleCandidates) {
|
|
if (!accept.has(candidate.property) && !blocked.has(candidate.property)) {
|
|
stale.set(candidate.property, candidate);
|
|
}
|
|
}
|
|
|
|
const result = { accept, blocked, stale, props };
|
|
entry.byStrategy.set(ctx.namingKey, result);
|
|
return result;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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.`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Values that carry their data in internal slots rather than in own enumerable properties.
|
|
*
|
|
* Walking one of these with `Object.keys` yields `{}` — a populated `Map` becomes an empty
|
|
* object and nothing anywhere says so. Keyed by `Symbol.toStringTag`, which every one of them
|
|
* defines on its prototype, so the lookup costs a single property read and still recognises
|
|
* instances that came from another realm.
|
|
*/
|
|
const UNREPRESENTABLE: Record<string, string> = {
|
|
Map: 'a Map',
|
|
Set: 'a Set',
|
|
WeakMap: 'a WeakMap',
|
|
WeakSet: 'a WeakSet',
|
|
WeakRef: 'a WeakRef',
|
|
Promise: 'a Promise',
|
|
ArrayBuffer: 'an ArrayBuffer',
|
|
SharedArrayBuffer: 'a SharedArrayBuffer',
|
|
DataView: 'a DataView',
|
|
Generator: 'a generator',
|
|
AsyncGenerator: 'an async generator',
|
|
};
|
|
|
|
/**
|
|
* Describes why a value cannot be represented in JSON, or returns null if it can.
|
|
*
|
|
* The alternative to raising this is what the engine used to do: emit `{}` for a `Map`,
|
|
* index-keyed noise for a `Uint8Array`, and a bigint that makes the caller's own
|
|
* `JSON.stringify` throw somewhere else entirely. This library's position is that silent
|
|
* success is the worst failure mode a mapping layer can have, and that has to include its own.
|
|
*/
|
|
function unrepresentableObject(value: object): string | null {
|
|
const tag = (value as Record<symbol, unknown>)[Symbol.toStringTag];
|
|
if (typeof tag === 'string') {
|
|
const known = UNREPRESENTABLE[tag];
|
|
if (known !== undefined) return known;
|
|
// Typed arrays are tagged with their own name and would serialize to `{"0":…,"1":…}`.
|
|
if (ArrayBuffer.isView(value)) return `a ${tag}`;
|
|
}
|
|
|
|
// RegExp and Error get their `Object.prototype.toString` tag from a spec special case
|
|
// rather than from `Symbol.toStringTag`, so neither is caught above.
|
|
if (value instanceof RegExp) return 'a RegExp';
|
|
if (value instanceof Error) return 'an Error, whose message and stack are not enumerable';
|
|
|
|
return null;
|
|
}
|
|
|
|
const BIGINT_REASON = 'a bigint, which JSON has no representation for';
|
|
|
|
/** The same question for a value of any type. `serialize` inlines the primitive half. */
|
|
function unrepresentable(value: unknown): string | null {
|
|
switch (typeof value) {
|
|
case 'bigint': return BIGINT_REASON;
|
|
case 'symbol': return 'a symbol';
|
|
case 'function': return 'a function';
|
|
case 'object': return value === null ? null : unrepresentableObject(value);
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The trail of keys walked to reach a value: property names as strings, array positions as
|
|
* numbers. Numbers are kept unformatted so that walking an array costs no string building.
|
|
*/
|
|
type Path = (string | number)[];
|
|
|
|
/** Renders a {@link Path} for error messages. */
|
|
function describePath(path: readonly (string | number)[]): string {
|
|
if (path.length === 0) return 'the value passed in';
|
|
let out = '';
|
|
for (const segment of path) {
|
|
if (typeof segment === 'number') out += `[${segment}]`;
|
|
else out += out === '' ? segment : `.${segment}`;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function refuseUnrepresentable(why: string, path: readonly (string | number)[]): never {
|
|
throw new JsonMappingError(
|
|
`${describePath(path)} is ${why}, which cannot be serialized to JSON. ` +
|
|
'Give the property a @JsonSerialize() serializer that converts it, or drop it from the ' +
|
|
'output with @JsonIgnore().'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param path The keys walked to reach `obj`, kept as a stack so that errors can name the
|
|
* offending property. Pushed and popped rather than concatenated, so the bookkeeping costs
|
|
* no string building on the way down.
|
|
*/
|
|
function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number, deferred: Deferred, path: Path): any {
|
|
if (obj === null || obj === undefined) return obj;
|
|
|
|
// Primitives dominate the walk, so their check is inline: one `typeof` and, for the three
|
|
// types JSON cannot carry, a throw. Everything else defers to `unrepresentableObject`,
|
|
// which is only reached once per object and skipped entirely for arrays and dates.
|
|
const type = typeof obj;
|
|
if (type !== 'object') {
|
|
if (type === 'bigint') refuseUnrepresentable(BIGINT_REASON, path);
|
|
if (type === 'symbol') refuseUnrepresentable('a symbol', path);
|
|
if (type === 'function') refuseUnrepresentable('a function', path);
|
|
return obj;
|
|
}
|
|
|
|
if (depth > ctx.maxDepth) {
|
|
throw new JsonMappingError(
|
|
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing at ${describePath(path)}. ` +
|
|
`Raise it with the maxDepth option if this structure is legitimate.`
|
|
);
|
|
}
|
|
|
|
if (obj instanceof Date) {
|
|
return obj.toISOString();
|
|
}
|
|
|
|
const isArray = Array.isArray(obj);
|
|
if (!isArray) {
|
|
const why = unrepresentableObject(obj);
|
|
if (why !== null) refuseUnrepresentable(why, path);
|
|
}
|
|
|
|
if (ancestors.has(obj)) {
|
|
throw new JsonMappingError(
|
|
`Circular reference detected during serialization at ${describePath(path)}. Break the cycle ` +
|
|
'with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for ' +
|
|
'that property.'
|
|
);
|
|
}
|
|
|
|
ancestors.add(obj);
|
|
try {
|
|
if (isArray) {
|
|
const out: any[] = [];
|
|
for (let index = 0; index < obj.length; index++) {
|
|
path.push(index);
|
|
out.push(serialize(obj[index], ancestors, ctx, depth + 1, deferred, path));
|
|
path.pop();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const model = modelOfInstance(obj);
|
|
|
|
const result: any = {};
|
|
for (const key of Object.keys(obj)) {
|
|
const property = outboundFor(model, key, ctx);
|
|
if (property.skip) continue;
|
|
|
|
const value = obj[key];
|
|
path.push(key);
|
|
|
|
// 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) {
|
|
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.
|
|
const where = [...path];
|
|
result[slot] = undefined;
|
|
deferred.push(produced.then((settled: any) => {
|
|
const bad = unrepresentable(settled);
|
|
if (bad !== null) refuseUnrepresentable(bad, where);
|
|
result[slot] = settled;
|
|
}));
|
|
} else {
|
|
// A serializer that hands back a Map is the same silent `{}` by another route.
|
|
const bad = unrepresentable(produced);
|
|
if (bad !== null) refuseUnrepresentable(bad, path);
|
|
result[property.name] = produced;
|
|
}
|
|
} else {
|
|
result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred, path);
|
|
}
|
|
|
|
path.pop();
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
// Only direct ancestors count as a cycle; the same object appearing twice in
|
|
// sibling positions (a diamond) is perfectly serializable.
|
|
ancestors.delete(obj);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
throw new JsonMappingError(
|
|
`Maximum nesting depth of ${ctx.maxDepth} exceeded while deserializing. ` +
|
|
`Raise it with the maxDepth option if this structure is legitimate.`
|
|
);
|
|
}
|
|
|
|
if (Array.isArray(plain)) {
|
|
return plain.map(item => deserialize(clazz, item, ctx, depth + 1, deferred)) as any;
|
|
}
|
|
|
|
if (typeof plain !== 'object') return plain;
|
|
|
|
const instance = new clazz();
|
|
const inbound = inboundNameMap(modelOf(clazz), ctx);
|
|
|
|
for (const incoming of Object.keys(plain)) {
|
|
if (FORBIDDEN_KEYS.has(incoming)) continue;
|
|
|
|
// A declared property the payload is not allowed to set. Ignoring it is deliberate:
|
|
// rejecting the whole request because a client echoed back a server-owned id is worse
|
|
// than quietly refusing to honour it.
|
|
if (inbound.blocked.has(incoming)) continue;
|
|
|
|
// A name that used to reach a declared property. Never assigned — doing so would land the
|
|
// value on that property having skipped every conversion declared for it — but a caller
|
|
// who asked to hear about unrecognised keys hears about this one by name, because it is a
|
|
// mismatch with whatever produced the payload rather than a deliberate refusal.
|
|
const outdated = inbound.stale.get(incoming);
|
|
if (outdated !== undefined) {
|
|
if (ctx.unknownKeys === 'error') {
|
|
throw new JsonMappingError(
|
|
`${JSON.stringify(incoming)} is not a JSON name for ${clazz.name}: property ` +
|
|
`"${outdated.property}" is mapped to ${JSON.stringify(outdated.accepted)}. ` +
|
|
`Send that name, or add @JsonAlias(${JSON.stringify(incoming)}) to keep accepting this one.`
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const key = inbound.accept.get(incoming);
|
|
if (key === undefined) {
|
|
// Not a declared property under the active naming strategy.
|
|
if (ctx.unknownKeys === 'strip') continue;
|
|
if (ctx.unknownKeys === 'error') {
|
|
throw new JsonMappingError(
|
|
`Unknown property ${JSON.stringify(incoming)} for ${clazz.name}. ` +
|
|
`Allowed: ${[...inbound.accept.keys()].map(k => JSON.stringify(k)).join(', ') || '(none declared)'}.`
|
|
);
|
|
}
|
|
instance[incoming as keyof T] = plain[incoming];
|
|
continue;
|
|
}
|
|
|
|
const value = plain[incoming];
|
|
|
|
const property = inbound.props.get(key);
|
|
|
|
// Custom Deserializer
|
|
if (property?.deserializer) {
|
|
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;
|
|
}
|
|
|
|
// Polymorphic
|
|
const poly = property?.polymorphic;
|
|
if (poly && value !== null && value !== undefined) {
|
|
const { discriminator, subTypes, onUnknown, fallback } = poly;
|
|
|
|
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, 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 ` +
|
|
`"${key}". Known values: ${subTypes.map((s: any) => JSON.stringify(s.name)).join(', ')}.`
|
|
);
|
|
}
|
|
// Preserve the raw value. Dropping it silently loses data the caller sent.
|
|
return item;
|
|
};
|
|
|
|
instance[key as keyof T] = Array.isArray(value) ? value.map(resolve) as any : resolve(value);
|
|
continue;
|
|
}
|
|
|
|
// Nested Type
|
|
const typeFn = property?.typeFn;
|
|
if (typeFn && value !== null && value !== undefined) {
|
|
const type = typeFn();
|
|
instance[key as keyof T] = deserialize(type, value, ctx, depth + 1, deferred);
|
|
continue;
|
|
}
|
|
|
|
instance[key as keyof T] = value;
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* Everything the validator needs to know about one property, resolved once.
|
|
*/
|
|
interface PropertyPlan {
|
|
key: string;
|
|
constraints: ValidationConstraint[];
|
|
isOptional: boolean;
|
|
isNested: boolean;
|
|
/** True for properties that never leave the process (@JsonWriteOnly / @JsonIgnore). */
|
|
redact: boolean;
|
|
condition?: (object: any) => boolean;
|
|
}
|
|
|
|
interface CachedPlan {
|
|
version: number;
|
|
plan: PropertyPlan[];
|
|
}
|
|
|
|
// Turning a class model into a per-property plan is cheap, but doing it on every call was
|
|
// measurably not: profiling showed roughly half of all validation time re-deriving answers
|
|
// that cannot change. Plans are memoized per model and invalidated by the model version.
|
|
const planCache = /*#__PURE__*/ new WeakMap<ClassModel, CachedPlan>();
|
|
|
|
/**
|
|
* Collapses rules that are genuinely identical.
|
|
*
|
|
* Inheritance is structural here: a subclass's model starts as a copy of its base's, so
|
|
* re-stating `@IsString()` on an override would otherwise report the same failure twice.
|
|
* Only rules with a fixed message are compared — anything with a computed message, custom
|
|
* validators in particular, is always kept, since two of them can differ while looking alike.
|
|
*/
|
|
function dedupe(constraints: ValidationConstraint[]): ValidationConstraint[] {
|
|
const kept: ValidationConstraint[] = [];
|
|
const seen = new Set<string>();
|
|
for (const constraint of constraints) {
|
|
if (typeof constraint.message === 'string') {
|
|
const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}|${constraint.each ?? false}`;
|
|
if (seen.has(identity)) continue;
|
|
seen.add(identity);
|
|
}
|
|
kept.push(constraint);
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
function validationPlan(model: ClassModel): PropertyPlan[] {
|
|
const cached = planCache.get(model);
|
|
if (cached && cached.version === modelVersion()) {
|
|
return cached.plan;
|
|
}
|
|
|
|
const plan: PropertyPlan[] = [];
|
|
for (const [key, property] of Object.entries(model) as [string, PropertyModel][]) {
|
|
const access = property.access ?? 'readwrite';
|
|
plan.push({
|
|
key,
|
|
constraints: dedupe(property.constraints),
|
|
isOptional: !!property.optional,
|
|
isNested: !!property.nested,
|
|
redact: access === 'writeonly' || access === 'none',
|
|
...(property.condition ? { condition: property.condition } : {}),
|
|
});
|
|
}
|
|
|
|
planCache.set(model, { version: modelVersion(), plan });
|
|
return plan;
|
|
}
|
|
|
|
// Serializers and deserializers are stateless by contract, so one instance per class is
|
|
// enough. Constructing a fresh one for every property of every object was pure waste.
|
|
const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
|
|
|
|
function converterFor(clazz: any): any {
|
|
let instance = converterCache.get(clazz);
|
|
if (!instance) {
|
|
instance = new clazz();
|
|
converterCache.set(clazz, instance);
|
|
}
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* Records a failure without letting a later constraint overwrite an earlier one that happens
|
|
* to share a name (two `@Min` rules, or a rule inherited and re-declared).
|
|
*/
|
|
function recordFailure(constraints: { [key: string]: string }, name: string, message: string) {
|
|
if (!(name in constraints)) {
|
|
constraints[name] = message;
|
|
return;
|
|
}
|
|
let suffix = 2;
|
|
while (`${name}_${suffix}` in constraints) suffix++;
|
|
constraints[`${name}_${suffix}`] = message;
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
if (depth > maxDepth) {
|
|
throw new JsonMappingError(
|
|
`Maximum nesting depth of ${maxDepth} exceeded while validating. ` +
|
|
`Raise it with the maxDepth option if this structure is legitimate.`
|
|
);
|
|
}
|
|
|
|
// A cycle has already been validated further up the stack; re-entering it would never
|
|
// terminate. Diamonds are still validated on each distinct path.
|
|
if (ancestors.has(obj)) return errors;
|
|
ancestors.add(obj);
|
|
|
|
try {
|
|
if (Array.isArray(obj)) {
|
|
for (let i = 0; i < obj.length; i++) {
|
|
const childErrors = validateInternal(obj[i], ancestors, depth + 1, maxDepth, deferred);
|
|
if (childErrors.length > 0) {
|
|
errors.push({
|
|
property: `[${i}]`,
|
|
value: obj[i],
|
|
constraints: {},
|
|
children: childErrors
|
|
});
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
for (const property of validationPlan(modelOfInstance(obj))) {
|
|
const key = property.key;
|
|
const value = obj[key];
|
|
|
|
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
|
|
if (property.condition && !property.condition(obj)) {
|
|
continue;
|
|
}
|
|
|
|
// Handle IsOptional
|
|
if (property.isOptional && (value === null || value === undefined)) {
|
|
continue;
|
|
}
|
|
|
|
const propertyErrors: ValidationError = {
|
|
property: key,
|
|
// 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: {}
|
|
};
|
|
|
|
const validationArgs: ValidationArguments = {
|
|
value: value,
|
|
object: obj,
|
|
property: key,
|
|
constraints: []
|
|
};
|
|
|
|
let awaited = false;
|
|
|
|
for (const constraint of property.constraints) {
|
|
validationArgs.constraints = constraint.constraints || [];
|
|
|
|
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.
|
|
? 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 (!outcome.ok) {
|
|
if (outcome.index >= 0) validationArgs.value = value[outcome.index];
|
|
const message = messageFor(constraint, validationArgs, outcome.index);
|
|
validationArgs.value = value;
|
|
recordFailure(propertyErrors.constraints, constraint.name, message);
|
|
}
|
|
}
|
|
|
|
// Recursive validation
|
|
if (property.isNested && value !== null && value !== undefined) {
|
|
const nestedErrors = validateInternal(value, ancestors, depth + 1, maxDepth, deferred);
|
|
if (nestedErrors.length > 0) {
|
|
propertyErrors.children = nestedErrors;
|
|
}
|
|
}
|
|
|
|
// `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);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
} finally {
|
|
ancestors.delete(obj);
|
|
}
|
|
}
|
|
|
|
function serializeContext(options?: TransformOptions): SerializeContext {
|
|
const resolved = resolveOptions(options);
|
|
return {
|
|
naming: resolveNamingStrategy(resolved.namingStrategy),
|
|
namingKey: resolved.namingStrategy,
|
|
maxDepth: resolved.maxDepth,
|
|
};
|
|
}
|
|
|
|
function deserializeContext(options?: TransformOptions): DeserializeContext {
|
|
const resolved = resolveOptions(options);
|
|
return {
|
|
naming: resolveNamingStrategy(resolved.namingStrategy),
|
|
namingKey: resolved.namingStrategy,
|
|
unknownKeys: resolved.unknownKeys,
|
|
maxDepth: resolved.maxDepth,
|
|
};
|
|
}
|
|
|
|
// --- Public API Functions ---
|
|
|
|
/**
|
|
* 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[]> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Validates an object and throws {@link JsonValidationError} if it fails.
|
|
*
|
|
* 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, 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts a class instance to a plain object.
|
|
*
|
|
* Validates first and throws {@link JsonValidationError} on failure, unless
|
|
* `{ validate: false }` is passed.
|
|
*
|
|
* @param obj The class instance to transform
|
|
* @param options Per-call transform options
|
|
* @returns Plain object
|
|
*/
|
|
export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<any> {
|
|
if (obj === null || obj === undefined) return obj;
|
|
|
|
if (resolveOptions(options).validate) {
|
|
const errors = await validate(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, []);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Converts a class instance to a JSON string.
|
|
* @param obj The class instance to transform
|
|
* @param options Per-call transform options
|
|
* @returns JSON string
|
|
*/
|
|
export async function toJson<T>(obj: T, options?: TransformOptions): Promise<string> {
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* Converts a plain object to a class instance.
|
|
*
|
|
* Validates the result and throws {@link JsonValidationError} on failure, unless
|
|
* `{ validate: false }` is passed.
|
|
*
|
|
* @param clazz The class constructor
|
|
* @param plain The plain object to transform
|
|
* @param options Per-call transform options
|
|
* @returns Class instance
|
|
*/
|
|
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
|
|
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);
|
|
if (errors.length > 0) {
|
|
throw new JsonValidationError('Validation failed during deserialization', errors);
|
|
}
|
|
}
|
|
|
|
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.
|
|
*
|
|
* `toInstance` also accepts arrays at runtime, but its return type says `T`. Use this when
|
|
* the payload is a collection so the static type matches what you actually get back.
|
|
*
|
|
* @param clazz The class constructor
|
|
* @param plain The array of plain objects to transform
|
|
* @param options Per-call transform options
|
|
* @returns Array of class instances
|
|
*/
|
|
export async function toInstanceArray<T>(
|
|
clazz: ClassConstructor<T>,
|
|
plain: any[],
|
|
options?: TransformOptions
|
|
): Promise<T[]> {
|
|
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
|
|
* @param json JSON string
|
|
* @param options Per-call transform options
|
|
* @returns Class instance
|
|
*/
|
|
export async function fromJson<T>(clazz: ClassConstructor<T>, json: string, options?: TransformOptions): Promise<T> {
|
|
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
|
|
* @param json JSON string holding an array
|
|
* @param options Per-call transform options
|
|
* @returns Array of class instances
|
|
*/
|
|
export async function fromJsonArray<T>(
|
|
clazz: ClassConstructor<T>,
|
|
json: string,
|
|
options?: TransformOptions
|
|
): Promise<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);
|
|
} catch (error) {
|
|
throw new JsonMappingError(
|
|
`Input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @returns Class instance
|
|
*/
|
|
export async function fromRequest<T>(
|
|
clazz: ClassConstructor<T>,
|
|
request: JsonBody,
|
|
options?: TransformOptions
|
|
): Promise<T> {
|
|
let plain: any;
|
|
try {
|
|
plain = await request.json();
|
|
} catch (error) {
|
|
throw new JsonMappingError(
|
|
`Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
return toInstance(clazz, plain, options);
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use standalone functions like toPlain, toInstance, etc.
|
|
*/
|
|
export class JsonMapper {
|
|
static toPlain = toPlain;
|
|
static toJson = toJson;
|
|
static toInstance = toInstance;
|
|
static toInstanceArray = toInstanceArray;
|
|
static fromJson = fromJson;
|
|
static fromJsonArray = fromJsonArray;
|
|
static fromRequest = fromRequest;
|
|
static validate = validate;
|
|
static validateOrReject = validateOrReject;
|
|
}
|