diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e6d5df4 --- /dev/null +++ b/src/config.ts @@ -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; + +const DEFAULTS: Required = { + namingStrategy: 'identity', + unknownKeys: 'allow', + validate: true, +}; + +let globalOptions: Required = { ...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 { + 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 { + if (!options) return globalOptions; + return { + namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy, + unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys, + validate: options.validate ?? globalOptions.validate, + }; +} diff --git a/src/decorators.ts b/src/decorators.ts index e3db72d..2215226 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -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) * Custom serializer decorator. diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..a17aa9d --- /dev/null +++ b/src/errors.ts @@ -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 { + const flat: Record = {}; + + 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(); +} diff --git a/src/index.ts b/src/index.ts index edf10ec..d8c295f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ export * from './interfaces.js'; +export * from './naming.js'; +export * from './config.js'; export * from './decorators.js'; +export * from './errors.js'; export * from './utils.js'; diff --git a/src/mapping.test.ts b/src/mapping.test.ts new file mode 100644 index 0000000..3a4025d --- /dev/null +++ b/src/mapping.test.ts @@ -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'); + }); +}); diff --git a/src/naming.ts b/src/naming.ts new file mode 100644 index 0000000..2ba4caa --- /dev/null +++ b/src/naming.ts @@ -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, 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; +} diff --git a/src/utils.ts b/src/utils.ts index d72fe54..911feac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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): Promise { +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; + /** + * 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; +} + +// 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>(); + +/** + * 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(); + const blocked = new Set(); + + 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, ctx: SerializeContext): Promise { if (obj === null || obj === undefined || typeof obj !== 'object') { return obj; } @@ -73,7 +162,7 @@ async function serialize(obj: any, ancestors: Set): Promise { 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): Promise { 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): Promise { } } -async function deserialize(clazz: ClassConstructor, plain: any): Promise { +async function deserialize(clazz: ClassConstructor, plain: any, ctx: DeserializeContext): Promise { 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(clazz: ClassConstructor, plain: any): Promise 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(clazz: ClassConstructor, plain: any): Promise => { 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(clazz: ClassConstructor, plain: any): Promise): Promise { } /** - * 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(obj: T): Promise { - if (obj === null || obj === undefined) return obj; - +export async function validateOrReject(obj: any): Promise { 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(obj: T, options?: TransformOptions): Promise { + 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(obj: T): Promise { - const plain = await toPlain(obj); +export async function toJson(obj: T, options?: TransformOptions): Promise { + 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(clazz: ClassConstructor, plain: any): Promise { - const instance = await deserialize(clazz, plain); +export async function toInstance(clazz: ClassConstructor, plain: any, options?: TransformOptions): Promise { + 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(clazz: ClassConstructor, plain: any[]): Promise { +export async function toInstanceArray( + clazz: ClassConstructor, + plain: any[], + options?: TransformOptions +): Promise { 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(clazz: ClassConstructor, json: string): Promise { - const plain = JSON.parse(json); - return toInstance(clazz, plain); +export async function fromJson(clazz: ClassConstructor, json: string, options?: TransformOptions): Promise { + 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(clazz: ClassConstructor, json: string): Promise { - return toInstanceArray(clazz, JSON.parse(json)); +export async function fromJsonArray( + clazz: ClassConstructor, + json: string, + options?: TransformOptions +): Promise { + 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(clazz: ClassConstructor, request: Request): Promise { +export async function fromRequest( + clazz: ClassConstructor, + request: Request, + options?: TransformOptions +): Promise { let plain: any; try { plain = await request.json(); @@ -429,7 +609,7 @@ export async function fromRequest(clazz: ClassConstructor, 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; }