✨ 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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, string[]> {
|
||||
const flat: Record<string, string[]> = {};
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Exclude<NamingStrategy, NamingStrategyFn>, 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;
|
||||
}
|
||||
+231
-50
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user