Profiling the validator showed roughly half of all validation time re-deriving
answers that cannot change — collectConstraints 22%, getOwnMetadata 12%,
getMetadataChain 9%, getProperties 4%, getMetadata 3%, plus 8% GC from the
allocation churn. The constraint predicates themselves were under 1%.
Decorator metadata is fixed once classes are declared, so the derived structures
are now memoized per prototype: the validation plan, the serialization plan, the
deserialization plan, and serializer/deserializer instances, which were being
constructed fresh for every property of every object. MetadataStorage carries a
version counter that invalidates the caches when metadata is written, so
registerDecorator after first use still works — covered by a test.
Measured against JSON.parse + JSON.stringify as a fixed reference:
validate (50 orders) 221.6 us -> 49.6 us 4.5x
validate (10 orders) 47.8 us -> 12.9 us 3.7x
toInstance (50 orders) 255.1 us -> 74.0 us 3.4x
toInstance (10 orders) 64.6 us -> 19.0 us 3.4x
toPlain (50 orders) 294.4 us -> 95.3 us 3.1x
Reliability, in the same pass:
- maxDepth option (default 64) on every mapping function, on validate(), and on
configure(). All three engines recurse, so a payload nested thousands of levels
deep could exhaust the call stack. Cycles were already handled; legitimate deep
nesting was not bounded.
- each: true failures now name the element that failed ("failed at index 3"). A
bad entry in a 200-item array previously produced a message that could not
locate it. A message function now receives the failing element as args.value
rather than the whole array; caller-supplied strings stay verbatim.
150 tests (up from 136), all green on the existing suite unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
450 lines
13 KiB
TypeScript
450 lines
13 KiB
TypeScript
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,
|
|
maxDepth: 64,
|
|
});
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|