✨ 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:
Claude
2026-08-03 23:46:18 +00:00
parent 6d04b43964
commit 666762a146
7 changed files with 977 additions and 50 deletions
+75
View File
@@ -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,
};
}
+93
View File
@@ -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.
+57
View File
@@ -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();
}
+3
View File
@@ -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';
+444
View File
@@ -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');
});
});
+74
View File
@@ -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
View File
@@ -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;
} }