✨ feat: add field-name mapping, access control and transform options
A library whose headline feature is "JSON mapping" could not map a name: there
was no way to read {"first_name": ...} into firstName, no way to keep a password
out of the response, and no way to parse a payload without validating it.
Name mapping
- @JsonProperty(name) renames a property in both directions
- @JsonAlias(...names) accepts extra names on input only, so a field can be
renamed without breaking older clients
- naming strategies (snake_case, kebab-case, SCREAMING_SNAKE_CASE, PascalCase,
camelCase, or your own function) for properties with no explicit name.
Acronyms split where a reader expects: parseHTTPResponse -> parse_http_response
Access control
- @JsonIgnore() excluded both ways
- @JsonWriteOnly() accepted from input, never echoed back (passwords)
- @JsonReadOnly() serialized, never settable by a client (server-owned ids)
Blocked names are dropped explicitly rather than falling through to the unknown
key path, which would otherwise have copied a rejected id straight back on under
the default policy.
Transform options, per call or globally via configure()
- validate: false to map without validating, for lenient parsing
- unknownKeys: 'allow' | 'strip' | 'error'
- namingStrategy
Error ergonomics — the nested ValidationError tree was hard to turn into an HTTP
400 body. flattenErrors() yields {"items[0].qty": ["qty must be at least 1"]},
plus formatErrors() and collectErrorMessages(). Adds validateOrReject().
All defaults preserve existing behaviour; the 68 prior tests pass unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
import { NamingStrategy } from './naming.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an incoming key that maps to no known property should be treated.
|
||||||
|
*
|
||||||
|
* - `allow` (default): copy it onto the instance untouched, preserving the previous behaviour.
|
||||||
|
* - `strip`: drop it, so instances only ever carry declared properties.
|
||||||
|
* - `error`: reject the payload with a {@link JsonMappingError}.
|
||||||
|
*/
|
||||||
|
export type UnknownKeyPolicy = 'allow' | 'strip' | 'error';
|
||||||
|
|
||||||
|
export interface TransformOptions {
|
||||||
|
/**
|
||||||
|
* Validate the result and throw {@link JsonValidationError} on failure.
|
||||||
|
*
|
||||||
|
* Defaults to `true`, matching the behaviour of every previous release. Set it to `false`
|
||||||
|
* to map without validating — useful when you want to inspect a partially-valid payload,
|
||||||
|
* or when validation happens elsewhere in your stack.
|
||||||
|
*/
|
||||||
|
validate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naming convention used on the JSON side for properties without an explicit
|
||||||
|
* `@JsonProperty`. Defaults to `identity` (property names are used as-is).
|
||||||
|
*/
|
||||||
|
namingStrategy?: NamingStrategy;
|
||||||
|
|
||||||
|
/** What to do with incoming keys that match no declared property. Deserialization only. */
|
||||||
|
unknownKeys?: UnknownKeyPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options that can be set once for the whole application via {@link configure}. */
|
||||||
|
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate'>;
|
||||||
|
|
||||||
|
const DEFAULTS: Required<GlobalOptions> = {
|
||||||
|
namingStrategy: 'identity',
|
||||||
|
unknownKeys: 'allow',
|
||||||
|
validate: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets library-wide defaults, so an application that consistently speaks `snake_case` does
|
||||||
|
* not have to repeat itself at every call site.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* configure({ namingStrategy: 'snake_case', unknownKeys: 'strip' });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Per-call options always take precedence over these.
|
||||||
|
*/
|
||||||
|
export function configure(options: GlobalOptions): void {
|
||||||
|
globalOptions = { ...globalOptions, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the current library-wide defaults. */
|
||||||
|
export function getConfig(): Required<GlobalOptions> {
|
||||||
|
return { ...globalOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores the library-wide defaults to their original values. */
|
||||||
|
export function resetConfig(): void {
|
||||||
|
globalOptions = { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges per-call options over the library-wide defaults. */
|
||||||
|
export function resolveOptions(options?: TransformOptions): Required<GlobalOptions> {
|
||||||
|
if (!options) return globalOptions;
|
||||||
|
return {
|
||||||
|
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
|
||||||
|
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
|
||||||
|
validate: options.validate ?? globalOptions.validate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,8 +10,21 @@ export const METADATA_KEYS = {
|
|||||||
POLYMORPHIC: 'cereale:polymorphic',
|
POLYMORPHIC: 'cereale:polymorphic',
|
||||||
IS_OPTIONAL: 'cereale:optional',
|
IS_OPTIONAL: 'cereale:optional',
|
||||||
NESTED: 'cereale:nested',
|
NESTED: 'cereale:nested',
|
||||||
|
NAME: 'cereale:name',
|
||||||
|
ALIASES: 'cereale:aliases',
|
||||||
|
ACCESS: 'cereale:access',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which directions a property participates in.
|
||||||
|
*
|
||||||
|
* - `readwrite` (default): mapped both ways.
|
||||||
|
* - `readonly`: written to JSON, never populated from incoming JSON (server-assigned ids).
|
||||||
|
* - `writeonly`: populated from incoming JSON, never written back out (passwords).
|
||||||
|
* - `none`: ignored entirely.
|
||||||
|
*/
|
||||||
|
export type PropertyAccess = 'readwrite' | 'readonly' | 'writeonly' | 'none';
|
||||||
|
|
||||||
export interface ValidationArguments {
|
export interface ValidationArguments {
|
||||||
value: any;
|
value: any;
|
||||||
object: any;
|
object: any;
|
||||||
@@ -73,6 +86,86 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
|
|||||||
|
|
||||||
// --- Mapping Decorators ---
|
// --- Mapping Decorators ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @JsonProperty(name: string)
|
||||||
|
* Maps this property to a different name in JSON, in both directions.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* class User {
|
||||||
|
* @JsonProperty('first_name')
|
||||||
|
* firstName: string; // <-> {"first_name": "Ada"}
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* An explicit name always wins over the active naming strategy.
|
||||||
|
*/
|
||||||
|
export function JsonProperty(name: string) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
registerProperty(target, propertyKey);
|
||||||
|
metadataStorage.defineMetadata(METADATA_KEYS.NAME, name, target, propertyKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @JsonAlias(...names: string[])
|
||||||
|
* Additional names accepted for this property when reading JSON.
|
||||||
|
*
|
||||||
|
* Aliases are input-only — output always uses the canonical name — which makes them the
|
||||||
|
* tool for accepting a renamed field from older clients without emitting it.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* class User {
|
||||||
|
* @JsonProperty('surname')
|
||||||
|
* @JsonAlias('last_name', 'lastName')
|
||||||
|
* surname: string;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function JsonAlias(...names: string[]) {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
registerProperty(target, propertyKey);
|
||||||
|
const existing: string[] = metadataStorage.getOwnMetadata(METADATA_KEYS.ALIASES, target, propertyKey) || [];
|
||||||
|
metadataStorage.defineMetadata(METADATA_KEYS.ALIASES, [...existing, ...names], target, propertyKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @JsonIgnore()
|
||||||
|
* Excludes this property from mapping in both directions.
|
||||||
|
*/
|
||||||
|
export function JsonIgnore() {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
registerProperty(target, propertyKey);
|
||||||
|
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'none', target, propertyKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @JsonReadOnly()
|
||||||
|
* Serialized to JSON, but never populated from incoming JSON.
|
||||||
|
*
|
||||||
|
* For server-owned fields — ids, timestamps — that a client must not be able to set.
|
||||||
|
*/
|
||||||
|
export function JsonReadOnly() {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
registerProperty(target, propertyKey);
|
||||||
|
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'readonly', target, propertyKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @JsonWriteOnly()
|
||||||
|
* Populated from incoming JSON, but never serialized back out.
|
||||||
|
*
|
||||||
|
* For secrets — passwords, tokens — that you accept but must never echo.
|
||||||
|
*/
|
||||||
|
export function JsonWriteOnly() {
|
||||||
|
return (target: any, propertyKey: string) => {
|
||||||
|
registerProperty(target, propertyKey);
|
||||||
|
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'writeonly', target, propertyKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @JsonSerialize(serializer: ClassConstructor<JsonSerializer>)
|
* @JsonSerialize(serializer: ClassConstructor<JsonSerializer>)
|
||||||
* Custom serializer decorator.
|
* Custom serializer decorator.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ValidationError } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the nested {@link ValidationError} tree into a flat map of dotted paths to
|
||||||
|
* messages — the shape you actually want when turning a failure into an HTTP 400 body.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* flattenErrors(errors);
|
||||||
|
* // {
|
||||||
|
* // "name": ["name must be a string"],
|
||||||
|
* // "items[0].qty": ["qty must be at least 1"]
|
||||||
|
* // }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function flattenErrors(errors: ValidationError[]): Record<string, string[]> {
|
||||||
|
const flat: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
const walk = (nodes: ValidationError[], prefix: string) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Array indices read as `items[0]`, named properties as `order.total`.
|
||||||
|
const path = node.property.startsWith('[')
|
||||||
|
? `${prefix}${node.property}`
|
||||||
|
: prefix ? `${prefix}.${node.property}` : node.property;
|
||||||
|
|
||||||
|
const messages = Object.values(node.constraints);
|
||||||
|
if (messages.length > 0) {
|
||||||
|
(flat[path] ??= []).push(...messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
walk(node.children, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(errors, '');
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the error tree as human-readable lines, one per failed rule.
|
||||||
|
*
|
||||||
|
* Intended for logs and CLI output; use {@link flattenErrors} when the destination is JSON.
|
||||||
|
*/
|
||||||
|
export function formatErrors(errors: ValidationError[]): string {
|
||||||
|
const flat = flattenErrors(errors);
|
||||||
|
return Object.entries(flat)
|
||||||
|
.flatMap(([path, messages]) => messages.map(message => `${path}: ${message}`))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects every message in the tree, discarding paths.
|
||||||
|
*/
|
||||||
|
export function collectErrorMessages(errors: ValidationError[]): string[] {
|
||||||
|
return Object.values(flattenErrors(errors)).flat();
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
export * from './interfaces.js';
|
export * from './interfaces.js';
|
||||||
|
export * from './naming.js';
|
||||||
|
export * from './config.js';
|
||||||
export * from './decorators.js';
|
export * from './decorators.js';
|
||||||
|
export * from './errors.js';
|
||||||
export * from './utils.js';
|
export * from './utils.js';
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, IsInt, IsOptional, ValidateNested, Min,
|
||||||
|
JsonProperty, JsonAlias, JsonIgnore, JsonReadOnly, JsonWriteOnly, JsonType,
|
||||||
|
JsonMappingError, JsonValidationError,
|
||||||
|
toPlain, toJson, toInstance, fromJson, validate, validateOrReject,
|
||||||
|
configure, resetConfig, getConfig,
|
||||||
|
flattenErrors, formatErrors, collectErrorMessages,
|
||||||
|
resolveNamingStrategy,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
afterEach(() => resetConfig());
|
||||||
|
|
||||||
|
describe('@JsonProperty', () => {
|
||||||
|
class User {
|
||||||
|
@JsonProperty('first_name')
|
||||||
|
@IsString()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@JsonProperty('last_name')
|
||||||
|
@IsString()
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renames on the way out', async () => {
|
||||||
|
const u = new User();
|
||||||
|
u.firstName = 'Ada';
|
||||||
|
u.lastName = 'Lovelace';
|
||||||
|
|
||||||
|
await expect(toPlain(u)).resolves.toEqual({ first_name: 'Ada', last_name: 'Lovelace' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames on the way in', async () => {
|
||||||
|
const u = await fromJson(User, '{"first_name":"Ada","last_name":"Lovelace"}');
|
||||||
|
expect(u.firstName).toBe('Ada');
|
||||||
|
expect(u.lastName).toBe('Lovelace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips', async () => {
|
||||||
|
const json = '{"first_name":"Ada","last_name":"Lovelace"}';
|
||||||
|
expect(await toJson(await fromJson(User, json))).toBe(json);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no longer accepts the raw property name once renamed', async () => {
|
||||||
|
const u = await toInstance(
|
||||||
|
User,
|
||||||
|
{ firstName: 'Ada', last_name: 'L' },
|
||||||
|
{ unknownKeys: 'strip', validate: false }
|
||||||
|
);
|
||||||
|
expect(u.firstName).toBeUndefined();
|
||||||
|
expect(u.lastName).toBe('L');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects two properties claiming the same JSON name', async () => {
|
||||||
|
class Clash {
|
||||||
|
@JsonProperty('name')
|
||||||
|
a: string;
|
||||||
|
|
||||||
|
@JsonProperty('name')
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(JsonMappingError);
|
||||||
|
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(/both map to the JSON name/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@JsonAlias', () => {
|
||||||
|
class Person {
|
||||||
|
@JsonProperty('surname')
|
||||||
|
@JsonAlias('last_name', 'lastName')
|
||||||
|
@IsString()
|
||||||
|
surname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts every alias on input', async () => {
|
||||||
|
for (const key of ['surname', 'last_name', 'lastName']) {
|
||||||
|
const p = await toInstance(Person, { [key]: 'Hopper' });
|
||||||
|
expect(p.surname).toBe('Hopper');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never emits an alias on output', async () => {
|
||||||
|
const p = new Person();
|
||||||
|
p.surname = 'Hopper';
|
||||||
|
await expect(toPlain(p)).resolves.toEqual({ surname: 'Hopper' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('access control decorators', () => {
|
||||||
|
it('@JsonIgnore drops the property in both directions', async () => {
|
||||||
|
class Secretive {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@JsonIgnore()
|
||||||
|
internalNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = new Secretive();
|
||||||
|
s.name = 'x';
|
||||||
|
s.internalNote = 'do not leak';
|
||||||
|
await expect(toPlain(s)).resolves.toEqual({ name: 'x' });
|
||||||
|
|
||||||
|
const parsed = await toInstance(Secretive, { name: 'x', internalNote: 'injected' });
|
||||||
|
expect(parsed.internalNote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonWriteOnly accepts input but never echoes it back', async () => {
|
||||||
|
class Credentials {
|
||||||
|
@IsString()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = await toInstance(Credentials, { email: 'a@b.com', password: 'hunter2' });
|
||||||
|
expect(c.password).toBe('hunter2');
|
||||||
|
await expect(toPlain(c)).resolves.toEqual({ email: 'a@b.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonReadOnly is emitted but cannot be set by a client', async () => {
|
||||||
|
class Record {
|
||||||
|
@JsonReadOnly()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await toInstance(Record, { id: 999, title: 'hello' });
|
||||||
|
expect(r.id).toBeUndefined();
|
||||||
|
|
||||||
|
r.id = 1;
|
||||||
|
await expect(toPlain(r)).resolves.toEqual({ id: 1, title: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonReadOnly is not resurrected by the default unknownKeys policy', async () => {
|
||||||
|
class Record {
|
||||||
|
@JsonProperty('identifier')
|
||||||
|
@JsonReadOnly()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await toInstance(Record, { identifier: 999, title: 't' }, { unknownKeys: 'allow' });
|
||||||
|
expect(r.id).toBeUndefined();
|
||||||
|
expect((r as any).identifier).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('naming strategies', () => {
|
||||||
|
class Account {
|
||||||
|
@IsString()
|
||||||
|
accountHolderName: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
balanceInCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('snake_case both ways', async () => {
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
a.balanceInCents = 100;
|
||||||
|
|
||||||
|
const plain = await toPlain(a, { namingStrategy: 'snake_case' });
|
||||||
|
expect(plain).toEqual({ account_holder_name: 'Ada', balance_in_cents: 100 });
|
||||||
|
|
||||||
|
const back = await toInstance(Account, plain, { namingStrategy: 'snake_case' });
|
||||||
|
expect(back.accountHolderName).toBe('Ada');
|
||||||
|
expect(back.balanceInCents).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kebab-case, SCREAMING_SNAKE_CASE and PascalCase', async () => {
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
a.balanceInCents = 1;
|
||||||
|
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
|
||||||
|
.resolves.toEqual({ 'account-holder-name': 'Ada', 'balance-in-cents': 1 });
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'SCREAMING_SNAKE_CASE' }))
|
||||||
|
.resolves.toEqual({ ACCOUNT_HOLDER_NAME: 'Ada', BALANCE_IN_CENTS: 1 });
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'PascalCase' }))
|
||||||
|
.resolves.toEqual({ AccountHolderName: 'Ada', BalanceInCents: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom function', async () => {
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
a.balanceInCents = 1;
|
||||||
|
|
||||||
|
await expect(toPlain(a, { namingStrategy: (k) => `x_${k}` }))
|
||||||
|
.resolves.toEqual({ x_accountHolderName: 'Ada', x_balanceInCents: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonProperty wins over the naming strategy', async () => {
|
||||||
|
class Mixed {
|
||||||
|
@JsonProperty('EXPLICIT')
|
||||||
|
someField: string;
|
||||||
|
|
||||||
|
otherField: string;
|
||||||
|
}
|
||||||
|
const m = new Mixed();
|
||||||
|
m.someField = 'a';
|
||||||
|
m.otherField = 'b';
|
||||||
|
|
||||||
|
await expect(toPlain(m, { namingStrategy: 'snake_case' }))
|
||||||
|
.resolves.toEqual({ EXPLICIT: 'a', other_field: 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits acronyms the way a reader expects', () => {
|
||||||
|
const snake = resolveNamingStrategy('snake_case');
|
||||||
|
expect(snake('parseHTTPResponse')).toBe('parse_http_response');
|
||||||
|
expect(snake('firstName')).toBe('first_name');
|
||||||
|
expect(snake('id')).toBe('id');
|
||||||
|
expect(snake('already_snake')).toBe('already_snake');
|
||||||
|
|
||||||
|
const camel = resolveNamingStrategy('camelCase');
|
||||||
|
expect(camel('first_name')).toBe('firstName');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown strategy name', () => {
|
||||||
|
expect(() => resolveNamingStrategy('shouty' as any)).toThrow(/Unknown naming strategy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies to nested objects too', async () => {
|
||||||
|
class Inner {
|
||||||
|
@IsString()
|
||||||
|
innerValue: string;
|
||||||
|
}
|
||||||
|
class Outer {
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Inner)
|
||||||
|
outerChild: Inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = await toInstance(
|
||||||
|
Outer,
|
||||||
|
{ outer_child: { inner_value: 'v' } },
|
||||||
|
{ namingStrategy: 'snake_case' }
|
||||||
|
);
|
||||||
|
expect(parsed.outerChild).toBeInstanceOf(Inner);
|
||||||
|
expect(parsed.outerChild.innerValue).toBe('v');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configure()', () => {
|
||||||
|
class Account {
|
||||||
|
@IsString()
|
||||||
|
accountHolderName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sets a library-wide default', async () => {
|
||||||
|
configure({ namingStrategy: 'snake_case' });
|
||||||
|
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
await expect(toPlain(a)).resolves.toEqual({ account_holder_name: 'Ada' });
|
||||||
|
expect(getConfig().namingStrategy).toBe('snake_case');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is overridden by per-call options', async () => {
|
||||||
|
configure({ namingStrategy: 'snake_case' });
|
||||||
|
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
|
||||||
|
.resolves.toEqual({ 'account-holder-name': 'Ada' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetConfig() restores the defaults', async () => {
|
||||||
|
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
|
||||||
|
resetConfig();
|
||||||
|
expect(getConfig()).toEqual({ namingStrategy: 'identity', unknownKeys: 'allow', validate: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknownKeys policy', () => {
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
known: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('allow (default) copies unknown keys through', async () => {
|
||||||
|
const d = await toInstance(Dto, { known: 'a', extra: 'b' });
|
||||||
|
expect((d as any).extra).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strip drops them', async () => {
|
||||||
|
const d = await toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'strip' });
|
||||||
|
expect((d as any).extra).toBeUndefined();
|
||||||
|
expect(d.known).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error rejects the payload and names the offending key', async () => {
|
||||||
|
await expect(toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'error' }))
|
||||||
|
.rejects.toThrow(/Unknown property "extra"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets __proto__ through, whatever the policy', async () => {
|
||||||
|
for (const unknownKeys of ['allow', 'strip', 'error'] as const) {
|
||||||
|
const d = await toInstance(Dto, JSON.parse('{"known":"a","__proto__":{"x":1}}'), { unknownKeys });
|
||||||
|
expect(Object.getPrototypeOf(d)).toBe(Dto.prototype);
|
||||||
|
expect(({} as any).x).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate option', () => {
|
||||||
|
class Strict {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
age?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('throws by default', async () => {
|
||||||
|
await expect(toInstance(Strict, { name: 123 })).rejects.toThrow(JsonValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps without validating when told to', async () => {
|
||||||
|
const s = await toInstance(Strict, { name: 123 }, { validate: false });
|
||||||
|
expect(s).toBeInstanceOf(Strict);
|
||||||
|
expect(s.name).toBe(123 as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips validation on the way out too', async () => {
|
||||||
|
const s = new Strict();
|
||||||
|
s.name = 123 as any;
|
||||||
|
await expect(toPlain(s, { validate: false })).resolves.toEqual({ name: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateOrReject throws, validate returns', async () => {
|
||||||
|
const s = new Strict();
|
||||||
|
s.name = 123 as any;
|
||||||
|
|
||||||
|
await expect(validateOrReject(s)).rejects.toThrow(JsonValidationError);
|
||||||
|
await expect(validate(s)).resolves.toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error helpers', () => {
|
||||||
|
class Item {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
@IsString()
|
||||||
|
reference: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Item)
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFailing = () => {
|
||||||
|
const bad = new Item();
|
||||||
|
bad.qty = -5;
|
||||||
|
const o = new Order();
|
||||||
|
o.reference = 42 as any;
|
||||||
|
o.items = [bad];
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('flattenErrors produces dotted paths with array indices', async () => {
|
||||||
|
const errors = await validate(buildFailing());
|
||||||
|
const flat = flattenErrors(errors);
|
||||||
|
|
||||||
|
expect(flat['reference']).toEqual(['reference must be a string']);
|
||||||
|
expect(flat['items[0].qty']).toEqual(['qty must be at least 1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatErrors renders one line per failure', async () => {
|
||||||
|
const errors = await validate(buildFailing());
|
||||||
|
const text = formatErrors(errors);
|
||||||
|
|
||||||
|
expect(text).toContain('reference: reference must be a string');
|
||||||
|
expect(text).toContain('items[0].qty: qty must be at least 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collectErrorMessages returns just the messages', async () => {
|
||||||
|
const messages = collectErrorMessages(await validate(buildFailing()));
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages).toContain('qty must be at least 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty result for a valid object', async () => {
|
||||||
|
const o = new Order();
|
||||||
|
o.reference = 'ok';
|
||||||
|
o.items = [];
|
||||||
|
expect(flattenErrors(await validate(o))).toEqual({});
|
||||||
|
expect(formatErrors(await validate(o))).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a realistic API payload', () => {
|
||||||
|
it('maps a snake_case request and answers without the secret', async () => {
|
||||||
|
class SignUp {
|
||||||
|
@JsonReadOnly()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@JsonProperty('email_address')
|
||||||
|
@IsString()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
id: 999, // client must not be able to set this
|
||||||
|
email_address: 'ada@example.com',
|
||||||
|
password: 'hunter2',
|
||||||
|
display_name: 'Ada',
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUp = await fromJson(SignUp, body, { namingStrategy: 'snake_case' });
|
||||||
|
expect(signUp.id).toBeUndefined();
|
||||||
|
expect(signUp.email).toBe('ada@example.com');
|
||||||
|
expect(signUp.password).toBe('hunter2');
|
||||||
|
expect(signUp.displayName).toBe('Ada');
|
||||||
|
|
||||||
|
signUp.id = 1;
|
||||||
|
const response = await toJson(signUp, { namingStrategy: 'snake_case' });
|
||||||
|
expect(JSON.parse(response)).toEqual({
|
||||||
|
id: 1,
|
||||||
|
email_address: 'ada@example.com',
|
||||||
|
display_name: 'Ada',
|
||||||
|
});
|
||||||
|
expect(response).not.toContain('hunter2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Translates a class property name into the name used in JSON.
|
||||||
|
*
|
||||||
|
* Applied only to properties that do not carry an explicit `@JsonProperty`, which always wins.
|
||||||
|
*/
|
||||||
|
export type NamingStrategyFn = (propertyKey: string) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A built-in strategy name, or your own function.
|
||||||
|
*
|
||||||
|
* The built-ins assume property names are written in the TypeScript convention (camelCase)
|
||||||
|
* and convert away from it.
|
||||||
|
*/
|
||||||
|
export type NamingStrategy =
|
||||||
|
| 'identity'
|
||||||
|
| 'camelCase'
|
||||||
|
| 'PascalCase'
|
||||||
|
| 'snake_case'
|
||||||
|
| 'SCREAMING_SNAKE_CASE'
|
||||||
|
| 'kebab-case'
|
||||||
|
| NamingStrategyFn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits an identifier into lowercase words.
|
||||||
|
*
|
||||||
|
* Handles the two boundaries that matter in practice: a lowercase-or-digit followed by an
|
||||||
|
* uppercase (`firstName`), and an acronym running into a new word (`parseHTTPResponse`,
|
||||||
|
* where the split belongs before `Response`, not inside `HTTP`). Existing separators are
|
||||||
|
* treated as boundaries too, so an already-converted name survives a second pass unchanged.
|
||||||
|
*/
|
||||||
|
function words(propertyKey: string): string[] {
|
||||||
|
return propertyKey
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.replace(/[_\-\s]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(word => word.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (word: string): string => (word ? word.charAt(0).toUpperCase() + word.slice(1) : word);
|
||||||
|
|
||||||
|
const BUILT_INS: Record<Exclude<NamingStrategy, NamingStrategyFn>, NamingStrategyFn> = {
|
||||||
|
identity: (key) => key,
|
||||||
|
camelCase: (key) => {
|
||||||
|
const parts = words(key);
|
||||||
|
if (parts.length === 0) return key;
|
||||||
|
return parts[0] + parts.slice(1).map(capitalize).join('');
|
||||||
|
},
|
||||||
|
PascalCase: (key) => words(key).map(capitalize).join('') || key,
|
||||||
|
snake_case: (key) => words(key).join('_') || key,
|
||||||
|
SCREAMING_SNAKE_CASE: (key) => words(key).join('_').toUpperCase() || key,
|
||||||
|
'kebab-case': (key) => words(key).join('-') || key,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a {@link NamingStrategy} to the function that implements it.
|
||||||
|
*
|
||||||
|
* @throws Error when given a name that is not one of the built-in strategies.
|
||||||
|
*/
|
||||||
|
export function resolveNamingStrategy(strategy: NamingStrategy | undefined): NamingStrategyFn {
|
||||||
|
if (!strategy) return BUILT_INS.identity;
|
||||||
|
if (typeof strategy === 'function') return strategy;
|
||||||
|
|
||||||
|
const builtIn = BUILT_INS[strategy];
|
||||||
|
if (!builtIn) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown naming strategy ${JSON.stringify(strategy)}. ` +
|
||||||
|
`Use one of: ${Object.keys(BUILT_INS).join(', ')}, or pass your own function.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return builtIn;
|
||||||
|
}
|
||||||
+231
-50
@@ -1,6 +1,8 @@
|
|||||||
import { ClassConstructor } from './interfaces.js';
|
import { ClassConstructor } from './interfaces.js';
|
||||||
import { METADATA_KEYS, ValidationConstraint, ValidationArguments } from './decorators.js';
|
import { METADATA_KEYS, PropertyAccess, ValidationConstraint, ValidationArguments } from './decorators.js';
|
||||||
import { metadataStorage } from './metadata-storage.js';
|
import { metadataStorage } from './metadata-storage.js';
|
||||||
|
import { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
|
||||||
|
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
|
||||||
|
|
||||||
export interface ValidationError {
|
export interface ValidationError {
|
||||||
property: string;
|
property: string;
|
||||||
@@ -41,6 +43,16 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|||||||
|
|
||||||
// --- Internal Engine ---
|
// --- Internal Engine ---
|
||||||
|
|
||||||
|
interface SerializeContext {
|
||||||
|
naming: NamingStrategyFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeserializeContext {
|
||||||
|
naming: NamingStrategyFn;
|
||||||
|
namingKey: unknown;
|
||||||
|
unknownKeys: UnknownKeyPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the metadata lookup target for a value.
|
* Resolves the metadata lookup target for a value.
|
||||||
*
|
*
|
||||||
@@ -52,7 +64,84 @@ function prototypeOf(obj: any): any {
|
|||||||
return Object.getPrototypeOf(obj) ?? undefined;
|
return Object.getPrototypeOf(obj) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serialize(obj: any, ancestors: Set<any>): Promise<any> {
|
function accessOf(target: any, key: string): PropertyAccess {
|
||||||
|
return (target ? metadataStorage.getMetadata(METADATA_KEYS.ACCESS, target, key) : undefined) ?? 'readwrite';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name this property takes in JSON: an explicit @JsonProperty, else the naming strategy. */
|
||||||
|
function outboundName(target: any, key: string, naming: NamingStrategyFn): string {
|
||||||
|
const explicit = target ? metadataStorage.getMetadata(METADATA_KEYS.NAME, target, key) : undefined;
|
||||||
|
return explicit ?? naming(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboundNames {
|
||||||
|
/** JSON name -> property key, for properties this payload is allowed to set. */
|
||||||
|
accept: Map<string, string>;
|
||||||
|
/**
|
||||||
|
* JSON names that belong to a declared property the payload may NOT set
|
||||||
|
* (`@JsonIgnore` / `@JsonReadOnly`). 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = new WeakMap<object, Map<unknown, InboundNames>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
||||||
|
*
|
||||||
|
* Only names the class actually declares are accepted: the `@JsonProperty` name (or the
|
||||||
|
* naming strategy's rendering of the property name) plus any `@JsonAlias`. Renaming a
|
||||||
|
* property therefore stops the old name from being silently accepted — add `@JsonAlias` to
|
||||||
|
* keep it working for older clients.
|
||||||
|
*/
|
||||||
|
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
||||||
|
let byStrategy = inboundCache.get(target);
|
||||||
|
if (!byStrategy) {
|
||||||
|
byStrategy = new Map();
|
||||||
|
inboundCache.set(target, byStrategy);
|
||||||
|
}
|
||||||
|
const cached = byStrategy.get(ctx.namingKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const accept = new Map<string, string>();
|
||||||
|
const blocked = new Set<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 of metadataStorage.getProperties(target)) {
|
||||||
|
const names = [
|
||||||
|
outboundName(target, key, ctx.naming),
|
||||||
|
...(metadataStorage.getMetadata(METADATA_KEYS.ALIASES, target, key) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const access = accessOf(target, key);
|
||||||
|
if (access === 'none' || access === 'readonly') {
|
||||||
|
for (const name of names) blocked.add(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of names) claim(name, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { accept, blocked };
|
||||||
|
byStrategy.set(ctx.namingKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext): Promise<any> {
|
||||||
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
@@ -73,7 +162,7 @@ async function serialize(obj: any, ancestors: Set<any>): Promise<any> {
|
|||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
const out: any[] = [];
|
const out: any[] = [];
|
||||||
for (const item of obj) {
|
for (const item of obj) {
|
||||||
out.push(await serialize(item, ancestors));
|
out.push(await serialize(item, ancestors, ctx));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -82,16 +171,21 @@ async function serialize(obj: any, ancestors: Set<any>): Promise<any> {
|
|||||||
|
|
||||||
const result: any = {};
|
const result: any = {};
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
|
const access = accessOf(target, key);
|
||||||
|
// `writeonly` is accepted on input but must never be echoed back out.
|
||||||
|
if (access === 'none' || access === 'writeonly') continue;
|
||||||
|
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
|
const name = outboundName(target, key, ctx.naming);
|
||||||
|
|
||||||
// Custom serializers only see real values. Handing a serializer `undefined` for a
|
// Custom serializers only see real values. Handing a serializer `undefined` for a
|
||||||
// property that was simply never set turns an optional field into a crash.
|
// property that was simply never set turns an optional field into a crash.
|
||||||
const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
|
const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
|
||||||
if (serializerCls && value !== null && value !== undefined) {
|
if (serializerCls && value !== null && value !== undefined) {
|
||||||
const serializer = new serializerCls();
|
const serializer = new serializerCls();
|
||||||
result[key] = await serializer.serialize(value);
|
result[name] = await serializer.serialize(value);
|
||||||
} else {
|
} else {
|
||||||
result[key] = await serialize(value, ancestors);
|
result[name] = await serialize(value, ancestors, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +197,11 @@ async function serialize(obj: any, ancestors: Set<any>): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
|
async function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: DeserializeContext): Promise<T> {
|
||||||
if (plain === null || plain === undefined) return plain;
|
if (plain === null || plain === undefined) return plain;
|
||||||
|
|
||||||
if (Array.isArray(plain)) {
|
if (Array.isArray(plain)) {
|
||||||
const results = await Promise.all(plain.map(item => deserialize(clazz, item)));
|
const results = await Promise.all(plain.map(item => deserialize(clazz, item, ctx)));
|
||||||
return results as any;
|
return results as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +209,31 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
|
|||||||
|
|
||||||
const instance = new clazz();
|
const instance = new clazz();
|
||||||
const target = clazz.prototype;
|
const target = clazz.prototype;
|
||||||
|
const inbound = inboundNameMap(target, ctx);
|
||||||
|
|
||||||
// Copy all properties from plain to instance
|
for (const incoming of Object.keys(plain)) {
|
||||||
for (const key of Object.keys(plain)) {
|
if (FORBIDDEN_KEYS.has(incoming)) continue;
|
||||||
if (FORBIDDEN_KEYS.has(key)) continue;
|
|
||||||
|
|
||||||
const value = plain[key];
|
// 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;
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
// Custom Deserializer
|
// Custom Deserializer
|
||||||
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
||||||
@@ -138,8 +251,8 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
|
|||||||
const resolve = async (item: any): Promise<any> => {
|
const resolve = async (item: any): Promise<any> => {
|
||||||
if (item === null || item === undefined || typeof item !== 'object') return item;
|
if (item === null || item === undefined || typeof item !== 'object') return item;
|
||||||
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
|
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
|
||||||
if (subTypeInfo) return deserialize(subTypeInfo.value, item);
|
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx);
|
||||||
if (fallback) return deserialize(fallback, item);
|
if (fallback) return deserialize(fallback, item, ctx);
|
||||||
if (onUnknown === 'error') {
|
if (onUnknown === 'error') {
|
||||||
throw new JsonMappingError(
|
throw new JsonMappingError(
|
||||||
`Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
|
`Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
|
||||||
@@ -160,7 +273,7 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
|
|||||||
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
||||||
if (typeFn && value !== null && value !== undefined) {
|
if (typeFn && value !== null && value !== undefined) {
|
||||||
const type = typeFn();
|
const type = typeFn();
|
||||||
instance[key as keyof T] = await deserialize(type, value);
|
instance[key as keyof T] = await deserialize(type, value, ctx);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +434,20 @@ async function validateInternal(obj: any, ancestors: Set<any>): Promise<Validati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeContext(options?: TransformOptions): SerializeContext {
|
||||||
|
const resolved = resolveOptions(options);
|
||||||
|
return { naming: resolveNamingStrategy(resolved.namingStrategy) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeContext(options?: TransformOptions): DeserializeContext {
|
||||||
|
const resolved = resolveOptions(options);
|
||||||
|
return {
|
||||||
|
naming: resolveNamingStrategy(resolved.namingStrategy),
|
||||||
|
namingKey: resolved.namingStrategy,
|
||||||
|
unknownKeys: resolved.unknownKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// --- Public API Functions ---
|
// --- Public API Functions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,94 +460,147 @@ export async function validate(obj: any): Promise<ValidationError[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a class instance to a plain object with validation.
|
* Validates an object and throws {@link JsonValidationError} if it fails.
|
||||||
* @param obj The class instance to transform
|
*
|
||||||
* @returns Plain object
|
* The counterpart to {@link validate} for callers who want an exception rather than an
|
||||||
|
* array they have to remember to check.
|
||||||
*/
|
*/
|
||||||
export async function toPlain<T>(obj: T): Promise<any> {
|
export async function validateOrReject(obj: any): Promise<void> {
|
||||||
if (obj === null || obj === undefined) return obj;
|
|
||||||
|
|
||||||
const errors = await validate(obj);
|
const errors = await validate(obj);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
throw new JsonValidationError('Validation failed during serialization', errors);
|
throw new JsonValidationError('Validation failed', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
return serialize(obj, new Set());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a class instance to a JSON string with validation.
|
* 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 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);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new JsonValidationError('Validation failed during serialization', errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialize(obj, new Set(), serializeContext(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a class instance to a JSON string.
|
||||||
|
* @param obj The class instance to transform
|
||||||
|
* @param options Per-call transform options
|
||||||
* @returns JSON string
|
* @returns JSON string
|
||||||
*/
|
*/
|
||||||
export async function toJson<T>(obj: T): Promise<string> {
|
export async function toJson<T>(obj: T, options?: TransformOptions): Promise<string> {
|
||||||
const plain = await toPlain(obj);
|
const plain = await toPlain(obj, options);
|
||||||
return JSON.stringify(plain);
|
return JSON.stringify(plain);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a plain object to a class instance with validation.
|
* 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 clazz The class constructor
|
||||||
* @param plain The plain object to transform
|
* @param plain The plain object to transform
|
||||||
* @returns Validated class instance
|
* @param options Per-call transform options
|
||||||
|
* @returns Class instance
|
||||||
*/
|
*/
|
||||||
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any): Promise<T> {
|
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
|
||||||
const instance = await deserialize(clazz, plain);
|
const instance = await deserialize(clazz, plain, deserializeContext(options));
|
||||||
|
|
||||||
const errors = await validate(instance);
|
if (resolveOptions(options).validate) {
|
||||||
if (errors.length > 0) {
|
const errors = await validate(instance);
|
||||||
throw new JsonValidationError('Validation failed during deserialization', errors);
|
if (errors.length > 0) {
|
||||||
|
throw new JsonValidationError('Validation failed during deserialization', errors);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an array of plain objects to an array of class instances with validation.
|
* 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
|
* `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.
|
* the payload is a collection so the static type matches what you actually get back.
|
||||||
*
|
*
|
||||||
* @param clazz The class constructor
|
* @param clazz The class constructor
|
||||||
* @param plain The array of plain objects to transform
|
* @param plain The array of plain objects to transform
|
||||||
* @returns Validated array of class instances
|
* @param options Per-call transform options
|
||||||
|
* @returns Array of class instances
|
||||||
*/
|
*/
|
||||||
export async function toInstanceArray<T>(clazz: ClassConstructor<T>, plain: any[]): Promise<T[]> {
|
export async function toInstanceArray<T>(
|
||||||
|
clazz: ClassConstructor<T>,
|
||||||
|
plain: any[],
|
||||||
|
options?: TransformOptions
|
||||||
|
): Promise<T[]> {
|
||||||
if (!Array.isArray(plain)) {
|
if (!Array.isArray(plain)) {
|
||||||
throw new JsonMappingError(`Expected an array to map to ${clazz.name}[], received ${typeof plain}.`);
|
throw new JsonMappingError(`Expected an array to map to ${clazz.name}[], received ${typeof plain}.`);
|
||||||
}
|
}
|
||||||
return (await toInstance(clazz, plain)) as unknown as T[];
|
return (await toInstance(clazz, plain, options)) as unknown as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a JSON string to a class instance with validation.
|
* Parses a JSON string to a class instance.
|
||||||
* @param clazz The class constructor
|
* @param clazz The class constructor
|
||||||
* @param json JSON string
|
* @param json JSON string
|
||||||
* @returns Validated class instance
|
* @param options Per-call transform options
|
||||||
|
* @returns Class instance
|
||||||
*/
|
*/
|
||||||
export async function fromJson<T>(clazz: ClassConstructor<T>, json: string): Promise<T> {
|
export async function fromJson<T>(clazz: ClassConstructor<T>, json: string, options?: TransformOptions): Promise<T> {
|
||||||
const plain = JSON.parse(json);
|
return toInstance(clazz, parseJson(json), options);
|
||||||
return toInstance(clazz, plain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a JSON string containing an array into validated class instances.
|
* Parses a JSON string containing an array into class instances.
|
||||||
* @param clazz The class constructor
|
* @param clazz The class constructor
|
||||||
* @param json JSON string holding an array
|
* @param json JSON string holding an array
|
||||||
* @returns Validated array of class instances
|
* @param options Per-call transform options
|
||||||
|
* @returns Array of class instances
|
||||||
*/
|
*/
|
||||||
export async function fromJsonArray<T>(clazz: ClassConstructor<T>, json: string): Promise<T[]> {
|
export async function fromJsonArray<T>(
|
||||||
return toInstanceArray(clazz, JSON.parse(json));
|
clazz: ClassConstructor<T>,
|
||||||
|
json: string,
|
||||||
|
options?: TransformOptions
|
||||||
|
): Promise<T[]> {
|
||||||
|
return toInstanceArray(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.)
|
* Helper for Fetch-based frameworks (Next.js, Hono, etc.)
|
||||||
* Extracts JSON from a Request and transforms it to a validated instance.
|
* Extracts JSON from a Request and transforms it to a class instance.
|
||||||
* @param clazz The class constructor
|
* @param clazz The class constructor
|
||||||
* @param request Web Request object
|
* @param request Web Request object
|
||||||
* @returns Validated class instance
|
* @param options Per-call transform options
|
||||||
|
* @returns Class instance
|
||||||
*/
|
*/
|
||||||
export async function fromRequest<T>(clazz: ClassConstructor<T>, request: Request): Promise<T> {
|
export async function fromRequest<T>(
|
||||||
|
clazz: ClassConstructor<T>,
|
||||||
|
request: Request,
|
||||||
|
options?: TransformOptions
|
||||||
|
): Promise<T> {
|
||||||
let plain: any;
|
let plain: any;
|
||||||
try {
|
try {
|
||||||
plain = await request.json();
|
plain = await request.json();
|
||||||
@@ -429,7 +609,7 @@ export async function fromRequest<T>(clazz: ClassConstructor<T>, request: Reques
|
|||||||
`Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
`Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return toInstance(clazz, plain);
|
return toInstance(clazz, plain, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,4 +624,5 @@ export class JsonMapper {
|
|||||||
static fromJsonArray = fromJsonArray;
|
static fromJsonArray = fromJsonArray;
|
||||||
static fromRequest = fromRequest;
|
static fromRequest = fromRequest;
|
||||||
static validate = validate;
|
static validate = validate;
|
||||||
|
static validateOrReject = validateOrReject;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user