Merge pull request #1 from avalon-vanguard/claude/library-development-i46yqm

Repair the test suite, fix eight engine defects, and add field-name mapping (0.1.0)

The test suite had never executed: vitest 4 transpiles with oxc, which does not
read experimentalDecorators from a tsconfig excluding the files it transforms,
so every decorator suite failed to parse and was reported as "0 test". Fixing
that revived 40 tests and exposed eight engine defects, each now pinned by a
regression test: inheritance silently discarding base-class constraints, a
circular reference exhausting the heap, stateful /g regexes in @Matches, an
unmatched polymorphic discriminator dropping data, serializers crashing on unset
optional properties, null-prototype objects, __proto__ from untrusted JSON, and
mangled or overwritten error messages.

Adds field-name mapping (@JsonProperty, @JsonAlias, naming strategies), access
control (@JsonIgnore, @JsonReadOnly, @JsonWriteOnly), transform options,
error-flattening helpers, and 30 validation decorators.

40 tests that never ran -> 136 that do, at 97% statement and 100% function
coverage, green on Node 20, 22 and 24.
This commit is contained in:
Senrokai
2026-08-04 13:20:48 +02:00
committed by GitHub
26 changed files with 6387 additions and 370 deletions
+16 -4
View File
@@ -2,17 +2,19 @@ name: CI
on: on:
push: push:
branches: [ main ] branches: [ main, develop ]
pull_request: pull_request:
branches: [ main ] branches: [ main, develop ]
jobs: jobs:
build: verify:
name: Node ${{ matrix.node-version }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
node-version: [18.x, 20.x, 22.x] node-version: [20.x, 22.x, 24.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -25,5 +27,15 @@ jobs:
run: npm ci run: npm ci
- name: Type Check - name: Type Check
run: npm run type-check run: npm run type-check
- name: Lint
run: npm run lint
- name: Test
run: npm run test:coverage
- name: Build
run: npm run build
- name: Verify published entry points load
run: |
node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');"
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
- name: Run Demo - name: Run Demo
run: npm run demo run: npm run demo
+2 -1
View File
@@ -1,6 +1,7 @@
# Node modules and dependency files # Node modules and dependency files
# NOTE: package-lock.json is intentionally committed — CI installs with `npm ci`,
# which requires a lockfile to be present in the repository.
/node_modules/ /node_modules/
/package-lock.json
# Build outputs # Build outputs
/dist/ /dist/
+118
View File
@@ -0,0 +1,118 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-08-03
The first release with a working test suite. Everything below the "Fixed" heading was
found by writing tests against the previous release; the suite has grown from 40 tests
that never executed to 136 that do.
### Added
**Field-name mapping.** A library whose headline feature is "JSON mapping" could not map
a name. It can now.
- `@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 — applied to properties with no explicit name.
Acronyms split where a reader expects: `parseHTTPResponse` → `parse_http_response`.
**Access control.**
- `@JsonIgnore()` — excluded in both directions.
- `@JsonWriteOnly()` — accepted from input, never echoed back (passwords).
- `@JsonReadOnly()` — serialized, never settable by a client (server-owned ids).
**Transform options**, per call or globally via `configure()`.
- `validate: false` maps without validating, for lenient parsing.
- `unknownKeys: 'allow' | 'strip' | 'error'` decides what happens to undeclared keys.
- `namingStrategy` selects the JSON naming convention.
**Error ergonomics.** Turning the nested `ValidationError` tree into an HTTP 400 body used
to be the caller's problem.
- `flattenErrors(errors)` → `{ "items[0].qty": ["qty must be at least 1"] }`
- `formatErrors(errors)` → one human-readable line per failure
- `collectErrorMessages(errors)` → just the messages
- `validateOrReject(obj)` throws instead of returning an array you might forget to check
**30 validation decorators.** `@Equals`, `@NotEquals`, `@IsEmpty`, `@IsEnum`, `@IsInstance`,
`@Length`, `@IsAlpha`, `@IsAlphanumeric`, `@IsNumberString`, `@IsLowercase`, `@IsUppercase`,
`@Contains`, `@NotContains`, `@StartsWith`, `@EndsWith`, `@IsUUID`, `@IsJSON`,
`@IsDateString`, `@IsSemVer`, `@IsHexColor`, `@IsIP`, `@IsDivisibleBy`, `@IsPort`,
`@IsLatitude`, `@IsLongitude`, `@IsBigInt`, `@MinDate`, `@MaxDate`, `@ArrayUnique`,
`@ArrayContains`, `@ArrayNotContains`.
**Conditional validation.** `@ValidateIf(o => ...)` makes a property's rules depend on the
rest of the object; `@Allow()` declares a property that needs no rules of its own.
**Correctly typed array entry points.** `toInstanceArray()` and `fromJsonArray()`.
`toInstance`/`fromJson` accept arrays at runtime but type the result as `T`, so callers had
to cast to reach the elements.
**`@JsonPolymorphic` options.** `{ onUnknown: 'error' }` and `{ fallback: SomeClass }`.
**`JsonMappingError`** — raised when a value cannot be mapped at all, as distinct from
mapping fine and failing validation.
### Fixed
- **Inheritance silently discarded base-class rules.** A subclass re-decorating an inherited
property registered its constraints against its own prototype, and the engine read only the
nearest set. Constraints now merge down the whole prototype chain, base first. The
library's own example was affected: `Media`'s `@IsString() title` had never been enforced
for `Book`.
- **A circular reference exhausted the heap.** `serialize()` recursed forever, taking 8 GB
and the process with it. It now raises a `JsonMappingError` naming the cause. Diamonds
still serialize; `validate()` skips back-edges.
- **`@Matches` with a `g` or `y` flag was stateful.** `RegExp.test` advances `lastIndex`, so
validating the same value twice gave different answers. Those flags are stripped.
- **An unmatched `@JsonPolymorphic` discriminator silently dropped the value.** The
single-object branch fell through without assigning; the property came back `undefined`.
The raw value is now preserved.
- **`@JsonSerialize` serializers ran on `null`/`undefined`**, crashing on any unset optional
property. They now only see real values.
- **`serialize()` crashed on null-prototype objects.** It read `obj.constructor.prototype`;
both engines now agree on `Object.getPrototypeOf`.
- **`__proto__`, `constructor` and `prototype` in untrusted JSON** were copied onto the
instance, detaching it from its own class. They are dropped.
- **Caller-supplied messages were mangled** by the `each element in ...` prefix, producing
sentences like "each element in tags must all be strings".
- **Two rules sharing a name overwrote each other**, so only one failure was ever reported.
- **`fromRequest` leaked a raw `SyntaxError`** for a non-JSON body; it now reports a
`JsonMappingError`.
- **`@ValidateNested({ each: true })`** was documented in the README but did not compile —
`ValidateNested()` accepted no arguments. It now does, and asserts the value is an array.
### Changed
- `toPlain`, `toJson`, `toInstance`, `fromJson`, `fromJsonArray`, `toInstanceArray` and
`fromRequest` accept an optional trailing options argument. All defaults preserve the
previous behaviour.
- `src/example.ts` is no longer published in `dist`. It called `runExample()` at import
time — an import side effect in a package declaring `"sideEffects": false`.
- Minimum supported Node is 20.
### Infrastructure
- **The test suite had never run.** Vitest 4 transpiles with oxc, which does not read
`experimentalDecorators` from a tsconfig that excludes the files it is transforming, so
every decorator-using suite failed to parse and was reported as "0 test" rather than as an
error. A `vitest.config.ts` enabling legacy decorators brought all 40 existing tests back
to life.
- Test files are now type-checked, which surfaced 17 strict-mode errors.
- CI runs lint, coverage tests, build and ESM/CJS entry-point smoke checks across Node
20/22/24, and `npm ci` works because `package-lock.json` is committed.
- `npm run build:docs` regenerates the previously hand-maintained `docs/cereale.js`.
## [0.0.1]
Initial release: mapping and validation decorators, polymorphic types, custom
serializers/deserializers, and the `toJson` / `fromJson` / `toPlain` / `toInstance` API.
+183 -46
View File
@@ -4,10 +4,12 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
## Features ## Features
- **Spring-like Decorators:** Familiar `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`. - **Spring-like Decorators:** Familiar `@JsonProperty`, `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
- **Field-name Mapping:** Map `first_name` to `firstName` per property or with a naming strategy.
- **Access Control:** Keep passwords out of responses and server-owned ids out of requests.
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects. - **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
- **Polymorphism Support:** Native handling of polymorphic types via discriminators. - **Polymorphism Support:** Native handling of polymorphic types via discriminators.
- **Integrated Validation:** Automatically validates objects during serialization and deserialization. - **Integrated Validation:** 50+ validation decorators, applied during mapping or on demand.
- **Type Safety:** Fully written in TypeScript for excellent developer experience. - **Type Safety:** Fully written in TypeScript for excellent developer experience.
- **Zero Dependencies:** Extremely lightweight and fast. - **Zero Dependencies:** Extremely lightweight and fast.
@@ -17,29 +19,27 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
npm install cereale npm install cereale
``` ```
Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your `tsconfig.json`: Enable `experimentalDecorators` in your `tsconfig.json`:
```json ```json
{ {
"compilerOptions": { "compilerOptions": {
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "target": "ES2022"
"target": "ES2025"
} }
} }
``` ```
Cereale stores its own metadata, so `reflect-metadata` is not required and
`emitDecoratorMetadata` is not read. Requires Node 20 or later.
## Quick Start ## Quick Start
### 1. Define your Models ### 1. Define your Models
Use decorators to define how your data should be transformed and validated.
```typescript ```typescript
import { import {
IsString, IsString,
IsInt,
Min,
IsDate, IsDate,
ValidateNested, ValidateNested,
JsonSerialize, JsonSerialize,
@@ -52,7 +52,7 @@ import {
// Custom Date Serializer // Custom Date Serializer
class DateSerializer implements JsonSerializer<Date, string> { class DateSerializer implements JsonSerializer<Date, string> {
serialize(value: Date): string { serialize(value: Date): string {
return value.toISOString().split('T')[0]; return value.toISOString().split('T')[0]!;
} }
} }
@@ -94,12 +94,13 @@ class Library {
} }
``` ```
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s
`@IsString() title` as well as its own rules.
### 2. Map JSON with Validation ### 2. Map JSON with Validation
Use standalone utility functions to handle the conversion process directly.
```typescript ```typescript
import { fromJson, toJson, JsonValidationError } from 'cereale'; import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale';
async function main() { async function main() {
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}'; const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
@@ -111,11 +112,11 @@ async function main() {
console.log(library.items[0] instanceof Book); // true console.log(library.items[0] instanceof Book); // true
// Serialize Class Instance back to JSON // Serialize Class Instance back to JSON
const outputJson = await toJson(library); console.log(await toJson(library));
console.log(outputJson);
} catch (error) { } catch (error) {
if (error instanceof JsonValidationError) { if (error instanceof JsonValidationError) {
console.error("Validation failed:", error.errors); console.error(flattenErrors(error.errors));
// { "items[0].title": ["title must be a string"] }
} }
} }
} }
@@ -135,20 +136,102 @@ app.post('/books', async (c) => {
}); });
``` ```
## Field-name Mapping
JSON rarely uses the same names as your classes.
```typescript
import { JsonProperty, JsonAlias } from 'cereale';
class User {
@JsonProperty('first_name')
firstName: string; // <-> {"first_name": "Ada"}
@JsonProperty('surname')
@JsonAlias('last_name') // also accepted on input, never emitted
lastName: string;
}
```
Or convert every property at once with a naming strategy:
```typescript
import { configure, toPlain } from 'cereale';
// once, for the whole application
configure({ namingStrategy: 'snake_case' });
// or per call
await toPlain(user, { namingStrategy: 'snake_case' });
```
Built-in strategies: `identity` (default), `camelCase`, `PascalCase`, `snake_case`,
`SCREAMING_SNAKE_CASE`, `kebab-case`. You can also pass your own
`(propertyKey: string) => string`. An explicit `@JsonProperty` always wins.
Acronyms split where a reader expects them to: `parseHTTPResponse` becomes
`parse_http_response`, not `parse_h_t_t_p_response`.
## Access Control
```typescript
import { JsonIgnore, JsonReadOnly, JsonWriteOnly } from 'cereale';
class Account {
@JsonReadOnly() // sent to clients, never settable by them
id: number;
@IsString()
email: string;
@JsonWriteOnly() // accepted from clients, never echoed back
@IsString()
password: string;
@JsonIgnore() // never crosses the boundary in either direction
internalNotes: string;
}
```
## Options
Every mapping function takes an optional trailing options argument, and `configure()` sets
defaults for the whole application. Per-call options win.
| Option | Values | Default | Meaning |
| --- | --- | --- | --- |
| `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. |
| `namingStrategy` | strategy name or function | `identity` | JSON naming convention for properties without `@JsonProperty`. |
| `unknownKeys` | `allow` \| `strip` \| `error` | `allow` | What to do with incoming keys matching no declared property. |
```typescript
// lenient parse: build the instance, inspect the damage yourself
const draft = await fromJson(Order, body, { validate: false });
const problems = flattenErrors(await validate(draft));
// strict intake: reject anything you did not declare
const order = await fromJson(Order, body, { unknownKeys: 'error' });
```
## API Reference ## API Reference
### Decorators ### Mapping Decorators
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Specifies a custom serializer for a property. - `@JsonProperty(name: string)`: Renames the property in JSON, both directions.
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Specifies a custom deserializer for a property. - `@JsonAlias(...names: string[])`: Extra names accepted on input only.
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations. - `@JsonIgnore()`: Excludes the property from mapping entirely.
- `@JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])`: Configures polymorphic transformation based on a discriminator field. - `@JsonReadOnly()`: Serialized, but never populated from incoming JSON.
- `@JsonWriteOnly()`: Populated from incoming JSON, but never serialized.
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Custom serializer for a property. Skipped when the value is `null`/`undefined`.
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Custom deserializer for a property.
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations. Applies element-wise to arrays.
- `@JsonPolymorphic(discriminator, subTypes, options?)`: Polymorphic transformation based on a discriminator field. `options` accepts `{ onUnknown: 'keep' | 'error' }` (default `keep`, which preserves the raw value) and `{ fallback: ClassConstructor }`.
#### Validation Decorators ### Validation Decorators
Most validation decorators accept an optional `ValidationOptions` object: Most validation decorators accept an optional `ValidationOptions` object:
- `each: boolean`: Apply validation to each element of an array. - `each: boolean`: Apply validation to each element of an array.
- `message: string | ((args: ValidationArguments) => string)`: Custom error message. - `message: string | ((args: ValidationArguments) => string)`: Custom error message, reported verbatim.
| Decorator | Description | | Decorator | Description |
| --- | --- | | --- | --- |
@@ -156,46 +239,88 @@ Most validation decorators accept an optional `ValidationOptions` object:
| `@IsNumber()` | Checks if value is a number (and not NaN). | | `@IsNumber()` | Checks if value is a number (and not NaN). |
| `@IsInt()` | Checks if value is an integer. | | `@IsInt()` | Checks if value is an integer. |
| `@IsBoolean()` | Checks if value is a boolean. | | `@IsBoolean()` | Checks if value is a boolean. |
| `@IsBigInt()` | Checks if value is a bigint. |
| `@IsObject()` | Checks if value is an object (not null/array). | | `@IsObject()` | Checks if value is an object (not null/array). |
| `@IsDate()` | Checks if value is a valid Date object. | | `@IsDate()` | Checks if value is a valid Date object. |
| `@IsDefined()` | Checks if value is not null or undefined. | | `@IsDefined()` | Checks if value is not null or undefined. |
| `@IsOptional()` | Skips other validations if value is null/undefined. | | `@IsOptional()` | Skips other validations if value is null/undefined. |
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. | | `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
| `@Min(value)` | Checks if number is >= value. | | `@IsEmpty()` | Checks if value is null/undefined/`''`/`[]`/`{}`. |
| `@Max(value)` | Checks if number is <= value. | | `@Equals(value)` / `@NotEquals(value)` | Strict equality against a fixed value. |
| `@Positive()` | Checks if number is > 0. | | `@IsEnum(enumObject)` | Checks membership of a TypeScript enum. |
| `@Negative()` | Checks if number is < 0. | | `@IsInstance(Class)` | Checks `value instanceof Class`. |
| `@MinLength(len)` | Checks if string length is >= len. | | `@Min(value)` / `@Max(value)` | Numeric bounds. |
| `@MaxLength(len)` | Checks if string length is <= len. | | `@Positive()` / `@Negative()` | Checks sign. |
| `@IsDivisibleBy(n)` | Checks `value % n === 0`. |
| `@IsPort()` | Integer in 0–65535, as number or numeric string. |
| `@IsLatitude()` / `@IsLongitude()` | Geographic bounds. |
| `@MinLength(len)` / `@MaxLength(len)` | String length bounds. |
| `@Length(min, max?)` | Both bounds in one rule. |
| `@IsAlpha()` / `@IsAlphanumeric()` | Character-class checks. |
| `@IsLowercase()` / `@IsUppercase()` | Case checks. |
| `@IsNumberString()` | String that parses as a finite number. |
| `@Contains(s)` / `@NotContains(s)` | Substring checks. |
| `@StartsWith(s)` / `@EndsWith(s)` | Affix checks. |
| `@Email()` | Checks if string is a valid email. | | `@Email()` | Checks if string is a valid email. |
| `@IsUrl()` | Checks if string is a valid URL. | | `@IsUrl()` | Checks if string is a valid URL. |
| `@IsUUID(version?)` | Checks if string is a valid UUID. |
| `@IsIP(version?)` | Checks if string is a valid IPv4/IPv6 address. |
| `@IsJSON()` | Checks if string parses as JSON. |
| `@IsDateString()` | Checks if string is a parseable date. |
| `@IsSemVer()` | Checks if string is a semantic version. |
| `@IsHexColor()` | Checks `#rgb`, `#rrggbb`, `#rrggbbaa`. |
| `@Matches(regex)` | Checks if string matches a regular expression. | | `@Matches(regex)` | Checks if string matches a regular expression. |
| `@MinDate(d)` / `@MaxDate(d)` | Date bounds. Accepts `() => Date` for a moving bound. |
| `@IsArray()` | Checks if value is an array. | | `@IsArray()` | Checks if value is an array. |
| `@ArrayNotEmpty()` | Checks if array is not empty. | | `@ArrayNotEmpty()` | Checks if array is not empty. |
| `@ArrayMinSize(n)`| Checks if array has at least n elements. | | `@ArrayMinSize(n)` / `@ArrayMaxSize(n)` | Array size bounds. |
| `@ArrayMaxSize(n)`| Checks if array has at most n elements. | | `@ArrayUnique(keyFn?)` | Checks for duplicate elements. |
| `@IsIn(values)` | Checks if value is in the allowed list. | | `@ArrayContains(vals)` / `@ArrayNotContains(vals)` | Membership checks. |
| `@IsNotIn(vals)` | Checks if value is NOT in the list. | | `@IsIn(values)` / `@IsNotIn(values)` | Allow/deny lists. |
| `@ValidateNested()`| Recursively validates nested objects/arrays. | | `@ValidateNested(options?)` | Recursively validates nested objects/arrays. |
| `@ValidateIf(o => boolean)` | Skips this property's rules when the condition is false. |
| `@Allow()` | Declares a property with no rules of its own. |
| `@Validate(validator, constraints?, options?)` | Applies a custom validator class or function. |
Write your own with `registerDecorator({ name, target, propertyName, validator })`.
### Utilities ### Utilities
- `toJson(obj: any)`: Validates and serializes an instance to a JSON string (Returns `Promise<string>`). - `toJson(obj, options?)`: Validates and serializes an instance to a JSON string (`Promise<string>`).
- `toPlain(obj: any)`: Validates and transforms an instance to a plain object (Returns `Promise<any>`). - `toPlain(obj, options?)`: Validates and transforms an instance to a plain object (`Promise<any>`).
- `fromJson(clazz: ClassConstructor, json: string)`: Parses JSON and transforms it to a validated class instance (Returns `Promise<T>`). - `fromJson(clazz, json, options?)`: Parses JSON to a validated class instance (`Promise<T>`).
- `toInstance(clazz: ClassConstructor, plain: any)`: Transforms a plain object to a validated class instance (Returns `Promise<T>`). - `fromJsonArray(clazz, json, options?)`: Same, for a JSON array (`Promise<T[]>`).
- `fromRequest(clazz: ClassConstructor, request: Request)`: Extracts JSON from a Fetch `Request` and transforms it to a validated instance (Returns `Promise<T>`). - `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
- `validate(obj: any)`: Performs full validation on an object/instance (Returns `Promise<ValidationError[]>`). - `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
- `validate(obj)`: Full validation, returning `Promise<ValidationError[]>`.
- `validateOrReject(obj)`: As above, but throws `JsonValidationError`.
- `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults.
### Error Handling
`JsonValidationError` carries a nested `ValidationError[]`. Three helpers turn it into
something you can return to a client:
```typescript
import { flattenErrors, formatErrors, collectErrorMessages } from 'cereale';
flattenErrors(errors); // { "items[0].qty": ["qty must be at least 1"] }
formatErrors(errors); // "items[0].qty: qty must be at least 1"
collectErrorMessages(errors); // ["qty must be at least 1"]
```
`JsonMappingError` is raised when a value cannot be mapped at all — a body that is not
JSON, a circular reference, an unknown discriminator under `{ onUnknown: 'error' }` — as
distinct from mapping fine and failing validation.
## Framework Integrations ## Framework Integrations
Cereale is designed to be compatible with all trending web frameworks.
### Hono / Next.js / Cloudflare Workers ### Hono / Next.js / Cloudflare Workers
Use `fromRequest` for seamless integration with the Fetch `Request` API. Use `fromRequest` for seamless integration with the Fetch `Request` API.
### NestJS ### NestJS
You can use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`. Use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`.
```typescript ```typescript
import { toInstance } from 'cereale'; import { toInstance } from 'cereale';
@@ -208,21 +333,33 @@ async create(@Body() body: any) {
``` ```
### Express / Fastify ### Express / Fastify
Easily integrate with traditional Node.js frameworks.
```typescript ```typescript
import { toInstance, toPlain } from 'cereale'; import { toInstance, toPlain, JsonValidationError, flattenErrors } from 'cereale';
app.post('/user', async (req, res) => { app.post('/user', async (req, res) => {
try { try {
const user = await toInstance(User, req.body); const user = await toInstance(User, req.body);
res.json(await toPlain(user)); res.json(await toPlain(user));
} catch (err) { } catch (err) {
res.status(400).json(err); if (err instanceof JsonValidationError) {
return res.status(400).json({ errors: flattenErrors(err.errors) });
}
throw err;
} }
}); });
``` ```
## Notes and Limitations
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
the cycle with `@JsonIgnore()` on the back-reference.
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
the instance you get back from `toInstance`, not the raw payload.
- **Renaming is not backwards-compatible by itself.** Once a property carries
`@JsonProperty`, its original name is no longer accepted on input — add `@JsonAlias` to
keep older clients working.
## Contributing ## Contributing
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
+2 -1
View File
File diff suppressed because one or more lines are too long
+41 -41
View File
@@ -136,12 +136,23 @@
<div> <div>
<h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3> <h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3>
<ul class="space-y-2 text-slate-600"> <ul class="space-y-2 text-slate-600">
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonProperty('first_name')</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonAlias(...names)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonSerialize(cls)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonSerialize(cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonDeserialize(cls)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonDeserialize(cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonType(() => cls)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonType(() => cls)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonPolymorphic(field, types)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@JsonPolymorphic(field, types)</code></li>
</ul> </ul>
</div> </div>
<div>
<h3 class="font-bold text-lg mb-4 text-indigo-600">Access Control</h3>
<ul class="space-y-2 text-slate-600">
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonIgnore()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonReadOnly()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonWriteOnly()</code></li>
<li class="text-sm pt-2">Naming strategies: <code class="text-sm bg-slate-100 p-1 rounded">snake_case</code>, <code class="text-sm bg-slate-100 p-1 rounded">kebab-case</code>, …</li>
</ul>
</div>
<div> <div>
<h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3> <h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3>
<ul class="space-y-2 text-slate-600"> <ul class="space-y-2 text-slate-600">
@@ -158,7 +169,9 @@
<li><code class="text-sm bg-slate-100 p-1 rounded">@Min(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Max(n)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@Min(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Max(n)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinLength(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@MaxLength(n)</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@MinLength(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@MaxLength(n)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@Email()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsUrl()</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@Email()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsUrl()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@ValidateNested()</code></li> <li><code class="text-sm bg-slate-100 p-1 rounded">@IsUUID(v?)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsEnum(e)</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinDate(d)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@ArrayUnique()</code></li>
<li><code class="text-sm bg-slate-100 p-1 rounded">@ValidateNested()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@ValidateIf(fn)</code></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -175,9 +188,10 @@
<script> <script>
const initialCode = `// 1. Define your model with decorators const initialCode = `// 1. Define your model with decorators
class User { class User {
@JsonProperty('display_name')
@IsString() @IsString()
@MinLength(3) @MinLength(3)
name; displayName;
@IsInt() @IsInt()
@Min(18) @Min(18)
@@ -186,26 +200,36 @@ class User {
@Email() @Email()
email; email;
constructor(name, age, email) { // Accepted from a request, never sent back out
this.name = name; @JsonWriteOnly()
@IsString()
password;
constructor(displayName, age, email, password) {
this.displayName = displayName;
this.age = age; this.age = age;
this.email = email; this.email = email;
this.password = password;
} }
} }
async function demo() { async function demo() {
console.log("--- Validating valid user ---"); console.log("--- Mapping a valid user ---");
const user = new User("Alice", 25, "alice@example.com"); const user = new User("Alice", 25, "alice@example.com", "hunter2");
const json = await JsonMapper.toJson(user); const json = await toJson(user);
console.log("JSON Output:", json); console.log("JSON Output:", json);
console.log("Password withheld:", !json.includes("hunter2"));
console.log("\\n--- Testing validation failure ---"); console.log("\\n--- Reading it back ---");
const parsed = await fromJson(User, json, { validate: false });
console.log("displayName read from display_name:", parsed.displayName);
console.log("\\n--- Reporting validation failures ---");
try { try {
const invalidJson = '{"name": "Bo", "age": 15, "email": "not-an-email"}'; await fromJson(User, '{"display_name": "Bo", "age": 15, "email": "nope", "password": "x"}');
await JsonMapper.fromJson(User, invalidJson);
} catch (error) { } catch (error) {
console.log("Caught Error:", error.message); console.log("Caught:", error.message);
console.log("Validation Errors:", JSON.stringify(error.errors, null, 2)); console.log("Flattened:", flattenErrors(error.errors));
} }
} }
@@ -247,36 +271,12 @@ demo();`;
] ]
}).code; }).code;
// Create a function with the library symbols in scope // Put every library export in scope. Derived from the bundle rather than
const { // hand-listed, so a new decorator is usable here the moment it is exported.
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested, const exportNames = Object.keys(Cereale).filter(name => /^[A-Za-z_$][\w$]*$/.test(name));
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
Positive, Negative,
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
JsonValidationError
} = Cereale;
const run = new Function( const run = new Function('console', ...exportNames, transpiled);
'console', await run({ log: logToOutput }, ...exportNames.map(name => Cereale[name]));
'IsString', 'IsInt', 'Min', 'Max', 'IsEmail', 'IsArray', 'IsDate', 'IsOptional', 'ValidateNested',
'IsBoolean', 'IsNumber', 'IsObject', 'IsDefined', 'IsNotEmpty', 'MinLength', 'MaxLength',
'Email', 'IsUrl', 'Matches', 'ArrayMinSize', 'ArrayMaxSize', 'ArrayNotEmpty', 'IsIn', 'IsNotIn',
'Positive', 'Negative',
'JsonSerialize', 'JsonDeserialize', 'JsonPolymorphic', 'JsonType', 'JsonMapper',
'JsonValidationError',
transpiled
);
await run(
{ log: logToOutput },
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested,
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
Positive, Negative,
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
JsonValidationError
);
} catch (err) { } catch (err) {
outputElement.textContent += 'Error: ' + err.message + '\\n'; outputElement.textContent += 'Error: ' + err.message + '\\n';
if (err.stack) { if (err.stack) {
+3258
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "cereale", "name": "cereale",
"version": "0.0.1", "version": "0.1.0",
"description": "Spring-like decorators for JSON mapping and validation in TypeScript", "description": "Spring-like decorators for JSON mapping and validation in TypeScript",
"type": "module", "type": "module",
"main": "./dist/cjs/index.js", "main": "./dist/cjs/index.js",
@@ -19,13 +19,19 @@
], ],
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", "build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
"build:docs": "esbuild src/index.ts --bundle --format=iife --global-name=Cereale --minify --tsconfig=tsconfig.json --outfile=docs/cereale.js",
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts", "demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"prepublishOnly": "npm run build" "verify": "npm run type-check && npm run lint && npm run test && npm run build",
"prepublishOnly": "npm run verify"
},
"engines": {
"node": ">=20.0.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -49,6 +55,7 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@vitest/coverage-v8": "^4.1.4", "@vitest/coverage-v8": "^4.1.4",
"esbuild": "^0.25.0",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"globals": "^17.5.0", "globals": "^17.5.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
+75
View File
@@ -0,0 +1,75 @@
import { NamingStrategy } from './naming.js';
/**
* How an incoming key that maps to no known property should be treated.
*
* - `allow` (default): copy it onto the instance untouched, preserving the previous behaviour.
* - `strip`: drop it, so instances only ever carry declared properties.
* - `error`: reject the payload with a {@link JsonMappingError}.
*/
export type UnknownKeyPolicy = 'allow' | 'strip' | 'error';
export interface TransformOptions {
/**
* Validate the result and throw {@link JsonValidationError} on failure.
*
* Defaults to `true`, matching the behaviour of every previous release. Set it to `false`
* to map without validating — useful when you want to inspect a partially-valid payload,
* or when validation happens elsewhere in your stack.
*/
validate?: boolean;
/**
* Naming convention used on the JSON side for properties without an explicit
* `@JsonProperty`. Defaults to `identity` (property names are used as-is).
*/
namingStrategy?: NamingStrategy;
/** What to do with incoming keys that match no declared property. Deserialization only. */
unknownKeys?: UnknownKeyPolicy;
}
/** Options that can be set once for the whole application via {@link configure}. */
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate'>;
const DEFAULTS: Required<GlobalOptions> = {
namingStrategy: 'identity',
unknownKeys: 'allow',
validate: true,
};
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
/**
* Sets library-wide defaults, so an application that consistently speaks `snake_case` does
* not have to repeat itself at every call site.
*
* ```ts
* configure({ namingStrategy: 'snake_case', unknownKeys: 'strip' });
* ```
*
* Per-call options always take precedence over these.
*/
export function configure(options: GlobalOptions): void {
globalOptions = { ...globalOptions, ...options };
}
/** Returns the current library-wide defaults. */
export function getConfig(): Required<GlobalOptions> {
return { ...globalOptions };
}
/** Restores the library-wide defaults to their original values. */
export function resetConfig(): void {
globalOptions = { ...DEFAULTS };
}
/** Merges per-call options over the library-wide defaults. */
export function resolveOptions(options?: TransformOptions): Required<GlobalOptions> {
if (!options) return globalOptions;
return {
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
validate: options.validate ?? globalOptions.validate,
};
}
+7 -7
View File
@@ -238,7 +238,7 @@ describe('Additional Decorators', () => {
t.val = 'wrong'; t.val = 'wrong';
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['CustomValidator']).toBe('val must be correct'); expect(errors[0]!.constraints['CustomValidator']).toBe('val must be correct');
}); });
}); });
@@ -304,7 +304,7 @@ describe('Additional Decorators', () => {
t.val = 5; t.val = 5;
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be ten'); expect(errors[0]!.constraints['custom']).toBe('must be ten');
}); });
it('should handle options as second argument', async () => { it('should handle options as second argument', async () => {
@@ -318,7 +318,7 @@ describe('Additional Decorators', () => {
t.val = 2; t.val = 2;
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['custom']).toBe('must be one'); expect(errors[0]!.constraints['custom']).toBe('must be one');
}); });
}); });
@@ -339,10 +339,10 @@ describe('Additional Decorators', () => {
name: string; name: string;
} }
const json = '[{"name": "a"}, {"name": "b"}]'; const json = '[{"name": "a"}, {"name": "b"}]';
const items = await JsonMapper.fromJson(Item, json); const items = (await JsonMapper.fromJson(Item, json)) as unknown as Item[];
expect(Array.isArray(items)).toBe(true); expect(Array.isArray(items)).toBe(true);
expect(items[0]).toBeInstanceOf(Item); expect(items[0]).toBeInstanceOf(Item);
expect(items[0].name).toBe('a'); expect(items[0]!.name).toBe('a');
}); });
it('should handle single polymorphic object', async () => { it('should handle single polymorphic object', async () => {
@@ -350,7 +350,7 @@ describe('Additional Decorators', () => {
@IsString() type: string; @IsString() type: string;
} }
class Dog extends Animal { class Dog extends Animal {
type = 'dog'; override type = 'dog';
@IsString() breed: string; @IsString() breed: string;
} }
class Test { class Test {
@@ -376,7 +376,7 @@ describe('Additional Decorators', () => {
t.tags = ['a', 1 as any]; t.tags = ['a', 1 as any];
const errors = await JsonMapper.validate(t); const errors = await JsonMapper.validate(t);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].constraints['isString']).toContain('each element'); expect(errors[0]!.constraints['isString']).toContain('each element');
}); });
}); });
}); });
+625 -7
View File
@@ -9,8 +9,23 @@ export const METADATA_KEYS = {
DESERIALIZER: 'cereale:deserializer', DESERIALIZER: 'cereale:deserializer',
POLYMORPHIC: 'cereale:polymorphic', POLYMORPHIC: 'cereale:polymorphic',
IS_OPTIONAL: 'cereale:optional', IS_OPTIONAL: 'cereale:optional',
NESTED: 'cereale:nested',
NAME: 'cereale:name',
ALIASES: 'cereale:aliases',
ACCESS: 'cereale:access',
CONDITION: 'cereale:condition',
}; };
/**
* 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 { export interface ValidationArguments {
value: any; value: any;
object: any; object: any;
@@ -29,6 +44,12 @@ export type ValidationConstraint = {
message: string | ((args: ValidationArguments) => string); message: string | ((args: ValidationArguments) => string);
constraints?: any[]; constraints?: any[];
each?: boolean; 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 { export interface ValidatorConstraintInterface {
@@ -55,6 +76,7 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
} }
if (options.message) { if (options.message) {
constraint.message = options.message; constraint.message = options.message;
constraint.hasCustomMessage = true;
} }
} }
@@ -65,6 +87,86 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
// --- Mapping Decorators --- // --- 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>) * @JsonSerialize(serializer: ClassConstructor<JsonSerializer>)
* Custom serializer decorator. * Custom serializer decorator.
@@ -98,14 +200,34 @@ export function JsonType(typeFunction: () => ClassConstructor<any>) {
}; };
} }
export interface PolymorphicOptions {
/** /**
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) * 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<any>;
}
/**
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[], options?: PolymorphicOptions)
* Defines polymorphic behavior for a property. * Defines polymorphic behavior for a property.
*/ */
export function JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[]) { export function JsonPolymorphic(
discriminator: string,
subTypes: { value: ClassConstructor<any>, name: string }[],
options?: PolymorphicOptions
) {
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey); 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 +455,16 @@ export function IsUrl(options?: ValidationOptions) {
* @Matches(pattern: RegExp) * @Matches(pattern: RegExp)
*/ */
export function Matches(pattern: RegExp, options?: ValidationOptions) { 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) => { return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, { addValidation(target, propertyKey, {
name: 'matches', 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`, message: `${propertyKey} must match ${pattern} regular expression`,
constraints: [pattern] constraints: [pattern]
}, options); }, options);
@@ -438,14 +566,504 @@ export function IsDate(options?: ValidationOptions) {
}; };
} }
// --- Equality and presence ---
/** /**
* @ValidateNested() * @Equals(comparison: any)
*/ */
export function ValidateNested() { export function Equals(comparison: any, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'equals',
validate: (v) => v === comparison,
message: `${propertyKey} must be equal to ${JSON.stringify(comparison)}`,
constraints: [comparison]
}, options);
};
}
/**
* @NotEquals(comparison: any)
*/
export function NotEquals(comparison: any, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'notEquals',
validate: (v) => v !== comparison,
message: `${propertyKey} must not be equal to ${JSON.stringify(comparison)}`,
constraints: [comparison]
}, options);
};
}
/**
* @IsEmpty()
* Passes for null, undefined, '', [] and {} — the mirror of `@IsNotEmpty`.
*/
export function IsEmpty(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isEmpty',
validate: (v) => {
if (v === null || v === undefined || v === '') return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === 'object') return Object.keys(v).length === 0;
return false;
},
message: `${propertyKey} must be empty`
}, options);
};
}
/**
* @IsEnum(entity: object)
* Checks the value is a member of a TypeScript enum (string or numeric).
*/
export function IsEnum(entity: Record<string, any>, options?: ValidationOptions) {
// A numeric enum compiles to a two-way map ({ A: 0, '0': 'A' }), so the reverse-mapped
// names have to be filtered out or 'A' would validate as a legal value.
const values = Object.keys(entity)
.filter(key => typeof entity[entity[key]] !== 'number')
.map(key => entity[key]);
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isEnum',
validate: (v) => values.includes(v),
message: `${propertyKey} must be one of the following values: ${values.join(', ')}`,
constraints: [values]
}, options);
};
}
/**
* @IsInstance(target: ClassConstructor)
*/
export function IsInstance(clazz: ClassConstructor<any>, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isInstance',
validate: (v) => v instanceof clazz,
message: `${propertyKey} must be an instance of ${clazz.name}`,
constraints: [clazz]
}, options);
};
}
// --- Strings ---
/**
* @Length(min: number, max?: number)
*/
export function Length(min: number, max?: number, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'length',
validate: (v) => typeof v === 'string' && v.length >= min && (max === undefined || v.length <= max),
message: max === undefined
? `${propertyKey} must be at least ${min} characters`
: `${propertyKey} must be between ${min} and ${max} characters`,
constraints: max === undefined ? [min] : [min, max]
}, options);
};
}
function stringPattern(name: string, regex: RegExp, describe: (property: string) => string) {
return (options?: ValidationOptions) => (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name,
validate: (v) => typeof v === 'string' && regex.test(v),
message: describe(propertyKey)
}, options);
};
}
/** @IsAlpha() — letters only. */
export const IsAlpha = stringPattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
/** @IsAlphanumeric() — letters and digits only. */
export const IsAlphanumeric = stringPattern(
'isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`
);
/** @IsNumberString() — a string that parses as a finite number. */
export function IsNumberString(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isNumberString',
validate: (v) => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)),
message: `${propertyKey} must be a number string`
}, options);
};
}
/** @IsLowercase() */
export function IsLowercase(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isLowercase',
validate: (v) => typeof v === 'string' && v === v.toLowerCase(),
message: `${propertyKey} must be lowercase`
}, options);
};
}
/** @IsUppercase() */
export function IsUppercase(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isUppercase',
validate: (v) => typeof v === 'string' && v === v.toUpperCase(),
message: `${propertyKey} must be uppercase`
}, options);
};
}
/** @Contains(seed: string) */
export function Contains(seed: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'contains',
validate: (v) => typeof v === 'string' && v.includes(seed),
message: `${propertyKey} must contain ${JSON.stringify(seed)}`,
constraints: [seed]
}, options);
};
}
/** @NotContains(seed: string) */
export function NotContains(seed: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'notContains',
validate: (v) => typeof v === 'string' && !v.includes(seed),
message: `${propertyKey} must not contain ${JSON.stringify(seed)}`,
constraints: [seed]
}, options);
};
}
/** @StartsWith(prefix: string) */
export function StartsWith(prefix: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'startsWith',
validate: (v) => typeof v === 'string' && v.startsWith(prefix),
message: `${propertyKey} must start with ${JSON.stringify(prefix)}`,
constraints: [prefix]
}, options);
};
}
/** @EndsWith(suffix: string) */
export function EndsWith(suffix: string, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'endsWith',
validate: (v) => typeof v === 'string' && v.endsWith(suffix),
message: `${propertyKey} must end with ${JSON.stringify(suffix)}`,
constraints: [suffix]
}, options);
};
}
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
const MAX_UUID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
/**
* @IsUUID(version?: 1|2|3|4|5|6|7|8)
* Without a version, accepts any RFC 9562 UUID plus the nil and max UUIDs.
*/
export function IsUUID(version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, options?: ValidationOptions) {
const pattern = version
? new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${version}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, 'i')
: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isUuid',
validate: (v) => {
if (typeof v !== 'string') return false;
if (!version && (v.toLowerCase() === NIL_UUID || v.toLowerCase() === MAX_UUID)) return true;
return pattern.test(v);
},
message: `${propertyKey} must be a valid UUID${version ? ` (version ${version})` : ''}`,
...(version ? { constraints: [version] } : {})
}, options);
};
}
/** @IsJSON() — a string that JSON.parse accepts. */
export function IsJSON(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isJson',
validate: (v) => {
if (typeof v !== 'string') return false;
try {
JSON.parse(v);
return true;
} catch {
return false;
}
},
message: `${propertyKey} must be a JSON string`
}, options);
};
}
/** @IsDateString() — an ISO-8601 string that parses to a real date. */
export function IsDateString(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isDateString',
validate: (v) => typeof v === 'string' && !isNaN(Date.parse(v)),
message: `${propertyKey} must be a valid ISO 8601 date string`
}, options);
};
}
/** @IsSemVer() */
export const IsSemVer = stringPattern(
'isSemVer',
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
p => `${p} must be a valid semantic version`
);
/** @IsHexColor() — #rgb, #rrggbb or #rrggbbaa. */
export const IsHexColor = stringPattern(
'isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`
);
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
/**
* @IsIP(version?: 4 | 6)
*/
export function IsIP(version?: 4 | 6, options?: ValidationOptions) {
const isV6 = (v: string) => {
// Node's URL parser is the most reliable IPv6 validator available without a dependency.
try {
return new URL(`http://[${v}]`).hostname === `[${v.toLowerCase()}]` || /^[0-9a-f:.]+$/i.test(v) && v.includes(':');
} catch {
return false;
}
};
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isIp',
validate: (v) => {
if (typeof v !== 'string') return false;
if (version === 4) return IPV4.test(v);
if (version === 6) return isV6(v);
return IPV4.test(v) || isV6(v);
},
message: `${propertyKey} must be a valid IP${version ? `v${version}` : ''} address`,
...(version ? { constraints: [version] } : {})
}, options);
};
}
// --- Numbers ---
/** @IsDivisibleBy(divisor: number) */
export function IsDivisibleBy(divisor: number, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isDivisibleBy',
validate: (v) => typeof v === 'number' && Number.isFinite(v) && divisor !== 0 && v % divisor === 0,
message: `${propertyKey} must be divisible by ${divisor}`,
constraints: [divisor]
}, options);
};
}
/** @IsPort() — an integer in 0..65535, as a number or a numeric string. */
export function IsPort(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isPort',
validate: (v) => {
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
},
message: `${propertyKey} must be a valid port number`
}, options);
};
}
/** @IsLatitude() */
export function IsLatitude(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isLatitude',
validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90,
message: `${propertyKey} must be a latitude between -90 and 90`
}, options);
};
}
/** @IsLongitude() */
export function IsLongitude(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isLongitude',
validate: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180,
message: `${propertyKey} must be a longitude between -180 and 180`
}, options);
};
}
/** @IsBigInt() */
export function IsBigInt(options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'isBigInt',
validate: (v) => typeof v === 'bigint',
message: `${propertyKey} must be a bigint`
}, options);
};
}
// --- Dates ---
type DateBound = Date | (() => Date);
const boundOf = (bound: DateBound): Date => (typeof bound === 'function' ? bound() : bound);
/**
* @MinDate(date: Date | (() => Date))
* Accepts a thunk so a moving boundary — "not in the past" — is evaluated per validation
* rather than frozen when the class was declared.
*/
export function MinDate(min: DateBound, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'minDate',
validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() >= boundOf(min).getTime(),
message: (args) => `${args.property} must not be earlier than ${boundOf(min).toISOString()}`,
constraints: [min]
}, options);
};
}
/**
* @MaxDate(date: Date | (() => Date))
*/
export function MaxDate(max: DateBound, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'maxDate',
validate: (v) => v instanceof Date && !isNaN(v.getTime()) && v.getTime() <= boundOf(max).getTime(),
message: (args) => `${args.property} must not be later than ${boundOf(max).toISOString()}`,
constraints: [max]
}, options);
};
}
// --- Arrays ---
/**
* @ArrayUnique(identifier?: (item: any) => any)
* Pass an extractor to deduplicate objects by a key rather than by reference.
*/
export function ArrayUnique(identifier?: (item: any) => any, options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'arrayUnique',
validate: (v) => {
if (!Array.isArray(v)) return false;
const keys = identifier ? v.map(identifier) : v;
return new Set(keys).size === keys.length;
},
message: `${propertyKey} must not contain duplicate values`
}, options);
};
}
/** @ArrayContains(values: any[]) — the array must contain every listed value. */
export function ArrayContains(values: any[], options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'arrayContains',
validate: (v) => Array.isArray(v) && values.every(value => v.includes(value)),
message: `${propertyKey} must contain the following values: ${values.join(', ')}`,
constraints: [values]
}, options);
};
}
/** @ArrayNotContains(values: any[]) — the array must contain none of the listed values. */
export function ArrayNotContains(values: any[], options?: ValidationOptions) {
return (target: any, propertyKey: string) => {
addValidation(target, propertyKey, {
name: 'arrayNotContains',
validate: (v) => Array.isArray(v) && values.every(value => !v.includes(value)),
message: `${propertyKey} must not contain any of the following values: ${values.join(', ')}`,
constraints: [values]
}, options);
};
}
// --- Control flow ---
/**
* @ValidateIf(condition: (object: any) => boolean)
* Skips every constraint on this property when the condition returns false.
*
* ```ts
* class Payment {
* @IsIn(['card', 'invoice'])
* method: string;
*
* @ValidateIf(o => o.method === 'card')
* @IsString()
* cardNumber?: string;
* }
* ```
*/
export function ValidateIf(condition: (object: any) => boolean) {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
metadataStorage.defineMetadata(METADATA_KEYS.CONDITION, condition, target, propertyKey);
};
}
/**
* @Allow()
* Declares a property with no constraints of its own.
*
* Useful with `unknownKeys: 'strip'` or `'error'`, where a property has to be declared to
* survive the payload even though nothing about its value needs checking.
*/
export function Allow() {
return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey);
};
}
/**
* @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(options?: ValidationOptions) {
return (target: any, propertyKey: string) => { return (target: any, propertyKey: string) => {
registerProperty(target, propertyKey); registerProperty(target, propertyKey);
// This is a marker for recursive validation // 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`
});
}
}; };
} }
+57
View File
@@ -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();
}
+107 -55
View File
@@ -5,11 +5,21 @@ import {
ValidateNested, ValidateNested,
IsArray, IsArray,
IsDate, IsDate,
IsEnum,
IsUUID,
ValidateIf,
JsonProperty,
JsonAlias,
JsonReadOnly,
JsonWriteOnly,
JsonSerialize, JsonSerialize,
JsonDeserialize, JsonDeserialize,
JsonPolymorphic, JsonPolymorphic,
toJson, toJson,
fromJson, fromJson,
toPlain,
validate,
flattenErrors,
JsonSerializer, JsonSerializer,
JsonDeserializer, JsonDeserializer,
Validate, Validate,
@@ -32,14 +42,14 @@ class IsLongerThan implements ValidatorConstraintInterface {
} }
} }
function IsUsername(options?: ValidationOptions) { function IsSlug(options?: ValidationOptions) {
return function (object: any, propertyName: string) { return function (object: any, propertyName: string) {
registerDecorator({ registerDecorator({
name: 'isUsername', name: 'isSlug',
target: object.constructor, target: object.constructor,
propertyName: propertyName, propertyName: propertyName,
...(options ? { options } : {}), ...(options ? { options } : {}),
validator: (value: any) => typeof value === 'string' && /^[a-zA-Z0-9_]+$/.test(value) validator: (value: any) => typeof value === 'string' && /^[a-z0-9-]+$/.test(value)
}); });
}; };
} }
@@ -48,11 +58,8 @@ function IsUsername(options?: ValidationOptions) {
class DateSerializer implements JsonSerializer<Date, string> { class DateSerializer implements JsonSerializer<Date, string> {
serialize(value: Date): string { serialize(value: Date): string {
if (value instanceof Date) {
return value.toISOString().split('T')[0] || ''; return value.toISOString().split('T')[0] || '';
} }
return String(value);
}
} }
class DateDeserializer implements JsonDeserializer<string, Date> { class DateDeserializer implements JsonDeserializer<string, Date> {
@@ -63,10 +70,16 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
// --- Domain Models --- // --- Domain Models ---
enum Format {
Hardback = 'hardback',
Paperback = 'paperback',
}
abstract class Media { abstract class Media {
@IsString() @IsString()
abstract type: string; abstract type: string;
// Declared once here. Subclasses inherit the rule without restating it.
@IsString() @IsString()
title: string; title: string;
} }
@@ -75,14 +88,14 @@ class Book extends Media {
@IsString() @IsString()
override type: string = 'book'; override type: string = 'book';
@IsString()
@IsUsername({ message: 'Title must be a valid alphanumeric username' })
declare title: string;
@IsString() @IsString()
@Validate(IsLongerThan, [5]) @Validate(IsLongerThan, [5])
author: string; author: string;
@IsEnum(Format)
format: Format = Format.Paperback;
@JsonProperty('published_at')
@JsonSerialize(DateSerializer) @JsonSerialize(DateSerializer)
@JsonDeserialize(DateDeserializer) @JsonDeserialize(DateDeserializer)
@IsDate() @IsDate()
@@ -91,19 +104,38 @@ class Book extends Media {
class Movie extends Media { class Movie extends Media {
@IsString() @IsString()
type: string = 'movie'; override type: string = 'movie';
@IsInt() @IsInt()
@Min(1) @Min(1)
duration: number; duration: number;
// Only checked for films that claim to be part of a series.
@ValidateIf((movie: Movie) => movie.duration > 200)
@IsString()
intermissionNote?: string;
} }
class Library { class Library {
@JsonReadOnly()
@IsUUID(4)
id: string;
@IsString() @IsString()
@IsSlug({ message: 'name must be a lowercase slug' })
name: string; name: string;
@JsonProperty('curator_email')
@JsonAlias('curatorEmail')
@IsString()
curatorEmail: string;
@JsonWriteOnly()
@IsString()
adminToken: string;
@IsArray() @IsArray()
@ValidateNested() @ValidateNested({ each: true })
@JsonPolymorphic('type', [ @JsonPolymorphic('type', [
{ value: Book, name: 'book' }, { value: Book, name: 'book' },
{ value: Movie, name: 'movie' } { value: Movie, name: 'movie' }
@@ -114,64 +146,84 @@ class Library {
// --- Execution --- // --- Execution ---
async function runExample() { async function runExample() {
console.log("--- Starting Example ---"); console.log('--- Starting Example ---');
// 1. Create a Library instance
const library = new Library(); const library = new Library();
library.name = "Central Library"; library.id = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
library.name = 'central-library';
library.curatorEmail = 'ada@example.com';
library.adminToken = 'super-secret';
const book = new Book(); const book = new Book();
book.title = "Gatsby"; book.title = 'Gatsby';
book.author = "Fitzgerald"; book.author = 'Fitzgerald';
book.publishedAt = new Date("1925-04-10"); book.format = Format.Hardback;
book.publishedAt = new Date('1925-04-10');
const movie = new Movie(); const movie = new Movie();
movie.title = "Inception"; movie.title = 'Inception';
movie.duration = 148; movie.duration = 148;
library.items = [book, movie]; library.items = [book, movie];
try { // 1. Serialize, honouring @JsonProperty and the write-only token
// 2. Serialize to JSON console.log('\n[1] Serializing Library to JSON...');
console.log("\n[1] Serializing Library to JSON...");
const json = await toJson(library); const json = await toJson(library);
console.log("JSON Output:", json); console.log('JSON Output:', json);
console.log('Secret withheld from output:', !json.includes('super-secret'));
// 3. Deserialize back to Instance // 2. Deserialize back, resolving the polymorphic items
console.log("\n[2] Deserializing JSON back to Library instance..."); console.log('\n[2] Deserializing JSON back to Library instance...');
const deserializedLibrary = await fromJson(Library, json); const restored = await fromJson(Library, json, { validate: false });
console.log("Deserialized Library Name:", deserializedLibrary.name); console.log('Curator (read via curator_email):', restored.curatorEmail);
console.log("Items count:", deserializedLibrary.items.length); console.log('Items count:', restored.items.length);
restored.items.forEach((item, index) => {
// Check Polymorphism console.log(`Item ${index} is a ${item.constructor.name}: ${item.title}`);
deserializedLibrary.items.forEach((item, index) => {
console.log(`Item ${index} is instance of ${item.constructor.name}: ${item.title}`);
if (item instanceof Book) { if (item instanceof Book) {
console.log(` > Book Author: ${item.author}`); console.log(` > Author: ${item.author}, format: ${item.format}`);
console.log(` > Published At: ${item.publishedAt.toISOString()} (instanceof Date: ${item.publishedAt instanceof Date})`); console.log(` > Published: ${item.publishedAt.toISOString()} (Date: ${item.publishedAt instanceof Date})`);
} else if (item instanceof Movie) { } else if (item instanceof Movie) {
console.log(` > Movie Duration: ${item.duration} mins`); console.log(` > Duration: ${item.duration} mins`);
} }
}); });
// 4. Test Validation Failure // 3. A client cannot set a @JsonReadOnly field
console.log("\n[3] Testing Validation Failure (Invalid Movie Duration)..."); console.log('\n[3] A client trying to set the read-only id...');
const invalidJson = JSON.stringify({ const hijacked = await fromJson(
name: "Invalid Library", Library,
items: [ JSON.stringify({ id: 'attacker-supplied', name: 'x', curator_email: 'a@b.c', adminToken: 't', items: [] }),
{ type: "movie", title: "Short Film", duration: -5 } // Invalid: duration < 1 { validate: false }
] );
console.log('id after mapping (expected undefined):', hijacked.id);
// 4. Validation failures, flattened for an HTTP response
console.log('\n[4] Reporting validation failures...');
const invalid = await fromJson(
Library,
JSON.stringify({
name: 'Not A Slug',
curator_email: 'a@b.c',
adminToken: 't',
items: [{ type: 'movie', title: 'Short Film', duration: -5 }]
}),
{ validate: false }
);
console.log(flattenErrors(await validate(invalid)));
// 5. A base-class rule applies to a subclass that never restates it
console.log('\n[5] Base-class constraints reach subclasses...');
const untitled = new Book();
untitled.title = undefined as any;
untitled.author = 'Fitzgerald';
untitled.publishedAt = new Date('1925-04-10');
console.log(flattenErrors(await validate(untitled)));
// 6. Naming strategies convert every property at once
console.log('\n[6] The same movie under snake_case...');
console.log(await toPlain(movie, { namingStrategy: 'snake_case' }));
}
runExample().catch((error) => {
console.error('Example failed:', error);
process.exitCode = 1;
}); });
await fromJson(Library, invalidJson);
} catch (error) {
if (error instanceof Error) {
console.log("Caught expected error:", error.message);
if ((error as any).errors) {
console.log("Validation details:", JSON.stringify((error as any).errors, null, 2));
}
}
}
}
runExample();
+9 -9
View File
@@ -202,8 +202,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('username'); expect(errors[0]!.property).toBe('username');
expect(errors[0].constraints).toHaveProperty('minLength'); expect(errors[0]!.constraints).toHaveProperty('minLength');
}); });
it('should fail on invalid email', async () => { it('should fail on invalid email', async () => {
@@ -215,8 +215,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('email'); expect(errors[0]!.property).toBe('email');
expect(errors[0].constraints).toHaveProperty('isEmail'); expect(errors[0]!.constraints).toHaveProperty('isEmail');
}); });
it('should fail on invalid role (IsIn)', async () => { it('should fail on invalid role (IsIn)', async () => {
@@ -228,8 +228,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('roles'); expect(errors[0]!.property).toBe('roles');
expect(errors[0].constraints).toHaveProperty('isIn'); expect(errors[0]!.constraints).toHaveProperty('isIn');
}); });
it('should skip validation for null optional field', async () => { it('should skip validation for null optional field', async () => {
@@ -238,7 +238,7 @@ describe('JsonMapper', () => {
user.email = 'john@example.com'; user.email = 'john@example.com';
user.active = true; user.active = true;
user.roles = ['user']; user.roles = ['user'];
user.age = undefined; // optional delete user.age; // optional
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
@@ -254,8 +254,8 @@ describe('JsonMapper', () => {
const errors = await JsonMapper.validate(user); const errors = await JsonMapper.validate(user);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('age'); expect(errors[0]!.property).toBe('age');
expect(errors[0].constraints).toHaveProperty('min'); expect(errors[0]!.constraints).toHaveProperty('min');
}); });
}); });
}); });
+3
View File
@@ -1,3 +1,6 @@
export * from './interfaces.js'; export * from './interfaces.js';
export * from './naming.js';
export * from './config.js';
export * from './decorators.js'; export * from './decorators.js';
export * from './errors.js';
export * from './utils.js'; export * from './utils.js';
+444
View File
@@ -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');
});
});
+23
View File
@@ -63,6 +63,29 @@ export class MetadataStorage {
return undefined; 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. * Gets metadata defined directly on the target.
*/ */
+74
View File
@@ -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;
}
+447
View File
@@ -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<Date, string> {
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<Date, string> {
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);
});
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ describe('Standalone Utility Functions', () => {
user.age = '30' as any; user.age = '30' as any;
const errors2 = await validate(user); const errors2 = await validate(user);
expect(errors2).toHaveLength(1); expect(errors2).toHaveLength(1);
expect(errors2[0].property).toBe('age'); expect(errors2[0]!.property).toBe('age');
}); });
it('should transform to plain object directly', async () => { it('should transform to plain object directly', async () => {
+408 -65
View File
@@ -1,6 +1,8 @@
import { ClassConstructor } from './interfaces.js'; 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 { metadataStorage } from './metadata-storage.js';
import { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
export interface ValidationError { export interface ValidationError {
property: string; property: string;
@@ -20,56 +22,218 @@ export class JsonValidationError extends Error {
} }
} }
// --- Internal Engine --- /**
* Thrown when a value cannot be mapped at all — as opposed to mapping fine but failing
async function serialize(obj: any): Promise<any> { * validation, which raises {@link JsonValidationError}.
if (obj === null || obj === undefined || typeof obj !== 'object') { */
return obj; export class JsonMappingError extends Error {
constructor(message: string) {
super(message);
this.name = 'JsonMappingError';
}
} }
if (Array.isArray(obj)) { /**
return Promise.all(obj.map(item => serialize(item))); * 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 ---
interface SerializeContext {
naming: NamingStrategyFn;
}
interface DeserializeContext {
naming: NamingStrategyFn;
namingKey: unknown;
unknownKeys: UnknownKeyPolicy;
}
/**
* 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;
}
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;
} }
if (obj instanceof Date) { if (obj instanceof Date) {
return obj.toISOString(); return obj.toISOString();
} }
const target = obj.constructor.prototype; 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.'
);
}
ancestors.add(obj);
try {
if (Array.isArray(obj)) {
const out: any[] = [];
for (const item of obj) {
out.push(await serialize(item, ancestors, ctx));
}
return out;
}
const target = prototypeOf(obj);
const result: any = {}; const result: any = {};
const allKeys = Object.keys(obj); 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;
for (const key of allKeys) {
const value = obj[key]; const value = obj[key];
const name = outboundName(target, key, ctx.naming);
// Check for custom serializer // Custom serializers only see real values. Handing a serializer `undefined` for a
const serializerCls = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key); // property that was simply never set turns an optional field into a crash.
if (serializerCls) { const serializerCls = target ? metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key) : undefined;
if (serializerCls && value !== null && value !== undefined) {
const serializer = new serializerCls(); const serializer = new serializerCls();
result[key] = await serializer.serialize(value); result[name] = await serializer.serialize(value);
} else { } else {
result[key] = await serialize(value); result[name] = await serialize(value, ancestors, ctx);
} }
} }
return result; 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<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 (plain === null || plain === undefined) return plain;
if (Array.isArray(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; return results as any;
} }
if (typeof plain !== 'object') return plain;
const instance = new clazz(); const instance = new clazz();
const target = clazz.prototype; const target = clazz.prototype;
const inbound = inboundNameMap(target, ctx);
// Copy all properties from plain to instance for (const incoming of Object.keys(plain)) {
for (const key 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 // Custom Deserializer
const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key); const deserializerCls = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
@@ -82,19 +246,26 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
// Polymorphic // Polymorphic
const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key); const poly = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
if (poly && value !== null && value !== undefined) { if (poly && value !== null && value !== undefined) {
const { discriminator, subTypes } = poly; const { discriminator, subTypes, onUnknown, fallback } = poly;
if (Array.isArray(value)) {
instance[key as keyof T] = await Promise.all(value.map(async item => { 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); const subTypeInfo = subTypes.find((s: any) => item[discriminator] === s.name);
return subTypeInfo ? deserialize(subTypeInfo.value, item) : item; if (subTypeInfo) return deserialize(subTypeInfo.value, item, ctx);
})) as any; if (fallback) return deserialize(fallback, item, ctx);
} else { if (onUnknown === 'error') {
const subTypeInfo = subTypes.find((s: any) => value[discriminator] === s.name); throw new JsonMappingError(
if (subTypeInfo) { `Unknown discriminator value ${JSON.stringify(item[discriminator])} for property ` +
instance[key as keyof T] = await deserialize(subTypeInfo.value, value); `"${key}". Known values: ${subTypes.map((s: any) => JSON.stringify(s.name)).join(', ')}.`
continue; );
}
} }
// 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; continue;
} }
@@ -102,7 +273,7 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key); const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
if (typeFn && value !== null && value !== undefined) { if (typeFn && value !== null && value !== undefined) {
const type = typeFn(); const type = typeFn();
instance[key as keyof T] = await deserialize(type, value); instance[key as keyof T] = await deserialize(type, value, ctx);
continue; continue;
} }
@@ -112,20 +283,63 @@ async function deserialize<T>(clazz: ClassConstructor<T>, plain: any): Promise<T
return instance; return instance;
} }
// --- Public API Functions --- /**
* Collects the validation constraints that apply to a property, merged across the whole
* prototype chain.
*
* A subclass that re-decorates an inherited property registers its constraints against its
* own prototype. Reading only the nearest set would silently drop everything the base class
* declared, so the chain is flattened base-first. Constraints that are genuinely identical
* (same rule, same fixed message) are collapsed so that re-stating `@IsString()` on an
* override does not report the same failure twice; anything with a computed message — custom
* validators in particular — is always kept.
*/
function collectConstraints(target: any, key: string): ValidationConstraint[] {
const levels: ValidationConstraint[][] = metadataStorage.getMetadataChain(METADATA_KEYS.VALIDATION, target, key);
const merged: ValidationConstraint[] = [];
const seen = new Set<string>();
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;
}
/** /**
* Validates a class instance or object against its decorators. * Records a failure without letting a later constraint overwrite an earlier one that happens
* @param obj The object to validate * to share a name (two `@Min` rules, or a rule inherited and re-declared).
* @returns Array of validation errors
*/ */
export async function validate(obj: any): Promise<ValidationError[]> { 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<any>): Promise<ValidationError[]> {
const errors: ValidationError[] = []; const errors: ValidationError[] = [];
if (obj === null || obj === undefined || typeof obj !== 'object') return errors; 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)) { if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) { for (let i = 0; i < obj.length; i++) {
const childErrors = await validate(obj[i]); const childErrors = await validateInternal(obj[i], ancestors);
if (childErrors.length > 0) { if (childErrors.length > 0) {
errors.push({ errors.push({
property: `[${i}]`, property: `[${i}]`,
@@ -138,7 +352,9 @@ export async function validate(obj: any): Promise<ValidationError[]> {
return errors; return errors;
} }
const target = Object.getPrototypeOf(obj); const target = prototypeOf(obj);
if (!target) return errors;
const properties: string[] = metadataStorage.getProperties(target); const properties: string[] = metadataStorage.getProperties(target);
for (const key of properties) { for (const key of properties) {
@@ -149,6 +365,12 @@ export async function validate(obj: any): Promise<ValidationError[]> {
constraints: {} constraints: {}
}; };
// Handle @ValidateIf — a false condition takes the property out of validation entirely.
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
if (condition && !condition(obj)) {
continue;
}
// Handle IsOptional // Handle IsOptional
const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key); const isOptional = metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key);
const isNullOrUndefined = value === null || value === undefined; const isNullOrUndefined = value === null || value === undefined;
@@ -158,7 +380,7 @@ export async function validate(obj: any): Promise<ValidationError[]> {
} }
// Check validation constraints // Check validation constraints
const constraints: ValidationConstraint[] = metadataStorage.getMetadata(METADATA_KEYS.VALIDATION, target, key) || []; const constraints = collectConstraints(target, key);
const validationArgs: ValidationArguments = { const validationArgs: ValidationArguments = {
value: value, value: value,
object: obj, object: obj,
@@ -187,18 +409,21 @@ export async function validate(obj: any): Promise<ValidationError[]> {
? constraint.message(validationArgs) ? constraint.message(validationArgs)
: constraint.message; : constraint.message;
if (constraint.each) { // 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}`; message = `each element in ${message}`;
} }
propertyErrors.constraints[constraint.name] = message; recordFailure(propertyErrors.constraints, constraint.name, message);
} }
} }
// Recursive validation // Recursive validation
const isNested = metadataStorage.getMetadata('cereale:nested', target, key); const isNested = metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key);
if (isNested && value !== null && value !== undefined) { if (isNested && value !== null && value !== undefined) {
const nestedErrors = await validate(value); const nestedErrors = await validateInternal(value, ancestors);
if (nestedErrors.length > 0) { if (nestedErrors.length > 0) {
propertyErrors.children = nestedErrors; propertyErrors.children = nestedErrors;
} }
@@ -210,72 +435,187 @@ export async function validate(obj: any): Promise<ValidationError[]> {
} }
return errors; return errors;
} finally {
ancestors.delete(obj);
}
}
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 ---
/**
* Validates a class instance or object against its decorators.
* @param obj The object to validate
* @returns Array of validation errors
*/
export async function validate(obj: any): Promise<ValidationError[]> {
return validateInternal(obj, new Set());
} }
/** /**
* Converts a class instance to a plain object with validation. * 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 validateOrReject(obj: any): Promise<void> {
const errors = await validate(obj);
if (errors.length > 0) {
throw new JsonValidationError('Validation failed', errors);
}
}
/**
* 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 obj The class instance to transform
* @param options Per-call transform options
* @returns Plain object * @returns Plain object
*/ */
export async function toPlain<T>(obj: T): Promise<any> { export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<any> {
if (obj === null || obj === undefined) return obj; if (obj === null || obj === undefined) return obj;
if (resolveOptions(options).validate) {
const errors = await validate(obj); const errors = await validate(obj);
if (errors.length > 0) { if (errors.length > 0) {
throw new JsonValidationError('Validation failed during serialization', errors); throw new JsonValidationError('Validation failed during serialization', errors);
} }
}
return serialize(obj); return serialize(obj, new Set(), serializeContext(options));
} }
/** /**
* Converts a class instance to a JSON string with validation. * Converts a class instance to a JSON string.
* @param obj The class instance to transform * @param obj The class instance to transform
* @param options Per-call transform options
* @returns JSON string * @returns JSON string
*/ */
export async function toJson<T>(obj: T): Promise<string> { export async function toJson<T>(obj: T, options?: TransformOptions): Promise<string> {
const plain = await toPlain(obj); const plain = await toPlain(obj, options);
return JSON.stringify(plain); 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 clazz The class constructor
* @param plain The plain object to transform * @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> { export async function toInstance<T>(clazz: ClassConstructor<T>, plain: any, options?: TransformOptions): Promise<T> {
const instance = await deserialize(clazz, plain); const instance = await deserialize(clazz, plain, deserializeContext(options));
if (resolveOptions(options).validate) {
const errors = await validate(instance); const errors = await validate(instance);
if (errors.length > 0) { if (errors.length > 0) {
throw new JsonValidationError('Validation failed during deserialization', errors); throw new JsonValidationError('Validation failed during deserialization', errors);
} }
}
return instance; return instance;
} }
/** /**
* Parses a JSON string to a class instance 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
* @param options Per-call transform options
* @returns Array of class instances
*/
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, options)) as unknown as T[];
}
/**
* Parses a JSON string to a class instance.
* @param clazz The class constructor * @param clazz The class constructor
* @param json JSON string * @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> { export async function fromJson<T>(clazz: ClassConstructor<T>, json: string, options?: TransformOptions): Promise<T> {
const plain = JSON.parse(json); return toInstance(clazz, parseJson(json), options);
return toInstance(clazz, plain); }
/**
* Parses a JSON string containing an array into class instances.
* @param clazz The class constructor
* @param json JSON string holding an array
* @param options Per-call transform options
* @returns Array of class instances
*/
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.) * 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 clazz The class constructor
* @param request Web Request object * @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>(
const plain = await request.json(); clazz: ClassConstructor<T>,
return toInstance(clazz, plain); request: Request,
options?: TransformOptions
): Promise<T> {
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, options);
} }
/** /**
@@ -285,7 +625,10 @@ export class JsonMapper {
static toPlain = toPlain; static toPlain = toPlain;
static toJson = toJson; static toJson = toJson;
static toInstance = toInstance; static toInstance = toInstance;
static toInstanceArray = toInstanceArray;
static fromJson = fromJson; static fromJson = fromJson;
static fromJsonArray = fromJsonArray;
static fromRequest = fromRequest; static fromRequest = fromRequest;
static validate = validate; static validate = validate;
static validateOrReject = validateOrReject;
} }
+323
View File
@@ -0,0 +1,323 @@
import { describe, it, expect } from 'vitest';
import {
Equals, NotEquals, IsEmpty, IsEnum, IsInstance,
Length, IsAlpha, IsAlphanumeric, IsNumberString, IsLowercase, IsUppercase,
Contains, NotContains, StartsWith, EndsWith,
IsUUID, IsJSON, IsDateString, IsSemVer, IsHexColor, IsIP,
IsDivisibleBy, IsPort, IsLatitude, IsLongitude, IsBigInt,
MinDate, MaxDate,
ArrayUnique, ArrayContains, ArrayNotContains,
ValidateIf, Allow, IsString, IsIn, IsOptional,
validate, toInstance,
} from './index.js';
/** Builds a one-property class, assigns `value`, and returns the constraint keys that failed. */
async function check(decorate: (target: any, key: string) => void, value: any): Promise<string[]> {
class Subject {
val: any;
}
decorate(Subject.prototype, 'val');
const subject = new Subject();
subject.val = value;
const errors = await validate(subject);
return errors.length ? Object.keys(errors[0]!.constraints) : [];
}
const passes = async (decorator: any, value: any) => expect(await check(decorator, value)).toEqual([]);
const fails = async (decorator: any, value: any) => expect((await check(decorator, value)).length).toBeGreaterThan(0);
describe('equality and presence', () => {
it('@Equals / @NotEquals', async () => {
await passes(Equals('x'), 'x');
await fails(Equals('x'), 'y');
await passes(NotEquals('x'), 'y');
await fails(NotEquals('x'), 'x');
});
it('@IsEmpty', async () => {
for (const empty of [null, undefined, '', [], {}]) await passes(IsEmpty(), empty);
for (const filled of ['a', [1], { a: 1 }, 0]) await fails(IsEmpty(), filled);
});
it('@IsInstance', async () => {
class Thing {}
await passes(IsInstance(Thing), new Thing());
await fails(IsInstance(Thing), {});
});
});
describe('@IsEnum', () => {
enum StringRole { Admin = 'admin', User = 'user' }
enum NumericLevel { Low, High }
it('accepts members of a string enum', async () => {
await passes(IsEnum(StringRole), 'admin');
await fails(IsEnum(StringRole), 'root');
});
it('accepts members of a numeric enum without accepting its reverse-mapped names', async () => {
await passes(IsEnum(NumericLevel), 0);
await passes(IsEnum(NumericLevel), 1);
await fails(IsEnum(NumericLevel), 2);
// 'Low' is the reverse mapping, not a legal value
await fails(IsEnum(NumericLevel), 'Low');
});
});
describe('strings', () => {
it('@Length with and without a maximum', async () => {
await passes(Length(2), 'ab');
await fails(Length(3), 'ab');
await passes(Length(2, 4), 'abc');
await fails(Length(2, 4), 'abcde');
});
it('@IsAlpha / @IsAlphanumeric', async () => {
await passes(IsAlpha(), 'abcDEF');
await fails(IsAlpha(), 'abc1');
await passes(IsAlphanumeric(), 'abc123');
await fails(IsAlphanumeric(), 'abc-123');
});
it('@IsNumberString', async () => {
await passes(IsNumberString(), '42');
await passes(IsNumberString(), '-1.5');
await fails(IsNumberString(), 'abc');
await fails(IsNumberString(), '');
await fails(IsNumberString(), 42);
});
it('@IsLowercase / @IsUppercase', async () => {
await passes(IsLowercase(), 'abc');
await fails(IsLowercase(), 'Abc');
await passes(IsUppercase(), 'ABC');
await fails(IsUppercase(), 'Abc');
});
it('@Contains / @NotContains / @StartsWith / @EndsWith', async () => {
await passes(Contains('ell'), 'hello');
await fails(Contains('xyz'), 'hello');
await passes(NotContains('xyz'), 'hello');
await fails(NotContains('ell'), 'hello');
await passes(StartsWith('he'), 'hello');
await fails(StartsWith('lo'), 'hello');
await passes(EndsWith('lo'), 'hello');
await fails(EndsWith('he'), 'hello');
});
});
describe('@IsUUID', () => {
const v4 = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
it('accepts any version when unversioned', async () => {
await passes(IsUUID(), v4);
await passes(IsUUID(), '00000000-0000-0000-0000-000000000000'); // nil
await fails(IsUUID(), 'not-a-uuid');
await fails(IsUUID(), 42);
});
it('enforces a requested version', async () => {
await passes(IsUUID(4), v4);
await fails(IsUUID(1), v4);
});
});
describe('formats', () => {
it('@IsJSON', async () => {
await passes(IsJSON(), '{"a":1}');
await passes(IsJSON(), '[1,2]');
await fails(IsJSON(), '{a:1}');
await fails(IsJSON(), { a: 1 });
});
it('@IsDateString', async () => {
await passes(IsDateString(), '2026-08-03T00:00:00Z');
await fails(IsDateString(), 'not a date');
});
it('@IsSemVer', async () => {
await passes(IsSemVer(), '1.2.3');
await passes(IsSemVer(), '1.0.0-alpha.1+build.5');
await fails(IsSemVer(), '1.2');
await fails(IsSemVer(), 'v1.2.3');
});
it('@IsHexColor', async () => {
await passes(IsHexColor(), '#fff');
await passes(IsHexColor(), '#A1B2C3');
await passes(IsHexColor(), '#A1B2C3FF');
await fails(IsHexColor(), 'fff');
await fails(IsHexColor(), '#ggg');
});
it('@IsIP', async () => {
await passes(IsIP(4), '192.168.0.1');
await fails(IsIP(4), '256.0.0.1');
await fails(IsIP(4), '::1');
await passes(IsIP(6), '::1');
await passes(IsIP(), '10.0.0.1');
await fails(IsIP(), 'nope');
});
});
describe('numbers', () => {
it('@IsDivisibleBy', async () => {
await passes(IsDivisibleBy(5), 10);
await fails(IsDivisibleBy(5), 11);
await fails(IsDivisibleBy(5), '10');
});
it('@IsPort', async () => {
await passes(IsPort(), 8080);
await passes(IsPort(), '443');
await fails(IsPort(), 70000);
await fails(IsPort(), -1);
await fails(IsPort(), 1.5);
});
it('@IsLatitude / @IsLongitude', async () => {
await passes(IsLatitude(), 48.85);
await fails(IsLatitude(), 91);
await passes(IsLongitude(), 2.35);
await fails(IsLongitude(), 181);
});
it('@IsBigInt', async () => {
await passes(IsBigInt(), 10n);
await fails(IsBigInt(), 10);
});
});
describe('dates', () => {
it('@MinDate / @MaxDate with a fixed bound', async () => {
const bound = new Date('2026-01-01T00:00:00Z');
await passes(MinDate(bound), new Date('2026-06-01T00:00:00Z'));
await fails(MinDate(bound), new Date('2025-06-01T00:00:00Z'));
await passes(MaxDate(bound), new Date('2025-06-01T00:00:00Z'));
await fails(MaxDate(bound), new Date('2026-06-01T00:00:00Z'));
});
it('@MinDate accepts a thunk so the bound moves', async () => {
await passes(MinDate(() => new Date(Date.now() - 1000)), new Date());
await fails(MinDate(() => new Date(Date.now() + 60_000)), new Date());
});
it('rejects a non-date', async () => {
await fails(MinDate(new Date(0)), '2026-01-01');
});
});
describe('arrays', () => {
it('@ArrayUnique by value', async () => {
await passes(ArrayUnique(), [1, 2, 3]);
await fails(ArrayUnique(), [1, 2, 2]);
});
it('@ArrayUnique by extracted key', async () => {
const byId = (item: any) => item.id;
await passes(ArrayUnique(byId), [{ id: 1 }, { id: 2 }]);
await fails(ArrayUnique(byId), [{ id: 1 }, { id: 1 }]);
});
it('@ArrayContains / @ArrayNotContains', async () => {
await passes(ArrayContains(['a']), ['a', 'b']);
await fails(ArrayContains(['c']), ['a', 'b']);
await passes(ArrayNotContains(['c']), ['a', 'b']);
await fails(ArrayNotContains(['a']), ['a', 'b']);
});
});
describe('@ValidateIf', () => {
class Payment {
@IsIn(['card', 'invoice'])
method: string;
@ValidateIf(o => o.method === 'card')
@IsString()
cardNumber?: string;
}
it('skips the constraint when the condition is false', async () => {
const p = new Payment();
p.method = 'invoice';
expect(await validate(p)).toEqual([]);
});
it('applies the constraint when the condition is true', async () => {
const p = new Payment();
p.method = 'card';
const errors = await validate(p);
expect(errors).toHaveLength(1);
expect(errors[0]!.property).toBe('cardNumber');
});
it('passes when the condition is true and the value is valid', async () => {
const p = new Payment();
p.method = 'card';
p.cardNumber = '4111111111111111';
expect(await validate(p)).toEqual([]);
});
});
describe('@Allow', () => {
it('declares a property so strict unknown-key policies keep it', async () => {
class Dto {
@IsString()
name: string;
@Allow()
metadata: unknown;
}
const d = await toInstance(
Dto,
{ name: 'x', metadata: { anything: true }, stray: 1 },
{ unknownKeys: 'strip' }
);
expect(d.metadata).toEqual({ anything: true });
expect((d as any).stray).toBeUndefined();
});
});
describe('new validators cooperate with existing options', () => {
it('honours each: true', async () => {
class T {
@IsUUID(4, { each: true })
ids: string[];
}
const t = new T();
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21'];
expect(await validate(t)).toEqual([]);
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21', 'nope'];
expect(await validate(t)).toHaveLength(1);
});
it('honours @IsOptional', async () => {
class T {
@IsOptional()
@IsSemVer()
version?: string;
}
const t = new T();
expect(await validate(t)).toEqual([]);
t.version = 'bad';
expect(await validate(t)).toHaveLength(1);
});
it('honours a custom message', async () => {
class T {
@IsPort({ message: 'give me a real port' })
port: number;
}
const t = new T();
t.port = -1;
const errors = await validate(t);
expect(errors[0]!.constraints['isPort']).toBe('give me a real port');
});
});
+2 -1
View File
@@ -5,5 +5,6 @@
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"outDir": "dist/cjs", "outDir": "dist/cjs",
"declaration": true "declaration": true
} },
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
} }
+2 -1
View File
@@ -4,5 +4,6 @@
"module": "NodeNext", "module": "NodeNext",
"outDir": "dist/esm", "outDir": "dist/esm",
"declaration": true "declaration": true
} },
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
} }
+3 -1
View File
@@ -35,6 +35,8 @@
"experimentalDecorators": true "experimentalDecorators": true
}, },
// NOTE: test files are deliberately included here so that `npm run type-check`
// covers them. The two build configs exclude them (and the demo) from `dist`.
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"] "exclude": ["node_modules", "dist"]
} }
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
// Vitest 4 transpiles with oxc, which does not read `experimentalDecorators`
// out of tsconfig.json for files the tsconfig does not `include`. Without this
// the decorator syntax in the test files fails to parse and every suite is
// silently reported as "0 test".
oxc: {
decorator: { legacy: true },
},
test: {
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/example.ts', 'src/index.ts'],
},
},
});