Skip to content

Commit 5055162

Browse files
committed
feat(validation): add per-source request validation
- Add Mware.validator factory and Validator middleware running source contracts - Add Reason mapper turning validation throws into 422 with reason cause - Add Source extractor reading body, cookies, headers, json, params, query - Add standalone Validator.check and Validator.read helpers - Add validated reserved state key to stateKeys and reservedStateKeys - Add validation interfaces: schema, source extractor, validated data - Export Validator and re-export Define, Loader, contract types from root
1 parent aafa1ec commit 5055162

12 files changed

Lines changed: 228 additions & 5 deletions

File tree

src/core/Handler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,17 @@ export class Handler {
1313
worker: Handler.stateKey<Types.WorkerRunHandle>('worker'),
1414
session: Handler.stateKey<Types.DataRecord | null>('session'),
1515
setSession: Handler.stateKey<(data: Types.DataRecord) => Promise<void>>('setSession'),
16-
clearSession: Handler.stateKey<() => void>('clearSession')
16+
clearSession: Handler.stateKey<() => void>('clearSession'),
17+
validated: Handler.stateKey<Types.DataRecord>('validated')
1718
} as const
1819
/** Reserved framework state key names, not writable by public setState */
1920
static readonly reservedStateKeys: ReadonlySet<string> = new Set([
2021
'view',
2122
'worker',
2223
'session',
2324
'setSession',
24-
'clearSession'
25+
'clearSession',
26+
'validated'
2527
])
2628

2729
/**

src/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
/** Public API for Deserve HTTP server. */
2-
export { Context } from '@core/Context.ts'
3-
export { Mware, WrapMware } from '@middleware/index.ts'
4-
export { Router } from '@routing/Router.ts'
52
export type * from '@interfaces/index.ts'
3+
export { Context } from '@core/index.ts'
4+
export { Mware, WrapMware } from '@middleware/index.ts'
5+
export { Router } from '@routing/index.ts'
6+
export { Validator } from '@validation/index.ts'
7+
8+
/** Re-exports Typebox contract helpers. */
9+
export type * from '@neabyte/typebox'
10+
export { Define, Loader } from '@neabyte/typebox'

src/interfaces/Routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export interface StateKeysMap {
9595
readonly setSession: Types.StateKey<(data: Types.DataRecord) => Promise<void>>
9696
/** Key for the session clearer */
9797
readonly clearSession: Types.StateKey<() => void>
98+
/** Key for validated request data */
99+
readonly validated: Types.StateKey<Types.DataRecord>
98100
}
99101

100102
/** Route entry for type-safe dispatch. */

src/interfaces/Validation.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type * as Types from '@interfaces/index.ts'
2+
import type * as Core from '@core/index.ts'
3+
import type { ContractFn } from '@neabyte/typebox'
4+
5+
/** Per-source validation contract schema. */
6+
export interface ValidationSchema {
7+
/** Contract for raw request body */
8+
readonly body?: ContractFn
9+
/** Contract for parsed request cookies */
10+
readonly cookies?: ContractFn
11+
/** Contract for request header record */
12+
readonly headers?: ContractFn
13+
/** Contract for parsed JSON body */
14+
readonly json?: ContractFn
15+
/** Contract for matched route params */
16+
readonly params?: ContractFn
17+
/** Contract for query string record */
18+
readonly query?: ContractFn
19+
}
20+
21+
/**
22+
* Extract raw data from a source.
23+
* @description Reads one validation source from the context.
24+
* @param ctx - Request context instance
25+
* @returns Source value, possibly async
26+
*/
27+
export type SourceExtractor = (ctx: Core.Context) => Types.MaybeAsync<unknown>
28+
29+
/**
30+
* Validated output mapped from a schema.
31+
* @description Maps each source to its contract output type.
32+
* @template SchemaType - Validation schema being validated
33+
*/
34+
export type ValidatedData<SchemaType extends ValidationSchema> = {
35+
readonly [Key in keyof SchemaType]: SchemaType[Key] extends (input: never) => infer OutputType
36+
? Awaited<OutputType>
37+
: never
38+
}
39+
40+
/** Allowed validation source key. */
41+
export type ValidationSource = keyof ValidationSchema

src/interfaces/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from '@interfaces/Middleware.ts'
44
export * from '@interfaces/Observability.ts'
55
export * from '@interfaces/Rendering.ts'
66
export * from '@interfaces/Routing.ts'
7+
export * from '@interfaces/Validation.ts'

src/middleware/Loaders.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from '@middleware/CSRF.ts'
66
export * from '@middleware/IP.ts'
77
export * from '@middleware/SecHeaders.ts'
88
export * from '@middleware/Session.ts'
9+
export * from '@middleware/Validator.ts'
910
export * from '@middleware/WebSocket.ts'

