✨ 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
+93
View File
@@ -10,8 +10,21 @@ export const METADATA_KEYS = {
POLYMORPHIC: 'cereale:polymorphic',
IS_OPTIONAL: 'cereale:optional',
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 {
value: any;
object: any;
@@ -73,6 +86,86 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
// --- 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>)
* Custom serializer decorator.