Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50c08aa556 | ||
|
|
6d30182ca8 | ||
|
|
203e5ec27b | ||
|
|
6e458fdd43 | ||
|
|
26cd6b5707 | ||
|
|
2acdf3b360 | ||
|
|
83ad2a289d | ||
|
|
44c8f28f4b | ||
|
|
666762a146 | ||
|
|
6d04b43964 | ||
|
|
66e19f690f |
@@ -2,17 +2,19 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
verify:
|
||||
name: Node ${{ matrix.node-version }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -25,5 +27,15 @@ jobs:
|
||||
run: npm ci
|
||||
- name: 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
|
||||
run: npm run demo
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
# 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/
|
||||
/package-lock.json
|
||||
|
||||
# Build outputs
|
||||
/dist/
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# 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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
**Synchronous API.** `validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`,
|
||||
`toInstanceSync`, `toInstanceArraySync`, `fromJsonSync` and `fromJsonArraySync`. Nothing on
|
||||
the default path is genuinely asynchronous — only a user-supplied serializer, deserializer or
|
||||
validator can be — so requiring `await` everywhere was a tax on the common case.
|
||||
|
||||
The engines are now written synchronously, and anything a hook makes asynchronous is recorded
|
||||
and reconciled once at the end. There is no second copy of the traversal logic to keep in
|
||||
step, and the async entry points stop paying for a microtask per property. If a hook does
|
||||
return a Promise, the `*Sync` call raises a `JsonMappingError` naming the async alternative
|
||||
rather than silently returning a half-built object.
|
||||
|
||||
`fromRequest` has no synchronous counterpart, because reading a request body is inherently
|
||||
asynchronous.
|
||||
|
||||
- `maxDepth` option (default 64) on every mapping function and on `configure()`. All three
|
||||
engines recurse, so a hostile payload nested thousands of levels deep could exhaust the
|
||||
call stack; it now raises a `JsonMappingError`. Cycles were already handled, but legitimate
|
||||
deep nesting was not bounded.
|
||||
- `validate(obj, options?)` accepts options, so `maxDepth` applies to standalone validation.
|
||||
- `REDACTED` export, the placeholder substituted for withheld values.
|
||||
|
||||
### Security
|
||||
|
||||
- **Validation errors no longer carry the value of a property that is never serialized.**
|
||||
A `@JsonWriteOnly` password that failed `@MinLength` put the rejected password into
|
||||
`ValidationError.value`, and from there into any log that recorded the error. Values for
|
||||
`@JsonWriteOnly` and `@JsonIgnore` properties are replaced with `REDACTED`; the property
|
||||
name and the failure message are unchanged, so the error is still actionable.
|
||||
|
||||
### Performance
|
||||
|
||||
Profiling the validator showed roughly **half of all validation time** was spent re-deriving
|
||||
answers that cannot change: `collectConstraints` (22%), `getOwnMetadata` (12%),
|
||||
`getMetadataChain` (9%), `getProperties` (4%) and `getMetadata` (3%), plus 8% garbage
|
||||
collection from the allocation churn. The constraint predicates themselves accounted for
|
||||
under 1%.
|
||||
|
||||
Decorator metadata is fixed once classes are declared, so the derived structures are now
|
||||
memoized per prototype — the validation plan, the serialization plan, the deserialization
|
||||
plan, and serializer/deserializer instances (previously constructed fresh for every property
|
||||
of every object). `MetadataStorage` carries a version counter that invalidates every cache
|
||||
if metadata is registered late, so `registerDecorator` after first use still works.
|
||||
|
||||
Together with the synchronous core, measured on a customer record with a nested address and
|
||||
orders, against `JSON.parse` + `JSON.stringify` (5.8 us) as a fixed reference point:
|
||||
|
||||
| Operation | 0.1.0 | Now | Speedup |
|
||||
| --- | --- | --- | --- |
|
||||
| `validate` (50 orders) | 221.6 us | 17.8 us | 12.4x |
|
||||
| `validate` (10 orders) | 47.8 us | 4.5 us | 10.6x |
|
||||
| `toPlain` (50 orders) | 294.4 us | 36.0 us | 8.2x |
|
||||
| `toInstance` (50 orders) | 255.1 us | 31.7 us | 8.0x |
|
||||
| `toInstance` (10 orders) | 64.6 us | 8.2 us | 7.9x |
|
||||
| `toInstance` (single) | 19.8 us | 5.2 us | 3.8x |
|
||||
|
||||
### Changed
|
||||
|
||||
- `each: true` failures now report which element failed — `"... (failed at index 3)"`. A bad
|
||||
entry in a 200-item array previously produced a message that could not locate it. A message
|
||||
function now receives the failing element as `args.value` rather than the whole array;
|
||||
caller-supplied string messages are still reported verbatim.
|
||||
|
||||
## [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.
|
||||
@@ -4,10 +4,12 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **Zero Dependencies:** Extremely lightweight and fast.
|
||||
|
||||
@@ -17,42 +19,40 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
|
||||
npm install cereale
|
||||
```
|
||||
|
||||
Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your `tsconfig.json`:
|
||||
Enable `experimentalDecorators` in your `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"target": "ES2025"
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cereale stores its own metadata, so `reflect-metadata` is not required and
|
||||
`emitDecoratorMetadata` is not read. Requires Node 20 or later.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Define your Models
|
||||
|
||||
Use decorators to define how your data should be transformed and validated.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
Min,
|
||||
IsDate,
|
||||
import {
|
||||
IsString,
|
||||
IsDate,
|
||||
ValidateNested,
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
JsonSerializer,
|
||||
JsonDeserializer
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
JsonSerializer,
|
||||
JsonDeserializer
|
||||
} from 'cereale';
|
||||
|
||||
// Custom Date Serializer
|
||||
class DateSerializer implements JsonSerializer<Date, string> {
|
||||
serialize(value: Date): string {
|
||||
return value.toISOString().split('T')[0];
|
||||
return value.toISOString().split('T')[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ abstract class Media {
|
||||
|
||||
class Book extends Media {
|
||||
type = 'book';
|
||||
|
||||
|
||||
@IsString()
|
||||
author: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
Use standalone utility functions to handle the conversion process directly.
|
||||
|
||||
```typescript
|
||||
import { fromJson, toJson, JsonValidationError } from 'cereale';
|
||||
import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale';
|
||||
|
||||
async function main() {
|
||||
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
|
||||
@@ -107,15 +108,15 @@ async function main() {
|
||||
try {
|
||||
// Deserialize JSON to Class Instance
|
||||
const library = await fromJson(Library, json);
|
||||
console.log(library.name); // "Central Library"
|
||||
console.log(library.name); // "Central Library"
|
||||
console.log(library.items[0] instanceof Book); // true
|
||||
|
||||
// Serialize Class Instance back to JSON
|
||||
const outputJson = await toJson(library);
|
||||
console.log(outputJson);
|
||||
console.log(await toJson(library));
|
||||
} catch (error) {
|
||||
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,123 @@ 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. |
|
||||
| `maxDepth` | `number` | `64` | Nesting depth before a `JsonMappingError` is raised, bounding hostile payloads. |
|
||||
|
||||
```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' });
|
||||
```
|
||||
|
||||
## Synchronous API
|
||||
|
||||
Nothing on the default path is genuinely asynchronous — only a serializer, deserializer or
|
||||
validator you supply can be — so every mapping function has a synchronous twin.
|
||||
|
||||
```typescript
|
||||
import { fromJsonSync, toJsonSync, validateSync } from 'cereale';
|
||||
|
||||
const user = fromJsonSync(User, body); // no await
|
||||
const errors = validateSync(user);
|
||||
const payload = toJsonSync(user);
|
||||
```
|
||||
|
||||
`validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`, `toInstanceSync`,
|
||||
`toInstanceArraySync`, `fromJsonSync`, `fromJsonArraySync`.
|
||||
|
||||
If one of your hooks does return a Promise, the synchronous call raises a `JsonMappingError`
|
||||
naming the async function to use instead, rather than handing back a half-built object.
|
||||
`fromRequest` has no synchronous form, since reading a request body is inherently async.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Decorators
|
||||
### Mapping Decorators
|
||||
|
||||
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Specifies a custom serializer for a property.
|
||||
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Specifies a custom deserializer for a property.
|
||||
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations.
|
||||
- `@JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])`: Configures polymorphic transformation based on a discriminator field.
|
||||
- `@JsonProperty(name: string)`: Renames the property in JSON, both directions.
|
||||
- `@JsonAlias(...names: string[])`: Extra names accepted on input only.
|
||||
- `@JsonIgnore()`: Excludes the property from mapping entirely.
|
||||
- `@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:
|
||||
- `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 |
|
||||
| --- | --- |
|
||||
@@ -156,46 +260,95 @@ Most validation decorators accept an optional `ValidationOptions` object:
|
||||
| `@IsNumber()` | Checks if value is a number (and not NaN). |
|
||||
| `@IsInt()` | Checks if value is an integer. |
|
||||
| `@IsBoolean()` | Checks if value is a boolean. |
|
||||
| `@IsBigInt()` | Checks if value is a bigint. |
|
||||
| `@IsObject()` | Checks if value is an object (not null/array). |
|
||||
| `@IsDate()` | Checks if value is a valid Date object. |
|
||||
| `@IsDefined()` | Checks if value is not null or undefined. |
|
||||
| `@IsOptional()` | Skips other validations if value is null/undefined. |
|
||||
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
|
||||
| `@Min(value)` | Checks if number is >= value. |
|
||||
| `@Max(value)` | Checks if number is <= value. |
|
||||
| `@Positive()` | Checks if number is > 0. |
|
||||
| `@Negative()` | Checks if number is < 0. |
|
||||
| `@MinLength(len)` | Checks if string length is >= len. |
|
||||
| `@MaxLength(len)` | Checks if string length is <= len. |
|
||||
| `@IsEmpty()` | Checks if value is null/undefined/`''`/`[]`/`{}`. |
|
||||
| `@Equals(value)` / `@NotEquals(value)` | Strict equality against a fixed value. |
|
||||
| `@IsEnum(enumObject)` | Checks membership of a TypeScript enum. |
|
||||
| `@IsInstance(Class)` | Checks `value instanceof Class`. |
|
||||
| `@Min(value)` / `@Max(value)` | Numeric bounds. |
|
||||
| `@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. |
|
||||
| `@IsUrl()` | Checks if string is a valid URL. |
|
||||
| `@Matches(regex)`| Checks if string matches a regular expression. |
|
||||
| `@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. |
|
||||
| `@MinDate(d)` / `@MaxDate(d)` | Date bounds. Accepts `() => Date` for a moving bound. |
|
||||
| `@IsArray()` | Checks if value is an array. |
|
||||
| `@ArrayNotEmpty()`| Checks if array is not empty. |
|
||||
| `@ArrayMinSize(n)`| Checks if array has at least n elements. |
|
||||
| `@ArrayMaxSize(n)`| Checks if array has at most n elements. |
|
||||
| `@IsIn(values)` | Checks if value is in the allowed list. |
|
||||
| `@IsNotIn(vals)` | Checks if value is NOT in the list. |
|
||||
| `@ValidateNested()`| Recursively validates nested objects/arrays. |
|
||||
| `@ArrayNotEmpty()` | Checks if array is not empty. |
|
||||
| `@ArrayMinSize(n)` / `@ArrayMaxSize(n)` | Array size bounds. |
|
||||
| `@ArrayUnique(keyFn?)` | Checks for duplicate elements. |
|
||||
| `@ArrayContains(vals)` / `@ArrayNotContains(vals)` | Membership checks. |
|
||||
| `@IsIn(values)` / `@IsNotIn(values)` | Allow/deny lists. |
|
||||
| `@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
|
||||
|
||||
- `toJson(obj: any)`: Validates and serializes an instance to a JSON string (Returns `Promise<string>`).
|
||||
- `toPlain(obj: any)`: Validates and transforms an instance to a plain object (Returns `Promise<any>`).
|
||||
- `fromJson(clazz: ClassConstructor, json: string)`: Parses JSON and transforms it to a validated class instance (Returns `Promise<T>`).
|
||||
- `toInstance(clazz: ClassConstructor, plain: any)`: Transforms a plain object to a validated class instance (Returns `Promise<T>`).
|
||||
- `fromRequest(clazz: ClassConstructor, request: Request)`: Extracts JSON from a Fetch `Request` and transforms it to a validated instance (Returns `Promise<T>`).
|
||||
- `validate(obj: any)`: Performs full validation on an object/instance (Returns `Promise<ValidationError[]>`).
|
||||
- `toJson(obj, options?)`: Validates and serializes an instance to a JSON string (`Promise<string>`).
|
||||
- `toPlain(obj, options?)`: Validates and transforms an instance to a plain object (`Promise<any>`).
|
||||
- `fromJson(clazz, json, options?)`: Parses JSON to a validated class instance (`Promise<T>`).
|
||||
- `fromJsonArray(clazz, json, options?)`: Same, for a JSON array (`Promise<T[]>`).
|
||||
- `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
|
||||
- `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
|
||||
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
|
||||
- `validate(obj, options?)`: Full validation, returning `Promise<ValidationError[]>`.
|
||||
- `validateOrReject(obj, options?)`: As above, but throws `JsonValidationError`.
|
||||
- Synchronous twins of all of the above except `fromRequest`: `toPlainSync`, `toJsonSync`,
|
||||
`fromJsonSync`, `fromJsonArraySync`, `toInstanceSync`, `toInstanceArraySync`,
|
||||
`validateSync`, `validateOrRejectSync`.
|
||||
- `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"]
|
||||
```
|
||||
|
||||
Values of properties that never leave the process — `@JsonWriteOnly` and `@JsonIgnore` — are
|
||||
replaced with `REDACTED` in `ValidationError.value`, so a rejected password does not travel
|
||||
into your logs inside an error object. The property name and message are unaffected.
|
||||
|
||||
`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
|
||||
|
||||
Cereale is designed to be compatible with all trending web frameworks.
|
||||
|
||||
### Hono / Next.js / Cloudflare Workers
|
||||
Use `fromRequest` for seamless integration with the Fetch `Request` API.
|
||||
|
||||
### 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
|
||||
import { toInstance } from 'cereale';
|
||||
@@ -208,25 +361,58 @@ async create(@Body() body: any) {
|
||||
```
|
||||
|
||||
### Express / Fastify
|
||||
Easily integrate with traditional Node.js frameworks.
|
||||
|
||||
```typescript
|
||||
import { toInstance, toPlain } from 'cereale';
|
||||
import { toInstance, toPlain, JsonValidationError, flattenErrors } from 'cereale';
|
||||
|
||||
app.post('/user', async (req, res) => {
|
||||
try {
|
||||
const user = await toInstance(User, req.body);
|
||||
res.json(await toPlain(user));
|
||||
} catch (err) {
|
||||
res.status(400).json(err);
|
||||
if (err instanceof JsonValidationError) {
|
||||
return res.status(400).json({ errors: flattenErrors(err.errors) });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Decorator metadata is fixed once your classes are declared, so cereale resolves each class's
|
||||
validation, serialization and deserialization plans once and memoizes them per prototype.
|
||||
A version counter invalidates the caches if metadata is registered late, so `registerDecorator`
|
||||
after first use still behaves correctly. The engines are synchronous internally, so the async
|
||||
entry points do not pay for a microtask per property.
|
||||
|
||||
Indicative throughput for a customer record with a nested address and 10 orders, measured
|
||||
against `JSON.parse` + `JSON.stringify` (5.8 us) on the same machine:
|
||||
|
||||
| Operation | Time |
|
||||
| --- | --- |
|
||||
| `toInstance` (deserialize + validate) | ~8 us |
|
||||
| `toInstance` with `{ validate: false }` | ~3 us |
|
||||
| `validate` on an existing instance | ~4.5 us |
|
||||
| `toPlain` (validate + serialize) | ~12 us |
|
||||
|
||||
If you validate at the edge and map internally afterwards, `{ validate: false }` skips the
|
||||
dominant cost.
|
||||
|
||||
## 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
|
||||
|
||||
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
|
||||
|
||||
## License
|
||||
|
||||
Cereale is licensed under the [MIT License](LICENSE).
|
||||
Cereale is licensed under the [MIT License](LICENSE).
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
+41
-41
@@ -136,12 +136,23 @@
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3>
|
||||
<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">@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">@JsonPolymorphic(field, types)</code></li>
|
||||
</ul>
|
||||
</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>
|
||||
<h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3>
|
||||
<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">@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">@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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,9 +188,10 @@
|
||||
<script>
|
||||
const initialCode = `// 1. Define your model with decorators
|
||||
class User {
|
||||
@JsonProperty('display_name')
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
name;
|
||||
displayName;
|
||||
|
||||
@IsInt()
|
||||
@Min(18)
|
||||
@@ -186,26 +200,36 @@ class User {
|
||||
@Email()
|
||||
email;
|
||||
|
||||
constructor(name, age, email) {
|
||||
this.name = name;
|
||||
// Accepted from a request, never sent back out
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
password;
|
||||
|
||||
constructor(displayName, age, email, password) {
|
||||
this.displayName = displayName;
|
||||
this.age = age;
|
||||
this.email = email;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
async function demo() {
|
||||
console.log("--- Validating valid user ---");
|
||||
const user = new User("Alice", 25, "alice@example.com");
|
||||
const json = await JsonMapper.toJson(user);
|
||||
console.log("--- Mapping a valid user ---");
|
||||
const user = new User("Alice", 25, "alice@example.com", "hunter2");
|
||||
const json = await toJson(user);
|
||||
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 {
|
||||
const invalidJson = '{"name": "Bo", "age": 15, "email": "not-an-email"}';
|
||||
await JsonMapper.fromJson(User, invalidJson);
|
||||
await fromJson(User, '{"display_name": "Bo", "age": 15, "email": "nope", "password": "x"}');
|
||||
} catch (error) {
|
||||
console.log("Caught Error:", error.message);
|
||||
console.log("Validation Errors:", JSON.stringify(error.errors, null, 2));
|
||||
console.log("Caught:", error.message);
|
||||
console.log("Flattened:", flattenErrors(error.errors));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,36 +271,12 @@ demo();`;
|
||||
]
|
||||
}).code;
|
||||
|
||||
// Create a function with the library symbols in scope
|
||||
const {
|
||||
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
|
||||
} = Cereale;
|
||||
// Put every library export in scope. Derived from the bundle rather than
|
||||
// hand-listed, so a new decorator is usable here the moment it is exported.
|
||||
const exportNames = Object.keys(Cereale).filter(name => /^[A-Za-z_$][\w$]*$/.test(name));
|
||||
|
||||
const run = new Function(
|
||||
'console',
|
||||
'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
|
||||
);
|
||||
const run = new Function('console', ...exportNames, transpiled);
|
||||
await run({ log: logToOutput }, ...exportNames.map(name => Cereale[name]));
|
||||
} catch (err) {
|
||||
outputElement.textContent += 'Error: ' + err.message + '\\n';
|
||||
if (err.stack) {
|
||||
|
||||
Generated
+3258
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cereale",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"description": "Spring-like decorators for JSON mapping and validation in TypeScript",
|
||||
"type": "module",
|
||||
"main": "./dist/cjs/index.js",
|
||||
@@ -19,13 +19,19 @@
|
||||
],
|
||||
"scripts": {
|
||||
"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",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"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": {
|
||||
"type": "git",
|
||||
@@ -49,6 +55,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^10.2.1",
|
||||
"globals": "^17.5.0",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Maximum nesting depth before a {@link JsonMappingError} is raised. Defaults to 64.
|
||||
*
|
||||
* All three engines recurse, so a hostile payload nested thousands of levels deep would
|
||||
* otherwise exhaust the call stack. Raise it if you legitimately model deep trees.
|
||||
*/
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
/** Options that can be set once for the whole application via {@link configure}. */
|
||||
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate' | 'maxDepth'>;
|
||||
|
||||
const DEFAULTS: Required<GlobalOptions> = {
|
||||
namingStrategy: 'identity',
|
||||
unknownKeys: 'allow',
|
||||
validate: true,
|
||||
maxDepth: 64,
|
||||
};
|
||||
|
||||
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,
|
||||
maxDepth: options.maxDepth ?? globalOptions.maxDepth,
|
||||
};
|
||||
}
|
||||
@@ -238,7 +238,7 @@ describe('Additional Decorators', () => {
|
||||
t.val = 'wrong';
|
||||
const errors = await JsonMapper.validate(t);
|
||||
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;
|
||||
const errors = await JsonMapper.validate(t);
|
||||
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 () => {
|
||||
@@ -318,7 +318,7 @@ describe('Additional Decorators', () => {
|
||||
t.val = 2;
|
||||
const errors = await JsonMapper.validate(t);
|
||||
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;
|
||||
}
|
||||
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(items[0]).toBeInstanceOf(Item);
|
||||
expect(items[0].name).toBe('a');
|
||||
expect(items[0]!.name).toBe('a');
|
||||
});
|
||||
|
||||
it('should handle single polymorphic object', async () => {
|
||||
@@ -350,7 +350,7 @@ describe('Additional Decorators', () => {
|
||||
@IsString() type: string;
|
||||
}
|
||||
class Dog extends Animal {
|
||||
type = 'dog';
|
||||
override type = 'dog';
|
||||
@IsString() breed: string;
|
||||
}
|
||||
class Test {
|
||||
@@ -376,7 +376,7 @@ describe('Additional Decorators', () => {
|
||||
t.tags = ['a', 1 as any];
|
||||
const errors = await JsonMapper.validate(t);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints['isString']).toContain('each element');
|
||||
expect(errors[0]!.constraints['isString']).toContain('each element');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+626
-8
@@ -9,8 +9,23 @@ export const METADATA_KEYS = {
|
||||
DESERIALIZER: 'cereale:deserializer',
|
||||
POLYMORPHIC: 'cereale:polymorphic',
|
||||
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 {
|
||||
value: any;
|
||||
object: any;
|
||||
@@ -29,6 +44,12 @@ export type ValidationConstraint = {
|
||||
message: string | ((args: ValidationArguments) => string);
|
||||
constraints?: any[];
|
||||
each?: boolean;
|
||||
/**
|
||||
* True when the message came from the user via `ValidationOptions.message`.
|
||||
* The engine only decorates default messages with the "each element in ..." prefix;
|
||||
* a message the user wrote is reported exactly as written.
|
||||
*/
|
||||
hasCustomMessage?: boolean;
|
||||
};
|
||||
|
||||
export interface ValidatorConstraintInterface {
|
||||
@@ -55,9 +76,10 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
|
||||
}
|
||||
if (options.message) {
|
||||
constraint.message = options.message;
|
||||
constraint.hasCustomMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const constraints: ValidationConstraint[] = metadataStorage.getOwnMetadata(METADATA_KEYS.VALIDATION, target, propertyKey) || [];
|
||||
constraints.push(constraint);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.VALIDATION, constraints, target, propertyKey);
|
||||
@@ -65,6 +87,86 @@ function addValidation(target: any, propertyKey: string, constraint: ValidationC
|
||||
|
||||
// --- Mapping Decorators ---
|
||||
|
||||
/**
|
||||
* @JsonProperty(name: string)
|
||||
* Maps this property to a different name in JSON, in both directions.
|
||||
*
|
||||
* ```ts
|
||||
* class User {
|
||||
* @JsonProperty('first_name')
|
||||
* firstName: string; // <-> {"first_name": "Ada"}
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* An explicit name always wins over the active naming strategy.
|
||||
*/
|
||||
export function JsonProperty(name: string) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.NAME, name, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonAlias(...names: string[])
|
||||
* Additional names accepted for this property when reading JSON.
|
||||
*
|
||||
* Aliases are input-only — output always uses the canonical name — which makes them the
|
||||
* tool for accepting a renamed field from older clients without emitting it.
|
||||
*
|
||||
* ```ts
|
||||
* class User {
|
||||
* @JsonProperty('surname')
|
||||
* @JsonAlias('last_name', 'lastName')
|
||||
* surname: string;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function JsonAlias(...names: string[]) {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
const existing: string[] = metadataStorage.getOwnMetadata(METADATA_KEYS.ALIASES, target, propertyKey) || [];
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.ALIASES, [...existing, ...names], target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonIgnore()
|
||||
* Excludes this property from mapping in both directions.
|
||||
*/
|
||||
export function JsonIgnore() {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'none', target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonReadOnly()
|
||||
* Serialized to JSON, but never populated from incoming JSON.
|
||||
*
|
||||
* For server-owned fields — ids, timestamps — that a client must not be able to set.
|
||||
*/
|
||||
export function JsonReadOnly() {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'readonly', target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonWriteOnly()
|
||||
* Populated from incoming JSON, but never serialized back out.
|
||||
*
|
||||
* For secrets — passwords, tokens — that you accept but must never echo.
|
||||
*/
|
||||
export function JsonWriteOnly() {
|
||||
return (target: any, propertyKey: string) => {
|
||||
registerProperty(target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.ACCESS, 'writeonly', target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonSerialize(serializer: ClassConstructor<JsonSerializer>)
|
||||
* Custom serializer decorator.
|
||||
@@ -98,14 +200,34 @@ export function JsonType(typeFunction: () => ClassConstructor<any>) {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PolymorphicOptions {
|
||||
/**
|
||||
* What to do when the discriminator value matches no registered subtype.
|
||||
* - `keep` (default): pass the raw value through untouched.
|
||||
* - `error`: throw a {@link JsonMappingError} naming the unknown discriminator value.
|
||||
*/
|
||||
onUnknown?: 'keep' | 'error';
|
||||
/** Subtype to use when the discriminator matches nothing. Takes precedence over `onUnknown`. */
|
||||
fallback?: ClassConstructor<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])
|
||||
* @JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[], options?: PolymorphicOptions)
|
||||
* 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) => {
|
||||
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)
|
||||
*/
|
||||
export function Matches(pattern: RegExp, options?: ValidationOptions) {
|
||||
// A `g` or `y` flag makes RegExp.prototype.test stateful: it advances lastIndex on a
|
||||
// match and resumes from there on the next call, so validating the same value twice
|
||||
// yields different answers. Validation must be a pure predicate, so drop those flags.
|
||||
const stateless = pattern.flags.includes('g') || pattern.flags.includes('y')
|
||||
? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
|
||||
: pattern;
|
||||
return (target: any, propertyKey: string) => {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'matches',
|
||||
validate: (v) => typeof v === 'string' && pattern.test(v),
|
||||
validate: (v) => typeof v === 'string' && stateless.test(v),
|
||||
message: `${propertyKey} must match ${pattern} regular expression`,
|
||||
constraints: [pattern]
|
||||
}, options);
|
||||
@@ -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) => {
|
||||
registerProperty(target, propertyKey);
|
||||
// This is a marker for recursive validation
|
||||
metadataStorage.defineMetadata('cereale:nested', true, target, propertyKey);
|
||||
metadataStorage.defineMetadata(METADATA_KEYS.NESTED, true, target, propertyKey);
|
||||
|
||||
if (options?.each) {
|
||||
addValidation(target, propertyKey, {
|
||||
name: 'nestedEach',
|
||||
validate: (v) => Array.isArray(v),
|
||||
message: `${propertyKey} must be an array`
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+123
-71
@@ -1,16 +1,26 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
Min,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
Min,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsDate,
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
IsEnum,
|
||||
IsUUID,
|
||||
ValidateIf,
|
||||
JsonProperty,
|
||||
JsonAlias,
|
||||
JsonReadOnly,
|
||||
JsonWriteOnly,
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
toJson,
|
||||
fromJson,
|
||||
JsonSerializer,
|
||||
toPlain,
|
||||
validate,
|
||||
flattenErrors,
|
||||
JsonSerializer,
|
||||
JsonDeserializer,
|
||||
Validate,
|
||||
ValidatorConstraintInterface,
|
||||
@@ -32,14 +42,14 @@ class IsLongerThan implements ValidatorConstraintInterface {
|
||||
}
|
||||
}
|
||||
|
||||
function IsUsername(options?: ValidationOptions) {
|
||||
function IsSlug(options?: ValidationOptions) {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isUsername',
|
||||
name: 'isSlug',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
...(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,10 +58,7 @@ function IsUsername(options?: ValidationOptions) {
|
||||
|
||||
class DateSerializer implements JsonSerializer<Date, string> {
|
||||
serialize(value: Date): string {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().split('T')[0] || '';
|
||||
}
|
||||
return String(value);
|
||||
return value.toISOString().split('T')[0] || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +70,16 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
|
||||
|
||||
// --- Domain Models ---
|
||||
|
||||
enum Format {
|
||||
Hardback = 'hardback',
|
||||
Paperback = 'paperback',
|
||||
}
|
||||
|
||||
abstract class Media {
|
||||
@IsString()
|
||||
abstract type: string;
|
||||
|
||||
// Declared once here. Subclasses inherit the rule without restating it.
|
||||
@IsString()
|
||||
title: string;
|
||||
}
|
||||
@@ -75,14 +88,14 @@ class Book extends Media {
|
||||
@IsString()
|
||||
override type: string = 'book';
|
||||
|
||||
@IsString()
|
||||
@IsUsername({ message: 'Title must be a valid alphanumeric username' })
|
||||
declare title: string;
|
||||
|
||||
@IsString()
|
||||
@Validate(IsLongerThan, [5])
|
||||
author: string;
|
||||
|
||||
@IsEnum(Format)
|
||||
format: Format = Format.Paperback;
|
||||
|
||||
@JsonProperty('published_at')
|
||||
@JsonSerialize(DateSerializer)
|
||||
@JsonDeserialize(DateDeserializer)
|
||||
@IsDate()
|
||||
@@ -91,19 +104,38 @@ class Book extends Media {
|
||||
|
||||
class Movie extends Media {
|
||||
@IsString()
|
||||
type: string = 'movie';
|
||||
override type: string = 'movie';
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
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 {
|
||||
@JsonReadOnly()
|
||||
@IsUUID(4)
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsSlug({ message: 'name must be a lowercase slug' })
|
||||
name: string;
|
||||
|
||||
@JsonProperty('curator_email')
|
||||
@JsonAlias('curatorEmail')
|
||||
@IsString()
|
||||
curatorEmail: string;
|
||||
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
adminToken: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested()
|
||||
@ValidateNested({ each: true })
|
||||
@JsonPolymorphic('type', [
|
||||
{ value: Book, name: 'book' },
|
||||
{ value: Movie, name: 'movie' }
|
||||
@@ -114,64 +146,84 @@ class Library {
|
||||
// --- Execution ---
|
||||
|
||||
async function runExample() {
|
||||
console.log("--- Starting Example ---");
|
||||
console.log('--- Starting Example ---');
|
||||
|
||||
// 1. Create a Library instance
|
||||
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();
|
||||
book.title = "Gatsby";
|
||||
book.author = "Fitzgerald";
|
||||
book.publishedAt = new Date("1925-04-10");
|
||||
book.title = 'Gatsby';
|
||||
book.author = 'Fitzgerald';
|
||||
book.format = Format.Hardback;
|
||||
book.publishedAt = new Date('1925-04-10');
|
||||
|
||||
const movie = new Movie();
|
||||
movie.title = "Inception";
|
||||
movie.title = 'Inception';
|
||||
movie.duration = 148;
|
||||
|
||||
library.items = [book, movie];
|
||||
|
||||
try {
|
||||
// 2. Serialize to JSON
|
||||
console.log("\n[1] Serializing Library to JSON...");
|
||||
const json = await toJson(library);
|
||||
console.log("JSON Output:", json);
|
||||
// 1. Serialize, honouring @JsonProperty and the write-only token
|
||||
console.log('\n[1] Serializing Library to JSON...');
|
||||
const json = await toJson(library);
|
||||
console.log('JSON Output:', json);
|
||||
console.log('Secret withheld from output:', !json.includes('super-secret'));
|
||||
|
||||
// 3. Deserialize back to Instance
|
||||
console.log("\n[2] Deserializing JSON back to Library instance...");
|
||||
const deserializedLibrary = await fromJson(Library, json);
|
||||
console.log("Deserialized Library Name:", deserializedLibrary.name);
|
||||
console.log("Items count:", deserializedLibrary.items.length);
|
||||
|
||||
// Check Polymorphism
|
||||
deserializedLibrary.items.forEach((item, index) => {
|
||||
console.log(`Item ${index} is instance of ${item.constructor.name}: ${item.title}`);
|
||||
if (item instanceof Book) {
|
||||
console.log(` > Book Author: ${item.author}`);
|
||||
console.log(` > Published At: ${item.publishedAt.toISOString()} (instanceof Date: ${item.publishedAt instanceof Date})`);
|
||||
} else if (item instanceof Movie) {
|
||||
console.log(` > Movie Duration: ${item.duration} mins`);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Test Validation Failure
|
||||
console.log("\n[3] Testing Validation Failure (Invalid Movie Duration)...");
|
||||
const invalidJson = JSON.stringify({
|
||||
name: "Invalid Library",
|
||||
items: [
|
||||
{ type: "movie", title: "Short Film", duration: -5 } // Invalid: duration < 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));
|
||||
}
|
||||
// 2. Deserialize back, resolving the polymorphic items
|
||||
console.log('\n[2] Deserializing JSON back to Library instance...');
|
||||
const restored = await fromJson(Library, json, { validate: false });
|
||||
console.log('Curator (read via curator_email):', restored.curatorEmail);
|
||||
console.log('Items count:', restored.items.length);
|
||||
restored.items.forEach((item, index) => {
|
||||
console.log(`Item ${index} is a ${item.constructor.name}: ${item.title}`);
|
||||
if (item instanceof Book) {
|
||||
console.log(` > Author: ${item.author}, format: ${item.format}`);
|
||||
console.log(` > Published: ${item.publishedAt.toISOString()} (Date: ${item.publishedAt instanceof Date})`);
|
||||
} else if (item instanceof Movie) {
|
||||
console.log(` > Duration: ${item.duration} mins`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. A client cannot set a @JsonReadOnly field
|
||||
console.log('\n[3] A client trying to set the read-only id...');
|
||||
const hijacked = await fromJson(
|
||||
Library,
|
||||
JSON.stringify({ id: 'attacker-supplied', name: 'x', curator_email: 'a@b.c', adminToken: 't', items: [] }),
|
||||
{ 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();
|
||||
runExample().catch((error) => {
|
||||
console.error('Example failed:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
||||
JsonSerializer, JsonDeserializer, JsonMappingError,
|
||||
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
|
||||
} from './index.js';
|
||||
|
||||
afterEach(() => resetConfig());
|
||||
|
||||
describe('plan caching', () => {
|
||||
// The validation plan for a class is memoized. It must not go stale when metadata is
|
||||
// registered after the class has already been validated once.
|
||||
it('picks up a decorator registered after the first validation', async () => {
|
||||
class Late {
|
||||
value: any;
|
||||
}
|
||||
|
||||
const before = new Late();
|
||||
before.value = 'anything';
|
||||
expect(await validate(before)).toEqual([]);
|
||||
|
||||
// Register a rule after the plan has already been built and cached.
|
||||
registerDecorator({
|
||||
name: 'isEven',
|
||||
target: Late,
|
||||
propertyName: 'value',
|
||||
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||
});
|
||||
|
||||
const after = new Late();
|
||||
after.value = 'anything';
|
||||
expect(await validate(after)).toHaveLength(1);
|
||||
|
||||
after.value = 4;
|
||||
expect(await validate(after)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps per-class plans separate', async () => {
|
||||
class A {
|
||||
@IsString()
|
||||
v: any;
|
||||
}
|
||||
class B {
|
||||
@IsInt()
|
||||
v: any;
|
||||
}
|
||||
|
||||
const a = new A();
|
||||
a.v = 'text';
|
||||
const b = new B();
|
||||
b.v = 'text';
|
||||
|
||||
expect(await validate(a)).toEqual([]);
|
||||
expect(await validate(b)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses one serializer instance rather than constructing per property', async () => {
|
||||
let constructed = 0;
|
||||
class Counting implements JsonSerializer<string, string> {
|
||||
constructor() { constructed++; }
|
||||
serialize(value: string): string { return value.toUpperCase(); }
|
||||
}
|
||||
class Doc {
|
||||
@JsonSerialize(Counting)
|
||||
a: string;
|
||||
|
||||
@JsonSerialize(Counting)
|
||||
b: string;
|
||||
}
|
||||
|
||||
const doc = new Doc();
|
||||
doc.a = 'x';
|
||||
doc.b = 'y';
|
||||
|
||||
await toPlain(doc);
|
||||
await toPlain(doc);
|
||||
await toPlain(doc);
|
||||
|
||||
expect(await toPlain(doc)).toEqual({ a: 'X', b: 'Y' });
|
||||
expect(constructed).toBe(1);
|
||||
});
|
||||
|
||||
it('still honours a deserializer after caching', async () => {
|
||||
class ToDate implements JsonDeserializer<string, Date> {
|
||||
deserialize(value: string): Date { return new Date(value); }
|
||||
}
|
||||
class Event {
|
||||
@JsonDeserialize(ToDate)
|
||||
at: Date;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const e = await toInstance(Event, { at: '2026-01-01T00:00:00Z' });
|
||||
expect(e.at).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxDepth guard', () => {
|
||||
const nest = (depth: number): any => {
|
||||
let node: any = { value: 'leaf' };
|
||||
for (let i = 0; i < depth; i++) node = { child: node };
|
||||
return node;
|
||||
};
|
||||
|
||||
class Node {
|
||||
@ValidateNested()
|
||||
@JsonType(() => Node)
|
||||
child?: Node;
|
||||
|
||||
value?: string;
|
||||
}
|
||||
|
||||
it('rejects a payload nested past the limit instead of exhausting the stack', async () => {
|
||||
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||
.rejects.toThrow(JsonMappingError);
|
||||
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||
.rejects.toThrow(/Maximum nesting depth/);
|
||||
});
|
||||
|
||||
it('accepts nesting within the limit', async () => {
|
||||
const parsed = await toInstance(Node, nest(10), { validate: false });
|
||||
expect(parsed).toBeInstanceOf(Node);
|
||||
});
|
||||
|
||||
it('is configurable per call and globally', async () => {
|
||||
await expect(toInstance(Node, nest(10), { validate: false, maxDepth: 3 }))
|
||||
.rejects.toThrow(/Maximum nesting depth of 3/);
|
||||
|
||||
configure({ maxDepth: 2 });
|
||||
await expect(toInstance(Node, nest(10), { validate: false }))
|
||||
.rejects.toThrow(/Maximum nesting depth of 2/);
|
||||
});
|
||||
|
||||
it('guards serialization too', async () => {
|
||||
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||
await expect(toPlain(deep, { validate: false, maxDepth: 5 }))
|
||||
.rejects.toThrow(/Maximum nesting depth/);
|
||||
});
|
||||
|
||||
it('guards validation too', async () => {
|
||||
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||
await expect(validate(deep, { maxDepth: 5 })).rejects.toThrow(/Maximum nesting depth/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('each: true error reporting', () => {
|
||||
it('names the index of the element that failed', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a', 'b'], { each: true })
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]!.constraints['isIn']).toContain('failed at index 3');
|
||||
});
|
||||
|
||||
it('leaves a caller-supplied message untouched', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a'], { each: true, message: 'bad tag' })
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'zzz'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
||||
});
|
||||
|
||||
it('gives the failing element to a message function, not the whole array', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'zzz'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
||||
});
|
||||
|
||||
it('reports nothing when every element passes', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a', 'b'], { each: true })
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'b'];
|
||||
expect(await validate(basket)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate() accepts options', () => {
|
||||
it('threads maxDepth through nested validation', async () => {
|
||||
class Item {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
qty: number;
|
||||
}
|
||||
class Order {
|
||||
@IsString()
|
||||
ref: string;
|
||||
|
||||
@ValidateNested()
|
||||
@JsonType(() => Item)
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
const bad = new Item();
|
||||
bad.qty = -1;
|
||||
const order = new Order();
|
||||
order.ref = 'r';
|
||||
order.items = [bad];
|
||||
|
||||
// Deep enough to be fine at the default, so behaviour is unchanged.
|
||||
expect(await validate(order)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+9
-9
@@ -202,8 +202,8 @@ describe('JsonMapper', () => {
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('username');
|
||||
expect(errors[0].constraints).toHaveProperty('minLength');
|
||||
expect(errors[0]!.property).toBe('username');
|
||||
expect(errors[0]!.constraints).toHaveProperty('minLength');
|
||||
});
|
||||
|
||||
it('should fail on invalid email', async () => {
|
||||
@@ -215,8 +215,8 @@ describe('JsonMapper', () => {
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('email');
|
||||
expect(errors[0].constraints).toHaveProperty('isEmail');
|
||||
expect(errors[0]!.property).toBe('email');
|
||||
expect(errors[0]!.constraints).toHaveProperty('isEmail');
|
||||
});
|
||||
|
||||
it('should fail on invalid role (IsIn)', async () => {
|
||||
@@ -228,8 +228,8 @@ describe('JsonMapper', () => {
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('roles');
|
||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
||||
expect(errors[0]!.property).toBe('roles');
|
||||
expect(errors[0]!.constraints).toHaveProperty('isIn');
|
||||
});
|
||||
|
||||
it('should skip validation for null optional field', async () => {
|
||||
@@ -238,7 +238,7 @@ describe('JsonMapper', () => {
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['user'];
|
||||
user.age = undefined; // optional
|
||||
delete user.age; // optional
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(0);
|
||||
@@ -254,8 +254,8 @@ describe('JsonMapper', () => {
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('age');
|
||||
expect(errors[0].constraints).toHaveProperty('min');
|
||||
expect(errors[0]!.property).toBe('age');
|
||||
expect(errors[0]!.constraints).toHaveProperty('min');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './interfaces.js';
|
||||
export * from './naming.js';
|
||||
export * from './config.js';
|
||||
export * from './decorators.js';
|
||||
export * from './errors.js';
|
||||
export * from './utils.js';
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
IsString, IsInt, IsOptional, ValidateNested, Min,
|
||||
JsonProperty, JsonAlias, JsonIgnore, JsonReadOnly, JsonWriteOnly, JsonType,
|
||||
JsonMappingError, JsonValidationError,
|
||||
toPlain, toJson, toInstance, fromJson, validate, validateOrReject,
|
||||
configure, resetConfig, getConfig,
|
||||
flattenErrors, formatErrors, collectErrorMessages,
|
||||
resolveNamingStrategy,
|
||||
} from './index.js';
|
||||
|
||||
afterEach(() => resetConfig());
|
||||
|
||||
describe('@JsonProperty', () => {
|
||||
class User {
|
||||
@JsonProperty('first_name')
|
||||
@IsString()
|
||||
firstName: string;
|
||||
|
||||
@JsonProperty('last_name')
|
||||
@IsString()
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
it('renames on the way out', async () => {
|
||||
const u = new User();
|
||||
u.firstName = 'Ada';
|
||||
u.lastName = 'Lovelace';
|
||||
|
||||
await expect(toPlain(u)).resolves.toEqual({ first_name: 'Ada', last_name: 'Lovelace' });
|
||||
});
|
||||
|
||||
it('renames on the way in', async () => {
|
||||
const u = await fromJson(User, '{"first_name":"Ada","last_name":"Lovelace"}');
|
||||
expect(u.firstName).toBe('Ada');
|
||||
expect(u.lastName).toBe('Lovelace');
|
||||
});
|
||||
|
||||
it('round-trips', async () => {
|
||||
const json = '{"first_name":"Ada","last_name":"Lovelace"}';
|
||||
expect(await toJson(await fromJson(User, json))).toBe(json);
|
||||
});
|
||||
|
||||
it('no longer accepts the raw property name once renamed', async () => {
|
||||
const u = await toInstance(
|
||||
User,
|
||||
{ firstName: 'Ada', last_name: 'L' },
|
||||
{ unknownKeys: 'strip', validate: false }
|
||||
);
|
||||
expect(u.firstName).toBeUndefined();
|
||||
expect(u.lastName).toBe('L');
|
||||
});
|
||||
|
||||
it('rejects two properties claiming the same JSON name', async () => {
|
||||
class Clash {
|
||||
@JsonProperty('name')
|
||||
a: string;
|
||||
|
||||
@JsonProperty('name')
|
||||
b: string;
|
||||
}
|
||||
|
||||
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(JsonMappingError);
|
||||
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(/both map to the JSON name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('@JsonAlias', () => {
|
||||
class Person {
|
||||
@JsonProperty('surname')
|
||||
@JsonAlias('last_name', 'lastName')
|
||||
@IsString()
|
||||
surname: string;
|
||||
}
|
||||
|
||||
it('accepts every alias on input', async () => {
|
||||
for (const key of ['surname', 'last_name', 'lastName']) {
|
||||
const p = await toInstance(Person, { [key]: 'Hopper' });
|
||||
expect(p.surname).toBe('Hopper');
|
||||
}
|
||||
});
|
||||
|
||||
it('never emits an alias on output', async () => {
|
||||
const p = new Person();
|
||||
p.surname = 'Hopper';
|
||||
await expect(toPlain(p)).resolves.toEqual({ surname: 'Hopper' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('access control decorators', () => {
|
||||
it('@JsonIgnore drops the property in both directions', async () => {
|
||||
class Secretive {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@JsonIgnore()
|
||||
internalNote: string;
|
||||
}
|
||||
|
||||
const s = new Secretive();
|
||||
s.name = 'x';
|
||||
s.internalNote = 'do not leak';
|
||||
await expect(toPlain(s)).resolves.toEqual({ name: 'x' });
|
||||
|
||||
const parsed = await toInstance(Secretive, { name: 'x', internalNote: 'injected' });
|
||||
expect(parsed.internalNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it('@JsonWriteOnly accepts input but never echoes it back', async () => {
|
||||
class Credentials {
|
||||
@IsString()
|
||||
email: string;
|
||||
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
const c = await toInstance(Credentials, { email: 'a@b.com', password: 'hunter2' });
|
||||
expect(c.password).toBe('hunter2');
|
||||
await expect(toPlain(c)).resolves.toEqual({ email: 'a@b.com' });
|
||||
});
|
||||
|
||||
it('@JsonReadOnly is emitted but cannot be set by a client', async () => {
|
||||
class Record {
|
||||
@JsonReadOnly()
|
||||
id: number;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
}
|
||||
|
||||
const r = await toInstance(Record, { id: 999, title: 'hello' });
|
||||
expect(r.id).toBeUndefined();
|
||||
|
||||
r.id = 1;
|
||||
await expect(toPlain(r)).resolves.toEqual({ id: 1, title: 'hello' });
|
||||
});
|
||||
|
||||
it('@JsonReadOnly is not resurrected by the default unknownKeys policy', async () => {
|
||||
class Record {
|
||||
@JsonProperty('identifier')
|
||||
@JsonReadOnly()
|
||||
id: number;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
}
|
||||
|
||||
const r = await toInstance(Record, { identifier: 999, title: 't' }, { unknownKeys: 'allow' });
|
||||
expect(r.id).toBeUndefined();
|
||||
expect((r as any).identifier).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming strategies', () => {
|
||||
class Account {
|
||||
@IsString()
|
||||
accountHolderName: string;
|
||||
|
||||
@IsInt()
|
||||
balanceInCents: number;
|
||||
}
|
||||
|
||||
it('snake_case both ways', async () => {
|
||||
const a = new Account();
|
||||
a.accountHolderName = 'Ada';
|
||||
a.balanceInCents = 100;
|
||||
|
||||
const plain = await toPlain(a, { namingStrategy: 'snake_case' });
|
||||
expect(plain).toEqual({ account_holder_name: 'Ada', balance_in_cents: 100 });
|
||||
|
||||
const back = await toInstance(Account, plain, { namingStrategy: 'snake_case' });
|
||||
expect(back.accountHolderName).toBe('Ada');
|
||||
expect(back.balanceInCents).toBe(100);
|
||||
});
|
||||
|
||||
it('kebab-case, SCREAMING_SNAKE_CASE and PascalCase', async () => {
|
||||
const a = new Account();
|
||||
a.accountHolderName = 'Ada';
|
||||
a.balanceInCents = 1;
|
||||
|
||||
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
|
||||
.resolves.toEqual({ 'account-holder-name': 'Ada', 'balance-in-cents': 1 });
|
||||
await expect(toPlain(a, { namingStrategy: 'SCREAMING_SNAKE_CASE' }))
|
||||
.resolves.toEqual({ ACCOUNT_HOLDER_NAME: 'Ada', BALANCE_IN_CENTS: 1 });
|
||||
await expect(toPlain(a, { namingStrategy: 'PascalCase' }))
|
||||
.resolves.toEqual({ AccountHolderName: 'Ada', BalanceInCents: 1 });
|
||||
});
|
||||
|
||||
it('accepts a custom function', async () => {
|
||||
const a = new Account();
|
||||
a.accountHolderName = 'Ada';
|
||||
a.balanceInCents = 1;
|
||||
|
||||
await expect(toPlain(a, { namingStrategy: (k) => `x_${k}` }))
|
||||
.resolves.toEqual({ x_accountHolderName: 'Ada', x_balanceInCents: 1 });
|
||||
});
|
||||
|
||||
it('@JsonProperty wins over the naming strategy', async () => {
|
||||
class Mixed {
|
||||
@JsonProperty('EXPLICIT')
|
||||
someField: string;
|
||||
|
||||
otherField: string;
|
||||
}
|
||||
const m = new Mixed();
|
||||
m.someField = 'a';
|
||||
m.otherField = 'b';
|
||||
|
||||
await expect(toPlain(m, { namingStrategy: 'snake_case' }))
|
||||
.resolves.toEqual({ EXPLICIT: 'a', other_field: 'b' });
|
||||
});
|
||||
|
||||
it('splits acronyms the way a reader expects', () => {
|
||||
const snake = resolveNamingStrategy('snake_case');
|
||||
expect(snake('parseHTTPResponse')).toBe('parse_http_response');
|
||||
expect(snake('firstName')).toBe('first_name');
|
||||
expect(snake('id')).toBe('id');
|
||||
expect(snake('already_snake')).toBe('already_snake');
|
||||
|
||||
const camel = resolveNamingStrategy('camelCase');
|
||||
expect(camel('first_name')).toBe('firstName');
|
||||
});
|
||||
|
||||
it('rejects an unknown strategy name', () => {
|
||||
expect(() => resolveNamingStrategy('shouty' as any)).toThrow(/Unknown naming strategy/);
|
||||
});
|
||||
|
||||
it('applies to nested objects too', async () => {
|
||||
class Inner {
|
||||
@IsString()
|
||||
innerValue: string;
|
||||
}
|
||||
class Outer {
|
||||
@ValidateNested()
|
||||
@JsonType(() => Inner)
|
||||
outerChild: Inner;
|
||||
}
|
||||
|
||||
const parsed = await toInstance(
|
||||
Outer,
|
||||
{ outer_child: { inner_value: 'v' } },
|
||||
{ namingStrategy: 'snake_case' }
|
||||
);
|
||||
expect(parsed.outerChild).toBeInstanceOf(Inner);
|
||||
expect(parsed.outerChild.innerValue).toBe('v');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configure()', () => {
|
||||
class Account {
|
||||
@IsString()
|
||||
accountHolderName: string;
|
||||
}
|
||||
|
||||
it('sets a library-wide default', async () => {
|
||||
configure({ namingStrategy: 'snake_case' });
|
||||
|
||||
const a = new Account();
|
||||
a.accountHolderName = 'Ada';
|
||||
await expect(toPlain(a)).resolves.toEqual({ account_holder_name: 'Ada' });
|
||||
expect(getConfig().namingStrategy).toBe('snake_case');
|
||||
});
|
||||
|
||||
it('is overridden by per-call options', async () => {
|
||||
configure({ namingStrategy: 'snake_case' });
|
||||
|
||||
const a = new Account();
|
||||
a.accountHolderName = 'Ada';
|
||||
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
|
||||
.resolves.toEqual({ 'account-holder-name': 'Ada' });
|
||||
});
|
||||
|
||||
it('resetConfig() restores the defaults', async () => {
|
||||
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
|
||||
resetConfig();
|
||||
expect(getConfig()).toEqual({
|
||||
namingStrategy: 'identity',
|
||||
unknownKeys: 'allow',
|
||||
validate: true,
|
||||
maxDepth: 64,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknownKeys policy', () => {
|
||||
class Dto {
|
||||
@IsString()
|
||||
known: string;
|
||||
}
|
||||
|
||||
it('allow (default) copies unknown keys through', async () => {
|
||||
const d = await toInstance(Dto, { known: 'a', extra: 'b' });
|
||||
expect((d as any).extra).toBe('b');
|
||||
});
|
||||
|
||||
it('strip drops them', async () => {
|
||||
const d = await toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'strip' });
|
||||
expect((d as any).extra).toBeUndefined();
|
||||
expect(d.known).toBe('a');
|
||||
});
|
||||
|
||||
it('error rejects the payload and names the offending key', async () => {
|
||||
await expect(toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'error' }))
|
||||
.rejects.toThrow(/Unknown property "extra"/);
|
||||
});
|
||||
|
||||
it('never lets __proto__ through, whatever the policy', async () => {
|
||||
for (const unknownKeys of ['allow', 'strip', 'error'] as const) {
|
||||
const d = await toInstance(Dto, JSON.parse('{"known":"a","__proto__":{"x":1}}'), { unknownKeys });
|
||||
expect(Object.getPrototypeOf(d)).toBe(Dto.prototype);
|
||||
expect(({} as any).x).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate option', () => {
|
||||
class Strict {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
age?: number;
|
||||
}
|
||||
|
||||
it('throws by default', async () => {
|
||||
await expect(toInstance(Strict, { name: 123 })).rejects.toThrow(JsonValidationError);
|
||||
});
|
||||
|
||||
it('maps without validating when told to', async () => {
|
||||
const s = await toInstance(Strict, { name: 123 }, { validate: false });
|
||||
expect(s).toBeInstanceOf(Strict);
|
||||
expect(s.name).toBe(123 as any);
|
||||
});
|
||||
|
||||
it('skips validation on the way out too', async () => {
|
||||
const s = new Strict();
|
||||
s.name = 123 as any;
|
||||
await expect(toPlain(s, { validate: false })).resolves.toEqual({ name: 123 });
|
||||
});
|
||||
|
||||
it('validateOrReject throws, validate returns', async () => {
|
||||
const s = new Strict();
|
||||
s.name = 123 as any;
|
||||
|
||||
await expect(validateOrReject(s)).rejects.toThrow(JsonValidationError);
|
||||
await expect(validate(s)).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error helpers', () => {
|
||||
class Item {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
qty: number;
|
||||
}
|
||||
class Order {
|
||||
@IsString()
|
||||
reference: string;
|
||||
|
||||
@ValidateNested()
|
||||
@JsonType(() => Item)
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
const buildFailing = () => {
|
||||
const bad = new Item();
|
||||
bad.qty = -5;
|
||||
const o = new Order();
|
||||
o.reference = 42 as any;
|
||||
o.items = [bad];
|
||||
return o;
|
||||
};
|
||||
|
||||
it('flattenErrors produces dotted paths with array indices', async () => {
|
||||
const errors = await validate(buildFailing());
|
||||
const flat = flattenErrors(errors);
|
||||
|
||||
expect(flat['reference']).toEqual(['reference must be a string']);
|
||||
expect(flat['items[0].qty']).toEqual(['qty must be at least 1']);
|
||||
});
|
||||
|
||||
it('formatErrors renders one line per failure', async () => {
|
||||
const errors = await validate(buildFailing());
|
||||
const text = formatErrors(errors);
|
||||
|
||||
expect(text).toContain('reference: reference must be a string');
|
||||
expect(text).toContain('items[0].qty: qty must be at least 1');
|
||||
});
|
||||
|
||||
it('collectErrorMessages returns just the messages', async () => {
|
||||
const messages = collectErrorMessages(await validate(buildFailing()));
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages).toContain('qty must be at least 1');
|
||||
});
|
||||
|
||||
it('returns an empty result for a valid object', async () => {
|
||||
const o = new Order();
|
||||
o.reference = 'ok';
|
||||
o.items = [];
|
||||
expect(flattenErrors(await validate(o))).toEqual({});
|
||||
expect(formatErrors(await validate(o))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a realistic API payload', () => {
|
||||
it('maps a snake_case request and answers without the secret', async () => {
|
||||
class SignUp {
|
||||
@JsonReadOnly()
|
||||
id: number;
|
||||
|
||||
@JsonProperty('email_address')
|
||||
@IsString()
|
||||
email: string;
|
||||
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
id: 999, // client must not be able to set this
|
||||
email_address: 'ada@example.com',
|
||||
password: 'hunter2',
|
||||
display_name: 'Ada',
|
||||
});
|
||||
|
||||
const signUp = await fromJson(SignUp, body, { namingStrategy: 'snake_case' });
|
||||
expect(signUp.id).toBeUndefined();
|
||||
expect(signUp.email).toBe('ada@example.com');
|
||||
expect(signUp.password).toBe('hunter2');
|
||||
expect(signUp.displayName).toBe('Ada');
|
||||
|
||||
signUp.id = 1;
|
||||
const response = await toJson(signUp, { namingStrategy: 'snake_case' });
|
||||
expect(JSON.parse(response)).toEqual({
|
||||
id: 1,
|
||||
email_address: 'ada@example.com',
|
||||
display_name: 'Ada',
|
||||
});
|
||||
expect(response).not.toContain('hunter2');
|
||||
});
|
||||
});
|
||||
+40
-1
@@ -1,6 +1,20 @@
|
||||
export class MetadataStorage {
|
||||
private static instance: MetadataStorage;
|
||||
|
||||
|
||||
/**
|
||||
* Bumped whenever any metadata is written.
|
||||
*
|
||||
* Decorators run at class-definition time, so in practice this stops changing once the
|
||||
* application has loaded. Derived structures (see the validation plan cache in utils.ts)
|
||||
* record the version they were built from and rebuild if it moves, which keeps caching
|
||||
* safe even for metadata registered late through `registerDecorator`.
|
||||
*/
|
||||
private _version = 0;
|
||||
|
||||
get version(): number {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
// Maps a prototype to its property names
|
||||
private properties = new WeakMap<any, string[]>();
|
||||
|
||||
@@ -24,6 +38,7 @@ export class MetadataStorage {
|
||||
* Defines metadata for a specific property on a target.
|
||||
*/
|
||||
defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
|
||||
this._version++;
|
||||
if (propertyKey) {
|
||||
let targetMap = this.propertyMetadata.get(target);
|
||||
if (!targetMap) {
|
||||
@@ -63,6 +78,29 @@ export class MetadataStorage {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects a metadata value from every level of the prototype chain that defines one.
|
||||
*
|
||||
* Unlike {@link getMetadata}, which stops at the first (most derived) match, this returns
|
||||
* every value found, ordered from the BASE class down to the most derived one. It exists
|
||||
* for metadata that must accumulate across an inheritance chain rather than be overridden —
|
||||
* validation constraints in particular, where a subclass re-decorating an inherited property
|
||||
* must add to the base class's rules instead of silently replacing them.
|
||||
*/
|
||||
getMetadataChain(key: string, target: any, propertyKey?: string): any[] {
|
||||
const chain: any[] = [];
|
||||
let current = target;
|
||||
while (current) {
|
||||
const value = this.getOwnMetadata(key, current, propertyKey);
|
||||
if (value !== undefined) {
|
||||
// Walking derived -> base, so prepend to end up base-first.
|
||||
chain.unshift(value);
|
||||
}
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets metadata defined directly on the target.
|
||||
*/
|
||||
@@ -78,6 +116,7 @@ export class MetadataStorage {
|
||||
* Registers a property for a target.
|
||||
*/
|
||||
registerProperty(target: any, propertyKey: string) {
|
||||
this._version++;
|
||||
let props = this.properties.get(target);
|
||||
if (!props) {
|
||||
props = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
IsString, IsInt, Min, MinLength, IsIn, ValidateNested, JsonType, JsonProperty,
|
||||
JsonSerialize, JsonDeserialize, JsonSerializer, JsonDeserializer,
|
||||
JsonIgnore, JsonWriteOnly, Validate, JsonMappingError, JsonValidationError, REDACTED,
|
||||
validate, validateSync, validateOrReject, validateOrRejectSync,
|
||||
toPlain, toPlainSync, toJson, toJsonSync,
|
||||
toInstance, toInstanceSync, toInstanceArray, toInstanceArraySync,
|
||||
fromJsonSync, fromJsonArraySync,
|
||||
flattenErrors,
|
||||
} from './index.js';
|
||||
|
||||
class Upper implements JsonSerializer<string, string> {
|
||||
serialize(value: string): string { return value.toUpperCase(); }
|
||||
}
|
||||
class Lower implements JsonDeserializer<string, string> {
|
||||
deserialize(value: string): string { return value.toLowerCase(); }
|
||||
}
|
||||
|
||||
class User {
|
||||
@JsonProperty('display_name')
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
displayName: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
age: number;
|
||||
}
|
||||
|
||||
describe('synchronous API', () => {
|
||||
it('toInstanceSync / fromJsonSync map and validate without a Promise', () => {
|
||||
const user = toInstanceSync(User, { display_name: 'Ada', age: 36 });
|
||||
expect(user).toBeInstanceOf(User);
|
||||
expect(user.displayName).toBe('Ada');
|
||||
|
||||
const parsed = fromJsonSync(User, '{"display_name":"Ada","age":36}');
|
||||
expect(parsed.displayName).toBe('Ada');
|
||||
});
|
||||
|
||||
it('toPlainSync / toJsonSync round-trip', () => {
|
||||
const user = new User();
|
||||
user.displayName = 'Ada';
|
||||
user.age = 36;
|
||||
|
||||
expect(toPlainSync(user)).toEqual({ display_name: 'Ada', age: 36 });
|
||||
expect(toJsonSync(user)).toBe('{"display_name":"Ada","age":36}');
|
||||
});
|
||||
|
||||
it('validateSync returns the same errors as validate', async () => {
|
||||
const user = new User();
|
||||
user.displayName = 'A';
|
||||
user.age = -1;
|
||||
|
||||
const sync = validateSync(user);
|
||||
const async = await validate(user);
|
||||
expect(flattenErrors(sync)).toEqual(flattenErrors(async));
|
||||
expect(Object.keys(flattenErrors(sync))).toEqual(['displayName', 'age']);
|
||||
});
|
||||
|
||||
it('throws JsonValidationError on invalid input, like the async form', () => {
|
||||
expect(() => toInstanceSync(User, { display_name: 'A', age: 5 })).toThrow(JsonValidationError);
|
||||
expect(() => validateOrRejectSync(Object.assign(new User(), { displayName: 'A', age: 1 })))
|
||||
.toThrow(JsonValidationError);
|
||||
});
|
||||
|
||||
it('honours options', () => {
|
||||
const lenient = toInstanceSync(User, { display_name: 'A', age: -1 }, { validate: false });
|
||||
expect(lenient.displayName).toBe('A');
|
||||
|
||||
expect(() => toInstanceSync(User, { display_name: 'Ada', age: 1, stray: 1 }, { unknownKeys: 'error' }))
|
||||
.toThrow(/Unknown property "stray"/);
|
||||
});
|
||||
|
||||
it('array entry points work synchronously', () => {
|
||||
class Item {
|
||||
@IsString()
|
||||
name: string;
|
||||
}
|
||||
expect(toInstanceArraySync(Item, [{ name: 'a' }])[0]!.name).toBe('a');
|
||||
expect(fromJsonArraySync(Item, '[{"name":"b"}]')[0]!.name).toBe('b');
|
||||
expect(() => toInstanceArraySync(Item, {} as any)).toThrow(JsonMappingError);
|
||||
});
|
||||
|
||||
it('runs synchronous custom serializers and deserializers', () => {
|
||||
class Doc {
|
||||
@JsonSerialize(Upper)
|
||||
@JsonDeserialize(Lower)
|
||||
code: string;
|
||||
}
|
||||
const doc = toInstanceSync(Doc, { code: 'ABC' }, { validate: false });
|
||||
expect(doc.code).toBe('abc');
|
||||
expect(toPlainSync(doc)).toEqual({ code: 'ABC' });
|
||||
});
|
||||
|
||||
it('handles nesting, cycles and depth the same way', () => {
|
||||
class Child { @IsString() name: string; }
|
||||
class Parent {
|
||||
@ValidateNested()
|
||||
@JsonType(() => Child)
|
||||
child: Child;
|
||||
}
|
||||
const parent = toInstanceSync(Parent, { child: { name: 'x' } });
|
||||
expect(parent.child).toBeInstanceOf(Child);
|
||||
|
||||
const cyclic: any = new Parent();
|
||||
cyclic.child = cyclic;
|
||||
expect(() => toPlainSync(cyclic, { validate: false })).toThrow(/Circular reference/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('synchronous API refuses asynchronous hooks', () => {
|
||||
class SlowSerializer implements JsonSerializer<string, string> {
|
||||
async serialize(value: string): Promise<string> { return value.toUpperCase(); }
|
||||
}
|
||||
class SlowDeserializer implements JsonDeserializer<string, string> {
|
||||
async deserialize(value: string): Promise<string> { return value.toLowerCase(); }
|
||||
}
|
||||
|
||||
it('reports a clear error for an async serializer and names the async alternative', () => {
|
||||
class Doc {
|
||||
@JsonSerialize(SlowSerializer)
|
||||
code: string;
|
||||
}
|
||||
const doc = new Doc();
|
||||
doc.code = 'abc';
|
||||
|
||||
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
|
||||
expect(() => toPlainSync(doc, { validate: false })).toThrow(/toPlainSync\(\) requires every/);
|
||||
expect(() => toPlainSync(doc, { validate: false })).toThrow(/Use toPlain\(\) instead/);
|
||||
});
|
||||
|
||||
it('reports a clear error for an async deserializer', () => {
|
||||
class Doc {
|
||||
@JsonDeserialize(SlowDeserializer)
|
||||
code: string;
|
||||
}
|
||||
expect(() => toInstanceSync(Doc, { code: 'ABC' }, { validate: false }))
|
||||
.toThrow(/toInstanceSync\(\) requires every/);
|
||||
});
|
||||
|
||||
it('reports a clear error for an async validator', () => {
|
||||
class Doc {
|
||||
@Validate(async (v: any) => v === 'ok')
|
||||
code: string;
|
||||
}
|
||||
const doc = new Doc();
|
||||
doc.code = 'ok';
|
||||
expect(() => validateSync(doc)).toThrow(/validateSync\(\) requires every/);
|
||||
});
|
||||
|
||||
it('does not leave an unhandled rejection behind when it refuses', async () => {
|
||||
class Exploding implements JsonSerializer<string, string> {
|
||||
serialize(): Promise<string> { return Promise.reject(new Error('boom')); }
|
||||
}
|
||||
class Doc {
|
||||
@JsonSerialize(Exploding)
|
||||
code: string;
|
||||
}
|
||||
const doc = new Doc();
|
||||
doc.code = 'x';
|
||||
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
}
|
||||
expect(unhandled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the async API still supports asynchronous hooks', () => {
|
||||
it('awaits an async serializer', async () => {
|
||||
class Slow implements JsonSerializer<string, string> {
|
||||
async serialize(value: string): Promise<string> {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
return value.toUpperCase();
|
||||
}
|
||||
}
|
||||
class Doc {
|
||||
@JsonSerialize(Slow)
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
other: string;
|
||||
}
|
||||
const doc = new Doc();
|
||||
doc.code = 'abc';
|
||||
doc.other = 'kept';
|
||||
|
||||
await expect(toPlain(doc)).resolves.toEqual({ code: 'ABC', other: 'kept' });
|
||||
await expect(toJson(doc)).resolves.toBe('{"code":"ABC","other":"kept"}');
|
||||
});
|
||||
|
||||
it('awaits an async deserializer, including inside a nested type', async () => {
|
||||
class Slow implements JsonDeserializer<string, Date> {
|
||||
async deserialize(value: string): Promise<Date> {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
return new Date(value);
|
||||
}
|
||||
}
|
||||
class Child {
|
||||
@JsonDeserialize(Slow)
|
||||
at: Date;
|
||||
}
|
||||
class Parent {
|
||||
@JsonType(() => Child)
|
||||
child: Child;
|
||||
}
|
||||
|
||||
const parent = await toInstance(Parent, { child: { at: '2026-01-01T00:00:00Z' } }, { validate: false });
|
||||
expect(parent.child.at).toBeInstanceOf(Date);
|
||||
expect(parent.child.at.getUTCFullYear()).toBe(2026);
|
||||
});
|
||||
|
||||
it('awaits an async deserializer inside an array', async () => {
|
||||
class Slow implements JsonDeserializer<string, string> {
|
||||
async deserialize(value: string): Promise<string> { return value.toUpperCase(); }
|
||||
}
|
||||
class Row {
|
||||
@JsonDeserialize(Slow)
|
||||
code: string;
|
||||
}
|
||||
const rows = await toInstanceArray(Row, [{ code: 'a' }, { code: 'b' }], { validate: false });
|
||||
expect(rows.map(r => r.code)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('awaits an async validator and reports its failure', async () => {
|
||||
class Doc {
|
||||
@Validate(async (v: any) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
return v === 'ok';
|
||||
}, { message: 'must be ok' })
|
||||
code: string;
|
||||
}
|
||||
const doc = new Doc();
|
||||
|
||||
doc.code = 'ok';
|
||||
await expect(validate(doc)).resolves.toEqual([]);
|
||||
|
||||
doc.code = 'wrong';
|
||||
const errors = await validate(doc);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]!.constraints['custom']).toBe('must be ok');
|
||||
});
|
||||
|
||||
it('awaits an async validator under each: true and keeps the index', async () => {
|
||||
class Doc {
|
||||
@Validate(async (v: any) => v === 'ok', { each: true })
|
||||
codes: string[];
|
||||
}
|
||||
const doc = new Doc();
|
||||
|
||||
doc.codes = ['ok', 'ok'];
|
||||
await expect(validate(doc)).resolves.toEqual([]);
|
||||
|
||||
doc.codes = ['ok', 'ok', 'bad'];
|
||||
const errors = await validate(doc);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]!.constraints['custom']).toContain('failed at index 2');
|
||||
});
|
||||
|
||||
it('mixes sync and async validators on one object without losing either failure', async () => {
|
||||
class Doc {
|
||||
@IsString()
|
||||
name: any;
|
||||
|
||||
@Validate(async (v: any) => v > 0, { message: 'must be positive' })
|
||||
amount: number;
|
||||
}
|
||||
const doc = new Doc();
|
||||
doc.name = 123;
|
||||
doc.amount = -5;
|
||||
|
||||
const flat = flattenErrors(await validate(doc));
|
||||
expect(flat['name']).toEqual(['name must be a string']);
|
||||
expect(flat['amount']).toEqual(['must be positive']);
|
||||
});
|
||||
|
||||
it('prunes provisional entries for async validators that pass', async () => {
|
||||
class Doc {
|
||||
@Validate(async () => true)
|
||||
a: string;
|
||||
|
||||
@Validate(async () => true)
|
||||
b: string;
|
||||
}
|
||||
await expect(validate(new Doc())).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('validateOrReject still rejects on an async failure', async () => {
|
||||
class Doc {
|
||||
@Validate(async () => false)
|
||||
code: string;
|
||||
}
|
||||
await expect(validateOrReject(new Doc())).rejects.toThrow(JsonValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('write-only redaction in validation errors', () => {
|
||||
it('redacts a @JsonWriteOnly value but keeps the failure message', async () => {
|
||||
class Credentials {
|
||||
@IsString()
|
||||
email: string;
|
||||
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
password: string;
|
||||
}
|
||||
|
||||
const creds = new Credentials();
|
||||
creds.email = 'ada@example.com';
|
||||
creds.password = 'hunter2';
|
||||
|
||||
const errors = await validate(creds);
|
||||
const failure = errors.find(e => e.property === 'password')!;
|
||||
|
||||
expect(failure.value).toBe(REDACTED);
|
||||
expect(failure.constraints['minLength']).toContain('12');
|
||||
expect(JSON.stringify(errors)).not.toContain('hunter2');
|
||||
});
|
||||
|
||||
it('redacts @JsonIgnore values too', async () => {
|
||||
class Record {
|
||||
@JsonIgnore()
|
||||
@IsString()
|
||||
internalSecret: any;
|
||||
}
|
||||
const record = new Record();
|
||||
record.internalSecret = 999;
|
||||
|
||||
const errors = await validate(record);
|
||||
expect(errors[0]!.value).toBe(REDACTED);
|
||||
expect(JSON.stringify(errors)).not.toContain('999');
|
||||
});
|
||||
|
||||
it('leaves ordinary property values in place', async () => {
|
||||
class Doc {
|
||||
@IsString()
|
||||
name: any;
|
||||
}
|
||||
const doc = new Doc();
|
||||
doc.name = 42;
|
||||
|
||||
const errors = await validate(doc);
|
||||
expect(errors[0]!.value).toBe(42);
|
||||
});
|
||||
|
||||
it('keeps the secret out of a thrown JsonValidationError', async () => {
|
||||
class SignUp {
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
password: string;
|
||||
}
|
||||
|
||||
await expect(toInstance(SignUp, { password: 'short' })).rejects.toThrow(JsonValidationError);
|
||||
try {
|
||||
await toInstance(SignUp, { password: 'short' });
|
||||
} catch (error) {
|
||||
expect(String((error as JsonValidationError).toString())).not.toContain('short');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts in the synchronous path as well', () => {
|
||||
class Credentials {
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
password: string;
|
||||
}
|
||||
const creds = new Credentials();
|
||||
creds.password = 'hunter2';
|
||||
|
||||
expect(validateSync(creds)[0]!.value).toBe(REDACTED);
|
||||
});
|
||||
|
||||
it('does not redact a value that merely sits next to a secret', async () => {
|
||||
class Form {
|
||||
@IsIn(['a', 'b'])
|
||||
choice: string;
|
||||
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
const form = new Form();
|
||||
form.choice = 'zzz';
|
||||
form.token = 'secret-token';
|
||||
|
||||
const errors = await validate(form);
|
||||
expect(errors.find(e => e.property === 'choice')!.value).toBe('zzz');
|
||||
expect(JSON.stringify(errors)).not.toContain('secret-token');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -30,7 +30,7 @@ describe('Standalone Utility Functions', () => {
|
||||
user.age = '30' as any;
|
||||
const errors2 = await validate(user);
|
||||
expect(errors2).toHaveLength(1);
|
||||
expect(errors2[0].property).toBe('age');
|
||||
expect(errors2[0]!.property).toBe('age');
|
||||
});
|
||||
|
||||
it('should transform to plain object directly', async () => {
|
||||
|
||||
+938
-184
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -5,5 +5,6 @@
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist/cjs",
|
||||
"declaration": true
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,5 +4,6 @@
|
||||
"module": "NodeNext",
|
||||
"outDir": "dist/esm",
|
||||
"declaration": true
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
|
||||
}
|
||||
|
||||
+3
-1
@@ -35,6 +35,8 @@
|
||||
|
||||
"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/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user