diff --git a/src/decorators.ts b/src/decorators.ts index d8edc93..e3db72d 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -9,6 +9,7 @@ export const METADATA_KEYS = { DESERIALIZER: 'cereale:deserializer', POLYMORPHIC: 'cereale:polymorphic', IS_OPTIONAL: 'cereale:optional', + NESTED: 'cereale:nested', }; export interface ValidationArguments { @@ -29,6 +30,12 @@ export type ValidationConstraint = { message: string | ((args: ValidationArguments) => string); constraints?: any[]; each?: boolean; + /** + * True when the message came from the user via `ValidationOptions.message`. + * The engine only decorates default messages with the "each element in ..." prefix; + * a message the user wrote is reported exactly as written. + */ + hasCustomMessage?: boolean; }; export interface ValidatorConstraintInterface { @@ -55,9 +62,10 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC } if (options.message) { constraint.message = options.message; + constraint.hasCustomMessage = true; } } - + const constraints: ValidationConstraint[] = metadataStorage.getOwnMetadata(METADATA_KEYS.VALIDATION, target, propertyKey) || []; constraints.push(constraint); metadataStorage.defineMetadata(METADATA_KEYS.VALIDATION, constraints, target, propertyKey); @@ -98,14 +106,34 @@ export function JsonType(typeFunction: () => ClassConstructor) { }; } +export interface PolymorphicOptions { + /** + * What to do when the discriminator value matches no registered subtype. + * - `keep` (default): pass the raw value through untouched. + * - `error`: throw a {@link JsonMappingError} naming the unknown discriminator value. + */ + onUnknown?: 'keep' | 'error'; + /** Subtype to use when the discriminator matches nothing. Takes precedence over `onUnknown`. */ + fallback?: ClassConstructor; +} + /** - * @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor, name: string }[]) + * @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor, name: string }[], options?: PolymorphicOptions) * Defines polymorphic behavior for a property. */ -export function JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor, name: string }[]) { +export function JsonPolymorphic( + discriminator: string, + subTypes: { value: ClassConstructor, name: string }[], + options?: PolymorphicOptions +) { return (target: any, propertyKey: string) => { registerProperty(target, propertyKey); - metadataStorage.defineMetadata(METADATA_KEYS.POLYMORPHIC, { discriminator, subTypes }, target, propertyKey); + metadataStorage.defineMetadata( + METADATA_KEYS.POLYMORPHIC, + { discriminator, subTypes, onUnknown: options?.onUnknown ?? 'keep', fallback: options?.fallback }, + target, + propertyKey + ); }; } @@ -333,10 +361,16 @@ export function IsUrl(options?: ValidationOptions) { * @Matches(pattern: RegExp) */ export function Matches(pattern: RegExp, options?: ValidationOptions) { + // A `g` or `y` flag makes RegExp.prototype.test stateful: it advances lastIndex on a + // match and resumes from there on the next call, so validating the same value twice + // yields different answers. Validation must be a pure predicate, so drop those flags. + const stateless = pattern.flags.includes('g') || pattern.flags.includes('y') + ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '')) + : pattern; return (target: any, propertyKey: string) => { addValidation(target, propertyKey, { name: 'matches', - validate: (v) => typeof v === 'string' && pattern.test(v), + validate: (v) => typeof v === 'string' && stateless.test(v), message: `${propertyKey} must match ${pattern} regular expression`, constraints: [pattern] }, options); @@ -439,13 +473,26 @@ export function IsDate(options?: ValidationOptions) { } /** - * @ValidateNested() + * @ValidateNested(options?: ValidationOptions) + * Recursively validates the value of this property. + * + * `{ each: true }` documents that the property holds a collection; nested validation + * already recurses into arrays, but passing `each` additionally asserts that the value + * really is an array. */ -export function ValidateNested() { +export function ValidateNested(options?: ValidationOptions) { return (target: any, propertyKey: string) => { registerProperty(target, propertyKey); // This is a marker for recursive validation - metadataStorage.defineMetadata('cereale:nested', true, target, propertyKey); + metadataStorage.defineMetadata(METADATA_KEYS.NESTED, true, target, propertyKey); + + if (options?.each) { + addValidation(target, propertyKey, { + name: 'nestedEach', + validate: (v) => Array.isArray(v), + message: `${propertyKey} must be an array` + }); + } }; } diff --git a/src/metadata-storage.ts b/src/metadata-storage.ts index bb413db..8c82a7f 100644 --- a/src/metadata-storage.ts +++ b/src/metadata-storage.ts @@ -63,6 +63,29 @@ export class MetadataStorage { return undefined; } + /** + * Collects a metadata value from every level of the prototype chain that defines one. + * + * Unlike {@link getMetadata}, which stops at the first (most derived) match, this returns + * every value found, ordered from the BASE class down to the most derived one. It exists + * for metadata that must accumulate across an inheritance chain rather than be overridden — + * validation constraints in particular, where a subclass re-decorating an inherited property + * must add to the base class's rules instead of silently replacing them. + */ + getMetadataChain(key: string, target: any, propertyKey?: string): any[] { + const chain: any[] = []; + let current = target; + while (current) { + const value = this.getOwnMetadata(key, current, propertyKey); + if (value !== undefined) { + // Walking derived -> base, so prepend to end up base-first. + chain.unshift(value); + } + current = Object.getPrototypeOf(current); + } + return chain; + } + /** * Gets metadata defined directly on the target. */ diff --git a/src/regressions.test.ts b/src/regressions.test.ts new file mode 100644 index 0000000..4f4ba5a --- /dev/null +++ b/src/regressions.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect } from 'vitest'; +import { + IsString, IsInt, Min, MinLength, Matches, IsIn, ValidateNested, JsonType, + JsonPolymorphic, JsonSerialize, JsonSerializer, JsonMappingError, + toInstance, toInstanceArray, toPlain, toJson, fromJson, fromJsonArray, fromRequest, validate, +} from './index.js'; + +/** + * Each block here pins down a defect that the engine used to have. The comment above the + * block describes the old, wrong behaviour. + */ +describe('regressions', () => { + describe('inheritance', () => { + // Was: a subclass re-decorating an inherited property registered its constraints on its + // own prototype, and the engine read only the nearest set — so every rule the base class + // declared was silently dropped. + it('merges validation constraints across the prototype chain', async () => { + class Base { + @MinLength(5) + name: string; + } + class Sub extends Base { + @IsString() + declare name: string; + } + + const s = new Sub(); + s.name = 'ab'; // satisfies Sub's @IsString, violates Base's @MinLength(5) + + const errors = await validate(s); + expect(errors).toHaveLength(1); + expect(errors[0]!.constraints).toHaveProperty('minLength'); + }); + + it('enforces base constraints that the subclass never restates', async () => { + abstract class Media { + @IsString() + title: string; + } + class Book extends Media { + @IsString() + author: string; + } + + const b = new Book(); + b.title = 42 as any; + b.author = 'Fitzgerald'; + + const errors = await validate(b); + expect(errors.map(e => e.property)).toContain('title'); + }); + + it('does not report an identical inherited rule twice', async () => { + class Base { + @IsString() + type: string; + } + class Sub extends Base { + @IsString() + declare type: string; + } + + const s = new Sub(); + s.type = 1 as any; + + const errors = await validate(s); + expect(errors).toHaveLength(1); + expect(Object.keys(errors[0]!.constraints)).toEqual(['isString']); + }); + }); + + describe('cycles', () => { + // Was: serialize() recursed forever on a cycle, exhausting an 8 GB heap and killing the + // process. A clear error beats an OOM. + it('reports a circular reference instead of exhausting the heap', async () => { + class Node { + @IsString() + name: string; + next?: any; + } + const a = new Node(); + a.name = 'a'; + a.next = a; + + await expect(toPlain(a)).rejects.toThrow(JsonMappingError); + await expect(toPlain(a)).rejects.toThrow(/Circular reference/); + }); + + it('still serializes a diamond, where one object is referenced twice', async () => { + class Leaf { + @IsString() + id: string; + } + class Holder { + left: Leaf; + right: Leaf; + } + + const shared = new Leaf(); + shared.id = 'shared'; + const h = new Holder(); + h.left = shared; + h.right = shared; + + const plain = await toPlain(h); + expect(plain).toEqual({ left: { id: 'shared' }, right: { id: 'shared' } }); + }); + + it('terminates when validating a cyclic @ValidateNested graph', async () => { + class Person { + @IsString() + name: string; + + @ValidateNested() + friend?: Person; + } + + const a = new Person(); + a.name = 'a'; + const b = new Person(); + b.name = 'b'; + a.friend = b; + b.friend = a; + + await expect(validate(a)).resolves.toEqual([]); + }); + }); + + describe('messages', () => { + // Was: the "each element in ..." prefix was glued onto every message, including ones the + // caller wrote, producing "each element in tags must all be strings". + it('reports a caller-supplied message verbatim under each:true', async () => { + class T { + @IsString({ each: true, message: 'tags must all be strings' }) + tags: any[]; + } + const t = new T(); + t.tags = [1]; + + const errors = await validate(t); + expect(errors[0]!.constraints['isString']).toBe('tags must all be strings'); + }); + + it('still prefixes the library default message under each:true', async () => { + class T { + @IsString({ each: true }) + tags: any[]; + } + const t = new T(); + t.tags = [1]; + + const errors = await validate(t); + expect(errors[0]!.constraints['isString']).toContain('each element in'); + }); + + // Was: two constraints sharing a name overwrote each other in the error record, so only + // the last failure was ever reported. + it('keeps every failure when two rules share a name', async () => { + class T { + @Min(10) + @Min(5) + n: number; + } + const t = new T(); + t.n = 1; + + const errors = await validate(t); + const messages = Object.values(errors[0]!.constraints); + expect(messages).toHaveLength(2); + expect(messages).toEqual(expect.arrayContaining([ + 'n must be at least 5', + 'n must be at least 10', + ])); + }); + }); + + describe('@Matches', () => { + // Was: a /g regex kept its lastIndex between calls, so validating the same value twice + // gave different answers — the second call spuriously failed. + it('is stateless when the pattern carries a g flag', async () => { + class T { + @Matches(/^[a-z]+$/g) + v: string; + } + const t = new T(); + t.v = 'abc'; + + expect(await validate(t)).toHaveLength(0); + expect(await validate(t)).toHaveLength(0); + expect(await validate(t)).toHaveLength(0); + }); + + it('is stateless when the pattern carries a y flag', async () => { + class T { + @Matches(/^[a-z]+$/y) + v: string; + } + const t = new T(); + t.v = 'abc'; + + expect(await validate(t)).toHaveLength(0); + expect(await validate(t)).toHaveLength(0); + }); + }); + + describe('@JsonPolymorphic', () => { + abstract class Animal { + @IsString() + type: string; + } + class Dog extends Animal { + @IsString() + breed: string; + } + + // Was: when the discriminator matched no subtype, the single-object branch fell through + // without assigning anything, so the property came back `undefined` and the caller's data + // vanished without a word. + it('keeps the raw value when the discriminator matches nothing', async () => { + class Holder { + @JsonPolymorphic('type', [{ value: Dog, name: 'dog' }]) + pet: Animal; + } + + const h = await toInstance(Holder, { pet: { type: 'cat', sound: 'meow' } }); + expect(h.pet).toBeDefined(); + expect(h.pet).toEqual({ type: 'cat', sound: 'meow' }); + }); + + it('can be told to reject an unknown discriminator instead', async () => { + class Holder { + @JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { onUnknown: 'error' }) + pet: Animal; + } + + await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(JsonMappingError); + await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(/Unknown discriminator/); + }); + + it('can fall back to a default subtype', async () => { + class Unknown extends Animal { + @IsString() + override type = 'unknown'; + } + class Holder { + @JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { fallback: Unknown }) + pet: Animal; + } + + const h = await toInstance(Holder, { pet: { type: 'cat' } }); + expect(h.pet).toBeInstanceOf(Unknown); + }); + + it('keeps unmatched entries inside an array', async () => { + class Holder { + @JsonPolymorphic('type', [{ value: Dog, name: 'dog' }]) + pets: Animal[]; + } + + const h = await toInstance(Holder, { + pets: [{ type: 'dog', breed: 'Lab' }, { type: 'cat', sound: 'meow' }], + }); + expect(h.pets[0]).toBeInstanceOf(Dog); + expect(h.pets[1]).toEqual({ type: 'cat', sound: 'meow' }); + }); + }); + + describe('prototype handling', () => { + // Was: serialize() read `obj.constructor.prototype`, which throws for an object created + // with a null prototype because it has no `constructor`. + it('serializes a null-prototype object', async () => { + const o = Object.create(null); + o.a = 1; + o.b = { c: 2 }; + + await expect(toPlain(o)).resolves.toEqual({ a: 1, b: { c: 2 } }); + }); + + // Was: `__proto__` arriving in a JSON body was copied straight onto the instance, which + // swaps the instance's prototype and detaches it from its own class. + it('drops __proto__ from untrusted input', async () => { + class Dto { + @IsString() + name: string; + } + + const malicious = JSON.parse('{"name":"x","__proto__":{"polluted":"yes"}}'); + const dto = await toInstance(Dto, malicious); + + expect(dto).toBeInstanceOf(Dto); + expect(Object.getPrototypeOf(dto)).toBe(Dto.prototype); + expect(({} as any).polluted).toBeUndefined(); + }); + + it('drops constructor and prototype keys from untrusted input', async () => { + class Dto { + @IsString() + name: string; + } + + const dto = await toInstance(Dto, JSON.parse('{"name":"x","constructor":1,"prototype":2}')); + expect(dto.constructor).toBe(Dto); + expect((dto as any).prototype).toBeUndefined(); + }); + }); + + describe('custom serializers', () => { + // Was: a @JsonSerialize serializer was invoked even when the property was null or + // undefined, so any serializer that touched the value crashed on an unset optional field. + it('is skipped for an unset optional property', async () => { + class IsoDate implements JsonSerializer { + serialize(value: Date): string { + return value.toISOString(); + } + } + class T { + @JsonSerialize(IsoDate) + when?: Date | undefined; + + @IsString() + other: string; + } + + const t = new T(); + t.other = 'x'; + t.when = undefined; + + await expect(toPlain(t)).resolves.toEqual({ when: undefined, other: 'x' }); + }); + + it('still runs for a property that has a value', async () => { + class IsoDate implements JsonSerializer { + serialize(value: Date): string { + return value.toISOString().slice(0, 10); + } + } + class T { + @JsonSerialize(IsoDate) + when: Date; + } + + const t = new T(); + t.when = new Date('1925-04-10T00:00:00Z'); + + await expect(toJson(t)).resolves.toBe('{"when":"1925-04-10"}'); + }); + }); + + describe('array entry points', () => { + class Item { + @IsString() + name: string; + } + + // Was: `toInstance`/`fromJson` accepted arrays at runtime but typed the result as `T`, + // so consumers had to cast to reach the elements. + it('toInstanceArray returns a correctly typed array', async () => { + const items = await toInstanceArray(Item, [{ name: 'a' }, { name: 'b' }]); + expect(items).toHaveLength(2); + expect(items[0]).toBeInstanceOf(Item); + expect(items[0]!.name).toBe('a'); + }); + + it('fromJsonArray parses and validates a JSON array', async () => { + const items = await fromJsonArray(Item, '[{"name":"a"}]'); + expect(items[0]!.name).toBe('a'); + }); + + it('toInstanceArray rejects a non-array payload', async () => { + await expect(toInstanceArray(Item, {} as any)).rejects.toThrow(JsonMappingError); + }); + + it('fromJson still accepts an array for backwards compatibility', async () => { + const items = (await fromJson(Item, '[{"name":"a"}]')) as unknown as Item[]; + expect(Array.isArray(items)).toBe(true); + }); + }); + + describe('fromRequest', () => { + it('reports a non-JSON body as a mapping error', async () => { + class Dto { + @IsString() + name: string; + } + const request = new Request('https://example.com', { method: 'POST', body: 'not json' }); + + await expect(fromRequest(Dto, request)).rejects.toThrow(JsonMappingError); + await expect( + fromRequest(Dto, new Request('https://example.com', { method: 'POST', body: '' })) + ).rejects.toThrow(/not valid JSON/); + }); + }); + + describe('@ValidateNested', () => { + it('accepts the documented { each: true } option', async () => { + class Item { + @IsInt() + @Min(1) + qty: number; + } + class Order { + @ValidateNested({ each: true }) + @JsonType(() => Item) + items: Item[]; + } + + const bad = new Item(); + bad.qty = -5; + const o = new Order(); + o.items = [bad]; + + const errors = await validate(o); + expect(errors).toHaveLength(1); + expect(errors[0]!.children?.[0]?.children?.[0]?.property).toBe('qty'); + }); + + it('{ each: true } asserts the value really is an array', async () => { + class Item { + @IsInt() + qty: number; + } + class Order { + @ValidateNested({ each: true }) + items: Item[]; + } + + const o = new Order(); + o.items = 'nope' as any; + + const errors = await validate(o); + expect(errors[0]!.constraints).toHaveProperty('nestedEach'); + }); + }); + + describe('@IsIn with each:true', () => { + it('rejects a non-array value rather than passing it through', async () => { + class T { + @IsIn(['a', 'b'], { each: true }) + tags: any; + } + const t = new T(); + t.tags = 'not-allowed'; + + expect(await validate(t)).toHaveLength(1); + }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index e79dd29..d72fe54 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,55 +20,106 @@ export class JsonValidationError extends Error { } } +/** + * Thrown when a value cannot be mapped at all — as opposed to mapping fine but failing + * validation, which raises {@link JsonValidationError}. + */ +export class JsonMappingError extends Error { + constructor(message: string) { + super(message); + this.name = 'JsonMappingError'; + } +} + +/** + * Keys that must never be copied from untrusted input onto an instance. Assigning + * `__proto__` swaps an object's prototype, and `constructor` / `prototype` are the usual + * next steps in a pollution chain. This library exists to parse request bodies, so the + * transform layer drops them rather than trusting callers to sanitise first. + */ +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + // --- Internal Engine --- -async function serialize(obj: any): Promise { +/** + * Resolves the metadata lookup target for a value. + * + * `Object.getPrototypeOf` rather than `obj.constructor.prototype`: the latter throws on + * null-prototype objects (which have no `constructor`) and lies for instances whose + * `constructor` property has been overwritten. + */ +function prototypeOf(obj: any): any { + return Object.getPrototypeOf(obj) ?? undefined; +} + +async function serialize(obj: any, ancestors: Set): Promise { if (obj === null || obj === undefined || typeof obj !== 'object') { return obj; } - if (Array.isArray(obj)) { - return Promise.all(obj.map(item => serialize(item))); - } - if (obj instanceof Date) { return obj.toISOString(); } - const target = obj.constructor.prototype; - - const result: any = {}; - const allKeys = Object.keys(obj); - - for (const key of allKeys) { - const value = obj[key]; - - // Check for custom serializer - const serializerCls = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key); - if (serializerCls) { - const serializer = new serializerCls(); - result[key] = await serializer.serialize(value); - } else { - result[key] = await serialize(value); - } + if (ancestors.has(obj)) { + throw new JsonMappingError( + 'Circular reference detected during serialization. Break the cycle with @JsonIgnore() ' + + 'on the back-reference, or supply a @JsonSerialize() serializer for that property.' + ); } - return result; + ancestors.add(obj); + try { + if (Array.isArray(obj)) { + const out: any[] = []; + for (const item of obj) { + out.push(await serialize(item, ancestors)); + } + return out; + } + + const target = prototypeOf(obj); + + const result: any = {}; + for (const key of Object.keys(obj)) { + const value = obj[key]; + + // 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); + } else { + result[key] = await serialize(value, ancestors); + } + } + + return result; + } finally { + // Only direct ancestors count as a cycle; the same object appearing twice in + // sibling positions (a diamond) is perfectly serializable. + ancestors.delete(obj); + } } async function deserialize(clazz: ClassConstructor, plain: any): Promise { if (plain === null || plain === undefined) return plain; - + if (Array.isArray(plain)) { const results = await Promise.all(plain.map(item => deserialize(clazz, item))); return results as any; } + if (typeof plain !== 'object') return plain; + const instance = new clazz(); const target = clazz.prototype; // Copy all properties from plain to instance for (const key of Object.keys(plain)) { + if (FORBIDDEN_KEYS.has(key)) continue; + const value = plain[key]; // Custom Deserializer @@ -82,19 +133,26 @@ async function deserialize(clazz: ClassConstructor, plain: any): Promise { - const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name); - return subTypeInfo ? deserialize(subTypeInfo.value, item) : item; - })) as any; - } else { - const subTypeInfo = subTypes.find((s: any) => value[discriminator] === s.name); - if (subTypeInfo) { - instance[key as keyof T] = await deserialize(subTypeInfo.value, value); - continue; + const { discriminator, subTypes, onUnknown, fallback } = poly; + + const resolve = async (item: 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 (onUnknown === 'error') { + throw new JsonMappingError( + `Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` + + `"${key}". Known values: ${subTypes.map((s: any) => JSON.stringify(s.name)).join(', ')}.` + ); } - } + // Preserve the raw value. Dropping it silently loses data the caller sent. + return item; + }; + + instance[key as keyof T] = Array.isArray(value) + ? (await Promise.all(value.map(resolve))) as any + : await resolve(value); continue; } @@ -112,6 +170,157 @@ async function deserialize(clazz: ClassConstructor, plain: any): Promise(); + + for (const level of levels) { + for (const constraint of level) { + if (typeof constraint.message === 'string') { + const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}`; + if (seen.has(identity)) continue; + seen.add(identity); + } + merged.push(constraint); + } + } + + return merged; +} + +/** + * Records a failure without letting a later constraint overwrite an earlier one that happens + * to share a name (two `@Min` rules, or a rule inherited and re-declared). + */ +function recordFailure(constraints: { [key: string]: string }, name: string, message: string) { + if (!(name in constraints)) { + constraints[name] = message; + return; + } + let suffix = 2; + while (`${name}_${suffix}` in constraints) suffix++; + constraints[`${name}_${suffix}`] = message; +} + +async function validateInternal(obj: any, ancestors: Set): Promise { + const errors: ValidationError[] = []; + if (obj === null || obj === undefined || typeof obj !== 'object') return errors; + + // A cycle has already been validated further up the stack; re-entering it would never + // terminate. Diamonds are still validated on each distinct path. + if (ancestors.has(obj)) return errors; + ancestors.add(obj); + + try { + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + const childErrors = await validateInternal(obj[i], ancestors); + if (childErrors.length > 0) { + errors.push({ + property: `[${i}]`, + value: obj[i], + constraints: {}, + children: childErrors + }); + } + } + return errors; + } + + const target = prototypeOf(obj); + if (!target) return errors; + + const properties: string[] = metadataStorage.getProperties(target); + + for (const key of properties) { + const value = obj[key]; + const propertyErrors: ValidationError = { + property: key, + value: value, + constraints: {} + }; + + // Handle IsOptional + const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key); + const isNullOrUndefined = value === null || value === undefined; + + if (isOptional && isNullOrUndefined) { + continue; + } + + // Check validation constraints + const constraints = collectConstraints(target, key); + const validationArgs: ValidationArguments = { + value: value, + object: obj, + property: key, + constraints: [] + }; + + for (const constraint of constraints) { + validationArgs.constraints = constraint.constraints || []; + + let isValid = true; + if (constraint.each && Array.isArray(value)) { + for (const item of value) { + const itemArgs = { ...validationArgs, value: item }; + if (!(await constraint.validate(item, itemArgs))) { + isValid = false; + break; + } + } + } else { + isValid = await constraint.validate(value, validationArgs); + } + + if (!isValid) { + let message = typeof constraint.message === 'function' + ? constraint.message(validationArgs) + : constraint.message; + + // Only decorate the library's own default wording. A message the caller wrote + // is reported verbatim — prefixing it produced sentences like + // "each element in tags must all be strings". + if (constraint.each && !constraint.hasCustomMessage) { + message = `each element in ${message}`; + } + + recordFailure(propertyErrors.constraints, constraint.name, message); + } + } + + // Recursive validation + const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key); + if (isNested && value !== null && value !== undefined) { + const nestedErrors = await validateInternal(value, ancestors); + if (nestedErrors.length > 0) { + propertyErrors.children = nestedErrors; + } + } + + if (Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) { + errors.push(propertyErrors); + } + } + + return errors; + } finally { + ancestors.delete(obj); + } +} + // --- Public API Functions --- /** @@ -120,96 +329,7 @@ async function deserialize(clazz: ClassConstructor, plain: any): Promise { - const errors: ValidationError[] = []; - if (obj === null || obj === undefined || typeof obj !== 'object') return errors; - - if (Array.isArray(obj)) { - for (let i = 0; i < obj.length; i++) { - const childErrors = await validate(obj[i]); - if (childErrors.length > 0) { - errors.push({ - property: `[${i}]`, - value: obj[i], - constraints: {}, - children: childErrors - }); - } - } - return errors; - } - - const target = Object.getPrototypeOf(obj); - const properties: string[] = metadataStorage.getProperties(target); - - for (const key of properties) { - const value = obj[key]; - const propertyErrors: ValidationError = { - property: key, - value: value, - constraints: {} - }; - - // Handle IsOptional - const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key); - const isNullOrUndefined = value === null || value === undefined; - - if (isOptional && isNullOrUndefined) { - continue; - } - - // Check validation constraints - const constraints: ValidationConstraint[] = metadataStorage.getMetadata(METADATA_KEYS.VALIDATION, target, key) || []; - const validationArgs: ValidationArguments = { - value: value, - object: obj, - property: key, - constraints: [] - }; - - for (const constraint of constraints) { - validationArgs.constraints = constraint.constraints || []; - - let isValid = true; - if (constraint.each && Array.isArray(value)) { - for (const item of value) { - const itemArgs = { ...validationArgs, value: item }; - if (!(await constraint.validate(item, itemArgs))) { - isValid = false; - break; - } - } - } else { - isValid = await constraint.validate(value, validationArgs); - } - - if (!isValid) { - let message = typeof constraint.message === 'function' - ? constraint.message(validationArgs) - : constraint.message; - - if (constraint.each) { - message = `each element in ${message}`; - } - - propertyErrors.constraints[constraint.name] = message; - } - } - - // Recursive validation - const isNested = metadataStorage.getMetadata('cereale:nested', target, key); - if (isNested && value !== null && value !== undefined) { - const nestedErrors = await validate(value); - if (nestedErrors.length > 0) { - propertyErrors.children = nestedErrors; - } - } - - if (Object.keys(propertyErrors.constraints).length > 0 || propertyErrors.children) { - errors.push(propertyErrors); - } - } - - return errors; + return validateInternal(obj, new Set()); } /** @@ -219,13 +339,13 @@ export async function validate(obj: any): Promise { */ export async function toPlain(obj: T): Promise { if (obj === null || obj === undefined) return obj; - + const errors = await validate(obj); if (errors.length > 0) { throw new JsonValidationError('Validation failed during serialization', errors); } - return serialize(obj); + return serialize(obj, new Set()); } /** @@ -246,15 +366,32 @@ export async function toJson(obj: T): Promise { */ export async function toInstance(clazz: ClassConstructor, plain: any): Promise { const instance = await deserialize(clazz, plain); - + 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. + * + * `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 + */ +export async function toInstanceArray(clazz: ClassConstructor, plain: any[]): 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[]; +} + /** * Parses a JSON string to a class instance with validation. * @param clazz The class constructor @@ -266,6 +403,16 @@ export async function fromJson(clazz: ClassConstructor, json: string): Pro return toInstance(clazz, plain); } +/** + * Parses a JSON string containing an array into validated class instances. + * @param clazz The class constructor + * @param json JSON string holding an array + * @returns Validated array of class instances + */ +export async function fromJsonArray(clazz: ClassConstructor, json: string): Promise { + return toInstanceArray(clazz, JSON.parse(json)); +} + /** * Helper for Fetch-based frameworks (Next.js, Hono, etc.) * Extracts JSON from a Request and transforms it to a validated instance. @@ -274,7 +421,14 @@ export async function fromJson(clazz: ClassConstructor, json: string): Pro * @returns Validated class instance */ export async function fromRequest(clazz: ClassConstructor, request: Request): Promise { - const plain = await request.json(); + let plain: any; + try { + plain = await request.json(); + } catch (error) { + throw new JsonMappingError( + `Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } return toInstance(clazz, plain); } @@ -285,7 +439,9 @@ export class JsonMapper { static toPlain = toPlain; static toJson = toJson; static toInstance = toInstance; + static toInstanceArray = toInstanceArray; static fromJson = fromJson; + static fromJsonArray = fromJsonArray; static fromRequest = fromRequest; static validate = validate; }