@verisure-italy/express-router-middleware
v1.9.1
Published
Express middleware for Verisure Italy AAA
Readme
@verisure-italy/express-router-middleware
Typed Express routing layer that connects route configuration, Zod validation, ACL, and DynamoDB-backed CRUD operations. The package is designed to reduce glue code between domain contracts and HTTP APIs.
Installation
pnpm add @verisure-italy/express-router-middlewareMain Exports
createRouter()createRestResource()andcreateRestResources()restControllersandcreateTypedControllers()- middleware building blocks:
authentication,authorization,preAuth,validation,dataTransformer,entityEnrichment,requestDataModel,requestProjectionFields paramResolver- types such as
RouterSettings,RouteConfig,RouteOptions, andRestResourceConfig
What This Package Gives You
- typed route definitions that stay aligned with your domain entities
- opt-in auth and ACL handling on a per-route basis
- Zod-based validation for
body,query, andparams - DynamoDB-backed CRUD handlers and list-query parsing
- a declarative way to map
dataModelsto tables, indexes, hooks, and list behavior
Request Lifecycle
For each route, the package builds the middleware chain in this order:
preAuthsecure->authenticationacl->authorizationvalidatedataTransformerprojectionFieldsdataModelentityEnrichment- handler
At startup it also:
- validates that every configured
dataModelexists insettings.dataModels - registers
router.param()handlers for each data model - exposes
/keepaliveby default
Quick Start
import express from 'express'
import { errorHandler } from '@verisure-italy/express-error-handler-middleware'
import { dynamoAuthMiddleware } from '@verisure-italy/express-authentication-middleware'
import {
createRestResource,
createRouter,
type RestResourceConfig,
} from '@verisure-italy/express-router-middleware'
import { userSchema, type User } from '@verisure-italy/aaa-types'
type DataModels = {
user: User
}
const userRoutes = createRestResource({
dataModel: 'user',
basePath: '/users',
sharedOptions: {
secure: true,
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN'],
},
},
},
methodOptions: {
create: {
validate: {
body: userSchema.omit({ id: true }).strip(),
},
},
update: {
validate: {
body: userSchema.partial().omit({ id: true }).strip(),
},
},
list: {
projectionFields: ['id', 'username', 'roles'],
},
},
} satisfies RestResourceConfig<User, 'user'>)
const app = express()
app.use(express.json())
app.use(dynamoAuthMiddleware())
app.use('/api', createRouter<DataModels>(userRoutes, {
dataModels: {
user: {
tableName: 'users',
idField: 'id',
},
},
}))
app.use(errorHandler())RouterSettings
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| dynamoConfig | Partial<DynamoConfig> | No | DynamoDB configuration passed to @verisure-italy/dynamo-kit |
| dataModels | record of EntityConfig values | No | Entity definitions keyed by data-model name |
| webhook | WebhookConfig | No | Webhook endpoint configuration |
| keepalive | KeepaliveConfig | No | Keepalive endpoint configuration |
EntityConfig
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| tableName | string | Yes | Physical base table name |
| idField | string | No | Entity id field. Defaults to id |
| indexes | record of index config | No | Index metadata used by list parsing and queries |
| generateId | (entity) => string \| Promise<string> | No | Custom id generator for create flows |
| queryConfig | QueryConfig<TEntity> | No | Filtering and sorting configuration for list routes |
| hooks | EntityHooks<TEntity> | No | Create, update, and delete lifecycle hooks |
hooks
| Hook | Runs when | Typical use |
| --- | --- | --- |
| preCreate | Before persisting a new entity | Normalize payloads, inject derived fields |
| postCreate | After persisting a new entity | Publish events, trigger side effects |
| preUpdate | Before writing an update | Validate transitions, derive fields |
| postUpdate | After writing an update | Audit, notifications, side effects |
| preDelete | Before deleting an entity | Prevent deletion or run guard checks |
| postDelete | After deleting an entity | Cleanup or async side effects |
QueryConfig
QueryConfig controls how the built-in list controller interprets query parameters.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| defaultIndex | string | No | Default index name used when query conditions match an index |
| filterableFields | record | No | Allowed filter fields and operators |
| sortableFields | (keyof TEntity)[] | No | Allowed sort fields |
| defaultSort | { field, direction } | No | Default sort applied when ?sort= is missing |
| defaultPageSize | number | No | Default list page size |
| maxPageSize | number | No | Upper bound for ?pageSize= |
filterableFields
Each filterable field defines:
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| operators | FilterOperator[] | Yes | Allowed query operators |
| transform | (value) => any | No | Optional value transformer before the filter is applied |
Supported route-config operators:
=<><<=>>=begins_withcontainsbetweeninexistsnot_exists
RouteOptions
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| secure | boolean | No | Require req._auth |
| preAuth | PreAuthHandler | No | Run a custom auth check before the auth middleware |
| acl | AclConfig | No | Role-based or custom authorization rules |
| validate | ValidationConfig | No | Zod schemas for body, query, and params |
| dataTransformer | DataTransformerConfig<TEntity> | No | Mutate the request body before the controller |
| dataModel | TDataModel | No | Entity name used by controllers and the param resolver |
| projectionFields | (keyof TEntity)[] | No | Limit fields returned by read and list responses |
| entityEnrichment | enrichment callbacks | No | Enrich the resolved entity before responding |
| cache | CacheConfig | No | Reserved for future cache middleware support |
| disableWebhook | boolean | No | Reserved for future webhook delivery control |
AclConfig
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| allow.roles | UserRole[] | No | At least one role must match |
| deny.roles | UserRole[] | No | Any matching role denies the request |
| handler | (auth, req) => boolean \| Promise<boolean> | No | Custom auth logic on top of role checks |
Route-Option Examples
Public route
const healthRoute = {
method: 'get',
path: '/health',
handler: (_req, res) => res.json({ ok: true }),
}Authenticated route
const meRoute = {
method: 'get',
path: '/me',
handler: (req, res) => res.json(req._auth),
options: {
secure: true,
},
}Role-gated route
const adminUsersRoute = {
method: 'get',
path: '/admin/users',
handler: restControllers.list<User>,
options: {
secure: true,
dataModel: 'user',
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'],
},
deny: {
roles: ['ROLE_AAA_READER'],
},
},
},
}Route with preAuth
const rebuildRoute = {
method: 'post',
path: '/internal/rebuild',
handler: async (_req, res) => res.status(202).json({ queued: true }),
options: {
preAuth: req => req.header('x-internal-key') === process.env.INTERNAL_KEY,
},
}Route with validation
import { z } from 'zod'
const readUserRoute = {
method: 'get',
path: '/users/:user',
handler: restControllers.read<User>,
options: {
secure: true,
dataModel: 'user',
validate: {
params: z.object({
user: z.string().min(1),
}),
query: z.object({
verbose: z.coerce.boolean().optional(),
}),
},
},
}Route with dataTransformer
const createUserRoute = {
method: 'post',
path: '/users',
handler: restControllers.create<User>,
options: {
dataModel: 'user',
dataTransformer: {
body: body => ({
...body,
username: body.username?.trim().toLowerCase(),
}),
},
},
}Route with projectionFields
const projectedReadRoute = {
method: 'get',
path: '/users/:user',
handler: restControllers.read<User>,
options: {
dataModel: 'user',
projectionFields: ['id', 'username', 'roles'],
},
}Route with entityEnrichment
const enrichedReadRoute = {
method: 'get',
path: '/users/:user',
handler: restControllers.read<User>,
options: {
dataModel: 'user',
entityEnrichment: {
user: async (user, req) => ({
...user,
isCurrentUser: req._auth?.user.id === user.id,
}),
},
},
}Type-Safe Resource Pattern
import {
createRestResource,
createRouter,
type RestResourceConfig,
} from '@verisure-italy/express-router-middleware'
import { userSchema, type User } from '@verisure-italy/aaa-types'
interface DataModels {
user: User
}
const userResourceConfig = {
dataModel: 'user',
basePath: '/users',
methods: ['create', 'read', 'update', 'list', 'delete'],
sharedOptions: {
secure: true,
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN'],
},
},
},
methodOptions: {
create: {
validate: {
body: userSchema.omit({ id: true }).strip(),
},
},
update: {
validate: {
body: userSchema.partial().omit({ id: true }).strip(),
},
},
},
} satisfies RestResourceConfig<User, 'user'>
const userRoutes = createRestResource(userResourceConfig)
const router = createRouter<DataModels>(userRoutes, {
dataModels: {
user: {
tableName: 'users',
idField: 'id',
},
},
})This pattern keeps the dataModel literal aligned between the resource config and RouterSettings.
REST Helpers
createRestResource() generates the CRUD routes below:
create->POST /resourceread->GET /resource/:dataModelupdate->PUT /resource/:dataModellist->GET /resourcedelete->DELETE /resource/:dataModel
Because the route parameter name is the dataModel key, dataModel: 'user' results in paths like /users/:user. That is what lets paramResolver automatically load req.user.
createRestResources() Example
const routes = createRestResources([
{
dataModel: 'user',
basePath: '/users',
sharedOptions: { secure: true },
},
{
dataModel: 'client',
basePath: '/clients',
methods: ['read', 'list'],
sharedOptions: {
secure: true,
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'],
},
},
},
},
] as const)Shared defaults with per-method overrides
const resource = createRestResource<User>({
dataModel: 'user',
basePath: '/users',
sharedOptions: {
secure: true,
},
methodOptions: {
list: {
projectionFields: ['id', 'username'],
},
create: {
acl: {
allow: { roles: ['ROLE_AAA_ADMIN'] },
},
},
},
})Partial CRUD
const resource = createRestResource<User>({
dataModel: 'user',
basePath: '/users',
methods: ['read', 'list'],
})List Query Syntax
The built-in list controller understands these query-string patterns:
?filter[field][operator]=value?sort=field?sort=-field?pageSize=20?nextToken=...?fields=id,username?index=name-of-index
Examples:
GET /users?fields=id,username
GET /users?filter[username][begins_with]=adm
GET /users?filter[createdAt][between]=1700000000,1800000000
GET /users?filter[roles][contains]=ROLE_AAA_ADMIN
GET /users?sort=-createdAt&pageSize=50
GET /leads?index=source-createdAt-index&filter[source][=]=source-1&sort=-createdAtIndex Selection Rules
When the list controller can find:
- a configured index in
EntityConfig.indexes - an index name from
?index=orqueryConfig.defaultIndex - an equality filter on that index hash key
it uses repo.queryIndex() instead of repo.scan().
If the selected index also has a range key and the request includes a filter on that field, the range condition is promoted into the DynamoDB KeyConditionExpression.
EntityConfig Example With Hooks and Query Config
const router = createRouter<DataModels>(routes, {
dataModels: {
user: {
tableName: 'users',
idField: 'id',
indexes: {
'username-index': {
hashKey: 'username',
},
},
generateId: () => crypto.randomUUID(),
queryConfig: {
defaultIndex: 'username-index',
filterableFields: {
username: {
operators: ['=', 'begins_with'],
},
createdAt: {
operators: ['>=', '<=', 'between'],
transform: value => Number(value),
},
},
sortableFields: ['createdAt'],
defaultSort: {
field: 'createdAt',
direction: 'desc',
},
defaultPageSize: 20,
maxPageSize: 100,
},
hooks: {
preCreate: entity => ({
...entity,
username: entity.username?.trim().toLowerCase(),
}),
postCreate: async user => {
console.log('created user', user.id)
},
},
},
},
})Working With Raw Controllers
If you do not want to use createRestResource(), you can wire the controllers yourself:
import {
createRouter,
restControllers,
} from '@verisure-italy/express-router-middleware'
const routes = [
{
method: 'get',
path: '/users/:user',
handler: restControllers.read<User>,
options: {
secure: true,
dataModel: 'user',
projectionFields: ['id', 'username'],
},
},
]Runtime Notes
validation()replacesreq.body,req.query, andreq.paramswith validated values and stores the originals onreq.original.paramResolverloads the entity and exposes it asreq[dataModel].keepaliveis enabled by default on/keepalive.authenticationonly checks forreq._auth; you are expected to mount an auth middleware such as@verisure-italy/express-authentication-middlewarebefore this router.- Webhook configuration exists in the public API. Inbound webhook routes can be registered today, but outbound trigger delivery is not yet implemented in the runtime.
