Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551d53912f | ||
|
|
d56c47d55c | ||
|
|
c696f10f74 | ||
|
|
305be7a16f | ||
|
|
297d3bfe77 | ||
|
|
50c08aa556 | ||
|
|
6d30182ca8 | ||
|
|
203e5ec27b | ||
|
|
6e458fdd43 | ||
|
|
26cd6b5707 | ||
|
|
2acdf3b360 | ||
|
|
83ad2a289d | ||
|
|
44c8f28f4b | ||
|
|
666762a146 | ||
|
|
6d04b43964 | ||
|
|
66e19f690f |
@@ -2,17 +2,19 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, develop ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main, develop ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
verify:
|
||||||
|
name: Node ${{ matrix.node-version }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [18.x, 20.x, 22.x]
|
node-version: [20.x, 22.x, 24.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -25,5 +27,15 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Test
|
||||||
|
run: npm run test:coverage
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Verify published entry points load
|
||||||
|
run: |
|
||||||
|
node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');"
|
||||||
|
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
|
||||||
- name: Run Demo
|
- name: Run Demo
|
||||||
run: npm run demo
|
run: npm run demo
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
# Node modules and dependency files
|
# Node modules and dependency files
|
||||||
|
# NOTE: package-lock.json is intentionally committed — CI installs with `npm ci`,
|
||||||
|
# which requires a lockfile to be present in the repository.
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/package-lock.json
|
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
/dist/
|
/dist/
|
||||||
|
|||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-08-04
|
||||||
|
|
||||||
|
> The project stays on 0.x while nothing has been published: under semver that signals the
|
||||||
|
> API may still move, which is honest for software with no real-world users. A breaking
|
||||||
|
> change is therefore a minor bump, which is why this is 0.2.0 rather than 2.0.0.
|
||||||
|
|
||||||
|
**Breaking.** Cereale moves to TC39 standard decorators, which is what makes validation rules
|
||||||
|
type-checked against the fields they are attached to.
|
||||||
|
|
||||||
|
### The headline
|
||||||
|
|
||||||
|
A rule that does not fit its field is now a compile error:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
class User {
|
||||||
|
@IsString() name!: string; // fine
|
||||||
|
@IsString() age!: number; // Type 'number' is not assignable to type 'string'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy decorators receive `(target: any, key: string)` and lose the field's type entirely, so
|
||||||
|
this was impossible in v1. Standard decorators receive `ClassFieldDecoratorContext<This, Value>`,
|
||||||
|
which carries it. Checked rules include:
|
||||||
|
|
||||||
|
- scalar rules against scalar fields (`@Min` on a string is rejected)
|
||||||
|
- `{ each: true }` against arrays (`@IsString({ each: true })` demands a `string[]`, and a bare
|
||||||
|
`@IsString()` on a `string[]` is rejected)
|
||||||
|
- `@JsonType(() => Address)` against the field's class
|
||||||
|
- `@JsonSerialize` / `@JsonDeserialize` against the field's type
|
||||||
|
- `@IsIn([...])` and `@IsEnum(E)` against the field's value type
|
||||||
|
|
||||||
|
17 tests invoke the real compiler to assert these stay rejected.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- Remove `"experimentalDecorators": true`; add `"ESNext.Decorators"` to `lib`.
|
||||||
|
- `registerDecorator({ target, propertyName, validator })` is replaced by
|
||||||
|
`defineRule(Class, 'field', constraint)`.
|
||||||
|
- Decorators cannot be applied to `abstract` fields. Declare the field concretely in the base.
|
||||||
|
- Field types may need tightening where a rule narrows them: `@IsIn(['a','b']) x!: string`
|
||||||
|
becomes `x!: 'a' | 'b'`.
|
||||||
|
- `@JsonPolymorphic` takes its base type explicitly to check subtypes:
|
||||||
|
`@JsonPolymorphic<Media>('type', [...])`.
|
||||||
|
- `@ValidateIf` takes the class as a type argument: `@ValidateIf<Movie>(m => ...)`.
|
||||||
|
|
||||||
|
Everything else — the engine, options, naming strategies, access control, error helpers, the
|
||||||
|
sync API — is unchanged.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `metadata-storage.ts` and its WeakMap singleton. Metadata now lives on `context.metadata`,
|
||||||
|
the language's own mechanism, which also removes the dual ESM/CJS double-singleton hazard.
|
||||||
|
- `registerDecorator`, replaced by `defineRule`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Inheritance merging is now structural rather than reconstructed: `context.metadata` inherits
|
||||||
|
through the prototype chain, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot
|
||||||
|
reoccur by construction. Identical inherited rules are still collapsed so re-stating a rule
|
||||||
|
on an override does not double-report.
|
||||||
|
|
||||||
|
### Toolchain
|
||||||
|
|
||||||
|
Standard decorators are transformed by `tsc` and by esbuild; **oxc does not implement them
|
||||||
|
yet**. The library builds with `tsc` and consumers bundling with esbuild or Vite are fine, but
|
||||||
|
the test runner (Vitest 4, which uses oxc) needs an esbuild transform plugin — see
|
||||||
|
`vitest.config.ts`. Projects on an oxc-based toolchain should stay on 0.1.x for now.
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-08-05
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -1,15 +1,58 @@
|
|||||||
# Cereale
|
# Cereale
|
||||||
|
|
||||||
Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support.
|
**Validated domain objects, not validated data.**
|
||||||
|
|
||||||
|
Cereale maps JSON onto your own classes and gives you back real instances — with your methods,
|
||||||
|
your inheritance, your `instanceof` checks — and type-checks the validation rules against the
|
||||||
|
fields they are attached to. Zero runtime dependencies.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class User {
|
||||||
|
@JsonProperty('display_name')
|
||||||
|
@IsString() @MinLength(2)
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsInt() @Min(0)
|
||||||
|
age!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
age2!: number; // ← compile error: Type 'number' is not assignable to type 'string'
|
||||||
|
|
||||||
|
greet() { return `Hi ${this.displayName}`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = fromJsonSync(User, body); // a real User
|
||||||
|
user.greet(); // your methods are still there
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why not Zod?
|
||||||
|
|
||||||
|
Zod is excellent, and if a plain validated object is what you want, use it. The difference is
|
||||||
|
what you get back:
|
||||||
|
|
||||||
|
| | Zod | Cereale |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Result of parsing | an anonymous object matching a schema | an instance of **your class** |
|
||||||
|
| Methods, getters, inheritance | none — data only | preserved |
|
||||||
|
| Where the type comes from | inferred from the schema | your class declaration |
|
||||||
|
| Rules checked against the type | not applicable — schema *is* the type | **yes, at compile time** |
|
||||||
|
| Bidirectional mapping (renaming both ways) | not the focus | first-class |
|
||||||
|
|
||||||
|
Cereale does not infer your type from a schema, so you still write the field type and the rule.
|
||||||
|
What it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not
|
||||||
|
compile. That is the guarantee class-validator has never offered.
|
||||||
|
|
||||||
|
Reach for Cereale when your domain model is already a class — a NestJS provider, a TypeORM
|
||||||
|
entity, anything with behaviour attached. Reach for Zod when you just want the data.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Spring-like Decorators:** Familiar `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
|
- **Strongly typed decorators:** a rule that does not fit its field is a compile error.
|
||||||
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
|
- **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes.
|
||||||
- **Polymorphism Support:** Native handling of polymorphic types via discriminators.
|
- **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions.
|
||||||
- **Integrated Validation:** Automatically validates objects during serialization and deserialization.
|
- **Access control:** keep passwords out of responses and server-owned ids out of requests.
|
||||||
- **Type Safety:** Fully written in TypeScript for excellent developer experience.
|
- **Sync and async:** every entry point has a synchronous twin.
|
||||||
- **Zero Dependencies:** Extremely lightweight and fast.
|
- **Zero dependencies**, ESM + CJS, Node 20+.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -17,110 +60,93 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
|
|||||||
npm install cereale
|
npm install cereale
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your `tsconfig.json`:
|
Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` flag:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
"target": "ES2022",
|
||||||
"emitDecoratorMetadata": true,
|
"lib": ["ESNext", "ESNext.Decorators"]
|
||||||
"target": "ES2025"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and
|
||||||
|
`emitDecoratorMetadata` is not read.
|
||||||
|
|
||||||
|
> **Toolchain note.** Standard decorators are transformed by `tsc` and by esbuild (so Vite
|
||||||
|
> works). They are **not** yet transformed by oxc; if your toolchain uses it, decorator syntax
|
||||||
|
> will fail to parse. The 0.1.x line, which uses legacy decorators, remains available for
|
||||||
|
> those setups.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Define your Models
|
### 1. Define your model
|
||||||
|
|
||||||
Use decorators to define how your data should be transformed and validated.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString, IsDate, IsInt, Min, ValidateNested, JsonType,
|
||||||
IsInt,
|
JsonProperty, JsonWriteOnly, JsonPolymorphic,
|
||||||
Min,
|
|
||||||
IsDate,
|
|
||||||
ValidateNested,
|
|
||||||
JsonSerialize,
|
|
||||||
JsonDeserialize,
|
|
||||||
JsonPolymorphic,
|
|
||||||
JsonSerializer,
|
|
||||||
JsonDeserializer
|
|
||||||
} from 'cereale';
|
} from 'cereale';
|
||||||
|
|
||||||
// Custom Date Serializer
|
class Address {
|
||||||
class DateSerializer implements JsonSerializer<Date, string> {
|
@IsString() street!: string;
|
||||||
serialize(value: Date): string {
|
@IsString() city!: string;
|
||||||
return value.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DateDeserializer implements JsonDeserializer<string, Date> {
|
format() { return `${this.street}, ${this.city}`; }
|
||||||
deserialize(value: string): Date {
|
|
||||||
return new Date(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
@IsString()
|
// Standard decorators cannot decorate an `abstract` member, so declare it concretely.
|
||||||
abstract type: string;
|
@IsString() type: string = '';
|
||||||
|
@IsString() title: string = '';
|
||||||
@IsString()
|
|
||||||
title: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
type = 'book';
|
@IsString() override type = 'book';
|
||||||
|
@IsString() author!: string;
|
||||||
@IsString()
|
|
||||||
author: string;
|
|
||||||
|
|
||||||
@JsonSerialize(DateSerializer)
|
@JsonProperty('published_at')
|
||||||
@JsonDeserialize(DateDeserializer)
|
|
||||||
@IsDate()
|
@IsDate()
|
||||||
publishedAt: Date;
|
publishedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
@IsString()
|
@IsString() name!: string;
|
||||||
name: string;
|
|
||||||
|
@ValidateNested() @JsonType(() => Address)
|
||||||
|
address!: Address; // the class must match the field
|
||||||
|
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@JsonPolymorphic('type', [
|
@JsonPolymorphic<Media>('type', [{ value: Book, name: 'book' }])
|
||||||
{ value: Book, name: 'book' }
|
items!: Media[];
|
||||||
])
|
|
||||||
items: Media[];
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Map JSON with Validation
|
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s rules as
|
||||||
|
well as its own, and re-stating a rule on an override does not report it twice.
|
||||||
|
|
||||||
Use standalone utility functions to handle the conversion process directly.
|
### 2. Map JSON, synchronously or not
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { fromJson, toJson, JsonValidationError } from 'cereale';
|
import { fromJsonSync, toJsonSync, JsonValidationError, flattenErrors } from 'cereale';
|
||||||
|
|
||||||
async function main() {
|
try {
|
||||||
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
|
const library = fromJsonSync(Library, json);
|
||||||
|
library.address.format(); // your method, on a real Address
|
||||||
try {
|
library.items[0] instanceof Book; // true
|
||||||
// Deserialize JSON to Class Instance
|
console.log(toJsonSync(library));
|
||||||
const library = await fromJson(Library, json);
|
} catch (error) {
|
||||||
console.log(library.name); // "Central Library"
|
if (error instanceof JsonValidationError) {
|
||||||
console.log(library.items[0] instanceof Book); // true
|
console.error(flattenErrors(error.errors));
|
||||||
|
// { "items[0].title": ["title must be a string"] }
|
||||||
// Serialize Class Instance back to JSON
|
|
||||||
const outputJson = await toJson(library);
|
|
||||||
console.log(outputJson);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof JsonValidationError) {
|
|
||||||
console.error("Validation failed:", error.errors);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every function has an async form too (`fromJson`, `toJson`, …) for when a serializer,
|
||||||
|
deserializer or validator of yours returns a Promise.
|
||||||
|
|
||||||
### 3. Modern Web Frameworks (Request Integration)
|
### 3. Modern Web Frameworks (Request Integration)
|
||||||
|
|
||||||
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
||||||
@@ -135,20 +161,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
|
## API Reference
|
||||||
|
|
||||||
### Decorators
|
### Mapping Decorators
|
||||||
|
|
||||||
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Specifies a custom serializer for a property.
|
- `@JsonProperty(name: string)`: Renames the property in JSON, both directions.
|
||||||
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Specifies a custom deserializer for a property.
|
- `@JsonAlias(...names: string[])`: Extra names accepted on input only.
|
||||||
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations.
|
- `@JsonIgnore()`: Excludes the property from mapping entirely.
|
||||||
- `@JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])`: Configures polymorphic transformation based on a discriminator field.
|
- `@JsonReadOnly()`: Serialized, but never populated from incoming JSON.
|
||||||
|
- `@JsonWriteOnly()`: Populated from incoming JSON, but never serialized.
|
||||||
|
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Custom serializer for a property. Skipped when the value is `null`/`undefined`.
|
||||||
|
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Custom deserializer for a property.
|
||||||
|
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations. Applies element-wise to arrays.
|
||||||
|
- `@JsonPolymorphic(discriminator, subTypes, options?)`: Polymorphic transformation based on a discriminator field. `options` accepts `{ onUnknown: 'keep' | 'error' }` (default `keep`, which preserves the raw value) and `{ fallback: ClassConstructor }`.
|
||||||
|
|
||||||
#### Validation Decorators
|
### Validation Decorators
|
||||||
|
|
||||||
Most validation decorators accept an optional `ValidationOptions` object:
|
Most validation decorators accept an optional `ValidationOptions` object:
|
||||||
- `each: boolean`: Apply validation to each element of an array.
|
- `each: boolean`: Apply validation to each element of an array.
|
||||||
- `message: string | ((args: ValidationArguments) => string)`: Custom error message.
|
- `message: string | ((args: ValidationArguments) => string)`: Custom error message, reported verbatim.
|
||||||
|
|
||||||
| Decorator | Description |
|
| Decorator | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -156,46 +285,95 @@ Most validation decorators accept an optional `ValidationOptions` object:
|
|||||||
| `@IsNumber()` | Checks if value is a number (and not NaN). |
|
| `@IsNumber()` | Checks if value is a number (and not NaN). |
|
||||||
| `@IsInt()` | Checks if value is an integer. |
|
| `@IsInt()` | Checks if value is an integer. |
|
||||||
| `@IsBoolean()` | Checks if value is a boolean. |
|
| `@IsBoolean()` | Checks if value is a boolean. |
|
||||||
|
| `@IsBigInt()` | Checks if value is a bigint. |
|
||||||
| `@IsObject()` | Checks if value is an object (not null/array). |
|
| `@IsObject()` | Checks if value is an object (not null/array). |
|
||||||
| `@IsDate()` | Checks if value is a valid Date object. |
|
| `@IsDate()` | Checks if value is a valid Date object. |
|
||||||
| `@IsDefined()` | Checks if value is not null or undefined. |
|
| `@IsDefined()` | Checks if value is not null or undefined. |
|
||||||
| `@IsOptional()` | Skips other validations if value is null/undefined. |
|
| `@IsOptional()` | Skips other validations if value is null/undefined. |
|
||||||
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
|
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
|
||||||
| `@Min(value)` | Checks if number is >= value. |
|
| `@IsEmpty()` | Checks if value is null/undefined/`''`/`[]`/`{}`. |
|
||||||
| `@Max(value)` | Checks if number is <= value. |
|
| `@Equals(value)` / `@NotEquals(value)` | Strict equality against a fixed value. |
|
||||||
| `@Positive()` | Checks if number is > 0. |
|
| `@IsEnum(enumObject)` | Checks membership of a TypeScript enum. |
|
||||||
| `@Negative()` | Checks if number is < 0. |
|
| `@IsInstance(Class)` | Checks `value instanceof Class`. |
|
||||||
| `@MinLength(len)` | Checks if string length is >= len. |
|
| `@Min(value)` / `@Max(value)` | Numeric bounds. |
|
||||||
| `@MaxLength(len)` | Checks if string length is <= len. |
|
| `@Positive()` / `@Negative()` | Checks sign. |
|
||||||
|
| `@IsDivisibleBy(n)` | Checks `value % n === 0`. |
|
||||||
|
| `@IsPort()` | Integer in 0–65535, as number or numeric string. |
|
||||||
|
| `@IsLatitude()` / `@IsLongitude()` | Geographic bounds. |
|
||||||
|
| `@MinLength(len)` / `@MaxLength(len)` | String length bounds. |
|
||||||
|
| `@Length(min, max?)` | Both bounds in one rule. |
|
||||||
|
| `@IsAlpha()` / `@IsAlphanumeric()` | Character-class checks. |
|
||||||
|
| `@IsLowercase()` / `@IsUppercase()` | Case checks. |
|
||||||
|
| `@IsNumberString()` | String that parses as a finite number. |
|
||||||
|
| `@Contains(s)` / `@NotContains(s)` | Substring checks. |
|
||||||
|
| `@StartsWith(s)` / `@EndsWith(s)` | Affix checks. |
|
||||||
| `@Email()` | Checks if string is a valid email. |
|
| `@Email()` | Checks if string is a valid email. |
|
||||||
| `@IsUrl()` | Checks if string is a valid URL. |
|
| `@IsUrl()` | Checks if string is a valid URL. |
|
||||||
| `@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. |
|
| `@IsArray()` | Checks if value is an array. |
|
||||||
| `@ArrayNotEmpty()`| Checks if array is not empty. |
|
| `@ArrayNotEmpty()` | Checks if array is not empty. |
|
||||||
| `@ArrayMinSize(n)`| Checks if array has at least n elements. |
|
| `@ArrayMinSize(n)` / `@ArrayMaxSize(n)` | Array size bounds. |
|
||||||
| `@ArrayMaxSize(n)`| Checks if array has at most n elements. |
|
| `@ArrayUnique(keyFn?)` | Checks for duplicate elements. |
|
||||||
| `@IsIn(values)` | Checks if value is in the allowed list. |
|
| `@ArrayContains(vals)` / `@ArrayNotContains(vals)` | Membership checks. |
|
||||||
| `@IsNotIn(vals)` | Checks if value is NOT in the list. |
|
| `@IsIn(values)` / `@IsNotIn(values)` | Allow/deny lists. |
|
||||||
| `@ValidateNested()`| Recursively validates nested objects/arrays. |
|
| `@ValidateNested(options?)` | Recursively validates nested objects/arrays. |
|
||||||
|
| `@ValidateIf(o => boolean)` | Skips this property's rules when the condition is false. |
|
||||||
|
| `@Allow()` | Declares a property with no rules of its own. |
|
||||||
|
| `@Validate(validator, constraints?, options?)` | Applies a custom validator class or function. |
|
||||||
|
|
||||||
|
Write your own with `registerDecorator({ name, target, propertyName, validator })`.
|
||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
|
|
||||||
- `toJson(obj: any)`: Validates and serializes an instance to a JSON string (Returns `Promise<string>`).
|
- `toJson(obj, options?)`: Validates and serializes an instance to a JSON string (`Promise<string>`).
|
||||||
- `toPlain(obj: any)`: Validates and transforms an instance to a plain object (Returns `Promise<any>`).
|
- `toPlain(obj, options?)`: Validates and transforms an instance to a plain object (`Promise<any>`).
|
||||||
- `fromJson(clazz: ClassConstructor, json: string)`: Parses JSON and transforms it to a validated class instance (Returns `Promise<T>`).
|
- `fromJson(clazz, json, options?)`: Parses JSON to a validated class instance (`Promise<T>`).
|
||||||
- `toInstance(clazz: ClassConstructor, plain: any)`: Transforms a plain object to a validated class instance (Returns `Promise<T>`).
|
- `fromJsonArray(clazz, json, options?)`: Same, for a JSON array (`Promise<T[]>`).
|
||||||
- `fromRequest(clazz: ClassConstructor, request: Request)`: Extracts JSON from a Fetch `Request` and transforms it to a validated instance (Returns `Promise<T>`).
|
- `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
|
||||||
- `validate(obj: any)`: Performs full validation on an object/instance (Returns `Promise<ValidationError[]>`).
|
- `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
|
||||||
|
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
|
||||||
|
- `validate(obj, 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
|
## Framework Integrations
|
||||||
|
|
||||||
Cereale is designed to be compatible with all trending web frameworks.
|
|
||||||
|
|
||||||
### Hono / Next.js / Cloudflare Workers
|
### Hono / Next.js / Cloudflare Workers
|
||||||
Use `fromRequest` for seamless integration with the Fetch `Request` API.
|
Use `fromRequest` for seamless integration with the Fetch `Request` API.
|
||||||
|
|
||||||
### NestJS
|
### NestJS
|
||||||
You can use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`.
|
Use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { toInstance } from 'cereale';
|
import { toInstance } from 'cereale';
|
||||||
@@ -208,25 +386,65 @@ async create(@Body() body: any) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Express / Fastify
|
### Express / Fastify
|
||||||
Easily integrate with traditional Node.js frameworks.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { toInstance, toPlain } from 'cereale';
|
import { toInstance, toPlain, JsonValidationError, flattenErrors } from 'cereale';
|
||||||
|
|
||||||
app.post('/user', async (req, res) => {
|
app.post('/user', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await toInstance(User, req.body);
|
const user = await toInstance(User, req.body);
|
||||||
res.json(await toPlain(user));
|
res.json(await toPlain(user));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json(err);
|
if (err instanceof JsonValidationError) {
|
||||||
|
return res.status(400).json({ errors: flattenErrors(err.errors) });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
||||||
|
cereale guarantees they agree. If you want the type derived from a schema, that is Zod's
|
||||||
|
model, not this one.
|
||||||
|
- **`abstract` fields cannot be decorated.** Standard decorators do not apply to abstract
|
||||||
|
members. Declare the field concretely in the base class instead.
|
||||||
|
- **oxc does not transform standard decorators yet.** `tsc` and esbuild do.
|
||||||
|
|
||||||
|
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
||||||
|
the cycle with `@JsonIgnore()` on the back-reference.
|
||||||
|
- **`validate()` on a plain object** returns no errors: rules live on the class, so validate
|
||||||
|
the instance you get back from `toInstance`, not the raw payload.
|
||||||
|
- **Renaming is not backwards-compatible by itself.** Once a property carries
|
||||||
|
`@JsonProperty`, its original name is no longer accepted on input — add `@JsonAlias` to
|
||||||
|
keep older clients working.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
|
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
|
||||||
|
|
||||||
## License
|
## 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>
|
<div>
|
||||||
<h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3>
|
<h3 class="font-bold text-lg mb-4 text-indigo-600">Mapping</h3>
|
||||||
<ul class="space-y-2 text-slate-600">
|
<ul class="space-y-2 text-slate-600">
|
||||||
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonProperty('first_name')</code></li>
|
||||||
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonAlias(...names)</code></li>
|
||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonSerialize(cls)</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonSerialize(cls)</code></li>
|
||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonDeserialize(cls)</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonDeserialize(cls)</code></li>
|
||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonType(() => cls)</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonType(() => cls)</code></li>
|
||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonPolymorphic(field, types)</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonPolymorphic(field, types)</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-lg mb-4 text-indigo-600">Access Control</h3>
|
||||||
|
<ul class="space-y-2 text-slate-600">
|
||||||
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonIgnore()</code></li>
|
||||||
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonReadOnly()</code></li>
|
||||||
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@JsonWriteOnly()</code></li>
|
||||||
|
<li class="text-sm pt-2">Naming strategies: <code class="text-sm bg-slate-100 p-1 rounded">snake_case</code>, <code class="text-sm bg-slate-100 p-1 rounded">kebab-case</code>, …</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3>
|
<h3 class="font-bold text-lg mb-4 text-purple-600">Basic Validation</h3>
|
||||||
<ul class="space-y-2 text-slate-600">
|
<ul class="space-y-2 text-slate-600">
|
||||||
@@ -158,7 +169,9 @@
|
|||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@Min(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Max(n)</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@Min(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@Max(n)</code></li>
|
||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinLength(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@MaxLength(n)</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinLength(n)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@MaxLength(n)</code></li>
|
||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@Email()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsUrl()</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@Email()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsUrl()</code></li>
|
||||||
<li><code class="text-sm bg-slate-100 p-1 rounded">@ValidateNested()</code></li>
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@IsUUID(v?)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@IsEnum(e)</code></li>
|
||||||
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@MinDate(d)</code>, <code class="text-sm bg-slate-100 p-1 rounded">@ArrayUnique()</code></li>
|
||||||
|
<li><code class="text-sm bg-slate-100 p-1 rounded">@ValidateNested()</code>, <code class="text-sm bg-slate-100 p-1 rounded">@ValidateIf(fn)</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,9 +188,10 @@
|
|||||||
<script>
|
<script>
|
||||||
const initialCode = `// 1. Define your model with decorators
|
const initialCode = `// 1. Define your model with decorators
|
||||||
class User {
|
class User {
|
||||||
|
@JsonProperty('display_name')
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(3)
|
@MinLength(3)
|
||||||
name;
|
displayName;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(18)
|
@Min(18)
|
||||||
@@ -186,26 +200,36 @@ class User {
|
|||||||
@Email()
|
@Email()
|
||||||
email;
|
email;
|
||||||
|
|
||||||
constructor(name, age, email) {
|
// Accepted from a request, never sent back out
|
||||||
this.name = name;
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
password;
|
||||||
|
|
||||||
|
constructor(displayName, age, email, password) {
|
||||||
|
this.displayName = displayName;
|
||||||
this.age = age;
|
this.age = age;
|
||||||
this.email = email;
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function demo() {
|
async function demo() {
|
||||||
console.log("--- Validating valid user ---");
|
console.log("--- Mapping a valid user ---");
|
||||||
const user = new User("Alice", 25, "alice@example.com");
|
const user = new User("Alice", 25, "alice@example.com", "hunter2");
|
||||||
const json = await JsonMapper.toJson(user);
|
const json = await toJson(user);
|
||||||
console.log("JSON Output:", json);
|
console.log("JSON Output:", json);
|
||||||
|
console.log("Password withheld:", !json.includes("hunter2"));
|
||||||
|
|
||||||
console.log("\\n--- Testing validation failure ---");
|
console.log("\\n--- Reading it back ---");
|
||||||
|
const parsed = await fromJson(User, json, { validate: false });
|
||||||
|
console.log("displayName read from display_name:", parsed.displayName);
|
||||||
|
|
||||||
|
console.log("\\n--- Reporting validation failures ---");
|
||||||
try {
|
try {
|
||||||
const invalidJson = '{"name": "Bo", "age": 15, "email": "not-an-email"}';
|
await fromJson(User, '{"display_name": "Bo", "age": 15, "email": "nope", "password": "x"}');
|
||||||
await JsonMapper.fromJson(User, invalidJson);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Caught Error:", error.message);
|
console.log("Caught:", error.message);
|
||||||
console.log("Validation Errors:", JSON.stringify(error.errors, null, 2));
|
console.log("Flattened:", flattenErrors(error.errors));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,36 +271,12 @@ demo();`;
|
|||||||
]
|
]
|
||||||
}).code;
|
}).code;
|
||||||
|
|
||||||
// Create a function with the library symbols in scope
|
// Put every library export in scope. Derived from the bundle rather than
|
||||||
const {
|
// hand-listed, so a new decorator is usable here the moment it is exported.
|
||||||
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested,
|
const exportNames = Object.keys(Cereale).filter(name => /^[A-Za-z_$][\w$]*$/.test(name));
|
||||||
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
|
|
||||||
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
|
|
||||||
Positive, Negative,
|
|
||||||
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
|
|
||||||
JsonValidationError
|
|
||||||
} = Cereale;
|
|
||||||
|
|
||||||
const run = new Function(
|
const run = new Function('console', ...exportNames, transpiled);
|
||||||
'console',
|
await run({ log: logToOutput }, ...exportNames.map(name => Cereale[name]));
|
||||||
'IsString', 'IsInt', 'Min', 'Max', 'IsEmail', 'IsArray', 'IsDate', 'IsOptional', 'ValidateNested',
|
|
||||||
'IsBoolean', 'IsNumber', 'IsObject', 'IsDefined', 'IsNotEmpty', 'MinLength', 'MaxLength',
|
|
||||||
'Email', 'IsUrl', 'Matches', 'ArrayMinSize', 'ArrayMaxSize', 'ArrayNotEmpty', 'IsIn', 'IsNotIn',
|
|
||||||
'Positive', 'Negative',
|
|
||||||
'JsonSerialize', 'JsonDeserialize', 'JsonPolymorphic', 'JsonType', 'JsonMapper',
|
|
||||||
'JsonValidationError',
|
|
||||||
transpiled
|
|
||||||
);
|
|
||||||
|
|
||||||
await run(
|
|
||||||
{ log: logToOutput },
|
|
||||||
IsString, IsInt, Min, Max, IsEmail, IsArray, IsDate, IsOptional, ValidateNested,
|
|
||||||
IsBoolean, IsNumber, IsObject, IsDefined, IsNotEmpty, MinLength, MaxLength,
|
|
||||||
Email, IsUrl, Matches, ArrayMinSize, ArrayMaxSize, ArrayNotEmpty, IsIn, IsNotIn,
|
|
||||||
Positive, Negative,
|
|
||||||
JsonSerialize, JsonDeserialize, JsonPolymorphic, JsonType, JsonMapper,
|
|
||||||
JsonValidationError
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
outputElement.textContent += 'Error: ' + err.message + '\\n';
|
outputElement.textContent += 'Error: ' + err.message + '\\n';
|
||||||
if (err.stack) {
|
if (err.stack) {
|
||||||
|
|||||||
Generated
+3258
File diff suppressed because it is too large
Load Diff
+19
-7
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.0.1",
|
"version": "0.2.0",
|
||||||
"description": "Spring-like decorators for JSON mapping and validation in TypeScript",
|
"description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"module": "./dist/esm/index.js",
|
"module": "./dist/esm/index.js",
|
||||||
@@ -13,19 +13,28 @@
|
|||||||
"require": "./dist/cjs/index.js"
|
"require": "./dist/cjs/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": [
|
||||||
|
"./dist/esm/metadata.js",
|
||||||
|
"./dist/cjs/metadata.js"
|
||||||
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
|
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
|
||||||
|
"build:docs": "esbuild src/index.ts --bundle --format=iife --global-name=Cereale --minify --tsconfig=tsconfig.json --outfile=docs/cereale.js",
|
||||||
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"prepublishOnly": "npm run build"
|
"verify": "npm run type-check && npm run lint && npm run test && npm run build",
|
||||||
|
"prepublishOnly": "npm run verify"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -33,11 +42,13 @@
|
|||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"json",
|
"json",
|
||||||
"mapping",
|
|
||||||
"validation",
|
"validation",
|
||||||
"decorators",
|
"decorators",
|
||||||
"spring",
|
"typescript",
|
||||||
"typescript"
|
"zod-alternative",
|
||||||
|
"dto",
|
||||||
|
"serialization",
|
||||||
|
"class-validator"
|
||||||
],
|
],
|
||||||
"author": "Avalon Vanguard",
|
"author": "Avalon Vanguard",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -49,6 +60,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vitest/coverage-v8": "^4.1.4",
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+21
-32
@@ -13,7 +13,7 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
IsNotIn,
|
IsNotIn,
|
||||||
Validate,
|
Validate,
|
||||||
registerDecorator,
|
defineRule,
|
||||||
JsonType,
|
JsonType,
|
||||||
JsonPolymorphic,
|
JsonPolymorphic,
|
||||||
JsonMapper,
|
JsonMapper,
|
||||||
@@ -238,27 +238,20 @@ describe('Additional Decorators', () => {
|
|||||||
t.val = 'wrong';
|
t.val = 'wrong';
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['CustomValidator']).toBe('val must be correct');
|
expect(errors[0]!.constraints['CustomValidator']).toBe('val must be correct');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registerDecorator', () => {
|
describe('registerDecorator', () => {
|
||||||
it('should register a custom decorator with functional validator', async () => {
|
it('should register a custom decorator with functional validator', async () => {
|
||||||
function IsEven() {
|
|
||||||
return function (object: any, propertyName: string) {
|
|
||||||
registerDecorator({
|
|
||||||
name: 'isEven',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
validator: (value: any) => typeof value === 'number' && value % 2 === 0,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class Test {
|
class Test {
|
||||||
@IsEven()
|
val: number = 0;
|
||||||
val: number;
|
|
||||||
}
|
}
|
||||||
|
defineRule(Test, 'val', {
|
||||||
|
name: 'isEven',
|
||||||
|
validate: (value: any) => typeof value === 'number' && value % 2 === 0,
|
||||||
|
message: 'val must be even',
|
||||||
|
});
|
||||||
|
|
||||||
const t = new Test();
|
const t = new Test();
|
||||||
t.val = 2;
|
t.val = 2;
|
||||||
@@ -271,19 +264,15 @@ describe('Additional Decorators', () => {
|
|||||||
class MyValidator implements ValidatorConstraintInterface {
|
class MyValidator implements ValidatorConstraintInterface {
|
||||||
validate(v: any) { return v === 'ok'; }
|
validate(v: any) { return v === 'ok'; }
|
||||||
}
|
}
|
||||||
function IsOk() {
|
|
||||||
return function (object: any, propertyName: string) {
|
|
||||||
registerDecorator({
|
|
||||||
name: 'isOk',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
validator: MyValidator,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
class Test {
|
class Test {
|
||||||
@IsOk() val: string;
|
val: string = '';
|
||||||
}
|
}
|
||||||
|
const validator = new MyValidator();
|
||||||
|
defineRule(Test, 'val', {
|
||||||
|
name: 'isOk',
|
||||||
|
validate: (v: any) => validator.validate(v),
|
||||||
|
message: 'val must be ok',
|
||||||
|
});
|
||||||
const t = new Test();
|
const t = new Test();
|
||||||
t.val = 'ok';
|
t.val = 'ok';
|
||||||
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
||||||
@@ -304,7 +293,7 @@ describe('Additional Decorators', () => {
|
|||||||
t.val = 5;
|
t.val = 5;
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['custom']).toBe('must be ten');
|
expect(errors[0]!.constraints['custom']).toBe('must be ten');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle options as second argument', async () => {
|
it('should handle options as second argument', async () => {
|
||||||
@@ -318,7 +307,7 @@ describe('Additional Decorators', () => {
|
|||||||
t.val = 2;
|
t.val = 2;
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['custom']).toBe('must be one');
|
expect(errors[0]!.constraints['custom']).toBe('must be one');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,10 +328,10 @@ describe('Additional Decorators', () => {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
const json = '[{"name": "a"}, {"name": "b"}]';
|
const json = '[{"name": "a"}, {"name": "b"}]';
|
||||||
const items = await JsonMapper.fromJson(Item, json);
|
const items = (await JsonMapper.fromJson(Item, json)) as unknown as Item[];
|
||||||
expect(Array.isArray(items)).toBe(true);
|
expect(Array.isArray(items)).toBe(true);
|
||||||
expect(items[0]).toBeInstanceOf(Item);
|
expect(items[0]).toBeInstanceOf(Item);
|
||||||
expect(items[0].name).toBe('a');
|
expect(items[0]!.name).toBe('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle single polymorphic object', async () => {
|
it('should handle single polymorphic object', async () => {
|
||||||
@@ -350,7 +339,7 @@ describe('Additional Decorators', () => {
|
|||||||
@IsString() type: string;
|
@IsString() type: string;
|
||||||
}
|
}
|
||||||
class Dog extends Animal {
|
class Dog extends Animal {
|
||||||
type = 'dog';
|
override type = 'dog';
|
||||||
@IsString() breed: string;
|
@IsString() breed: string;
|
||||||
}
|
}
|
||||||
class Test {
|
class Test {
|
||||||
@@ -376,7 +365,7 @@ describe('Additional Decorators', () => {
|
|||||||
t.tags = ['a', 1 as any];
|
t.tags = ['a', 1 as any];
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['isString']).toContain('each element');
|
expect(errors[0]!.constraints['isString']).toContain('each element');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+635
-511
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
}
|
||||||
+132
-87
@@ -1,22 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsInt,
|
IsInt,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsDate,
|
IsDate,
|
||||||
JsonSerialize,
|
IsEnum,
|
||||||
JsonDeserialize,
|
IsUUID,
|
||||||
JsonPolymorphic,
|
ValidateIf,
|
||||||
|
JsonProperty,
|
||||||
|
JsonAlias,
|
||||||
|
JsonReadOnly,
|
||||||
|
JsonWriteOnly,
|
||||||
|
JsonSerialize,
|
||||||
|
JsonDeserialize,
|
||||||
|
JsonPolymorphic,
|
||||||
toJson,
|
toJson,
|
||||||
fromJson,
|
fromJson,
|
||||||
JsonSerializer,
|
toPlain,
|
||||||
|
validate,
|
||||||
|
flattenErrors,
|
||||||
|
JsonSerializer,
|
||||||
JsonDeserializer,
|
JsonDeserializer,
|
||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraintInterface,
|
ValidatorConstraintInterface,
|
||||||
ValidationArguments,
|
ValidationArguments,
|
||||||
registerDecorator,
|
Matches,
|
||||||
ValidationOptions
|
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
// --- Custom Validators ---
|
// --- Custom Validators ---
|
||||||
@@ -32,26 +41,14 @@ class IsLongerThan implements ValidatorConstraintInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function IsUsername(options?: ValidationOptions) {
|
/** A custom rule is just a decorator that composes an existing one. */
|
||||||
return function (object: any, propertyName: string) {
|
const IsSlug = () => Matches(/^[a-z0-9-]+$/, { message: 'name must be a lowercase slug' });
|
||||||
registerDecorator({
|
|
||||||
name: 'isUsername',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
...(options ? { options } : {}),
|
|
||||||
validator: (value: any) => typeof value === 'string' && /^[a-zA-Z0-9_]+$/.test(value)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Custom Serializers ---
|
// --- Custom Serializers ---
|
||||||
|
|
||||||
class DateSerializer implements JsonSerializer<Date, string> {
|
class DateSerializer implements JsonSerializer<Date, string> {
|
||||||
serialize(value: Date): string {
|
serialize(value: Date): string {
|
||||||
if (value instanceof Date) {
|
return value.toISOString().split('T')[0] || '';
|
||||||
return value.toISOString().split('T')[0] || '';
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,26 +60,34 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
|
|||||||
|
|
||||||
// --- Domain Models ---
|
// --- Domain Models ---
|
||||||
|
|
||||||
abstract class Media {
|
enum Format {
|
||||||
@IsString()
|
Hardback = 'hardback',
|
||||||
abstract type: string;
|
Paperback = 'paperback',
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Media {
|
||||||
|
// Standard decorators cannot be applied to an `abstract` member, so the discriminator is a
|
||||||
|
// concrete field the subclasses override.
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string;
|
type: string = '';
|
||||||
|
|
||||||
|
// Declared once here. Subclasses inherit the rule without restating it.
|
||||||
|
@IsString()
|
||||||
|
title: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
override type: string = 'book';
|
override type: string = 'book';
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsUsername({ message: 'Title must be a valid alphanumeric username' })
|
|
||||||
declare title: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Validate(IsLongerThan, [5])
|
@Validate(IsLongerThan, [5])
|
||||||
author: string;
|
author: string;
|
||||||
|
|
||||||
|
@IsEnum(Format)
|
||||||
|
format: Format = Format.Paperback;
|
||||||
|
|
||||||
|
@JsonProperty('published_at')
|
||||||
@JsonSerialize(DateSerializer)
|
@JsonSerialize(DateSerializer)
|
||||||
@JsonDeserialize(DateDeserializer)
|
@JsonDeserialize(DateDeserializer)
|
||||||
@IsDate()
|
@IsDate()
|
||||||
@@ -91,87 +96,127 @@ class Book extends Media {
|
|||||||
|
|
||||||
class Movie extends Media {
|
class Movie extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'movie';
|
override type: string = 'movie';
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
duration: number;
|
duration: number;
|
||||||
|
|
||||||
|
// Only checked for films that claim to be part of a series.
|
||||||
|
@ValidateIf<Movie>(movie => movie.duration > 200)
|
||||||
|
@IsString()
|
||||||
|
intermissionNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
|
@JsonReadOnly()
|
||||||
|
@IsUUID(4)
|
||||||
|
id: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsSlug()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@JsonProperty('curator_email')
|
||||||
|
@JsonAlias('curatorEmail')
|
||||||
|
@IsString()
|
||||||
|
curatorEmail: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
adminToken: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested()
|
@ValidateNested({ each: true })
|
||||||
@JsonPolymorphic('type', [
|
// Naming the base type has the subtype list checked against it.
|
||||||
|
@JsonPolymorphic<Media>('type', [
|
||||||
{ value: Book, name: 'book' },
|
{ value: Book, name: 'book' },
|
||||||
{ value: Movie, name: 'movie' }
|
{ value: Movie, name: 'movie' }
|
||||||
])
|
])
|
||||||
items: Media[];
|
items: Media[] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Execution ---
|
// --- Execution ---
|
||||||
|
|
||||||
async function runExample() {
|
async function runExample() {
|
||||||
console.log("--- Starting Example ---");
|
console.log('--- Starting Example ---');
|
||||||
|
|
||||||
// 1. Create a Library instance
|
|
||||||
const library = new Library();
|
const library = new Library();
|
||||||
library.name = "Central Library";
|
library.id = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
|
||||||
|
library.name = 'central-library';
|
||||||
|
library.curatorEmail = 'ada@example.com';
|
||||||
|
library.adminToken = 'super-secret';
|
||||||
|
|
||||||
const book = new Book();
|
const book = new Book();
|
||||||
book.title = "Gatsby";
|
book.title = 'Gatsby';
|
||||||
book.author = "Fitzgerald";
|
book.author = 'Fitzgerald';
|
||||||
book.publishedAt = new Date("1925-04-10");
|
book.format = Format.Hardback;
|
||||||
|
book.publishedAt = new Date('1925-04-10');
|
||||||
|
|
||||||
const movie = new Movie();
|
const movie = new Movie();
|
||||||
movie.title = "Inception";
|
movie.title = 'Inception';
|
||||||
movie.duration = 148;
|
movie.duration = 148;
|
||||||
|
|
||||||
library.items = [book, movie];
|
library.items = [book, movie];
|
||||||
|
|
||||||
try {
|
// 1. Serialize, honouring @JsonProperty and the write-only token
|
||||||
// 2. Serialize to JSON
|
console.log('\n[1] Serializing Library to JSON...');
|
||||||
console.log("\n[1] Serializing Library to JSON...");
|
const json = await toJson(library);
|
||||||
const json = await toJson(library);
|
console.log('JSON Output:', json);
|
||||||
console.log("JSON Output:", json);
|
console.log('Secret withheld from output:', !json.includes('super-secret'));
|
||||||
|
|
||||||
// 3. Deserialize back to Instance
|
// 2. Deserialize back, resolving the polymorphic items
|
||||||
console.log("\n[2] Deserializing JSON back to Library instance...");
|
console.log('\n[2] Deserializing JSON back to Library instance...');
|
||||||
const deserializedLibrary = await fromJson(Library, json);
|
const restored = await fromJson(Library, json, { validate: false });
|
||||||
console.log("Deserialized Library Name:", deserializedLibrary.name);
|
console.log('Curator (read via curator_email):', restored.curatorEmail);
|
||||||
console.log("Items count:", deserializedLibrary.items.length);
|
console.log('Items count:', restored.items.length);
|
||||||
|
restored.items.forEach((item, index) => {
|
||||||
// Check Polymorphism
|
console.log(`Item ${index} is a ${item.constructor.name}: ${item.title}`);
|
||||||
deserializedLibrary.items.forEach((item, index) => {
|
if (item instanceof Book) {
|
||||||
console.log(`Item ${index} is instance of ${item.constructor.name}: ${item.title}`);
|
console.log(` > Author: ${item.author}, format: ${item.format}`);
|
||||||
if (item instanceof Book) {
|
console.log(` > Published: ${item.publishedAt.toISOString()} (Date: ${item.publishedAt instanceof Date})`);
|
||||||
console.log(` > Book Author: ${item.author}`);
|
} else if (item instanceof Movie) {
|
||||||
console.log(` > Published At: ${item.publishedAt.toISOString()} (instanceof Date: ${item.publishedAt instanceof Date})`);
|
console.log(` > Duration: ${item.duration} mins`);
|
||||||
} 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// 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,221 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
||||||
|
JsonSerializer, JsonDeserializer, JsonMappingError,
|
||||||
|
defineRule, 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.
|
||||||
|
defineRule(Late, 'value', {
|
||||||
|
name: 'isEven',
|
||||||
|
validate: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||||
|
message: 'value must be even',
|
||||||
|
});
|
||||||
|
|
||||||
|
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!: ('a' | 'b')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'b', 'a', 'nope' as 'a', '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!: 'a'[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'zzz' as 'a'];
|
||||||
|
|
||||||
|
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!: 'a'[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'zzz' as 'a'];
|
||||||
|
|
||||||
|
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!: ('a' | 'b')[];
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-15
@@ -41,16 +41,18 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
|
|||||||
|
|
||||||
// --- Domain Models ---
|
// --- Domain Models ---
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
|
// Standard decorators cannot be applied to an `abstract` member, so the base declares a
|
||||||
|
// concrete field the subclasses override.
|
||||||
@IsString()
|
@IsString()
|
||||||
abstract type: string;
|
type: string = '';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string;
|
title: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'book';
|
override type: string = 'book';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
author: string;
|
author: string;
|
||||||
@@ -63,7 +65,7 @@ class Book extends Media {
|
|||||||
|
|
||||||
class Movie extends Media {
|
class Movie extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'movie';
|
override type: string = 'movie';
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -162,7 +164,7 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
@ArrayNotEmpty()
|
@ArrayNotEmpty()
|
||||||
@IsIn(['admin', 'user', 'guest'], { each: true })
|
@IsIn(['admin', 'user', 'guest'], { each: true })
|
||||||
roles: string[];
|
roles!: ('admin' | 'user' | 'guest')[];
|
||||||
|
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -202,8 +204,8 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('username');
|
expect(errors[0]!.property).toBe('username');
|
||||||
expect(errors[0].constraints).toHaveProperty('minLength');
|
expect(errors[0]!.constraints).toHaveProperty('minLength');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail on invalid email', async () => {
|
it('should fail on invalid email', async () => {
|
||||||
@@ -215,8 +217,8 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('email');
|
expect(errors[0]!.property).toBe('email');
|
||||||
expect(errors[0].constraints).toHaveProperty('isEmail');
|
expect(errors[0]!.constraints).toHaveProperty('isEmail');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail on invalid role (IsIn)', async () => {
|
it('should fail on invalid role (IsIn)', async () => {
|
||||||
@@ -224,12 +226,12 @@ describe('JsonMapper', () => {
|
|||||||
user.username = 'johndoe';
|
user.username = 'johndoe';
|
||||||
user.email = 'john@example.com';
|
user.email = 'john@example.com';
|
||||||
user.active = true;
|
user.active = true;
|
||||||
user.roles = ['superadmin'];
|
user.roles = ['superadmin' as 'admin'];
|
||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('roles');
|
expect(errors[0]!.property).toBe('roles');
|
||||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
expect(errors[0]!.constraints).toHaveProperty('isIn');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip validation for null optional field', async () => {
|
it('should skip validation for null optional field', async () => {
|
||||||
@@ -238,7 +240,7 @@ describe('JsonMapper', () => {
|
|||||||
user.email = 'john@example.com';
|
user.email = 'john@example.com';
|
||||||
user.active = true;
|
user.active = true;
|
||||||
user.roles = ['user'];
|
user.roles = ['user'];
|
||||||
user.age = undefined; // optional
|
delete user.age; // optional
|
||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(0);
|
expect(errors).toHaveLength(0);
|
||||||
@@ -254,8 +256,8 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('age');
|
expect(errors[0]!.property).toBe('age');
|
||||||
expect(errors[0].constraints).toHaveProperty('min');
|
expect(errors[0]!.constraints).toHaveProperty('min');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export * from './interfaces.js';
|
export * from './interfaces.js';
|
||||||
|
export * from './metadata.js';
|
||||||
|
export * from './naming.js';
|
||||||
|
export * from './config.js';
|
||||||
export * from './decorators.js';
|
export * from './decorators.js';
|
||||||
|
export * from './errors.js';
|
||||||
export * from './utils.js';
|
export * from './utils.js';
|
||||||
|
|||||||
+44
-5
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Interface for custom JSON serializers.
|
* Interface for custom JSON serializers.
|
||||||
*
|
*
|
||||||
* @template T - The type of the value to serialize (usually a class instance or a specific field).
|
* @template T - The type of the value to serialize (usually a class instance or a specific field).
|
||||||
* @template R - The type of the serialized value (usually a string, number, or plain object).
|
* @template R - The type of the serialized value (usually a string, number, or plain object).
|
||||||
*/
|
*/
|
||||||
export interface JsonSerializer<T = any, R = any> {
|
export interface JsonSerializer<T = any, R = any> {
|
||||||
/**
|
/**
|
||||||
* Serializes the value into a representation suitable for JSON output.
|
* Serializes the value into a representation suitable for JSON output.
|
||||||
*
|
*
|
||||||
* @param value - The value to be serialized.
|
* @param value - The value to be serialized.
|
||||||
* @returns The serialized value or a promise resolving to it.
|
* @returns The serialized value or a promise resolving to it.
|
||||||
*/
|
*/
|
||||||
@@ -16,14 +16,14 @@ export interface JsonSerializer<T = any, R = any> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for custom JSON deserializers.
|
* Interface for custom JSON deserializers.
|
||||||
*
|
*
|
||||||
* @template T - The type of the value to deserialize (usually a string or plain object from JSON).
|
* @template T - The type of the value to deserialize (usually a string or plain object from JSON).
|
||||||
* @template R - The type of the deserialized value (usually a class instance or a specific field).
|
* @template R - The type of the deserialized value (usually a class instance or a specific field).
|
||||||
*/
|
*/
|
||||||
export interface JsonDeserializer<T = any, R = any> {
|
export interface JsonDeserializer<T = any, R = any> {
|
||||||
/**
|
/**
|
||||||
* Deserializes the value from a JSON-like representation back to its original type.
|
* Deserializes the value from a JSON-like representation back to its original type.
|
||||||
*
|
*
|
||||||
* @param value - The value to be deserialized.
|
* @param value - The value to be deserialized.
|
||||||
* @returns The deserialized value or a promise resolving to it.
|
* @returns The deserialized value or a promise resolving to it.
|
||||||
*/
|
*/
|
||||||
@@ -32,9 +32,48 @@ export interface JsonDeserializer<T = any, R = any> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a class constructor function.
|
* Represents a class constructor function.
|
||||||
*
|
*
|
||||||
* @template T - The type of the instance created by this constructor.
|
* @template T - The type of the instance created by this constructor.
|
||||||
*/
|
*/
|
||||||
export type ClassConstructor<T> = {
|
export type ClassConstructor<T> = {
|
||||||
new (...args: any[]): T;
|
new (...args: any[]): T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decorator that may only be applied to a field whose type is assignable to `Allowed`.
|
||||||
|
*
|
||||||
|
* This is what makes cereale's rules type-checked rather than merely declared. Standard
|
||||||
|
* decorators receive a `ClassFieldDecoratorContext<This, Value>` that carries the field's
|
||||||
|
* declared type, so applying `@IsString()` to a `number` field is a compile error rather
|
||||||
|
* than a runtime surprise:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* class User {
|
||||||
|
* @IsString() name!: string; // fine
|
||||||
|
* @IsString() age!: number; // Type 'number' is not assignable to type 'string'
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `null` and `undefined` are included in the `Allowed` union of every built-in rule so
|
||||||
|
* optional fields (`nickname?: string`) still accept the rule that describes them.
|
||||||
|
*/
|
||||||
|
export type FieldDecorator<Allowed> = <This, Value extends Allowed>(
|
||||||
|
target: undefined,
|
||||||
|
context: ClassFieldDecoratorContext<This, Value>
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** A field holding a string, or nothing. */
|
||||||
|
export type StringField = string | null | undefined;
|
||||||
|
/** A field holding a number, or nothing. */
|
||||||
|
export type NumberField = number | null | undefined;
|
||||||
|
/** A field holding a boolean, or nothing. */
|
||||||
|
export type BooleanField = boolean | null | undefined;
|
||||||
|
/** A field holding a bigint, or nothing. */
|
||||||
|
export type BigIntField = bigint | null | undefined;
|
||||||
|
/** A field holding a Date, or nothing. */
|
||||||
|
export type DateField = Date | null | undefined;
|
||||||
|
/** A field holding an array, or nothing. */
|
||||||
|
export type ArrayField = readonly unknown[] | null | undefined;
|
||||||
|
|
||||||
|
/** The element type of an array field, used by rules that run per element. */
|
||||||
|
export type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
export class MetadataStorage {
|
|
||||||
private static instance: MetadataStorage;
|
|
||||||
|
|
||||||
// Maps a prototype to its property names
|
|
||||||
private properties = new WeakMap<any, string[]>();
|
|
||||||
|
|
||||||
// Maps a prototype and property name to its metadata
|
|
||||||
// Map<Prototype, Map<PropertyKey, Map<MetadataKey, Value>>>
|
|
||||||
private propertyMetadata = new WeakMap<any, Map<string, Map<string, any>>>();
|
|
||||||
|
|
||||||
// Maps a prototype to its class-level metadata
|
|
||||||
private classMetadata = new WeakMap<any, Map<string, any>>();
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
static getInstance(): MetadataStorage {
|
|
||||||
if (!MetadataStorage.instance) {
|
|
||||||
MetadataStorage.instance = new MetadataStorage();
|
|
||||||
}
|
|
||||||
return MetadataStorage.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines metadata for a specific property on a target.
|
|
||||||
*/
|
|
||||||
defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
|
|
||||||
if (propertyKey) {
|
|
||||||
let targetMap = this.propertyMetadata.get(target);
|
|
||||||
if (!targetMap) {
|
|
||||||
targetMap = new Map();
|
|
||||||
this.propertyMetadata.set(target, targetMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
let propertyMap = targetMap.get(propertyKey);
|
|
||||||
if (!propertyMap) {
|
|
||||||
propertyMap = new Map();
|
|
||||||
targetMap.set(propertyKey, propertyMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
propertyMap.set(key, value);
|
|
||||||
} else {
|
|
||||||
let targetMap = this.classMetadata.get(target);
|
|
||||||
if (!targetMap) {
|
|
||||||
targetMap = new Map();
|
|
||||||
this.classMetadata.set(target, targetMap);
|
|
||||||
}
|
|
||||||
targetMap.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets metadata for a specific property on a target, including from the prototype chain.
|
|
||||||
*/
|
|
||||||
getMetadata(key: string, target: any, propertyKey?: string): any {
|
|
||||||
let current = target;
|
|
||||||
while (current) {
|
|
||||||
const value = this.getOwnMetadata(key, current, propertyKey);
|
|
||||||
if (value !== undefined) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
current = Object.getPrototypeOf(current);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets metadata defined directly on the target.
|
|
||||||
*/
|
|
||||||
getOwnMetadata(key: string, target: any, propertyKey?: string): any {
|
|
||||||
if (propertyKey) {
|
|
||||||
return this.propertyMetadata.get(target)?.get(propertyKey)?.get(key);
|
|
||||||
} else {
|
|
||||||
return this.classMetadata.get(target)?.get(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a property for a target.
|
|
||||||
*/
|
|
||||||
registerProperty(target: any, propertyKey: string) {
|
|
||||||
let props = this.properties.get(target);
|
|
||||||
if (!props) {
|
|
||||||
props = [];
|
|
||||||
this.properties.set(target, props);
|
|
||||||
}
|
|
||||||
if (!props.includes(propertyKey)) {
|
|
||||||
props.push(propertyKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all registered properties for a target, including from the prototype chain.
|
|
||||||
*/
|
|
||||||
getProperties(target: any): string[] {
|
|
||||||
const allProps = new Set<string>();
|
|
||||||
let current = target;
|
|
||||||
while (current) {
|
|
||||||
const props = this.properties.get(current);
|
|
||||||
if (props) {
|
|
||||||
props.forEach(p => allProps.add(p));
|
|
||||||
}
|
|
||||||
current = Object.getPrototypeOf(current);
|
|
||||||
}
|
|
||||||
return Array.from(allProps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadataStorage = MetadataStorage.getInstance();
|
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
import type { ClassConstructor } from './interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key decorator metadata is stored under.
|
||||||
|
*
|
||||||
|
* Resolved into a binding rather than read as `Symbol.metadata` at each use. If the well-known
|
||||||
|
* symbol is absent, `Symbol.metadata` evaluates to `undefined` and `clazz[undefined]` quietly
|
||||||
|
* reads a property literally named "undefined" — `modelOf` would return an empty model and
|
||||||
|
* every object would validate clean. Silent success is the worst failure mode a validation
|
||||||
|
* library can have, so the fallback is baked into the value the code actually uses.
|
||||||
|
*
|
||||||
|
* `Symbol.for` matches what the decorator transforms emit (esbuild's `__knownSymbol` uses the
|
||||||
|
* same fallback), and keeps the key identical across duplicate copies of the library, which
|
||||||
|
* the dual ESM/CJS build can otherwise produce.
|
||||||
|
*/
|
||||||
|
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
|
||||||
|
|
||||||
|
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata`
|
||||||
|
// directly. package.json marks this module as having side effects so it survives bundling.
|
||||||
|
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
|
||||||
|
|
||||||
|
export interface ValidationArguments {
|
||||||
|
value: any;
|
||||||
|
object: any;
|
||||||
|
property: string;
|
||||||
|
constraints: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationOptions {
|
||||||
|
/** Apply the rule to each element of an array rather than to the array itself. */
|
||||||
|
each?: boolean;
|
||||||
|
/** Replaces the built-in message. Reported verbatim — the engine never decorates it. */
|
||||||
|
message?: string | ((args: ValidationArguments) => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrowed form used by the `each: true` decorator overloads. */
|
||||||
|
export interface EachValidationOptions extends ValidationOptions {
|
||||||
|
each: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationConstraint = {
|
||||||
|
name: string;
|
||||||
|
validate: (value: any, args: ValidationArguments) => boolean | Promise<boolean>;
|
||||||
|
message: string | ((args: ValidationArguments) => string);
|
||||||
|
constraints?: any[];
|
||||||
|
each?: boolean;
|
||||||
|
/**
|
||||||
|
* True when the message came from the caller. The engine only decorates its own default
|
||||||
|
* wording with the "each element in ..." prefix.
|
||||||
|
*/
|
||||||
|
hasCustomMessage?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ValidatorConstraintInterface {
|
||||||
|
validate(value: any, args: ValidationArguments): boolean | Promise<boolean>;
|
||||||
|
defaultMessage?(args: ValidationArguments): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 PolymorphicInfo {
|
||||||
|
discriminator: string;
|
||||||
|
subTypes: { value: ClassConstructor<any>; name: string }[];
|
||||||
|
onUnknown: 'keep' | 'error';
|
||||||
|
fallback?: ClassConstructor<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything cereale knows about one field. */
|
||||||
|
export interface PropertyModel {
|
||||||
|
constraints: ValidationConstraint[];
|
||||||
|
optional?: boolean;
|
||||||
|
nested?: boolean;
|
||||||
|
condition?: (object: any) => boolean;
|
||||||
|
/** Explicit JSON name from `@JsonProperty`. */
|
||||||
|
name?: string;
|
||||||
|
aliases?: string[];
|
||||||
|
access?: PropertyAccess;
|
||||||
|
serializer?: ClassConstructor<any>;
|
||||||
|
deserializer?: ClassConstructor<any>;
|
||||||
|
type?: () => ClassConstructor<any>;
|
||||||
|
polymorphic?: PolymorphicInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClassModel = Record<string, PropertyModel>;
|
||||||
|
|
||||||
|
const MODEL = Symbol.for('cereale.model');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped whenever a model is written. Derived structures (the plans in engine.ts) record the
|
||||||
|
* version they were built from and rebuild if it moves, so programmatic registration after a
|
||||||
|
* class has already been used stays correct.
|
||||||
|
*/
|
||||||
|
let version = 0;
|
||||||
|
|
||||||
|
export function modelVersion(): number {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the model owned by this class, creating it if necessary.
|
||||||
|
*
|
||||||
|
* `context.metadata` inherits from the base class's metadata through the prototype chain, so
|
||||||
|
* a subclass starts out seeing everything its base declared. Writing requires an own copy —
|
||||||
|
* otherwise a subclass would mutate its parent — and the inherited entries are deep-copied so
|
||||||
|
* that a subclass re-decorating an inherited field *adds to* the base's rules instead of
|
||||||
|
* replacing them. That inheritance-merging behaviour is structural here; the previous
|
||||||
|
* WeakMap-based storage had to reconstruct it by walking prototypes on every read.
|
||||||
|
*/
|
||||||
|
function ownModel(metadata: DecoratorMetadata): ClassModel {
|
||||||
|
if (!Object.hasOwn(metadata, MODEL)) {
|
||||||
|
const inherited = (metadata as Record<symbol, ClassModel | undefined>)[MODEL];
|
||||||
|
const own: ClassModel = {};
|
||||||
|
for (const [key, property] of Object.entries(inherited ?? {})) {
|
||||||
|
own[key] = { ...property, constraints: [...property.constraints] };
|
||||||
|
}
|
||||||
|
(metadata as Record<symbol, ClassModel>)[MODEL] = own;
|
||||||
|
}
|
||||||
|
return (metadata as Record<symbol, ClassModel>)[MODEL]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns (creating if needed) the model entry for one field. */
|
||||||
|
export function propertyModel(metadata: DecoratorMetadata, property: string): PropertyModel {
|
||||||
|
version++;
|
||||||
|
const model = ownModel(metadata);
|
||||||
|
return (model[property] ??= { constraints: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends a validation rule to a field, honouring `each` and a caller-supplied message. */
|
||||||
|
export function addConstraint(
|
||||||
|
metadata: DecoratorMetadata,
|
||||||
|
property: string,
|
||||||
|
constraint: ValidationConstraint,
|
||||||
|
options?: ValidationOptions
|
||||||
|
): void {
|
||||||
|
if (options?.each) constraint.each = true;
|
||||||
|
if (options?.message) {
|
||||||
|
constraint.message = options.message;
|
||||||
|
constraint.hasCustomMessage = true;
|
||||||
|
}
|
||||||
|
propertyModel(metadata, property).constraints.push(constraint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the model declared on a class. Returns an empty model for undecorated classes. */
|
||||||
|
export function modelOf(clazz: unknown): ClassModel {
|
||||||
|
if (typeof clazz !== 'function') return {};
|
||||||
|
const metadata = (clazz as unknown as Record<symbol, DecoratorMetadata | undefined>)[METADATA_KEY];
|
||||||
|
return (metadata as Record<symbol, ClassModel> | undefined)?.[MODEL] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the model that applies to an instance.
|
||||||
|
*
|
||||||
|
* Guarded rather than reading `obj.constructor` directly: null-prototype objects have no
|
||||||
|
* constructor, and an instance whose `constructor` property has been overwritten would lie.
|
||||||
|
*/
|
||||||
|
export function modelOfInstance(obj: object): ClassModel {
|
||||||
|
const prototype = Object.getPrototypeOf(obj);
|
||||||
|
if (!prototype) return {};
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor');
|
||||||
|
return modelOf(descriptor?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a rule on a class from outside a decorator.
|
||||||
|
*
|
||||||
|
* The escape hatch for rules that cannot be expressed at the declaration site — built from
|
||||||
|
* configuration, say. Prefer decorators, which are type-checked against the field.
|
||||||
|
*/
|
||||||
|
export function defineRule<T>(
|
||||||
|
clazz: ClassConstructor<T>,
|
||||||
|
property: keyof T & string,
|
||||||
|
constraint: ValidationConstraint,
|
||||||
|
options?: ValidationOptions
|
||||||
|
): void {
|
||||||
|
const holder = clazz as unknown as Record<symbol, DecoratorMetadata | undefined>;
|
||||||
|
holder[METADATA_KEY] ??= Object.create(null) as DecoratorMetadata;
|
||||||
|
addConstraint(holder[METADATA_KEY]!, property, constraint, options);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
override 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()
|
||||||
|
override 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!: 'a' | 'b';
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
const form = new Form();
|
||||||
|
form.choice = 'zzz' as 'a';
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The headline guarantee of v2 is that a rule cannot be attached to a field it does not fit.
|
||||||
|
* That is a *compile-time* claim, so asserting it needs the compiler: each case below is
|
||||||
|
* type-checked in isolation and must fail.
|
||||||
|
*
|
||||||
|
* These run the real `tsc`, so they are slower than the rest of the suite — but a guarantee
|
||||||
|
* nobody checks is a guarantee that quietly stops holding.
|
||||||
|
*/
|
||||||
|
const TSC = resolve('node_modules/.bin/tsc');
|
||||||
|
const SRC = resolve('src/index.js').replace(/\.js$/, '');
|
||||||
|
|
||||||
|
function typeCheck(body: string): { ok: boolean; output: string } {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'cereale-types-'));
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext',
|
||||||
|
// DOM supplies URL/Request, which the library's own signatures reference. A real
|
||||||
|
// consumer has these from either DOM or @types/node.
|
||||||
|
lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true,
|
||||||
|
strictPropertyInitialization: false, noEmit: true, skipLibCheck: true,
|
||||||
|
},
|
||||||
|
include: ['case.ts'],
|
||||||
|
}));
|
||||||
|
writeFileSync(join(dir, 'case.ts'), `import {\n IsString, IsInt, Min, MinLength, IsArray, ArrayMinSize, ArrayUnique,\n IsDate, MinDate, IsBoolean, IsIn, IsEnum, JsonType, JsonSerialize,\n JsonDeserialize, JsonSerializer, JsonDeserializer,\n} from ${JSON.stringify(SRC + '.js')};\n\n${body}\n`);
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, [TSC, '-p', dir], { stdio: 'pipe' });
|
||||||
|
return { ok: true, output: '' };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { ok: false, output: String(error.stdout ?? '') + String(error.stderr ?? '') };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compiles = (body: string) => {
|
||||||
|
const result = typeCheck(body);
|
||||||
|
if (!result.ok) throw new Error(`expected this to compile but it did not:\n${result.output}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejects = (body: string) => {
|
||||||
|
const result = typeCheck(body);
|
||||||
|
expect(result.ok, 'expected a compile error, but it compiled').toBe(false);
|
||||||
|
return result.output;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('rules are checked against the field type', () => {
|
||||||
|
it('accepts rules that match the field', () => {
|
||||||
|
compiles(`
|
||||||
|
class Ok {
|
||||||
|
@IsString() @MinLength(2) name!: string;
|
||||||
|
@IsInt() @Min(0) age!: number;
|
||||||
|
@IsBoolean() active!: boolean;
|
||||||
|
@IsDate() @MinDate(new Date(0)) when!: Date;
|
||||||
|
@IsArray() @ArrayMinSize(1) tags!: string[];
|
||||||
|
@IsString() nickname?: string;
|
||||||
|
@IsString() maybe!: string | null;
|
||||||
|
}
|
||||||
|
void Ok;
|
||||||
|
`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a string rule on a number field', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString() age!: number } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a number rule on a string field', () => {
|
||||||
|
expect(rejects(`class Bad { @Min(0) label!: string } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an array rule on a non-array field', () => {
|
||||||
|
expect(rejects(`class Bad { @ArrayMinSize(1) count!: number } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a date rule on a string field', () => {
|
||||||
|
expect(rejects(`class Bad { @MinDate(new Date(0)) when!: string } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('each: true moves the rule onto the elements', () => {
|
||||||
|
it('accepts a matching array field', () => {
|
||||||
|
compiles(`class Ok { @IsString({ each: true }) tags!: string[] } void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects each:true on a scalar field', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString({ each: true }) tag!: string } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a bare rule on an array field', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString() tags!: string[] } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an element-type mismatch', () => {
|
||||||
|
expect(rejects(`class Bad { @IsString({ each: true }) nums!: number[] } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nested types and converters are checked', () => {
|
||||||
|
const shapes = `
|
||||||
|
class Address { street!: string }
|
||||||
|
class Money { amount!: number }
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('accepts the matching class', () => {
|
||||||
|
compiles(`${shapes}
|
||||||
|
class Ok {
|
||||||
|
@JsonType(() => Address) ship!: Address;
|
||||||
|
@JsonType(() => Address) history!: Address[];
|
||||||
|
}
|
||||||
|
void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an unrelated class', () => {
|
||||||
|
expect(rejects(`${shapes}
|
||||||
|
class Bad { @JsonType(() => Money) ship!: Address }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a serializer whose input does not match the field', () => {
|
||||||
|
expect(rejects(`
|
||||||
|
class DateToString implements JsonSerializer<Date, string> {
|
||||||
|
serialize(v: Date) { return v.toISOString(); }
|
||||||
|
}
|
||||||
|
class Bad { @JsonSerialize(DateToString) name!: string }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a deserializer whose output does not match the field', () => {
|
||||||
|
expect(rejects(`
|
||||||
|
class StringToDate implements JsonDeserializer<string, Date> {
|
||||||
|
deserialize(v: string) { return new Date(v); }
|
||||||
|
}
|
||||||
|
class Bad { @JsonDeserialize(StringToDate) name!: string }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('membership rules narrow the field', () => {
|
||||||
|
it('accepts a field typed as the allowed union', () => {
|
||||||
|
compiles(`class Ok { @IsIn(['a', 'b']) choice!: 'a' | 'b' } void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects a field that cannot hold the allowed values', () => {
|
||||||
|
expect(rejects(`class Bad { @IsIn(['a', 'b']) choice!: number } void Bad;`))
|
||||||
|
.toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('rejects an enum rule on a mismatched field', () => {
|
||||||
|
expect(rejects(`
|
||||||
|
enum Role { Admin = 'admin' }
|
||||||
|
class Bad { @IsEnum(Role) role!: number }
|
||||||
|
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('accepts an enum rule on the enum field', () => {
|
||||||
|
compiles(`
|
||||||
|
enum Role { Admin = 'admin', User = 'user' }
|
||||||
|
class Ok { @IsEnum(Role) role!: Role }
|
||||||
|
void Ok;`);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
+1
-1
@@ -30,7 +30,7 @@ describe('Standalone Utility Functions', () => {
|
|||||||
user.age = '30' as any;
|
user.age = '30' as any;
|
||||||
const errors2 = await validate(user);
|
const errors2 = await validate(user);
|
||||||
expect(errors2).toHaveLength(1);
|
expect(errors2).toHaveLength(1);
|
||||||
expect(errors2[0].property).toBe('age');
|
expect(errors2[0]!.property).toBe('age');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transform to plain object directly', async () => {
|
it('should transform to plain object directly', async () => {
|
||||||
|
|||||||
+912
-192
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a decorator to a synthetic one-field class and reports which rules failed.
|
||||||
|
*
|
||||||
|
* Standard decorators are invoked as `(undefined, context)` rather than against a prototype,
|
||||||
|
* so the context is built by hand here. Only `name` and `metadata` are read by the library;
|
||||||
|
* the rest satisfies the shape.
|
||||||
|
*/
|
||||||
|
async function check(decorator: any, value: any): Promise<string[]> {
|
||||||
|
const metadata = Object.create(null) as DecoratorMetadata;
|
||||||
|
decorator(undefined, {
|
||||||
|
kind: 'field',
|
||||||
|
name: 'val',
|
||||||
|
static: false,
|
||||||
|
private: false,
|
||||||
|
metadata,
|
||||||
|
access: { has: () => true, get: (o: any) => o.val, set: (o: any, v: any) => { o.val = v; } },
|
||||||
|
addInitializer: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
class Subject {
|
||||||
|
val: any;
|
||||||
|
}
|
||||||
|
(Subject as any)[Symbol.metadata] = metadata;
|
||||||
|
|
||||||
|
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!: 'card' | 'invoice';
|
||||||
|
|
||||||
|
@ValidateIf<Payment>(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",
|
"moduleResolution": "Bundler",
|
||||||
"outDir": "dist/cjs",
|
"outDir": "dist/cjs",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,5 +4,6 @@
|
|||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"outDir": "dist/esm",
|
"outDir": "dist/esm",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -8,7 +8,7 @@
|
|||||||
// Environment Settings
|
// Environment Settings
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"target": "ES2025",
|
"target": "ES2025",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|
||||||
// Other Outputs
|
// Other Outputs
|
||||||
@@ -33,8 +33,12 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
"experimentalDecorators": true
|
// NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what
|
||||||
|
// gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of
|
||||||
|
// rules against field types. The two decorator systems cannot coexist in one program.
|
||||||
},
|
},
|
||||||
|
// NOTE: test files are deliberately included here so that `npm run type-check`
|
||||||
|
// covers them. The two build configs exclude them (and the demo) from `dist`.
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { transform } from 'esbuild';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transpiles test sources with esbuild instead of oxc.
|
||||||
|
*
|
||||||
|
* Vitest 4 transforms with oxc, which does not yet implement the TC39 standard decorator
|
||||||
|
* transform — it leaves the syntax in place and Node then fails to parse it, reporting
|
||||||
|
* "0 test" rather than an error. esbuild and tsc both implement it, so the library's own
|
||||||
|
* build (`tsc`) and consumers bundling with esbuild or Vite are unaffected; only the test
|
||||||
|
* runner needs this. Remove it once oxc gains standard-decorator support.
|
||||||
|
*/
|
||||||
|
function standardDecorators() {
|
||||||
|
return {
|
||||||
|
name: 'cereale:standard-decorators',
|
||||||
|
enforce: 'pre' as const,
|
||||||
|
async transform(code: string, id: string) {
|
||||||
|
if (!/\.ts$/.test(id) || id.includes('node_modules')) return null;
|
||||||
|
const result = await transform(code, {
|
||||||
|
loader: 'ts',
|
||||||
|
target: 'es2022',
|
||||||
|
sourcefile: id,
|
||||||
|
sourcemap: true,
|
||||||
|
// Standard semantics, not the legacy ones: the library reads context.metadata.
|
||||||
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||||
|
});
|
||||||
|
return { code: result.code, map: result.map };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [standardDecorators()],
|
||||||
|
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