✨ 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,
};
}