✨ 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
+231 -50
View File
@@ -1,6 +1,8 @@
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 { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
export interface ValidationError {
property: string;
@@ -41,6 +43,16 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
// --- Internal Engine ---
interface SerializeContext {
naming: NamingStrategyFn;
}
interface DeserializeContext {
naming: NamingStrategyFn;
namingKey: unknown;
unknownKeys: UnknownKeyPolicy;
}
/**
* Resolves the metadata lookup target for a value.
*
@@ -52,7 +64,84 @@ function prototypeOf(obj: any): any {
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') {
return obj;
}
@@ -73,7 +162,7 @@ async function serialize(obj: any, ancestors: Set<any>): Promise<any> {
if (Array.isArray(obj)) {
const out: any[] = [];
for (const item of obj) {
out.push(await serialize(item, ancestors));
out.push(await serialize(item, ancestors, ctx));
}
return out;
}
@@ -82,16 +171,21 @@ async function serialize(obj: any, ancestors: Set<any>): Promise<any> {
const result: any = {};
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 name = outboundName(target, key, ctx.naming);
// 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.
const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
if (serializerCls && value !== null && value !== undefined) {
const serializer = new serializerCls();
result[key] = await serializer.serialize(value);
result[name] = await serializer.serialize(value);
} 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 (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;
}
@@ -115,12 +209,31 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
const instance = new clazz();
const target = clazz.prototype;
const inbound = inboundNameMap(target, ctx);
// Copy all properties from plain to instance
for (const key of Object.keys(plain)) {
if (FORBIDDEN_KEYS.has(key)) continue;
for (const incoming of Object.keys(plain)) {
if (FORBIDDEN_KEYS.has(incoming)) 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
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> => {
if (item === null || item === undefined || typeof item !== 'object') return item;
const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
if (subTypeInfo) return deserialize(subTypeInfo.value, item);
if (fallback) return deserialize(fallback, item);
if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx);
if (fallback) return deserialize(fallback, item, ctx);
if (onUnknown === 'error') {
throw new JsonMappingError(
`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);
if (typeFn && value !== null && value !== undefined) {
const type = typeFn();
instance[key as keyof T] = await deserialize(type, value);
instance[key as keyof T] = await deserialize(type, value, ctx);
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 ---
/**
@@ -333,94 +460,147 @@ export async function validate(obj: any): Promise<ValidationError[]> {
}
/**
* Converts a class instance to a plain object with validation.
* @param obj The class instance to transform
* @returns Plain object
* Validates an object and throws {@link JsonValidationError} if it fails.
*
* The counterpart to {@link validate} for callers who want an exception rather than an
* array they have to remember to check.
*/
export async function toPlain<T>(obj: T): Promise<any> {
if (obj === null || obj === undefined) return obj;
export async function validateOrReject(obj: any): Promise<void> {
const errors = await validate(obj);
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 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
*/
export async function toJson<T>(obj: T): Promise<string> {
const plain = await toPlain(obj);
export async function toJson<T>(obj: T, options?: TransformOptions): Promise<string> {
const plain = await toPlain(obj, options);
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 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> {
const instance = await deserialize(clazz, plain);
export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
const instance = await deserialize(clazz, plain, deserializeContext(options));
const errors = await validate(instance);
if (errors.length > 0) {
throw new JsonValidationError('Validation failed during deserialization', errors);
if (resolveOptions(options).validate) {
const errors = await validate(instance);
if (errors.length > 0) {
throw new JsonValidationError('Validation failed during deserialization', errors);
}
}
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
* the payload is a collection so the static type matches what you actually get back.
*
* @param clazz The class constructor
* @param plain The array of plain objects to transform
* @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)) {
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 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> {
const plain = JSON.parse(json);
return toInstance(clazz, plain);
export async function fromJson<T>(clazz: ClassConstructor<T>, json: string, options?: TransformOptions): Promise<T> {
return toInstance(clazz, parseJson(json), options);
}
/**
* 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 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[]> {
return toInstanceArray(clazz, JSON.parse(json));
export async function fromJsonArray<T>(
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.)
* 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 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;
try {
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)}`
);
}
return toInstance(clazz, plain);
return toInstance(clazz, plain, options);
}
/**
@@ -444,4 +624,5 @@ export class JsonMapper {
static fromJsonArray = fromJsonArray;
static fromRequest = fromRequest;
static validate = validate;
static validateOrReject = validateOrReject;
}