✨ 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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user