@stone-js/validation 0.8.7 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/ValidationServiceProvider.d.ts +9 -1
- package/dist/Validator.d.ts +1 -1
- package/dist/adapters/standardSchema.d.ts +1 -1
- package/dist/adapters/zod.d.ts +1 -1
- package/dist/declarations.d.ts +36 -2
- package/dist/decorators/Validate.d.ts +34 -0
- package/dist/decorators/Validation.d.ts +29 -0
- package/dist/decorators/ValidationSchema.d.ts +18 -0
- package/dist/decorators/constants.d.ts +11 -0
- package/dist/errors/ValidationError.d.ts +1 -1
- package/dist/index.d.ts +18 -10
- package/dist/index.js +470 -19
- package/dist/middleware/BlueprintMiddleware.d.ts +18 -0
- package/dist/middleware/ValidateRouteMiddleware.d.ts +101 -0
- package/dist/middleware/validate.d.ts +1 -1
- package/dist/options/ValidationBlueprint.d.ts +7 -3
- package/dist/schema.d.ts +1 -1
- package/dist/schemaClass.d.ts +74 -0
- package/dist/sources.d.ts +79 -0
- package/dist/validateEvent.d.ts +8 -2
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -25,6 +25,33 @@ domain once, and the context (runtime, protocol, caller) applies to it at run ti
|
|
|
25
25
|
npm i @stone-js/validation
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
## Enabling it
|
|
29
|
+
|
|
30
|
+
Like every Stone.js module, it is enabled in one of two ways, and configured afterwards under
|
|
31
|
+
`stone.validation`. It registers the validation provider, so the validator is injectable and the route decorators resolve.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { Validation } from '@stone-js/validation'
|
|
35
|
+
import { StoneApp } from '@stone-js/core'
|
|
36
|
+
|
|
37
|
+
@Validation()
|
|
38
|
+
@StoneApp({ name: 'my-app' })
|
|
39
|
+
export class Application {}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { defineStoneApp } from '@stone-js/core'
|
|
44
|
+
import { validationBlueprint } from '@stone-js/validation'
|
|
45
|
+
|
|
46
|
+
export const Application = defineStoneApp({ name: 'my-app' }, [validationBlueprint])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Configure it from a `@Configuration` class or `defineConfig`:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.validation', { /* ... */ }))
|
|
53
|
+
```
|
|
54
|
+
|
|
28
55
|
## Usage
|
|
29
56
|
|
|
30
57
|
```ts
|
|
@@ -2,6 +2,14 @@ import { IContainer, IServiceProvider, Promiseable } from '@stone-js/core';
|
|
|
2
2
|
/**
|
|
3
3
|
* Registers the {@link Validator} service (singleton) in the container, aliased as
|
|
4
4
|
* `validator`/`Validator`, so middleware, handlers and services can resolve it.
|
|
5
|
+
*
|
|
6
|
+
* It also binds whatever schema engines the application declared under
|
|
7
|
+
* `stone.validation.engines`, so a schema class can take its engine through its constructor:
|
|
8
|
+
* `constructor ({ zod })`. That is more elegant than importing the library at every schema, and more
|
|
9
|
+
* testable, since a test hands the class a fake instead of mocking a module.
|
|
10
|
+
*
|
|
11
|
+
* The application names the engine; this module never imports one. That is what keeps it agnostic:
|
|
12
|
+
* Zod, Valibot and ArkType arrive through Standard Schema, and a native schema needs no engine at all.
|
|
5
13
|
*/
|
|
6
14
|
export declare class ValidationServiceProvider implements IServiceProvider {
|
|
7
15
|
private readonly container;
|
|
@@ -10,7 +18,7 @@ export declare class ValidationServiceProvider implements IServiceProvider {
|
|
|
10
18
|
*/
|
|
11
19
|
constructor(container: IContainer);
|
|
12
20
|
/**
|
|
13
|
-
* Register the validation service.
|
|
21
|
+
* Register the validation service and any declared schema engine.
|
|
14
22
|
*/
|
|
15
23
|
register(): Promiseable<void>;
|
|
16
24
|
}
|
package/dist/Validator.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ValidationSchema, StandardSchemaV1 } from '../declarations';
|
|
1
|
+
import { ValidationSchema, StandardSchemaV1 } from '../declarations.js';
|
|
2
2
|
/**
|
|
3
3
|
* Adapts a [Standard Schema](https://standardschema.dev) (Zod 3.24+, Valibot, ArkType, …) to the
|
|
4
4
|
* Stone.js {@link ValidationSchema} contract. Only the synchronous path is supported here; an
|
package/dist/adapters/zod.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ValidationSchema, ZodLikeSchema } from '../declarations';
|
|
1
|
+
import { ValidationSchema, ZodLikeSchema } from '../declarations.js';
|
|
2
2
|
/**
|
|
3
3
|
* Adapts a Zod-style schema (anything exposing a synchronous `safeParse`) to the Stone.js
|
|
4
4
|
* {@link ValidationSchema} contract. Structural — never imports Zod, so it works with any
|
package/dist/declarations.d.ts
CHANGED
|
@@ -95,6 +95,40 @@ export interface IValidator {
|
|
|
95
95
|
* Validation configuration (`stone.validation.*`).
|
|
96
96
|
*/
|
|
97
97
|
export interface ValidationOptions {
|
|
98
|
-
/**
|
|
99
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Named rule sets a route can refer to by name, instead of importing the schemas at the route.
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* blueprint.set('stone.validation.schemas', { createUser: { body: CreateUserSchema } })
|
|
103
|
+
* // then, on the route: { validation: 'createUser' }
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* Naming a rule set that is not registered fails loudly at request time rather than validating
|
|
107
|
+
* nothing, because silently accepting anything is the one outcome a validator must never have.
|
|
108
|
+
*/
|
|
109
|
+
schemas?: Record<string, Record<string, SchemaInput>>;
|
|
110
|
+
/**
|
|
111
|
+
* Schema engines to make resolvable from the container, keyed by the name to resolve them under.
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* import { z } from 'zod'
|
|
115
|
+
* blueprint.set('stone.validation.engines', { zod: z })
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* A schema class then takes its engine through its constructor, which is both more elegant and
|
|
119
|
+
* more testable: a test hands it a fake instead of mocking a module.
|
|
120
|
+
*
|
|
121
|
+
* ```ts
|
|
122
|
+
* @ValidationSchema('createUser')
|
|
123
|
+
* export class CreateUserSchema implements IValidationSchema {
|
|
124
|
+
* constructor ({ zod }: { zod: typeof z }) { this.z = zod }
|
|
125
|
+
* rules () { return { body: this.z.object({ email: this.z.string().email() }) } }
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* The application names the engine; this module never imports one. That is what keeps it agnostic:
|
|
130
|
+
* Zod, Valibot and ArkType arrive through Standard Schema, and a native schema needs no engine at
|
|
131
|
+
* all. Binding a specific library here would make every application depend on it.
|
|
132
|
+
*/
|
|
133
|
+
engines?: Record<string, unknown>;
|
|
100
134
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { RouteValidationInput } from '../sources.js';
|
|
2
|
+
/**
|
|
3
|
+
* What a handler declares it accepts: a schema (the body), a map of schemas by source, or the alias
|
|
4
|
+
* of a schema class registered with `@ValidationSchema`.
|
|
5
|
+
*/
|
|
6
|
+
export type ValidateInput = RouteValidationInput | string;
|
|
7
|
+
/**
|
|
8
|
+
* What `@Validate` records for one handler method.
|
|
9
|
+
*/
|
|
10
|
+
export interface ValidateMetadata {
|
|
11
|
+
/** The decorated method's name, so the declaration can be found again at request time. */
|
|
12
|
+
action: string | symbol;
|
|
13
|
+
/** What that method accepts. */
|
|
14
|
+
validation: ValidateInput;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Method decorator: declare what a handler accepts.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* @Validate(CreateUserSchema) // the body
|
|
21
|
+
* @Validate({ body: CreateUserSchema, query: Page }) // several sources
|
|
22
|
+
* @Validate('createUser') // a registered schema class
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* This knows nothing about the router, and that is the point: the declaration is recorded on the
|
|
26
|
+
* handler itself, under this module's own key, so validation runs the same in a routed application,
|
|
27
|
+
* a single-handler service, a CLI command or the browser. When a router *is* in play you may instead
|
|
28
|
+
* put it on the route (`@Post('/users', { validation: … })`), which keeps the method uncluttered and
|
|
29
|
+
* puts everything a route does in one place; both forms end up in the same middleware.
|
|
30
|
+
*
|
|
31
|
+
* @param validation - What the handler accepts.
|
|
32
|
+
* @returns A method decorator.
|
|
33
|
+
*/
|
|
34
|
+
export declare const Validate: <T extends Function = Function>(validation: ValidateInput) => MethodDecorator;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ValidationConfig } from '../options/ValidationBlueprint.js';
|
|
2
|
+
import { ClassType } from '@stone-js/core';
|
|
3
|
+
/**
|
|
4
|
+
* Options for the `@Validation` decorator: the `stone.validation` bucket, every key optional.
|
|
5
|
+
*/
|
|
6
|
+
export interface ValidationDecoratorOptions extends ValidationConfig {
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Class decorator: validate incoming data against the schemas you already write, declaratively.
|
|
10
|
+
*
|
|
11
|
+
* `@Validation()` registers the validation service provider, so `constructor ({ validator })` works
|
|
12
|
+
* anywhere and the `@Validate()` route decorators have something to resolve. There is no kernel
|
|
13
|
+
* middleware here: validation runs where it is asked for, not on every event.
|
|
14
|
+
*
|
|
15
|
+
* The declarative half of the pair; `validationBlueprint` handed to `defineStoneApp` is the imperative one.
|
|
16
|
+
*
|
|
17
|
+
* @param options - The validation configuration. Everything is optional.
|
|
18
|
+
* @returns A class decorator.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { Validation } from '@stone-js/validation'
|
|
23
|
+
*
|
|
24
|
+
* @Validation({ abortEarly: false })
|
|
25
|
+
* @StoneApp({ name: 'my-app' })
|
|
26
|
+
* export class Application {}
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare const Validation: <T extends ClassType = ClassType>(options?: ValidationDecoratorOptions) => ClassDecorator;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class decorator: register a schema class under a name.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* @ValidationSchema('createUser')
|
|
6
|
+
* export class CreateUserSchema implements IValidationSchema { rules () { … } }
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Routes and handlers then refer to it by name (`@Validate('createUser')`), so schemas live in their
|
|
10
|
+
* own files, organised however the application likes, and nothing has to be imported at the route.
|
|
11
|
+
* The class is resolved by the container, so its constructor receives services and `rules()` can use
|
|
12
|
+
* them.
|
|
13
|
+
*
|
|
14
|
+
* @param alias - The name the schema is registered under. Defaults to the class name, which the
|
|
15
|
+
* discovery middleware fills in, since it is the one holding the class.
|
|
16
|
+
* @returns A class decorator.
|
|
17
|
+
*/
|
|
18
|
+
export declare const ValidationSchema: (alias?: string) => ClassDecorator;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata key carrying what a handler method declared with `@Validate`.
|
|
3
|
+
*
|
|
4
|
+
* The module owns its key, which is what makes it independent: validation works whether or not a
|
|
5
|
+
* router is in play, because the declaration lives on the handler, not on a route.
|
|
6
|
+
*/
|
|
7
|
+
export declare const VALIDATE_KEY = "@stone-js/validation/validate";
|
|
8
|
+
/**
|
|
9
|
+
* Metadata key carrying the alias a schema class registered itself under.
|
|
10
|
+
*/
|
|
11
|
+
export declare const VALIDATION_SCHEMA_KEY = "@stone-js/validation/schema";
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
export * from './ValidationServiceProvider';
|
|
2
|
-
export * from './Validator';
|
|
3
|
-
export * from './adapters/standardSchema';
|
|
4
|
-
export * from './adapters/zod';
|
|
5
|
-
export * from './declarations';
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './
|
|
8
|
-
export * from './
|
|
9
|
-
export * from './
|
|
10
|
-
export * from './
|
|
1
|
+
export * from './ValidationServiceProvider.js';
|
|
2
|
+
export * from './Validator.js';
|
|
3
|
+
export * from './adapters/standardSchema.js';
|
|
4
|
+
export * from './adapters/zod.js';
|
|
5
|
+
export * from './declarations.js';
|
|
6
|
+
export * from './decorators/Validate.js';
|
|
7
|
+
export * from './decorators/Validation.js';
|
|
8
|
+
export * from './decorators/ValidationSchema.js';
|
|
9
|
+
export * from './decorators/constants.js';
|
|
10
|
+
export * from './errors/ValidationError.js';
|
|
11
|
+
export * from './middleware/BlueprintMiddleware.js';
|
|
12
|
+
export * from './middleware/ValidateRouteMiddleware.js';
|
|
13
|
+
export * from './middleware/validate.js';
|
|
14
|
+
export * from './options/ValidationBlueprint.js';
|
|
15
|
+
export * from './schema.js';
|
|
16
|
+
export * from './schemaClass.js';
|
|
17
|
+
export * from './sources.js';
|
|
18
|
+
export * from './validateEvent.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { IntegrationError } from '@stone-js/core';
|
|
1
|
+
import { IntegrationError, hasMetadata, getMetadata, classDecoratorLegacyWrapper, addBlueprint, methodDecoratorLegacyWrapper, addMetadata, setClassMetadata } from '@stone-js/core';
|
|
2
|
+
import { cloneValue } from '@stone-js/config';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Adapts a Zod-style schema (anything exposing a synchronous `safeParse`) to the Stone.js
|
|
@@ -198,6 +199,14 @@ class Validator {
|
|
|
198
199
|
/**
|
|
199
200
|
* Registers the {@link Validator} service (singleton) in the container, aliased as
|
|
200
201
|
* `validator`/`Validator`, so middleware, handlers and services can resolve it.
|
|
202
|
+
*
|
|
203
|
+
* It also binds whatever schema engines the application declared under
|
|
204
|
+
* `stone.validation.engines`, so a schema class can take its engine through its constructor:
|
|
205
|
+
* `constructor ({ zod })`. That is more elegant than importing the library at every schema, and more
|
|
206
|
+
* testable, since a test hands the class a fake instead of mocking a module.
|
|
207
|
+
*
|
|
208
|
+
* The application names the engine; this module never imports one. That is what keeps it agnostic:
|
|
209
|
+
* Zod, Valibot and ArkType arrive through Standard Schema, and a native schema needs no engine at all.
|
|
201
210
|
*/
|
|
202
211
|
class ValidationServiceProvider {
|
|
203
212
|
container;
|
|
@@ -208,13 +217,141 @@ class ValidationServiceProvider {
|
|
|
208
217
|
this.container = container;
|
|
209
218
|
}
|
|
210
219
|
/**
|
|
211
|
-
* Register the validation service.
|
|
220
|
+
* Register the validation service and any declared schema engine.
|
|
212
221
|
*/
|
|
213
222
|
register() {
|
|
214
223
|
this.container
|
|
215
224
|
.singletonIf(Validator, () => Validator.create())
|
|
216
225
|
.alias(Validator, ['validator', 'Validator']);
|
|
226
|
+
const engines = this.container
|
|
227
|
+
.make('blueprint')
|
|
228
|
+
.get('stone.validation', {})
|
|
229
|
+
.engines ?? {};
|
|
230
|
+
for (const [name, engine] of Object.entries(engines)) {
|
|
231
|
+
// `instanceIf` so an application that already bound the name keeps its own binding.
|
|
232
|
+
this.container.instanceIf(name, engine);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Whether a value is a schema rather than a map of schemas.
|
|
239
|
+
*
|
|
240
|
+
* A schema is recognised by what it can do, not by what it is: every engine Stone.js accepts exposes
|
|
241
|
+
* one of `validate`, `~standard`, `parse` or `safeParse`. Anything else is treated as a map of
|
|
242
|
+
* sources, which is why `{ body: X }` and `X` can share one option without ambiguity.
|
|
243
|
+
*
|
|
244
|
+
* @param value - The declared value.
|
|
245
|
+
* @returns Whether it is a single schema.
|
|
246
|
+
*/
|
|
247
|
+
function isSchemaLike(value) {
|
|
248
|
+
if (typeof value !== 'object' || value === null) {
|
|
249
|
+
return typeof value === 'function';
|
|
250
|
+
}
|
|
251
|
+
const candidate = value;
|
|
252
|
+
return (typeof candidate.validate === 'function' ||
|
|
253
|
+
typeof candidate.parse === 'function' ||
|
|
254
|
+
typeof candidate.safeParse === 'function' ||
|
|
255
|
+
candidate['~standard'] !== undefined);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Normalise what a route declared into rules keyed by source.
|
|
259
|
+
*
|
|
260
|
+
* A bare schema becomes `{ body: schema }`, because a route that names one schema means its payload.
|
|
261
|
+
*
|
|
262
|
+
* @param declared - What the route declared.
|
|
263
|
+
* @returns The rules, keyed by source.
|
|
264
|
+
*/
|
|
265
|
+
function toValidationRules(declared) {
|
|
266
|
+
return isSchemaLike(declared) ? { body: declared } : declared;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Read a whole source off an event.
|
|
270
|
+
*
|
|
271
|
+
* `body`, `query` and `params` are read as wholes, because a schema validates the payload, not one
|
|
272
|
+
* field of it. `event.get('body')` would look for a field *named* body inside the body, which is a
|
|
273
|
+
* different question. Any other key falls back to `event.get`, so a context that exposes something
|
|
274
|
+
* else (a CLI argument set, a message attribute) validates just as well.
|
|
275
|
+
*
|
|
276
|
+
* `query` is normalised from `URLSearchParams` to a plain object, since that is what a schema can
|
|
277
|
+
* parse.
|
|
278
|
+
*
|
|
279
|
+
* @param event - The incoming event.
|
|
280
|
+
* @param source - The source name.
|
|
281
|
+
* @returns The value to validate.
|
|
282
|
+
*/
|
|
283
|
+
function readSource(event, source) {
|
|
284
|
+
if (source === 'body') {
|
|
285
|
+
return event.getBody?.() ?? event.body;
|
|
217
286
|
}
|
|
287
|
+
if (source === 'params') {
|
|
288
|
+
return event.params ?? event.get(source);
|
|
289
|
+
}
|
|
290
|
+
if (source === 'query') {
|
|
291
|
+
const query = event.query ?? event.get(source);
|
|
292
|
+
return query instanceof URLSearchParams ? Object.fromEntries(query) : query;
|
|
293
|
+
}
|
|
294
|
+
return event.get(source);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The metadata key a validated source is published under.
|
|
298
|
+
*
|
|
299
|
+
* `body` becomes `validatedBody`, `query` becomes `validatedQuery`. The name is predictable on
|
|
300
|
+
* purpose: a handler reads `event.get<CreateUser>('validatedBody')` and needs no helper, no import
|
|
301
|
+
* and nothing to remember beyond the source it declared.
|
|
302
|
+
*
|
|
303
|
+
* @param source - The source name.
|
|
304
|
+
* @returns The metadata key.
|
|
305
|
+
*/
|
|
306
|
+
function metadataKeyFor(source) {
|
|
307
|
+
return `validated${source.charAt(0).toUpperCase()}${source.slice(1)}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Whether a value is a schema class rather than a schema or a rule map.
|
|
312
|
+
*
|
|
313
|
+
* @param value - The candidate.
|
|
314
|
+
* @returns Whether it must be resolved before use.
|
|
315
|
+
*/
|
|
316
|
+
function isValidationSchemaClass(value) {
|
|
317
|
+
return typeof value === 'function' && typeof value.prototype?.rules === 'function';
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Whether a value is an already-built schema instance.
|
|
321
|
+
*
|
|
322
|
+
* @param value - The candidate.
|
|
323
|
+
* @returns Whether it exposes `rules()`.
|
|
324
|
+
*/
|
|
325
|
+
function isValidationSchema(value) {
|
|
326
|
+
return typeof value === 'object' && value !== null && typeof value.rules === 'function';
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The imperative counterpart of a schema class: a plain function returning the rules.
|
|
330
|
+
*
|
|
331
|
+
* It receives the same dependencies a class would get through its constructor, so it can reach the
|
|
332
|
+
* container's services too.
|
|
333
|
+
*
|
|
334
|
+
* @param rules - Returns what to validate, per source.
|
|
335
|
+
* @returns A schema instance.
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```typescript
|
|
339
|
+
* export const createUserSchema = defineValidationSchema(({ i18n }) => ({
|
|
340
|
+
* body: z.object({ email: z.string().email(i18n.t('validation.email')) })
|
|
341
|
+
* }))
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
function defineValidationSchema(rules) {
|
|
345
|
+
return (dependencies) => ({ rules: () => rules(dependencies) });
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Read the rules out of whatever a declaration resolved to.
|
|
349
|
+
*
|
|
350
|
+
* @param resolved - A schema instance, a schema, or a rule map.
|
|
351
|
+
* @returns The rules, keyed by source.
|
|
352
|
+
*/
|
|
353
|
+
function rulesOf(resolved) {
|
|
354
|
+
return toValidationRules(isValidationSchema(resolved) ? resolved.rules() : resolved);
|
|
218
355
|
}
|
|
219
356
|
|
|
220
357
|
/**
|
|
@@ -227,16 +364,26 @@ class ValidationServiceProvider {
|
|
|
227
364
|
* Platform-agnostic: the event only needs a `get(key)` method, so it works for HTTP, CLI, browser
|
|
228
365
|
* or any other context.
|
|
229
366
|
*
|
|
367
|
+
* Returns the **parsed** values, keyed as the rules were. A schema does not only accept or reject,
|
|
368
|
+
* it coerces and strips: `z.coerce.number()` turns `"42"` into `42`, and a strict object drops the
|
|
369
|
+
* keys you did not declare. Throwing the parsed value away and reading the raw input again is how
|
|
370
|
+
* an application ends up validating one value and using another.
|
|
371
|
+
*
|
|
230
372
|
* @param event - The incoming event (anything with `get`).
|
|
231
373
|
* @param rules - The validation rules.
|
|
232
374
|
* @param validator - The validator to use (defaults to a fresh stateless one).
|
|
375
|
+
* @returns The parsed value for each rule.
|
|
233
376
|
* @throws {ValidationError} When any input fails validation.
|
|
234
377
|
*/
|
|
235
378
|
function validateEvent(event, rules, validator = Validator.create()) {
|
|
236
379
|
const issues = [];
|
|
380
|
+
const parsed = {};
|
|
237
381
|
for (const [key, schema] of Object.entries(rules)) {
|
|
238
382
|
const result = validator.validate(schema, event.get(key));
|
|
239
|
-
if (
|
|
383
|
+
if (result.success) {
|
|
384
|
+
parsed[key] = result.value;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
240
387
|
for (const issue of result.issues) {
|
|
241
388
|
issues.push({ ...issue, path: [key, ...issue.path] });
|
|
242
389
|
}
|
|
@@ -245,8 +392,327 @@ function validateEvent(event, rules, validator = Validator.create()) {
|
|
|
245
392
|
if (issues.length > 0) {
|
|
246
393
|
throw new ValidationError('The given data failed validation.', { issues });
|
|
247
394
|
}
|
|
395
|
+
return parsed;
|
|
248
396
|
}
|
|
249
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Metadata key carrying what a handler method declared with `@Validate`.
|
|
400
|
+
*
|
|
401
|
+
* The module owns its key, which is what makes it independent: validation works whether or not a
|
|
402
|
+
* router is in play, because the declaration lives on the handler, not on a route.
|
|
403
|
+
*/
|
|
404
|
+
const VALIDATE_KEY = '@stone-js/validation/validate';
|
|
405
|
+
/**
|
|
406
|
+
* Metadata key carrying the alias a schema class registered itself under.
|
|
407
|
+
*/
|
|
408
|
+
const VALIDATION_SCHEMA_KEY = '@stone-js/validation/schema';
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Route middleware: validates what a route declared, before its handler runs.
|
|
412
|
+
*
|
|
413
|
+
* A route says what it accepts, once, where the route is defined:
|
|
414
|
+
*
|
|
415
|
+
* ```ts
|
|
416
|
+
* @Post('/users', { validation: { body: CreateUserSchema } })
|
|
417
|
+
* ```
|
|
418
|
+
*
|
|
419
|
+
* A single schema means the body, which is what almost every route means; a map validates several
|
|
420
|
+
* sources at once (`{ body, query, params }`).
|
|
421
|
+
*
|
|
422
|
+
* This middleware reads that from the matched route and validates it. On success each **parsed**
|
|
423
|
+
* source is published in the event's metadata under a predictable name, so a handler reads
|
|
424
|
+
* `event.get<CreateUser>('validatedBody')` with no helper and no import. That matters more than it
|
|
425
|
+
* looks: a schema coerces and strips, and re-reading the raw value is how an application validates
|
|
426
|
+
* one thing and uses another. On failure it throws a `ValidationError` carrying every issue at once.
|
|
427
|
+
*
|
|
428
|
+
* The route stays the single description of itself, which is what lets `@stone-js/openapi` publish
|
|
429
|
+
* the request schema without being told a second time.
|
|
430
|
+
*/
|
|
431
|
+
class ValidateRouteMiddleware {
|
|
432
|
+
blueprint;
|
|
433
|
+
container;
|
|
434
|
+
validator;
|
|
435
|
+
/**
|
|
436
|
+
* @param dependencies - Auto-wired container services.
|
|
437
|
+
*/
|
|
438
|
+
constructor({ blueprint, container }) {
|
|
439
|
+
this.blueprint = blueprint;
|
|
440
|
+
this.container = container;
|
|
441
|
+
this.validator = Validator.create();
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Validate the matched route's declared input, then continue.
|
|
445
|
+
*
|
|
446
|
+
* @param event - The incoming event.
|
|
447
|
+
* @param next - The next middleware.
|
|
448
|
+
* @returns The response.
|
|
449
|
+
*/
|
|
450
|
+
async handle(event, next) {
|
|
451
|
+
const rules = this.rulesFor(event);
|
|
452
|
+
if (rules !== undefined) {
|
|
453
|
+
event.setMetadataValue(this.validateSources(event, rules));
|
|
454
|
+
}
|
|
455
|
+
return await next(event);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Validate every declared source, and return what to publish on the event.
|
|
459
|
+
*
|
|
460
|
+
* Every source is validated before anything is thrown, so a caller sees the full picture rather
|
|
461
|
+
* than one failure at a time.
|
|
462
|
+
*
|
|
463
|
+
* @param event - The incoming event.
|
|
464
|
+
* @param rules - The rules, keyed by source.
|
|
465
|
+
* @returns The parsed sources, keyed by their metadata name.
|
|
466
|
+
* @throws {ValidationError} When any source fails.
|
|
467
|
+
*/
|
|
468
|
+
validateSources(event, rules) {
|
|
469
|
+
const issues = [];
|
|
470
|
+
const parsed = {};
|
|
471
|
+
for (const [source, schema] of Object.entries(rules)) {
|
|
472
|
+
const result = this.validator.validate(schema, readSource(event, source));
|
|
473
|
+
if (result.success) {
|
|
474
|
+
parsed[metadataKeyFor(source)] = result.value;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
issues.push(...result.issues.map((issue) => ({ ...issue, path: [source, ...issue.path] })));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (issues.length > 0) {
|
|
481
|
+
throw new ValidationError('The given data failed validation.', { issues });
|
|
482
|
+
}
|
|
483
|
+
return parsed;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The rules the matched route declared, with a registered name resolved to its rule set.
|
|
487
|
+
*
|
|
488
|
+
* @param event - The incoming event.
|
|
489
|
+
* @returns The rules, or `undefined` when the route declares none.
|
|
490
|
+
*/
|
|
491
|
+
rulesFor(event) {
|
|
492
|
+
const declared = this.declarationFor(event);
|
|
493
|
+
if (declared === undefined) {
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
if (typeof declared !== 'string') {
|
|
497
|
+
return rulesOf(declared);
|
|
498
|
+
}
|
|
499
|
+
const registry = this.blueprint.get('stone.validation', {}).schemas ?? {};
|
|
500
|
+
const named = registry[declared];
|
|
501
|
+
if (named === undefined) {
|
|
502
|
+
throw new TypeError(`The route declares \`validation: '${declared}'\`, but no rule set is registered under that ` +
|
|
503
|
+
'name. Register it with `blueprint.set(\'stone.validation.schemas\', { ' + declared + ': { … } })`, ' +
|
|
504
|
+
'or declare the rules inline on the route.');
|
|
505
|
+
}
|
|
506
|
+
return rulesOf(this.resolve(named));
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* What the handler about to run declared, from either of the two places it may live.
|
|
510
|
+
*
|
|
511
|
+
* The route's own option comes first, because when a router is in play a route is the single
|
|
512
|
+
* description of itself and that is where a reader looks. Failing that, the handler's own
|
|
513
|
+
* `@Validate` metadata is read: that form owns its key and needs no router at all, so the same
|
|
514
|
+
* module validates a routed request, a single-handler service, a CLI command or a browser event.
|
|
515
|
+
*
|
|
516
|
+
* @param event - The incoming event.
|
|
517
|
+
* @returns What was declared, or `undefined`.
|
|
518
|
+
*/
|
|
519
|
+
declarationFor(event) {
|
|
520
|
+
// Duck-typed throughout: the kernel is agnostic, and an event without a router carries no route.
|
|
521
|
+
const route = event.getRoute?.();
|
|
522
|
+
const onRoute = route?.getOption?.('validation');
|
|
523
|
+
if (onRoute !== undefined) {
|
|
524
|
+
return onRoute;
|
|
525
|
+
}
|
|
526
|
+
const handler = route?.getOption?.('handler') ??
|
|
527
|
+
this.blueprint.get('stone.kernel.eventHandler', {});
|
|
528
|
+
return this.declaredOnHandler(handler);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* What a handler declared with `@Validate`, if anything.
|
|
532
|
+
*
|
|
533
|
+
* @param handler - The handler about to run.
|
|
534
|
+
* @returns What the matching method declared, or `undefined`.
|
|
535
|
+
*/
|
|
536
|
+
declaredOnHandler(handler) {
|
|
537
|
+
const module = handler?.module;
|
|
538
|
+
if (module === undefined || !hasMetadata(module, VALIDATE_KEY)) {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
const declarations = getMetadata(module, VALIDATE_KEY, []);
|
|
542
|
+
const action = handler?.action;
|
|
543
|
+
// A single-handler module declares one; a controller declares one per method.
|
|
544
|
+
return (action === undefined
|
|
545
|
+
? declarations[0]
|
|
546
|
+
: declarations.find((declaration) => declaration.action === action))?.validation;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Resolve a registered entry: a schema class goes through the container, so its constructor gets
|
|
550
|
+
* the services it asked for and `rules()` can use them, i18n included.
|
|
551
|
+
*
|
|
552
|
+
* @param entry - What the registry holds.
|
|
553
|
+
* @returns Something `rulesOf` can read.
|
|
554
|
+
*/
|
|
555
|
+
resolve(entry) {
|
|
556
|
+
if (!isValidationSchemaClass(entry)) {
|
|
557
|
+
return entry;
|
|
558
|
+
}
|
|
559
|
+
return this.container?.resolve?.(entry, true) ?? new entry({});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Meta middleware for route-declared validation.
|
|
564
|
+
*
|
|
565
|
+
* Registered on `stone.router.middleware` by {@link validationBlueprint}, so it runs for every
|
|
566
|
+
* matched route and does nothing on the routes that declare no validation.
|
|
567
|
+
*/
|
|
568
|
+
const MetaValidateRouteMiddleware = {
|
|
569
|
+
module: ValidateRouteMiddleware,
|
|
570
|
+
isClass: true,
|
|
571
|
+
priority: 5
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Build-phase middleware: collect every class registered with `@ValidationSchema` into the registry.
|
|
576
|
+
*
|
|
577
|
+
* The same scan the router does for its route definitions, applied to this module's own key. After
|
|
578
|
+
* it runs, `stone.validation.schemas` maps each alias to its class, so a route or a handler can name
|
|
579
|
+
* a schema instead of importing it, and `@stone-js/openapi` can walk the registry to publish request
|
|
580
|
+
* schemas without loading anything itself.
|
|
581
|
+
*
|
|
582
|
+
* @param context - The blueprint context.
|
|
583
|
+
* @param next - The next blueprint middleware.
|
|
584
|
+
* @returns The blueprint.
|
|
585
|
+
*/
|
|
586
|
+
async function ValidationSchemaMiddleware(context, next) {
|
|
587
|
+
const registered = context
|
|
588
|
+
.modules
|
|
589
|
+
.filter((module) => hasMetadata(module, VALIDATION_SCHEMA_KEY))
|
|
590
|
+
.reduce((registry, module) => {
|
|
591
|
+
const { alias } = getMetadata(module, VALIDATION_SCHEMA_KEY, {});
|
|
592
|
+
return { ...registry, [alias ?? module.name]: module };
|
|
593
|
+
}, {});
|
|
594
|
+
if (Object.keys(registered).length > 0) {
|
|
595
|
+
context.blueprint.set('stone.validation.schemas', {
|
|
596
|
+
...context.blueprint.get('stone.validation.schemas', {}),
|
|
597
|
+
...registered
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return await next(context);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Meta blueprint middleware for schema discovery.
|
|
604
|
+
*/
|
|
605
|
+
const MetaValidationSchemaMiddleware = {
|
|
606
|
+
module: ValidationSchemaMiddleware,
|
|
607
|
+
priority: 5
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Opt-in blueprint: import and register it to enable validation.
|
|
612
|
+
*
|
|
613
|
+
* It contributes the validation service provider and the route middleware that validates what a
|
|
614
|
+
* route declared under `validation`. Both `stone.providers` and `stone.router.middleware` are
|
|
615
|
+
* arrays, so this merges with the rest of the app rather than replacing anything.
|
|
616
|
+
*
|
|
617
|
+
* The route middleware is a no-op on routes that declare nothing, so enabling validation costs an
|
|
618
|
+
* application that does not use it one function call per request.
|
|
619
|
+
*/
|
|
620
|
+
const validationBlueprint = {
|
|
621
|
+
stone: {
|
|
622
|
+
validation: {},
|
|
623
|
+
blueprint: {
|
|
624
|
+
middleware: [
|
|
625
|
+
MetaValidationSchemaMiddleware
|
|
626
|
+
]
|
|
627
|
+
},
|
|
628
|
+
providers: [
|
|
629
|
+
ValidationServiceProvider
|
|
630
|
+
],
|
|
631
|
+
router: {
|
|
632
|
+
middleware: [
|
|
633
|
+
MetaValidateRouteMiddleware
|
|
634
|
+
]
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Class decorator: validate incoming data against the schemas you already write, declaratively.
|
|
641
|
+
*
|
|
642
|
+
* `@Validation()` registers the validation service provider, so `constructor ({ validator })` works
|
|
643
|
+
* anywhere and the `@Validate()` route decorators have something to resolve. There is no kernel
|
|
644
|
+
* middleware here: validation runs where it is asked for, not on every event.
|
|
645
|
+
*
|
|
646
|
+
* The declarative half of the pair; `validationBlueprint` handed to `defineStoneApp` is the imperative one.
|
|
647
|
+
*
|
|
648
|
+
* @param options - The validation configuration. Everything is optional.
|
|
649
|
+
* @returns A class decorator.
|
|
650
|
+
*
|
|
651
|
+
* @example
|
|
652
|
+
* ```typescript
|
|
653
|
+
* import { Validation } from '@stone-js/validation'
|
|
654
|
+
*
|
|
655
|
+
* @Validation({ abortEarly: false })
|
|
656
|
+
* @StoneApp({ name: 'my-app' })
|
|
657
|
+
* export class Application {}
|
|
658
|
+
* ```
|
|
659
|
+
*/
|
|
660
|
+
const Validation = (options = {}) => {
|
|
661
|
+
return classDecoratorLegacyWrapper((target, context) => {
|
|
662
|
+
// The blueprint is the single source of truth for what the module declares; the decorator only
|
|
663
|
+
// overrides what it can, its options bucket. Cloning is what lets it: two decorated applications
|
|
664
|
+
// get their own copy instead of sharing the exported constant.
|
|
665
|
+
const blueprint = cloneValue(validationBlueprint);
|
|
666
|
+
blueprint.stone.validation = { ...blueprint.stone.validation, ...options };
|
|
667
|
+
addBlueprint(target, context, blueprint);
|
|
668
|
+
});
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Method decorator: declare what a handler accepts.
|
|
673
|
+
*
|
|
674
|
+
* ```ts
|
|
675
|
+
* @Validate(CreateUserSchema) // the body
|
|
676
|
+
* @Validate({ body: CreateUserSchema, query: Page }) // several sources
|
|
677
|
+
* @Validate('createUser') // a registered schema class
|
|
678
|
+
* ```
|
|
679
|
+
*
|
|
680
|
+
* This knows nothing about the router, and that is the point: the declaration is recorded on the
|
|
681
|
+
* handler itself, under this module's own key, so validation runs the same in a routed application,
|
|
682
|
+
* a single-handler service, a CLI command or the browser. When a router *is* in play you may instead
|
|
683
|
+
* put it on the route (`@Post('/users', { validation: … })`), which keeps the method uncluttered and
|
|
684
|
+
* puts everything a route does in one place; both forms end up in the same middleware.
|
|
685
|
+
*
|
|
686
|
+
* @param validation - What the handler accepts.
|
|
687
|
+
* @returns A method decorator.
|
|
688
|
+
*/
|
|
689
|
+
const Validate = (validation) => {
|
|
690
|
+
return methodDecoratorLegacyWrapper((_target, context) => {
|
|
691
|
+
addMetadata(context, VALIDATE_KEY, { action: context.name, validation });
|
|
692
|
+
});
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Class decorator: register a schema class under a name.
|
|
697
|
+
*
|
|
698
|
+
* ```ts
|
|
699
|
+
* @ValidationSchema('createUser')
|
|
700
|
+
* export class CreateUserSchema implements IValidationSchema { rules () { … } }
|
|
701
|
+
* ```
|
|
702
|
+
*
|
|
703
|
+
* Routes and handlers then refer to it by name (`@Validate('createUser')`), so schemas live in their
|
|
704
|
+
* own files, organised however the application likes, and nothing has to be imported at the route.
|
|
705
|
+
* The class is resolved by the container, so its constructor receives services and `rules()` can use
|
|
706
|
+
* them.
|
|
707
|
+
*
|
|
708
|
+
* @param alias - The name the schema is registered under. Defaults to the class name, which the
|
|
709
|
+
* discovery middleware fills in, since it is the one holding the class.
|
|
710
|
+
* @returns A class decorator.
|
|
711
|
+
*/
|
|
712
|
+
const ValidationSchema = (alias) => {
|
|
713
|
+
return setClassMetadata(VALIDATION_SCHEMA_KEY, { alias });
|
|
714
|
+
};
|
|
715
|
+
|
|
250
716
|
/**
|
|
251
717
|
* Builds a route middleware that validates the event's inputs before the handler runs.
|
|
252
718
|
*
|
|
@@ -268,19 +734,4 @@ function validate(rules) {
|
|
|
268
734
|
};
|
|
269
735
|
}
|
|
270
736
|
|
|
271
|
-
|
|
272
|
-
* Opt-in blueprint: import and register it to enable validation.
|
|
273
|
-
*
|
|
274
|
-
* It contributes the validation service provider. `stone.providers` is an array, so this merges
|
|
275
|
-
* with the rest of the app rather than replacing anything.
|
|
276
|
-
*/
|
|
277
|
-
const validationBlueprint = {
|
|
278
|
-
stone: {
|
|
279
|
-
validation: {},
|
|
280
|
-
providers: [
|
|
281
|
-
ValidationServiceProvider
|
|
282
|
-
]
|
|
283
|
-
}
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
export { ValidationError, ValidationServiceProvider, Validator, fromStandard, fromZod, isNativeSchema, isStandardSchema, isZodLike, resolveSchema, validate, validateEvent, validationBlueprint };
|
|
737
|
+
export { MetaValidateRouteMiddleware, MetaValidationSchemaMiddleware, VALIDATE_KEY, VALIDATION_SCHEMA_KEY, Validate, ValidateRouteMiddleware, Validation, ValidationError, ValidationSchema, ValidationSchemaMiddleware, ValidationServiceProvider, Validator, defineValidationSchema, fromStandard, fromZod, isNativeSchema, isSchemaLike, isStandardSchema, isValidationSchema, isValidationSchemaClass, isZodLike, metadataKeyFor, readSource, resolveSchema, rulesOf, toValidationRules, validate, validateEvent, validationBlueprint };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BlueprintContext, ClassType, IBlueprint, NextMiddleware, type MetaMiddleware } from '@stone-js/core';
|
|
2
|
+
/**
|
|
3
|
+
* Build-phase middleware: collect every class registered with `@ValidationSchema` into the registry.
|
|
4
|
+
*
|
|
5
|
+
* The same scan the router does for its route definitions, applied to this module's own key. After
|
|
6
|
+
* it runs, `stone.validation.schemas` maps each alias to its class, so a route or a handler can name
|
|
7
|
+
* a schema instead of importing it, and `@stone-js/openapi` can walk the registry to publish request
|
|
8
|
+
* schemas without loading anything itself.
|
|
9
|
+
*
|
|
10
|
+
* @param context - The blueprint context.
|
|
11
|
+
* @param next - The next blueprint middleware.
|
|
12
|
+
* @returns The blueprint.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ValidationSchemaMiddleware(context: BlueprintContext<IBlueprint, ClassType>, next: NextMiddleware<BlueprintContext<IBlueprint, ClassType>, IBlueprint>): Promise<IBlueprint>;
|
|
15
|
+
/**
|
|
16
|
+
* Meta blueprint middleware for schema discovery.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MetaValidationSchemaMiddleware: MetaMiddleware<any, any>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { RouteValidationInput } from '../sources.js';
|
|
2
|
+
import { IBlueprint, IContainer, IncomingEvent, NextMiddleware, OutgoingResponse, type MetaMiddleware } from '@stone-js/core';
|
|
3
|
+
/**
|
|
4
|
+
* What a route's `validation` option accepts: a schema, a map of schemas keyed by source, or the
|
|
5
|
+
* name of either registered under `stone.validation.schemas`.
|
|
6
|
+
*/
|
|
7
|
+
export type RouteValidation = RouteValidationInput | string;
|
|
8
|
+
/**
|
|
9
|
+
* Route middleware: validates what a route declared, before its handler runs.
|
|
10
|
+
*
|
|
11
|
+
* A route says what it accepts, once, where the route is defined:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* @Post('/users', { validation: { body: CreateUserSchema } })
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* A single schema means the body, which is what almost every route means; a map validates several
|
|
18
|
+
* sources at once (`{ body, query, params }`).
|
|
19
|
+
*
|
|
20
|
+
* This middleware reads that from the matched route and validates it. On success each **parsed**
|
|
21
|
+
* source is published in the event's metadata under a predictable name, so a handler reads
|
|
22
|
+
* `event.get<CreateUser>('validatedBody')` with no helper and no import. That matters more than it
|
|
23
|
+
* looks: a schema coerces and strips, and re-reading the raw value is how an application validates
|
|
24
|
+
* one thing and uses another. On failure it throws a `ValidationError` carrying every issue at once.
|
|
25
|
+
*
|
|
26
|
+
* The route stays the single description of itself, which is what lets `@stone-js/openapi` publish
|
|
27
|
+
* the request schema without being told a second time.
|
|
28
|
+
*/
|
|
29
|
+
export declare class ValidateRouteMiddleware {
|
|
30
|
+
private readonly blueprint;
|
|
31
|
+
private readonly container?;
|
|
32
|
+
private readonly validator;
|
|
33
|
+
/**
|
|
34
|
+
* @param dependencies - Auto-wired container services.
|
|
35
|
+
*/
|
|
36
|
+
constructor({ blueprint, container }: {
|
|
37
|
+
blueprint: IBlueprint;
|
|
38
|
+
container?: IContainer;
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* Validate the matched route's declared input, then continue.
|
|
42
|
+
*
|
|
43
|
+
* @param event - The incoming event.
|
|
44
|
+
* @param next - The next middleware.
|
|
45
|
+
* @returns The response.
|
|
46
|
+
*/
|
|
47
|
+
handle(event: IncomingEvent, next: NextMiddleware<IncomingEvent, OutgoingResponse>): Promise<OutgoingResponse>;
|
|
48
|
+
/**
|
|
49
|
+
* Validate every declared source, and return what to publish on the event.
|
|
50
|
+
*
|
|
51
|
+
* Every source is validated before anything is thrown, so a caller sees the full picture rather
|
|
52
|
+
* than one failure at a time.
|
|
53
|
+
*
|
|
54
|
+
* @param event - The incoming event.
|
|
55
|
+
* @param rules - The rules, keyed by source.
|
|
56
|
+
* @returns The parsed sources, keyed by their metadata name.
|
|
57
|
+
* @throws {ValidationError} When any source fails.
|
|
58
|
+
*/
|
|
59
|
+
private validateSources;
|
|
60
|
+
/**
|
|
61
|
+
* The rules the matched route declared, with a registered name resolved to its rule set.
|
|
62
|
+
*
|
|
63
|
+
* @param event - The incoming event.
|
|
64
|
+
* @returns The rules, or `undefined` when the route declares none.
|
|
65
|
+
*/
|
|
66
|
+
private rulesFor;
|
|
67
|
+
/**
|
|
68
|
+
* What the handler about to run declared, from either of the two places it may live.
|
|
69
|
+
*
|
|
70
|
+
* The route's own option comes first, because when a router is in play a route is the single
|
|
71
|
+
* description of itself and that is where a reader looks. Failing that, the handler's own
|
|
72
|
+
* `@Validate` metadata is read: that form owns its key and needs no router at all, so the same
|
|
73
|
+
* module validates a routed request, a single-handler service, a CLI command or a browser event.
|
|
74
|
+
*
|
|
75
|
+
* @param event - The incoming event.
|
|
76
|
+
* @returns What was declared, or `undefined`.
|
|
77
|
+
*/
|
|
78
|
+
private declarationFor;
|
|
79
|
+
/**
|
|
80
|
+
* What a handler declared with `@Validate`, if anything.
|
|
81
|
+
*
|
|
82
|
+
* @param handler - The handler about to run.
|
|
83
|
+
* @returns What the matching method declared, or `undefined`.
|
|
84
|
+
*/
|
|
85
|
+
private declaredOnHandler;
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a registered entry: a schema class goes through the container, so its constructor gets
|
|
88
|
+
* the services it asked for and `rules()` can use them, i18n included.
|
|
89
|
+
*
|
|
90
|
+
* @param entry - What the registry holds.
|
|
91
|
+
* @returns Something `rulesOf` can read.
|
|
92
|
+
*/
|
|
93
|
+
private resolve;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Meta middleware for route-declared validation.
|
|
97
|
+
*
|
|
98
|
+
* Registered on `stone.router.middleware` by {@link validationBlueprint}, so it runs for every
|
|
99
|
+
* matched route and does nothing on the routes that declare no validation.
|
|
100
|
+
*/
|
|
101
|
+
export declare const MetaValidateRouteMiddleware: MetaMiddleware<any, any>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ValidationRules } from '../validateEvent';
|
|
1
|
+
import { ValidationRules } from '../validateEvent.js';
|
|
2
2
|
import { IncomingEvent, OutgoingResponse, FunctionalMiddleware } from '@stone-js/core';
|
|
3
3
|
/**
|
|
4
4
|
* Builds a route middleware that validates the event's inputs before the handler runs.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ValidationOptions } from '../declarations';
|
|
1
|
+
import { ValidationOptions } from '../declarations.js';
|
|
2
2
|
import { AppConfig, StoneBlueprint } from '@stone-js/core';
|
|
3
3
|
/**
|
|
4
4
|
* Validation configuration bucket (`stone.validation`).
|
|
@@ -20,7 +20,11 @@ export interface ValidationBlueprint extends StoneBlueprint {
|
|
|
20
20
|
/**
|
|
21
21
|
* Opt-in blueprint: import and register it to enable validation.
|
|
22
22
|
*
|
|
23
|
-
* It contributes the validation service provider
|
|
24
|
-
*
|
|
23
|
+
* It contributes the validation service provider and the route middleware that validates what a
|
|
24
|
+
* route declared under `validation`. Both `stone.providers` and `stone.router.middleware` are
|
|
25
|
+
* arrays, so this merges with the rest of the app rather than replacing anything.
|
|
26
|
+
*
|
|
27
|
+
* The route middleware is a no-op on routes that declare nothing, so enabling validation costs an
|
|
28
|
+
* application that does not use it one function call per request.
|
|
25
29
|
*/
|
|
26
30
|
export declare const validationBlueprint: ValidationBlueprint;
|
package/dist/schema.d.ts
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ValidationRules } from './validateEvent.js';
|
|
2
|
+
import { RouteValidationInput } from './sources.js';
|
|
3
|
+
/**
|
|
4
|
+
* The contract a schema class exposes.
|
|
5
|
+
*
|
|
6
|
+
* One method, `rules()`, returning what to validate per source. It is deliberately declarative:
|
|
7
|
+
* a contract that *describes* itself can be read by `@stone-js/openapi` to publish the request
|
|
8
|
+
* schema, which a method that merely validated could never be.
|
|
9
|
+
*
|
|
10
|
+
* The class is resolved by the container, so its constructor receives services and `rules()` can use
|
|
11
|
+
* them. That is what makes translated messages possible without a second method for them:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* @ValidationSchema('createUser')
|
|
15
|
+
* export class CreateUserSchema implements IValidationSchema {
|
|
16
|
+
* private readonly i18n: II18n
|
|
17
|
+
* constructor ({ i18n }: { i18n: II18n }) { this.i18n = i18n }
|
|
18
|
+
*
|
|
19
|
+
* rules () {
|
|
20
|
+
* return {
|
|
21
|
+
* body: z.object({ email: z.string().email(this.i18n.t('validation.email')) })
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The same class validates a form on the frontend, because nothing here knows about HTTP: resolve it
|
|
28
|
+
* from the container and call `rules()`, or hand it to the `Validator` directly. One schema, both
|
|
29
|
+
* sides, which is the whole reason the module is agnostic.
|
|
30
|
+
*/
|
|
31
|
+
export interface IValidationSchema {
|
|
32
|
+
/** What this schema validates, per source. A bare schema is read as the body. */
|
|
33
|
+
rules: () => RouteValidationInput;
|
|
34
|
+
}
|
|
35
|
+
/** A class that can be resolved into an {@link IValidationSchema}. */
|
|
36
|
+
export type ValidationSchemaClass = new (...args: any[]) => IValidationSchema;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a value is a schema class rather than a schema or a rule map.
|
|
39
|
+
*
|
|
40
|
+
* @param value - The candidate.
|
|
41
|
+
* @returns Whether it must be resolved before use.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isValidationSchemaClass(value: unknown): value is ValidationSchemaClass;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a value is an already-built schema instance.
|
|
46
|
+
*
|
|
47
|
+
* @param value - The candidate.
|
|
48
|
+
* @returns Whether it exposes `rules()`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isValidationSchema(value: unknown): value is IValidationSchema;
|
|
51
|
+
/**
|
|
52
|
+
* The imperative counterpart of a schema class: a plain function returning the rules.
|
|
53
|
+
*
|
|
54
|
+
* It receives the same dependencies a class would get through its constructor, so it can reach the
|
|
55
|
+
* container's services too.
|
|
56
|
+
*
|
|
57
|
+
* @param rules - Returns what to validate, per source.
|
|
58
|
+
* @returns A schema instance.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* export const createUserSchema = defineValidationSchema(({ i18n }) => ({
|
|
63
|
+
* body: z.object({ email: z.string().email(i18n.t('validation.email')) })
|
|
64
|
+
* }))
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export declare function defineValidationSchema(rules: (dependencies: any) => RouteValidationInput): (dependencies: any) => IValidationSchema;
|
|
68
|
+
/**
|
|
69
|
+
* Read the rules out of whatever a declaration resolved to.
|
|
70
|
+
*
|
|
71
|
+
* @param resolved - A schema instance, a schema, or a rule map.
|
|
72
|
+
* @returns The rules, keyed by source.
|
|
73
|
+
*/
|
|
74
|
+
export declare function rulesOf(resolved: IValidationSchema | RouteValidationInput): ValidationRules;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { SchemaInput } from './declarations.js';
|
|
2
|
+
import { ValidationRules } from './validateEvent.js';
|
|
3
|
+
/**
|
|
4
|
+
* What a route may declare under `validation`.
|
|
5
|
+
*
|
|
6
|
+
* A single schema validates the **body**, which is what almost every route means:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* @Post('/users', { validation: CreateUserSchema })
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* A map validates several sources at once, each under its own key:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* @Get('/users', { validation: { query: ListQuerySchema, params: IdSchema } })
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export type RouteValidationInput = SchemaInput | ValidationRules;
|
|
19
|
+
/** The source a rule reads from, when it is one of the ones an event exposes wholesale. */
|
|
20
|
+
export type ValidationSource = 'body' | 'query' | 'params';
|
|
21
|
+
/**
|
|
22
|
+
* A duck-typed event: the kernel is agnostic, so this reads whatever the context happens to expose
|
|
23
|
+
* and never imports an HTTP type.
|
|
24
|
+
*/
|
|
25
|
+
interface SourceEvent {
|
|
26
|
+
body?: unknown;
|
|
27
|
+
params?: unknown;
|
|
28
|
+
query?: unknown;
|
|
29
|
+
getBody?: <T>() => T | undefined;
|
|
30
|
+
get: <T>(key: string) => T | undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether a value is a schema rather than a map of schemas.
|
|
34
|
+
*
|
|
35
|
+
* A schema is recognised by what it can do, not by what it is: every engine Stone.js accepts exposes
|
|
36
|
+
* one of `validate`, `~standard`, `parse` or `safeParse`. Anything else is treated as a map of
|
|
37
|
+
* sources, which is why `{ body: X }` and `X` can share one option without ambiguity.
|
|
38
|
+
*
|
|
39
|
+
* @param value - The declared value.
|
|
40
|
+
* @returns Whether it is a single schema.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isSchemaLike(value: unknown): value is SchemaInput;
|
|
43
|
+
/**
|
|
44
|
+
* Normalise what a route declared into rules keyed by source.
|
|
45
|
+
*
|
|
46
|
+
* A bare schema becomes `{ body: schema }`, because a route that names one schema means its payload.
|
|
47
|
+
*
|
|
48
|
+
* @param declared - What the route declared.
|
|
49
|
+
* @returns The rules, keyed by source.
|
|
50
|
+
*/
|
|
51
|
+
export declare function toValidationRules(declared: RouteValidationInput): ValidationRules;
|
|
52
|
+
/**
|
|
53
|
+
* Read a whole source off an event.
|
|
54
|
+
*
|
|
55
|
+
* `body`, `query` and `params` are read as wholes, because a schema validates the payload, not one
|
|
56
|
+
* field of it. `event.get('body')` would look for a field *named* body inside the body, which is a
|
|
57
|
+
* different question. Any other key falls back to `event.get`, so a context that exposes something
|
|
58
|
+
* else (a CLI argument set, a message attribute) validates just as well.
|
|
59
|
+
*
|
|
60
|
+
* `query` is normalised from `URLSearchParams` to a plain object, since that is what a schema can
|
|
61
|
+
* parse.
|
|
62
|
+
*
|
|
63
|
+
* @param event - The incoming event.
|
|
64
|
+
* @param source - The source name.
|
|
65
|
+
* @returns The value to validate.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readSource(event: SourceEvent, source: string): unknown;
|
|
68
|
+
/**
|
|
69
|
+
* The metadata key a validated source is published under.
|
|
70
|
+
*
|
|
71
|
+
* `body` becomes `validatedBody`, `query` becomes `validatedQuery`. The name is predictable on
|
|
72
|
+
* purpose: a handler reads `event.get<CreateUser>('validatedBody')` and needs no helper, no import
|
|
73
|
+
* and nothing to remember beyond the source it declared.
|
|
74
|
+
*
|
|
75
|
+
* @param source - The source name.
|
|
76
|
+
* @returns The metadata key.
|
|
77
|
+
*/
|
|
78
|
+
export declare function metadataKeyFor(source: string): string;
|
|
79
|
+
export {};
|
package/dist/validateEvent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IValidator, SchemaInput } from './declarations';
|
|
1
|
+
import { IValidator, SchemaInput } from './declarations.js';
|
|
2
2
|
/**
|
|
3
3
|
* A map of event keys to the schema that validates each one.
|
|
4
4
|
*
|
|
@@ -22,9 +22,15 @@ export interface ReadableEvent {
|
|
|
22
22
|
* Platform-agnostic: the event only needs a `get(key)` method, so it works for HTTP, CLI, browser
|
|
23
23
|
* or any other context.
|
|
24
24
|
*
|
|
25
|
+
* Returns the **parsed** values, keyed as the rules were. A schema does not only accept or reject,
|
|
26
|
+
* it coerces and strips: `z.coerce.number()` turns `"42"` into `42`, and a strict object drops the
|
|
27
|
+
* keys you did not declare. Throwing the parsed value away and reading the raw input again is how
|
|
28
|
+
* an application ends up validating one value and using another.
|
|
29
|
+
*
|
|
25
30
|
* @param event - The incoming event (anything with `get`).
|
|
26
31
|
* @param rules - The validation rules.
|
|
27
32
|
* @param validator - The validator to use (defaults to a fresh stateless one).
|
|
33
|
+
* @returns The parsed value for each rule.
|
|
28
34
|
* @throws {ValidationError} When any input fails validation.
|
|
29
35
|
*/
|
|
30
|
-
export declare function validateEvent(event: ReadableEvent, rules: ValidationRules, validator?: IValidator):
|
|
36
|
+
export declare function validateEvent(event: ReadableEvent, rules: ValidationRules, validator?: IValidator): Record<string, unknown>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stone-js/validation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "Framework-agnostic input validation for Stone.js. Define a schema once (Zod, Valibot, ArkType — anything Standard Schema) and validate it identically on the backend and the frontend.",
|
|
5
5
|
"author": "Mr. Stone <evensstone@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"node": ">=18.17.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@stone-js/core": "0.8.
|
|
43
|
+
"@stone-js/core": "0.8.9"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@commitlint/cli": "^19.8.1",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"typescript": "^5.6.3",
|
|
63
63
|
"vitest": "^3.2.4",
|
|
64
64
|
"zod": "^3.24.1",
|
|
65
|
-
"@stone-js/core": "0.8.
|
|
65
|
+
"@stone-js/core": "0.8.9"
|
|
66
66
|
},
|
|
67
67
|
"ts-standard": {
|
|
68
68
|
"globals": [
|
|
@@ -74,6 +74,9 @@
|
|
|
74
74
|
"beforeEach"
|
|
75
75
|
]
|
|
76
76
|
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"@stone-js/config": "0.8.9"
|
|
79
|
+
},
|
|
77
80
|
"scripts": {
|
|
78
81
|
"lint": "ts-standard src",
|
|
79
82
|
"lint:fix": "ts-standard --fix src tests",
|