src/middleware/Validator.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type * as Types from '@interfaces/index.ts'
2+
import * as Core from '@core/index.ts'
3+
import * as Middleware from '@middleware/index.ts'
4+
import * as Validation from '@validation/index.ts'
5+
import type { ContractFn } from '@neabyte/typebox'
6+
7+
/**
8+
* Request validation middleware for sources.
9+
* @description Runs source contracts, stores validated data on context.
10+
*/
11+
export class Validator {
12+
/**
13+
* Create validation middleware from schema.
14+
* @description Validates each source contract, stores result on context.
15+
* @param schema - Per-source validation contracts
16+
* @returns Middleware that validates request sources
17+
* @throws {Deno.errors.InvalidData} When schema has no source contract
18+
* @throws {Deno.errors.InvalidData} When schema validates params, unavailable before routing
19+
*/
20+
static create(schema: Types.ValidationSchema): Types.MiddlewareFn {
21+
const entries = Object.entries(schema).filter(
22+
(entry): entry is [Types.ValidationSource, ContractFn] => typeof entry[1] === 'function'
23+
)
24+
if (entries.length === 0) {
25+
throw new Deno.errors.InvalidData('Validator requires at least one source contract')
26+
}
27+
if (entries.some(([source]) => source === 'params')) {
28+
throw new Deno.errors.InvalidData(
29+
'Validator cannot validate params in middleware, route params resolve after middleware runs, validate them inside the handler with Validator.check(contract, ctx.params())'
30+
)
31+
}
32+
return Middleware.WrapMware('Validation error', async (ctx, next) => {
33+
const existing = ctx.getState(Core.Handler.stateKeys.validated)
34+
const validated: Types.DataRecord = { ...existing }
35+
for (const [source, contract] of entries) {
36+
const input = await Validation.Source.extract(source, ctx)
37+
try {
38+
validated[source] = contract(input as never)
39+
} catch (error) {
40+
throw Validation.Reason.toStatusError(error)
41+
}
42+
}
43+
ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.validated, validated)
44+
return await next()
45+
})
46+
}
47+
}

src/middleware/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export const Mware = {
2525
Loader.SecHeaders.create(options),
2626
/** Session middleware factory */
2727
session: (options: Types.SessionOptions): Types.MiddlewareFn => Loader.Session.create(options),
28+
/** Validation middleware factory */
29+
validator: (schema: Types.ValidationSchema): Types.MiddlewareFn =>
30+
Loader.Validator.create(schema),
2831
/** WebSocket upgrade middleware factory */
2932
websocket: (options?: Types.WebSocketOptions): Types.MiddlewareFn =>
3033
Loader.WebSocket.create(options)

src/validation/Reason.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type * as Types from '@interfaces/index.ts'
2+
import * as Core from '@core/index.ts'
3+
4+
/**
5+
* Validation error to status mapper.
6+
* @description Maps validation throws to HTTP status errors.
7+
*/
8+
export class Reason {
9+
/**
10+
* Map a validation error to status.
11+
* @description Preserves existing status, else maps client input to 422.
12+
* @param error - Unknown value thrown during validation
13+
* @returns StatusError carrying the resolved status code
14+
*/
15+
static toStatusError(error: unknown): Types.StatusError {
16+
if (Core.Handler.isErrorWithStatus(error)) {
17+
return error
18+
}
19+
if (error instanceof Error && Array.isArray(error.cause)) {
20+
const reasons = (error.cause as readonly unknown[]).filter(
21+
(reason): reason is string => typeof reason === 'string'
22+
)
23+
const message = reasons.length > 0 ? reasons.join('; ') : 'Validation failed'
24+
const statusError = Core.Handler.createStatusError(422, message)
25+
Object.defineProperty(statusError, 'cause', {
26+
value: reasons,
27+
writable: false,
28+
enumerable: false,
29+
configurable: false
30+
})
31+
return statusError
32+
}
33+
return Core.Handler.createStatusError(422, 'Unprocessable request input')
34+
}
35+
}

src/validation/Source.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type * as Types from '@interfaces/index.ts'
2+
import type * as Core from '@core/index.ts'
3+
4+
/**
5+
* Request source value extractor.
6+
* @description Reads validation input from request sources.
7+
*/
8+
export class Source {
9+
/** Extractor map per validation source */
10+
private static readonly extractors: Readonly<
11+
Record<Types.ValidationSource, Types.SourceExtractor>
12+
> = {
13+
body: (ctx) => ctx.body(),
14+
cookies: (ctx) => ctx.cookie(),
15+
headers: (ctx) => ctx.header(),
16+
json: (ctx) => ctx.json(),
17+
params: (ctx) => ctx.params(),
18+
query: (ctx) => ctx.query()
19+
}
20+
21+
/**
22+
* Extract input value for source.
23+
* @description Reads request data for the given source.
24+
* @param source - Validation source name
25+
* @param ctx - Request context instance
26+
* @returns Extracted source value
27+
*/
28+
static extract(source: Types.ValidationSource, ctx: Core.Context): Types.MaybeAsync<unknown> {
29+
return Source.extractors[source](ctx)
30+
}
31+
}

0 commit comments

Comments
 (0)