Middlewares
The framework ships a set of self-documenting middlewares that do two things at once:
- Validate / guard the incoming request at runtime — throwing the right
HttpExceptionwhen something is wrong. - Contribute their own piece of the OpenAPI 3.1 spec — request body schemas, parameter entries, security requirements, allowed content types, auto-error responses.
There's no parallel request: { body, query, headers } declaration on the controller. The middleware is the source of truth. A schema and a guard can't drift apart because they're the same object.
import {
s, defineController,
validateBody, validateQuery, validateHeaders, validateCookies, validatePathParams,
validateContentType,
requireAuth, requireRoles,
} from '@supersec-ai/superman';
defineController<IPostsService>({
middlewares: [
requireAuth('bearerAuth'), // ➡️ user: Principal
requireRoles('author', 'admin'),
validateContentType('application/json'),
validateHeaders(TenancyHeaders),
validatePathParams(PostIdParam), // ➡️ params: { postId }
validateBody(UpdatePostBody, { message: 'Please supply a valid post payload.' }),
],
responses: { 200: { schema: PostResponse } },
handler: async ({ postId, body, user, service }) => // flat: postId; body=UpdatePostDto
service.update(postId, body, user.id),
});Every validate* middleware accepts either an s.* builder (recommended — see docs/schemas.md) or a plain JSON Schema object.
Custom exception message. All validate* middlewares accept an optional second argument { message } that overrides the default exception message — the metadata field (validation errors, supported types, etc.) is preserved.
validateBody(CreatePostBody, { message: 'Please supply a valid post payload.' })
validateQuery(ListPostsQuery, { message: 'Invalid pagination on /posts.' })
validateHeaders(TenancyHeaders, { message: 'Missing tenant context.' })
validatePathParams(PostIdParam, { message: 'Bad post id format.' })
validateContentType({ types: ['application/json'], message: 'This endpoint only accepts JSON.' })Typed handler context. Each shipped middleware also brands its return type so
defineController's handler argument is automatically typed.validateBody(CreatePostBody)produces abody: Infer<typeof CreatePostBody>slot,validatePathParams(PostIdParam)producesparams,requireAuthproducesuser: Principal, and so on. The same body/query/params/headers/cookies leaf properties are also spread at the context root (precedenceparams > body > query > headers > cookies) so handlers can destructure values directly:async ({ postId, title, content, user, service }) => .... Users writing custom self-documenting middlewares can opt into this by returningTypedHandler<'body' | 'query' | …, T>instead of a plainRequestHandler. See docs/api-controllers.md.
At a glance
| Middleware | Runtime effect | Throws | Auto-OpenAPI contribution |
|---|---|---|---|
validateBody | Validates req.body against a schema (or media-type map). | BadRequestException w/ metadata.errors | requestBody.content, auto 400 |
validateQuery | Validates req.query and coerces strings ➡️ typed values. | same | parameters[in: 'query'], auto 400 |
validateHeaders | Validates req.headers and coerces. | same | parameters[in: 'header'], auto 400 |
validateCookies | Validates req.cookies and coerces. | same | parameters[in: 'cookie'], auto 400 |
validatePathParams | Validates req.params, refines :id defaults. | same | refined path-param schemas, auto 400 |
validateContentType | Rejects mismatched Content-Type. | UnsupportedMediaTypeException (415) | requestBody.content keys, auto 415 |
requireAuth | Runs a verifier, populates req.user. | UnauthorizedException (401) | security: [{ scheme: [] }], auto 401 |
requireRoles / authorize | Checks req.user.roles / scopes. | ForbiddenException (403) | scopes merge onto preceding auth scheme, auto 403 |
The framework always auto-injects 429 (rate-limit), 500 (uncaught error), default (catch-all), the X-RateLimit-Remaining response header on every response, and the Retry-After response header on 429 — regardless of which middlewares are present.
Validation middlewares
validateBody
Signature
validateBody(
schemaOrMediaMap: SchemaInput | Record<string, SchemaInput>,
options?: { message?: string },
): RequestHandler
type SchemaInput = JsonSchema | Schema<unknown> // accepts s.* builders or raw JSON SchemaBehaviour
- Validates
req.bodyagainst the supplied schema. No coercion (JSON bodies are already typed). - On failure, throws
BadRequestException('Request body validation failed.', { errors })whereerrorsisArray<{ path, keyword, message }>. - Accepts either a single schema (defaults to
application/json) or a media-type ➡️ schema map. With the map form, the middleware picks the right schema from the incomingContent-Type.
Single-schema form (recommended — s.* builder)
validateBody(s.object({
name: s.string().min(1),
email: s.string().email(),
}))Single-schema form (raw JSON Schema)
validateBody({
type: 'object',
properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' } },
required: ['name', 'email'],
additionalProperties: false,
})Multi-media-type form
validateBody({
'application/json': CreateUserJsonSchema, // s.object({...}) or raw
'multipart/form-data': CreateUserMultipartSchema,
})OpenAPI emission
- Single form ➡️
requestBody.content['application/json'].schema = <your schema> - Map form ➡️ one entry per media type under
requestBody.content - Always adds a
400response referencingFrameworkErrorwithmetadata: { errors }.
Why no coercion? Bodies arrive parsed by Fastify / multipart parsers / etc — numbers are already numbers, booleans are booleans. Coercion would mask real client bugs.
validateQuery
Signature
validateQuery(schema: SchemaInput, options?: { message?: string }): RequestHandlerBehaviour
- Validates
req.queryagainst an object schema. - Coerces strings ➡️ integers/numbers/booleans/null when the schema's
typeexpects them.?page=3&active=truebecomes{ page: 3, active: true }inreq.query. - Each top-level property in the schema becomes one OpenAPI parameter; properties listed in
required[]are marked required; per-propertydescription,deprecated,example,examplesflow through.
validateQuery(s.object({
page: s.integer().min(1).default(1).describe('Page number.'),
limit: s.integer().min(1).max(100).default(20),
q: s.string().max(100).optional(),
}))OpenAPI emission
- One
parameters[in: 'query']entry per property - Auto
400
validateHeaders
Signature
validateHeaders(schema: SchemaInput, options?: { message?: string }): RequestHandlerBehaviour
- Validates
req.headersagainst an object schema. - Coerces strings ➡️ typed values per the schema (header values arrive as strings).
- The builder filters out
Authorization,Accept, andContent-Typefrom the emitted parameters with a one-time warning — OpenAPI 3.1 forbids declaring them asparameters[in: 'header'](they're modeled viasecurityandrequestBody.contentrespectively).
validateHeaders(s.object({
'X-Tenant-Id': s.string().uuid(),
'Idempotency-Key': s.string().min(8).optional(),
}))OpenAPI emission
- One
parameters[in: 'header']entry per non-filtered property - Auto
400
validateCookies
Signature
validateCookies(schema: SchemaInput, options?: { message?: string }): RequestHandlerBehaviour
- Validates
req.cookiesagainst an object schema. - Coerces strings ➡️ typed values per the schema.
- Requires
cookie-parser(or equivalent) mounted upstream to populatereq.cookies.
validateCookies(s.object({
session: s.string().describe('Session token.'),
}))OpenAPI emission
- One
parameters[in: 'cookie']entry per property - Auto
400
validatePathParams
Signature
validatePathParams(schema: SchemaInput, options?: { message?: string }): RequestHandlerBehaviour
- Validates
req.paramsagainst an object schema (one property per:placeholderin the route). - Coerces strings ➡️ typed values (e.g.
id: '42'➡️42whens.integer()). - Path params are already extracted automatically from the route (
/users/:id➡️parameters[in: 'path', name: 'id']with a default{ type: 'string' }schema). Use this middleware only when you want stronger typing (.uuid(),.min(), etc.) and richer per-param documentation.
validatePathParams(s.object({
id: s.string().uuid().describe('User id.'),
}))OpenAPI emission
- Refines the auto-generated path-param schemas with the user's tighter version
- Auto
400
validateContentType
Signature
validateContentType(...types: string[]): RequestHandler
validateContentType(options: { types: string[]; message?: string }): RequestHandlerBehaviour
- Rejects requests whose
Content-Type(parameters stripped) isn't in the allowed set. - Throws
UnsupportedMediaTypeException(415, ..., { supported }).
validateContentType('application/json', 'multipart/form-data')OpenAPI emission
- The allowed types flow into
requestBody.contentkeys (the body schema, if also declared viavalidateBody, is paired with every allowed type) - Auto
415withmetadata: { supported: string[] }
Auth middlewares
requireAuth
Signature
requireAuth(schemeName: string): RequestHandler
requireAuth(options: { scheme: string; verify?: AuthVerifier }): RequestHandler
type AuthVerifier = (req: Request) => Promise<Principal> | Principal
interface Principal { id: string; roles?: string[]; scopes?: string[]; [k: string]: unknown }Behaviour
- Looks up a verifier for the scheme:
- Per-middleware
verifyoverride (highest priority) - Falls back to
config.openapi.auth[scheme]registered indefineConfig
- Per-middleware
- Runs the verifier. If it throws or returns falsy, throws
UnauthorizedException. Otherwise attaches the returnedPrincipaltoreq.user.
Form 1 — use the verifier registered in defineConfig
// server.config.ts
defineConfig({
openapi: {
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
auth: {
bearerAuth: async (req) => {
const token = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '');
const claims = await verifyJwt(token);
return { id: claims.sub, roles: claims.roles ?? [] };
},
},
},
});
// controller
middlewares: [requireAuth('bearerAuth')]Form 2 — per-middleware override
middlewares: [
requireAuth({
scheme: 'bearerAuth',
verify: async (req) => myCustomVerifier(req),
}),
]OpenAPI emission
operation.securityadds{ [scheme]: [] }components.securitySchemespopulated fromdefineConfig.openapi.securitySchemes- Auto
401
requireRoles / authorize
Signatures
requireRoles(...roles: string[]): RequestHandler // shorthand
authorize(options: { roles?: string[]; scopes?: string[] }): RequestHandlerBehaviour
- Reads
req.user(populated by an earlierrequireAuth(...)middleware). If absent, throwsUnauthorizedException(the user forgot to chainrequireAuthfirst). - Checks that the principal holds all required roles and all required scopes. On any miss, throws
ForbiddenException('Insufficient permissions.', { requiredRoles, requiredScopes }).
middlewares: [
requireAuth('bearerAuth'),
requireRoles('admin'), // roles only
// or
authorize({ roles: ['admin'], scopes: ['users:write'] }),
]OpenAPI emission
- Scopes are merged onto the immediately preceding
requireAuthscheme in the spec — i.e.security: [{ bearerAuth: ['users:write'] }], not a separate requirement. - Auto
403withmetadata: { requiredRoles?: string[]; requiredScopes?: string[] }
Custom self-documenting middlewares
You can write your own middleware that contributes to the spec by attaching an OpenApiMiddlewareMeta annotation:
import type { FastifyMiddleware } from '@supersec-ai/superman';
import { attachOpenApiMeta, BadRequestException } from '@supersec-ai/superman';
export const checkIdempotencyKey = (): FastifyMiddleware => {
const handler: FastifyMiddleware = async (req, _res) => {
if (!req.headers['idempotency-key']) {
throw new BadRequestException('Missing Idempotency-Key.', {
errors: [{ path: '/headers/idempotency-key', keyword: 'required', message: 'Required header.' }],
});
}
};
return attachOpenApiMeta(handler, {
kind: 'headers',
schema: {
type: 'object',
properties: { 'Idempotency-Key': { type: 'string', minLength: 8 } },
required: ['Idempotency-Key'],
},
errorStatuses: [{
status: 400,
description: 'Missing Idempotency-Key.',
metadataSchema: {
type: 'object',
properties: { errors: { type: 'array', items: { type: 'object' } } },
},
}],
});
};The framework will treat your middleware exactly like a built-in one: validation runs at request time, OpenAPI parameters and a 400 response are emitted into the spec, and defineController users don't need to declare anything extra.
OpenApiMiddlewareMeta shape
interface OpenApiMiddlewareMeta {
kind: 'body' | 'query' | 'headers' | 'cookies' | 'path' | 'content-type' | 'auth' | 'roles';
schema?: JsonSchema; // body/query/headers/cookies/path
bodyContent?: Record<string, MediaTypeDefinition>; // overrides `schema` when present
mediaTypes?: string[]; // content-type
security?: SecurityRequirement; // auth
errorStatuses?: ReadonlyArray<{ status: number; description: string; metadataSchema?: JsonSchema }>;
}Conventions the controller-metadata synthesizer applies:
- Last writer wins per slot (
body,query,headers,cookies,path). - Auth schemes accumulate in declaration order;
authorize({ scopes })scopes merge onto the nearest preceding auth scheme. - Each middleware's
errorStatuses[]flows into the operation'serrors[]— deduplicated by status (first wins). Controller-declarederrors[]always override middleware ones with the same status.
Common chain patterns
Public read — no auth, just shape validation:
middlewares: [validateQuery(ListThingsQuery)]Authenticated write — auth + role check + body validation:
middlewares: [
requireAuth('bearerAuth'),
requireRoles('admin'),
validateBody(CreateThingBody),
]Scoped write — auth + scope check:
middlewares: [
requireAuth('bearerAuth'),
authorize({ scopes: ['things:write'] }),
validateBody(CreateThingBody),
]
// ➡️ spec: security: [{ bearerAuth: ['things:write'] }]File upload — multi-media-type body:
middlewares: [
requireAuth('bearerAuth'),
validateContentType('multipart/form-data'),
validateBody(s.object({
file: s.raw({ type: 'string', format: 'binary' }), // binary needs a raw fragment
})),
]Strict typed path/query:
middlewares: [
validatePathParams(s.object({ id: s.string().uuid() })),
validateQuery(s.object({ include: s.enum(['sessions', 'tokens'] as const).optional() })),
]Order matters
Middlewares execute top-to-bottom. The framework's general advice:
requireAuth(...)first — short-circuit unauthenticated traffic before doing expensive validation work.requireRoles/authorizenext — reject under-privileged callers before reading the body.validateContentType(...)beforevalidateBody— pointless to validate a body whose Content-Type you'll reject anyway.validateHeaders/validateQuery/validatePathParams— cheap, fail fast on malformed requests.validateBody(...)last — body parsing/validation is the most expensive step.
Spec emission is order-independent — the OpenAPI document looks the same regardless of middleware order.
See also
- docs/schemas.md — JSON Schema authoring, the built-in validator's supported subset, TypeScript-types ergonomics, CRUD schemas recipe.
- docs/api-config.md —
defineConfig.openapi.securitySchemesand theauthverifier registry. - docs/api-controllers.md —
defineControlleroptions + how middlewares plug in.
