@verisure-italy/express-router-middleware
v1.8.8
Published
Express middleware for Verisure Italy AAA
Readme
Router Middleware
A modern, type-safe router middleware for Express that integrates DynamoDB, Zod validation, authentication, and authorization.
Features
- ✅ Type-safe - Full TypeScript typing with automatic inference
- ✅ Zod Validation - Reusable and type-safe schemas
- ✅ DynamoDB Integration - Automatic CRUD via dynamo-kit
- ✅ ACL with UserRole - Typed role-based access control
- ✅ REST Controllers - Ready-to-use CRUD operations with generics
- ✅ Param Resolver - Automatic entity loading from URL
- ✅ Entity Enrichment - Type-safe data enrichment
- ✅ Projection Fields - Field selection with autocomplete
- ✅ Generic Middlewares - All middlewares support type inference
Installation
pnpm add @verisure-italy/router-middlewareBasic Usage with Type Safety
1. Define Your Data Models
import { AccessToken, User } from '@verisure-italy/aaa-types'
import { createRouter, restControllers, RouteConfig } from '@verisure-italy/router-middleware'
import { z } from 'zod'
// Define your data model types
interface DataModels {
accessToken: AccessToken
user: User
}2. Configure Router with Type Safety
const router = createRouter<DataModels>(
[
// ✅ GET /access-tokens/:accessToken - Read with type-safe projection fields
{
method: 'get',
path: '/access-tokens/:accessToken',
handler: restControllers.read<AccessToken>,
options: {
dataModel: 'accessToken',
// ✅ TypeScript knows these are AccessToken fields!
projectionFields: ['id', 'token', 'user', 'expires'],
secure: true,
acl: {
allow: {
// ✅ UserRole typed with autocomplete!
roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER']
}
}
}
} satisfies RouteConfig<string, AccessToken>,
// ✅ POST /access-tokens - Create with validation
{
method: 'post',
path: '/access-tokens',
handler: restControllers.create<AccessToken>,
options: {
dataModel: 'accessToken',
secure: true,
validate: {
body: z.object({
token: z.string().min(1),
user: z.string(),
client: z.object({
id: z.string()
}),
expires: z.number().positive(),
scope: z.string().optional()
})
},
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN']
}
}
}
} satisfies RouteConfig<string, AccessToken>,
// ✅ GET /access-tokens - List
{
method: 'get',
path: '/access-tokens',
handler: restControllers.list<AccessToken>,
options: {
dataModel: 'accessToken',
secure: true,
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER']
}
}
}
} satisfies RouteConfig<string, AccessToken>,
// ✅ PUT /access-tokens/:accessToken - Update
{
method: 'put',
path: '/access-tokens/:accessToken',
handler: restControllers.update<AccessToken>,
options: {
dataModel: 'accessToken',
secure: true,
validate: {
body: z.object({
expires: z.number().positive().optional(),
scope: z.string().optional()
})
},
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN']
}
}
}
} satisfies RouteConfig<string, AccessToken>,
// ✅ DELETE /access-tokens/:accessToken
{
method: 'delete',
path: '/access-tokens/:accessToken',
handler: restControllers.deleteEntity<AccessToken>,
options: {
dataModel: 'accessToken',
secure: true,
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN']
}
}
}
} satisfies RouteConfig<string, AccessToken>,
],
{
// ✅ Data model configuration with type safety
dataModels: {
accessToken: {
tableName: 'access_token',
idField: 'id'
},
user: {
tableName: 'user',
idField: 'id'
}
},
dynamoConfig: {
region: 'eu-west-1'
}
}
)
export default router3. Custom Handlers with Type Safety
import { Request, Response, NextFunction } from 'express'
import { AccessToken } from '@verisure-italy/aaa-types'
// ✅ Custom handler with type safety
const customTokenHandler = async (req: Request, res: Response, next: NextFunction) => {
try {
// ✅ req.accessToken is typed as AccessToken!
const token: AccessToken = req.accessToken
// ✅ TypeScript knows all available fields
const isExpired = token.expires < Math.floor(Date.now() / 1000)
res.json({
token: token.token,
isExpired,
user: token.user
})
} catch (error) {
next(error)
}
}
// Use the custom handler
{
method: 'get',
path: '/access-tokens/:accessToken/status',
handler: customTokenHandler,
options: {
dataModel: 'accessToken',
secure: true
}
} satisfies RouteConfig<string, AccessToken>Advanced Features
Lifecycle Hooks
Execute custom logic before and after entity operations (create, update, delete). Hooks are perfect for:
- Data modification: Add timestamps, normalize data, generate computed fields
- Validation: Enforce business rules beyond basic validation
- Side effects: Send queue messages, trigger webhooks, update caches
- Audit logging: Track all changes with who, when, and what changed
For detailed examples and best practices, see Lifecycle Hooks Guide.
import { createRouter, RouterSettings } from '@verisure-italy/router-middleware'
import { User } from '@verisure-italy/aaa-types'
const settings: RouterSettings<{ user: User }> = {
dataModels: {
user: {
tableName: 'users',
hooks: {
// Pre-hooks: modify data before saving
preCreate: async (entity, req) => ({
...entity,
createdAt: Date.now(),
createdBy: req.auth?.user?.username,
status: entity.status || 'active',
}),
// Post-hooks: trigger side effects after saving
postCreate: async (entity, req) => {
await sendWelcomeEmail(entity.email)
await publishToQueue('user-created', { userId: entity.id })
},
// Validate business rules in pre-hooks
preUpdate: async (updateData, existingEntity, req) => {
if (updateData.email && existingEntity.emailVerified) {
throw new Error('Cannot change verified email')
}
return {
...updateData,
updatedAt: Date.now(),
updatedBy: req.auth?.user?.username,
}
},
// Track changes in post-hooks
postUpdate: async (updated, previous, req) => {
await logAuditTrail({
action: 'user-updated',
userId: updated.id,
changes: getChanges(previous, updated),
by: req.auth?.user?.username,
})
},
// Prevent deletion with business logic
preDelete: async (entity, req) => {
if (entity.roles?.includes('ROLE_ADMIN')) {
throw new Error('Cannot delete admin users')
}
},
// Cleanup after deletion
postDelete: async (entity, req) => {
await deleteUserSessions(entity.id)
await publishToQueue('user-deleted', { userId: entity.id })
},
},
},
},
}Available Hooks:
preCreate: Modify entity before creationpostCreate: Execute after creation (e.g., send notifications)preUpdate: Modify update data, access existing entitypostUpdate: Execute after update, compare old vs newpreDelete: Validate before deletion, can prevent itpostDelete: Cleanup after deletion
See the Configuration Reference below for detailed hook signatures and more examples.
Filtering, Sorting and Pagination
The router middleware now supports advanced filtering, sorting, and pagination for list operations. See the dedicated guides:
- Filtering Guide - Complete guide to filtering, sorting, and pagination
- Filtering Example - Practical examples with React integration
Quick example:
const settings: RouterSettings<{ user: User }> = {
dataModels: {
user: {
tableName: 'users',
queryConfig: {
filterableFields: {
status: { operators: ['=', 'in'] },
email: { operators: ['=', 'begins_with', 'contains'] },
age: { operators: ['=', '<', '<=', '>', '>=', 'between'] },
},
sortableFields: ['createdAt', 'updatedAt'],
defaultSort: { field: 'createdAt', direction: 'desc' },
defaultPageSize: 25,
},
},
},
}
// API usage:
// GET /users?filter[status][=]=active&filter[age][>=]=18&sort=-createdAt&pageSize=50Type-Safe Projection Fields
The projectionFields middleware now supports generics for complete type safety:
import { AccessToken } from '@verisure-italy/aaa-types'
import { requestProjectionFields } from '@verisure-italy/router-middleware'
// ✅ TypeScript will autocomplete and validate field names!
const projectionMiddleware = requestProjectionFields<AccessToken>([
'id',
'token',
'user',
'expires'
// ❌ TypeScript error if you add a field that doesn't exist in AccessToken
])
// Use in route
{
method: 'get',
path: '/access-tokens/:accessToken',
handler: [projectionMiddleware, restControllers.read<AccessToken>],
options: {
dataModel: 'accessToken'
}
}Type-Safe Entity Enrichment
Enrich entities with additional data while maintaining type safety:
import { AccessToken, User } from '@verisure-italy/aaa-types'
import { entityEnrichment } from '@verisure-italy/router-middleware'
// ✅ Create type-safe enrichment
const enrichAccessToken = entityEnrichment<AccessToken>({
accessToken: async (token: AccessToken, req) => {
// Load user details
const userDetails = await getUserDetails(token.user)
// ✅ Return type must match AccessToken structure
return {
...token,
userDetails // Additional data
}
}
})
// Use in route
{
method: 'get',
path: '/access-tokens/:accessToken',
handler: restControllers.read<AccessToken>,
options: {
dataModel: 'accessToken',
// Or inline:
entityEnrichment: {
accessToken: async (token: AccessToken) => {
const user = await getUserById(token.user)
return { ...token, userDetails: user }
}
}
}
}Data Transformation
Transform data before validation:
{
method: 'post',
path: '/access-tokens',
handler: restControllers.create<AccessToken>,
options: {
dataModel: 'accessToken',
dataTransformer: {
body: async (body) => {
// Add expiration time automatically
return {
...body,
expires: Math.floor(Date.now() / 1000) + 3600 // 1 hour
}
}
},
validate: {
body: accessTokenSchema
}
}
}Pre-Authentication
Custom authentication logic:
{
method: 'get',
path: '/internal/tokens',
handler: restControllers.list<AccessToken>,
options: {
dataModel: 'accessToken',
// ✅ Custom authentication logic
preAuth: async (req) => {
const apiKey = req.headers['x-api-key']
return apiKey === process.env.INTERNAL_API_KEY
}
}
}Custom Authorization Handler
Complex authorization logic with type safety:
{
method: 'delete',
path: '/access-tokens/:accessToken',
handler: restControllers.deleteEntity<AccessToken>,
options: {
dataModel: 'accessToken',
secure: true,
acl: {
allow: {
roles: ['ROLE_AAA_ADMIN']
},
// ✅ Custom authorization logic
handler: async (auth, req) => {
const token: AccessToken = req.accessToken
// Allow only if user is deleting their own token or is admin
return auth.user.username === token.user ||
auth.user.roles.includes('ROLE_AAA_ADMIN')
}
}
}
}Type-Safe REST Controllers
All REST controllers are now generic and provide full type inference:
import { restControllers } from '@verisure-italy/router-middleware'
import { AccessToken } from '@verisure-italy/aaa-types'
// ✅ All controllers accept a generic type parameter
restControllers.create<AccessToken> // POST - Create entity
restControllers.read<AccessToken> // GET - Read single entity
restControllers.update<AccessToken> // PUT - Update entity
restControllers.list<AccessToken> // GET - List entities
restControllers.deleteEntity<AccessToken> // DELETE - Delete entity
// The generic type provides:
// - Type-safe repository operations
// - Correct field inference
// - Autocomplete for all entity propertiesCreating Typed Controllers (Recommended)
To avoid repeating the generic type on every controller, use createTypedControllers:
import { AccessToken } from '@verisure-italy/aaa-types'
import { createTypedControllers, createRouter } from '@verisure-italy/router-middleware'
// ✅ Create typed controllers once for your entity
const accessTokenCtrl = createTypedControllers<AccessToken>()
// ✅ Now use them without repeating the generic type!
const router = createRouter<DataModels>([
{
method: 'get',
path: '/access-tokens/:accessToken',
handler: accessTokenCtrl.read, // No need for <AccessToken>!
options: {
dataModel: 'accessToken',
projectionFields: ['id', 'token', 'user', 'expires']
}
},
{
method: 'post',
path: '/access-tokens',
handler: accessTokenCtrl.create, // Clean and simple!
options: {
dataModel: 'accessToken',
validate: { body: accessTokenSchema }
}
},
{
method: 'put',
path: '/access-tokens/:accessToken',
handler: accessTokenCtrl.update, // Type-safe!
options: {
dataModel: 'accessToken'
}
},
{
method: 'get',
path: '/access-tokens',
handler: accessTokenCtrl.list,
options: {
dataModel: 'accessToken'
}
},
{
method: 'delete',
path: '/access-tokens/:accessToken',
handler: accessTokenCtrl.delete,
options: {
dataModel: 'accessToken'
}
}
])
// You can create controllers for different entities
const userCtrl = createTypedControllers<User>()
const clientCtrl = createTypedControllers<Client>()Benefits:
- ✅ Define the type once per entity
- ✅ Reuse controllers throughout your routes
- ✅ Cleaner, more readable code
- ✅ Fully type-safe with all the advantages
REST Resource Generation
For standard REST APIs without too many customizations, use createRestResource to automatically generate all CRUD routes with a simple configuration.
Basic REST Resource
import { AccessToken } from '@verisure-italy/aaa-types'
import { createRestResource, createRouter } from '@verisure-italy/router-middleware'
// ✅ One declaration creates all 5 routes
const routes = createRestResource<AccessToken>({
dataModel: 'accessToken',
basePath: '/access-tokens',
sharedOptions: {
secure: true,
acl: {
allow: { roles: ['ROLE_AAA_ADMIN'] }
}
}
})
// Automatically generates:
// POST /access-tokens → create
// GET /access-tokens/:accessToken → read
// PUT /access-tokens/:accessToken → update
// GET /access-tokens → list
// DELETE /access-tokens/:accessToken → deleteSelect Specific Methods
// ✅ Generate only read and list (no create, update, delete)
const userRoutes = createRestResource<User>({
dataModel: 'user',
basePath: '/users',
methods: ['read', 'list'], // Only these methods
sharedOptions: {
secure: true,
projectionFields: ['id', 'username', 'roles']
}
})
// Only creates:
// GET /users/:user → read
// GET /users → listMethod-Specific Options
Use methodOptions to override shared options for specific methods:
const accessTokenRoutes = createRestResource<AccessToken>({
dataModel: 'accessToken',
basePath: '/access-tokens',
// ✅ Shared options apply to ALL routes
sharedOptions: {
secure: true,
acl: {
allow: { roles: ['ROLE_AAA_ADMIN'] }
}
},
// ✅ Method-specific options override shared options
methodOptions: {
read: {
projectionFields: ['id', 'token', 'user', 'expires']
},
list: {
// Override ACL: list also allows READER role
acl: {
allow: { roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'] }
}
},
create: {
validate: {
body: z.object({
token: z.string().min(1),
user: z.string(),
client: z.object({ id: z.string() }),
expires: z.number().positive(),
scope: z.string().optional()
})
}
},
update: {
validate: {
body: z.object({
expires: z.number().positive().optional(),
scope: z.string().optional()
})
}
}
}
})Batch Create Multiple Resources
Use createRestResources to generate routes for multiple entities at once:
import { createRestResources, createRouter } from '@verisure-italy/router-middleware'
import { AccessToken, User, Client } from '@verisure-italy/aaa-types'
// ✅ Create multiple REST resources in one call
const allRoutes = createRestResources([
// Access Tokens - Full CRUD
{
dataModel: 'accessToken',
basePath: '/access-tokens',
sharedOptions: {
secure: true,
acl: { allow: { roles: ['ROLE_AAA_ADMIN'] } }
},
methodOptions: {
read: {
projectionFields: ['id', 'token', 'user', 'expires', 'scope']
},
list: {
acl: { allow: { roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'] } }
}
}
},
// Users - Read-only
{
dataModel: 'user',
basePath: '/users',
methods: ['read', 'list'],
sharedOptions: {
secure: true,
acl: { allow: { roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'] } }
}
},
// Clients - Full CRUD with validation
{
dataModel: 'client',
basePath: '/clients',
sharedOptions: {
secure: true,
acl: { allow: { roles: ['ROLE_AAA_ADMIN'] } }
},
methodOptions: {
create: {
validate: {
body: z.object({
id: z.string(),
grants: z.array(z.string()).min(1),
redirectUris: z.array(z.url())
})
}
}
}
}
])
const router = createRouter<DataModels>(allRoutes, {
dataModels: {
accessToken: { tableName: 'access_token', idField: 'id' },
user: { tableName: 'user', idField: 'id' },
client: { tableName: 'client', idField: 'id' }
}
})Complete Real-World Example
import { AccessToken, User, Client } from '@verisure-italy/aaa-types'
import { createRestResources, createRouter } from '@verisure-italy/router-middleware'
import { z } from 'zod'
interface DataModels {
accessToken: AccessToken
user: User
client: Client
}
const routes = createRestResources([
// Access Tokens - Full CRUD with admin access
{
dataModel: 'accessToken',
basePath: '/access-tokens',
sharedOptions: {
secure: true,
acl: { allow: { roles: ['ROLE_AAA_ADMIN'] } }
},
methodOptions: {
read: {
projectionFields: ['id', 'token', 'user', 'expires', 'scope'],
entityEnrichment: {
accessToken: async (token: AccessToken) => {
const userDetails = await getUserById(token.user)
return { ...token, userDetails }
}
}
},
list: {
acl: { allow: { roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'] } }
},
create: {
validate: {
body: z.object({
token: z.string().min(1),
user: z.string(),
client: z.object({ id: z.string() }),
expires: z.number().positive(),
scope: z.string().optional()
})
},
dataTransformer: {
body: async (body) => ({
...body,
// Auto-generate token if not provided
token: body.token || generateSecureToken()
})
}
}
}
},
// Users - Read-only with projection
{
dataModel: 'user',
basePath: '/users',
methods: ['read', 'list'],
sharedOptions: {
secure: true,
acl: { allow: { roles: ['ROLE_AAA_ADMIN', 'ROLE_AAA_READER'] } },
projectionFields: ['id', 'username', 'roles']
}
},
// Clients - Full CRUD with validation
{
dataModel: 'client',
basePath: '/clients',
sharedOptions: {
secure: true,
acl: { allow: { roles: ['ROLE_AAA_ADMIN'] } }
},
methodOptions: {
create: {
validate: {
body: z.object({
id: z.string(),
grants: z.array(z.string()).min(1),
refreshTokenLifetime: z.number().positive().nullable(),
accessTokenLifetime: z.number().positive().nullable(),
redirectUris: z.array(z.url())
})
}
},
update: {
validate: {
body: z.object({
grants: z.array(z.string()).min(1).optional(),
refreshTokenLifetime: z.number().positive().nullable().optional(),
accessTokenLifetime: z.number().positive().nullable().optional(),
redirectUris: z.array(z.url()).optional()
})
}
}
}
}
])
export default createRouter<DataModels>(routes, {
dataModels: {
accessToken: { tableName: 'access_token', idField: 'id' },
user: { tableName: 'user', idField: 'id' },
client: { tableName: 'client', idField: 'id' }
},
dynamoConfig: {
region: 'eu-west-1'
}
})Route Configuration Options
Main Route Configuration
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| method | 'get' \| 'post' \| 'put' \| 'patch' \| 'delete' | ✅ Yes | - | HTTP method for the route |
| path | string | ✅ Yes | - | Route path (e.g., /access-tokens/:id) |
| handler | RequestHandler \| RequestHandler[] | ✅ Yes | - | Express request handler(s) |
| options | RouteOptions<TEntity> | ❌ No | {} | Additional route options (see below) |
Route Options (RouteOptions)
Security Options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| secure | boolean | ❌ No | false | Require authentication (checks req._auth) |
| preAuth | (req: Request) => Promise<boolean> \| boolean | ❌ No | - | Custom authentication logic executed before standard auth |
| acl | AclConfig | ❌ No | - | Role-based access control configuration |
ACL Configuration (AclConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| allow.roles | UserRole[] | ❌ No | [] | Array of roles allowed to access this route |
| deny.roles | UserRole[] | ❌ No | [] | Array of roles explicitly denied access |
| handler | (auth: AuthInfo, req: Request) => Promise<boolean> \| boolean | ❌ No | - | Custom authorization logic with access to auth info |
Validation Options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| validate | ValidationConfig | ❌ No | - | Zod schema validation configuration |
Validation Configuration (ValidationConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| body | z.ZodSchema | ❌ No | - | Zod schema for request body validation |
| query | z.ZodSchema | ❌ No | - | Zod schema for query parameters validation |
| params | z.ZodSchema | ❌ No | - | Zod schema for URL parameters validation |
Data Handling Options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| dataModel | string | ❌ No | - | Name of the data model (must exist in RouterSettings.dataModels) |
| projectionFields | (keyof TEntity)[] | ❌ No | - | Array of entity fields to return in response (type-safe) |
| dataTransformer | DataTransformerConfig | ❌ No | - | Transform request data before validation |
| entityEnrichment | TypedEntityEnrichmentConfig<TEntity> | ❌ No | - | Enrich entity with additional data (type-safe) |
Data Transformer Configuration (DataTransformerConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| body | (data: any, req: Request) => Promise<any> \| any | ❌ No | - | Transform request body |
Note:
queryandparamstransformation has been removed in favor of Express 5 compliance. Use Zod transforms in validation for query/params manipulation.
Entity Enrichment Configuration (TypedEntityEnrichmentConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| [key: string] | (entity: TEntity, req: Request) => Promise<TEntity> \| TEntity | ❌ No | - | Function to enrich the entity (key matches entity name) |
Advanced Options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| filterable | FilterableConfig | ❌ No | - | Configuration for list filtering and pagination |
| cache | CacheConfig | ❌ No | - | Response caching configuration |
| disableWebhook | boolean | ❌ No | false | Disable webhook trigger for this route |
Filterable Configuration (FilterableConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| filtersMap | Record<string, FilterMapConfig> | ✅ Yes | - | Map of available filters |
| defaultFilters | Record<string, any> \| ((req: Request) => Record<string, any>) | ❌ No | - | Default filters to apply |
| pageSize | number | ❌ No | - | Default page size for pagination |
| filterParser | (filter: any) => any | ❌ No | - | Custom filter parsing function |
Filter Map Configuration (FilterMapConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| name | string | ✅ Yes | - | Filter field name in DynamoDB |
| condition | string | ✅ Yes | - | Filter condition (e.g., '=', '>', '<') |
| secondary | string | ❌ No | - | Secondary filter field for range queries |
Cache Configuration (CacheConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| ttl | string | ✅ Yes | - | Time to live (e.g., '5 minutes', '1 hour') |
Router Settings Configuration
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| dynamoConfig | Partial<DynamoConfig> | ❌ No | - | DynamoDB client configuration |
| dataModels | { [K in keyof TDataModels]: EntityConfig<TDataModels[K]> } | ❌ No | {} | Entity/data model definitions |
| webhook | WebhookConfig | ❌ No | - | Webhook configuration |
| keepalive | KeepaliveConfig | ❌ No | { enabled: true } | Keepalive/health check endpoint |
DynamoDB Configuration (DynamoConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| region | string | ❌ No | 'eu-west-1' | AWS region |
| endpoint | string | ❌ No | - | Custom DynamoDB endpoint (for local development) |
| tablePrefix | string | ❌ No | '' | Prefix for table names |
| credentials | AwsCredentialIdentity | ❌ No | - | AWS credentials |
Entity Configuration (EntityConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| tableName | string | ✅ Yes | - | DynamoDB table name |
| idField | keyof TEntity & string | ❌ No | 'id' | Name of the ID field (type-safe) |
| indexes | Record<string, IndexConfig<TEntity>> | ❌ No | {} | DynamoDB index configurations |
| generateId | (entity: Partial<TEntity>) => string \| Promise<string> | ❌ No | UUID v7 generator | Custom ID generator function (receives entity data, returns unique ID) |
| hooks | EntityHooks<TEntity> | ❌ No | {} | Lifecycle hooks for entity operations (preCreate, postCreate, preUpdate, postUpdate, preDelete, postDelete) |
Note: If
generateIdis not specified, UUID v7 (time-ordered UUID) is used by default. See ID Generation Configuration for detailed examples.
Index Configuration (IndexConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| hashKey | keyof TEntity & string | ✅ Yes | - | Hash key field name (type-safe) |
| rangeKey | keyof TEntity & string | ❌ No | - | Range key field name (type-safe) |
Lifecycle Hooks (EntityHooks)
Lifecycle hooks allow you to execute custom logic before and after entity operations (create, update, delete). This is useful for:
- Modifying entity data before saving (e.g., adding timestamps, normalizing data)
- Validating business rules
- Sending messages to queues
- Triggering side effects (e.g., sending emails, updating caches)
- Logging changes
| Hook | Type | Description |
|------|------|-------------|
| preCreate | (entity: Partial<TEntity>, req: Request) => Promise<Partial<TEntity>> \| Partial<TEntity> | Executed before creating an entity. Can modify the entity data before it's saved. |
| postCreate | (entity: TEntity, req: Request) => Promise<void> \| void | Executed after creating an entity. Useful for side effects like sending messages to a queue. |
| preUpdate | (entity: Partial<TEntity>, existingEntity: TEntity, req: Request) => Promise<Partial<TEntity>> \| Partial<TEntity> | Executed before updating an entity. Can modify the update data before it's saved. Receives both the update data and the current entity. |
| postUpdate | (entity: TEntity, previousEntity: TEntity, req: Request) => Promise<void> \| void | Executed after updating an entity. Receives both the updated entity and the previous state. |
| preDelete | (entity: TEntity, req: Request) => Promise<void> \| void | Executed before deleting an entity. Can prevent deletion by throwing an error. |
| postDelete | (entity: TEntity, req: Request) => Promise<void> \| void | Executed after deleting an entity. Useful for cleanup or sending messages. |
Example: Using Lifecycle Hooks
import { createRouter, RouterSettings } from '@verisure-italy/router-middleware'
import { User } from '@verisure-italy/aaa-types'
interface DataModels {
user: User
}
const settings: RouterSettings<DataModels> = {
dataModels: {
user: {
tableName: 'users',
idField: 'id',
hooks: {
// Add timestamps before creating
preCreate: async (entity, req) => {
const now = Date.now()
return {
...entity,
createdAt: now,
updatedAt: now,
createdBy: req.auth?.user?.username || 'system',
}
},
// Send welcome email after creating
postCreate: async (entity, req) => {
await sendWelcomeEmail(entity.email)
await publishToQueue('user-created', { userId: entity.id })
console.log(`User created: ${entity.id} by ${req.auth?.user?.username}`)
},
// Update timestamp and validate before updating
preUpdate: async (updateData, existingEntity, req) => {
// Add updated timestamp
const data = {
...updateData,
updatedAt: Date.now(),
updatedBy: req.auth?.user?.username || 'system',
}
// Business rule: prevent changing email if verified
if (updateData.email && existingEntity.emailVerified) {
throw new Error('Cannot change verified email')
}
return data
},
// Log changes and notify after updating
postUpdate: async (updatedEntity, previousEntity, req) => {
await logAuditTrail({
action: 'user-updated',
userId: updatedEntity.id,
changes: getChanges(previousEntity, updatedEntity),
by: req.auth?.user?.username,
})
// Notify if status changed
if (updatedEntity.status !== previousEntity.status) {
await publishToQueue('user-status-changed', {
userId: updatedEntity.id,
oldStatus: previousEntity.status,
newStatus: updatedEntity.status,
})
}
},
// Prevent deletion of admin users
preDelete: async (entity, req) => {
if (entity.roles?.includes('ROLE_AAA_ADMIN')) {
throw new Error('Cannot delete admin users')
}
// Soft delete by updating status instead
if (!req.query.force) {
throw new Error('Use force=true to permanently delete')
}
},
// Cleanup and notify after deletion
postDelete: async (entity, req) => {
// Delete related data
await deleteUserSessions(entity.id)
await deleteUserTokens(entity.id)
// Notify systems
await publishToQueue('user-deleted', { userId: entity.id })
// Log for audit
console.log(`User deleted: ${entity.id} by ${req.auth?.user?.username}`)
},
},
},
},
dynamoConfig: {
region: 'eu-west-1',
},
}
const router = createRouter<DataModels>(routes, settings)Example: Modifying Data in Pre-Hooks
const settings: RouterSettings<DataModels> = {
dataModels: {
product: {
tableName: 'products',
hooks: {
preCreate: async (entity) => {
return {
...entity,
// Generate SKU automatically
sku: generateSKU(entity.name, entity.category),
// Normalize name
name: entity.name?.trim().toLowerCase(),
// Add slug
slug: slugify(entity.name),
// Set default values
status: entity.status || 'draft',
stock: entity.stock || 0,
}
},
preUpdate: async (updateData, existingEntity) => {
const data = { ...updateData }
// Recalculate slug if name changed
if (data.name && data.name !== existingEntity.name) {
data.slug = slugify(data.name)
}
// Auto-update status based on stock
if (data.stock !== undefined) {
data.status = data.stock > 0 ? 'available' : 'out-of-stock'
}
return data
},
},
},
},
}Example: Queue Integration in Post-Hooks
import { SQS } from '@aws-sdk/client-sqs'
const sqs = new SQS({ region: 'eu-west-1' })
const settings: RouterSettings<DataModels> = {
dataModels: {
order: {
tableName: 'orders',
hooks: {
postCreate: async (order) => {
// Send to order processing queue
await sqs.sendMessage({
QueueUrl: process.env.ORDER_QUEUE_URL,
MessageBody: JSON.stringify({
type: 'order-created',
orderId: order.id,
customerId: order.customerId,
amount: order.total,
}),
})
},
postUpdate: async (order, previousOrder) => {
// Notify if order status changed
if (order.status !== previousOrder.status) {
await sqs.sendMessage({
QueueUrl: process.env.NOTIFICATION_QUEUE_URL,
MessageBody: JSON.stringify({
type: 'order-status-changed',
orderId: order.id,
customerId: order.customerId,
oldStatus: previousOrder.status,
newStatus: order.status,
}),
})
}
},
},
},
},
}Webhook Configuration (WebhookConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| enabled | boolean | ✅ Yes | - | Enable webhook functionality |
| path | string | ❌ No | '/webhooks' | Webhook endpoint path |
| handler | RequestHandler | ❌ No | - | Custom webhook handler |
| queue | string | ❌ No | - | Queue name for webhook events |
| service | string | ❌ No | - | Service name for webhook events |
| triggers | WebhookTrigger[] | ❌ No | [] | Array of webhook triggers |
Webhook Trigger Configuration (WebhookTrigger)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| entity | string | ✅ Yes | - | Entity name that triggers webhook |
| events | ('create' \| 'update' \| 'delete')[] | ✅ Yes | - | Array of events that trigger webhook |
| endpoints | string[] | ✅ Yes | - | Array of webhook endpoint URLs |
Keepalive Configuration (KeepaliveConfig)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| enabled | boolean | ❌ No | true | Enable keepalive/health check endpoint |
| path | string | ❌ No | '/keepalive' | Keepalive endpoint path |
| handler | (req: Request, res: Response) => Promise<void> \| void | ❌ No | - | Custom keepalive handler |
REST Resource Configuration
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| dataModel | string | ✅ Yes | - | Data model name (must exist in RouterSettings) |
| basePath | string | ✅ Yes | - | Base path for all routes (e.g., /access-tokens) |
| methods | ('create' \| 'read' \| 'update' \| 'list' \| 'delete')[] | ❌ No | ['create', 'read', 'update', 'list', 'delete'] | Which REST methods to generate |
| sharedOptions | RouteOptions<TEntity> | ❌ No | {} | Options applied to ALL generated routes |
| methodOptions | { create?, read?, update?, list?, delete?: RouteOptions<TEntity> } | ❌ No | {} | Options specific to each method (override shared) |
Type Reference
UserRole: Imported from @verisure-italy/aaa-types
type UserRole =
| 'ROLE_AAA_ADMIN'
| 'ROLE_AAA_READER'
| 'ROLE_AAA_WRITER'
// ... other roles defined in aaa-typesAuthInfo: Authentication information
interface AuthInfo {
user: {
id: string
username: string
roles: UserRole[]
[key: string]: any
}
token: {
accessToken: string
accessTokenExpiresAt: string
scope: string[]
}
}RequestHandler: Express request handler
type RequestHandler = (
req: Request,
res: Response,
next: NextFunction
) => void | Promise<void>Route Configuration
interface RouteConfig<TPath, TEntity> {
method: 'get' | 'post' | 'put' | 'patch' | 'delete'
path: TPath
handler: RequestHandler | RequestHandler[]
options?: RouteOptions<TEntity>
}Route Options
interface RouteOptions<TEntity> {
// Security
secure?: boolean // Require authentication
preAuth?: PreAuthHandler // Custom auth logic
acl?: AclConfig // Role-based access control
// Validation
validate?: ValidationConfig // Zod schemas for body/query/params
// Data handling
dataModel?: string // Data model name
projectionFields?: (keyof TEntity)[] // ✅ Type-safe field selection
dataTransformer?: DataTransformerConfig
entityEnrichment?: TypedEntityEnrichmentConfig<TEntity> // ✅ Type-safe enrichment
// Advanced
filterable?: FilterableConfig // List filtering
cache?: CacheConfig // Response caching
disableWebhook?: boolean // Disable webhook trigger
}