Resourceful Routing
The resourceful router macro automatically generates RESTful CRUD endpoints for your Lucid models, complete with OpenAPI documentation, field-level access control, validation, and advanced query capabilities.
Introduction
The router.resourceful() macro eliminates boilerplate by automatically creating standardized API endpoints for your models. Each model you register gets a complete set of CRUD operations with built-in support for:
- Pagination - List endpoints with configurable page size
- Filtering - Lucene-style query syntax for complex filters
- Sorting - Multi-field sorting with ascending/descending order
- Field Selection - Control which fields are returned
- Relationship Loading - Automatic preloading of related data
- Validation - Joi-based schema validation for create/update operations
- Access Control - Field-level and operation-level permissions
- OpenAPI Documentation - Auto-generated interactive API docs
start/routes.ts
import router from '@adonisjs/core/services/router'
router.resourceful({
users: {
model: () => import('#models/user')
},
posts: {
model: () => import('#models/post')
}
})This single declaration creates 21 routes per model including list, create, read, update, delete operations plus metadata endpoints.
Basic Usage
Registering Models
The router.resourceful() method accepts two arguments:
- Models map - Object mapping resource names to model configurations
- Options - Global configuration for all routes (optional)
start/routes.ts
import router from '@adonisjs/core/services/router'
router.resourceful(
{
// Resource name becomes URL prefix: /users
users: {
model: () => import('#models/user')
},
// Kebab-case names work too: /blog-posts
'blog-posts': {
model: () => import('#models/post')
}
},
{
// Global options apply to all models
prefix: '/api/v1',
middleware: ['auth']
}
)Lazy Model Loading
Models can be provided in several ways:
start/routes.ts
import User from '#models/user'
router.resourceful({
// Direct reference (loaded immediately)
users: {
model: User
},
// Lazy import (recommended - loaded on first request)
posts: {
model: () => import('#models/post')
},
// Promise-based
comments: {
model: import('#models/comment')
}
})The lazy import approach is recommended as it improves application startup time.
Generated Routes
For each model, the following routes are automatically created:
Index Operations
| Method | Path | Action | Description |
|---|---|---|---|
| GET | /{resource} | index | List all records with pagination |
| GET | /{resource}/s/:encoded | index | List records using encoded query parameters |
| GET | /{resource}/$meta.index | index:meta | Get metadata for list operation |
Create Operations
| Method | Path | Action | Description |
|---|---|---|---|
| POST | /{resource} | create | Create a new record |
| GET | /{resource}/$meta.create | create:meta | Get validation schema for creation |
Read Operations
| Method | Path | Action | Description |
|---|---|---|---|
| GET | /{resource}/:id | read | Retrieve a single record |
| GET | /{resource}/:id/:relationship | readRelated | Retrieve related records |
| GET | /{resource}/:id/:relationship/s/:encoded | readRelated | Retrieve related records (encoded) |
| GET | /{resource}/:id/:relationship/$meta.index | readRelated:meta | Get metadata for related records |
Update Operations
| Method | Path | Action | Description |
|---|---|---|---|
| PUT | /{resource} | update | Update record (ID in body) |
| PUT | /{resource}/:id | update | Update record by ID |
| PATCH | /{resource}/:id | update | Partially update record |
| GET | /{resource}/$meta.update | update:meta | Get validation schema for updates |
Bulk Update Operations
| Method | Path | Action | Description |
|---|---|---|---|
| PUT | /{resource}/$bulk | bulkUpdate | Update multiple records (IDs in body) |
| PUT | /{resource}/$bulk/:ids | bulkUpdate | Update multiple records by ID list |
| PATCH | /{resource}/$bulk | bulkUpdate | Partially update multiple records |
| PATCH | /{resource}/$bulk/:ids | bulkUpdate | Partially update multiple records |
| GET | /{resource}/$meta.$bulk.update | bulkUpdate:meta | Get validation schema for bulk updates |
Sync Related Operations
| Method | Path | Action | Description |
|---|---|---|---|
| PUT | /{resource}/:id/:relationship | syncRelated | Sync many-to-many relationship |
| PATCH | /{resource}/:id/:relationship | syncRelated | Sync many-to-many relationship |
Delete Operations
| Method | Path | Action | Description |
|---|---|---|---|
| DELETE | /{resource}/:id | delete | Delete a record |
Query Parameters
Pagination
All list endpoints support pagination via query parameters:
GET /users?page=2&perPage=50page- Page number (default: 1, min: 1)perPage- Records per page (default: 20, min: 1, max: 100)
Filtering
Use Lucene-style syntax for complex filtering:
GET /users?filter=email:*@gmail.com AND age:[25 TO 35]Supported operators:
- AND, OR - Logical operators
- :value - Equals
- [min TO max] - Range (inclusive)
- {min TO max} - Range (exclusive)
- >value, >=value - Greater than
- <value, <=value - Less than
- *pattern - Wildcard matching
- "exact phrase" - Exact match
Date filtering requires ISO8601 format in UTC:
GET /posts?filter=createdAt:[2024-01-01T00:00:00Z TO 2024-12-31T23:59:59Z]Sorting
Specify sort fields and direction:
GET /users?sort[name]=asc&sort[createdAt]=descDefault sort is by id ascending.
Field Selection
Request specific fields to reduce payload size:
GET /users?fields=id,name,emailFields are subject to field-level access control.
Aggregations
Request aggregate calculations on numeric fields:
GET /products?aggregations[price][]=avg&aggregations[price][]=maxSupported aggregations:
avg- Average valuemin- Minimum valuemax- Maximum valuesum- Sum of valuescountDistinct- Count of unique valuessumDistinct- Sum of unique valuesavgDistinct- Average of unique values
Configuration Options
Global Options
Configure behavior for all models:
start/routes.ts
router.resourceful(
{ /* models */ },
{
// URL prefix for all routes
prefix: '/api/v1',
// Domain restriction
domain: 'api.example.com',
// Global middleware (applied to all routes)
middleware: ['auth', 'ratelimit'],
// Exclude operations from all models
except: ['delete', 'bulkUpdate'],
// OpenAPI documentation info
info: {
title: 'My API',
version: '1.0.0',
description: 'RESTful API for my application'
},
// Enable automatic error handling
catchThrown: true,
// Custom response headers
headers: {
'X-API-Version': '1.0.0',
'X-Powered-By': 'Lucid-Resourceful'
}
}
)Per-Model Options
Override global settings for specific models:
start/routes.ts
router.resourceful({
users: {
model: () => import('#models/user'),
// Model-specific middleware (added after global middleware)
middleware: ['permission:users.access'],
// Exclude operations for this model only
except: ['delete'],
// Query scope callbacks (filter all queries)
scopeRestrictors: [
(ctx, app, query) => {
// Only show active users
query.where('isActive', true)
}
],
// Payload validation restrictors (modify validation)
payloadRestrictors: [
(ctx, app) => {
return joi.object({
// Additional validation rules
email: joi.string().email().endsWith('@company.com')
})
}
]
}
})CRUD Action-Specific Middleware
Apply middleware to specific CRUD operations:
start/routes.ts
router.resourceful({
posts: {
model: () => import('#models/post'),
// Different middleware per operation
crudActionMiddlewares: {
index: 'public', // Anyone can list
read: 'public', // Anyone can read
create: ['auth', 'permission:posts.create'], // Auth required
update: ['auth', 'permission:posts.update'],
delete: ['auth', 'admin'], // Only admins can delete
bulkUpdate: ['auth', 'admin', 'ratelimit:strict'],
syncRelated: ['auth', 'permission:posts.manage-relations'],
readRelated: 'auth'
}
}
})Middleware can be:
- String - Named middleware (e.g.,
'auth') - Function - Inline middleware function
- Array - Multiple middlewares in execution order
Additional Routes
Add custom routes alongside generated CRUD endpoints:
start/routes.ts
router.resourceful(
{
users: {
model: () => import('#models/user'),
// Custom routes for this model
additional: {
// Method and path format: 'method|path'
'get|/:id/avatar': async ({ params, response }) => {
// Handle request
return response.ok({ url: `/avatars/${params.id}.png` })
},
// With OpenAPI metadata
'post|/:id/reset-password': {
title: 'Reset Password',
description: 'Send password reset email to user',
handler: async ({ params }) => {
// Handle request
return { message: 'Email sent' }
},
requestPayloadSchema: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' }
}
},
tags: ['authentication']
}
}
}
},
{
// Global additional routes (not tied to a model)
additional: {
'get|/health': {
title: 'Health Check',
description: 'API health status',
handler: ({ response }) => response.ok({ status: 'healthy' }),
tags: ['monitoring']
}
}
}
)Error Handling
Automatic Error Handling
Enable automatic error handling with catchThrown:
start/routes.ts
router.resourceful(
{ /* models */ },
{
catchThrown: true,
// Custom error handlers
onJoiValidationError: (error) => {
console.error('Validation failed:', error.message)
},
onException: (error) => {
console.error('Exception:', error.message)
},
onError: (error) => {
console.error('Error:', error.message)
}
}
)When enabled, validation and runtime errors are automatically caught and formatted as consistent error responses.
Built-in Error Types
The following errors are handled automatically:
- Validation Errors - Invalid request payload or query parameters
- E_RECORD_NOT_FOUND_EXCEPTION - Requested record doesn't exist
- E_RELATIONSHIP_NOT_FOUND_EXCEPTION - Requested relationship doesn't exist
- E_ROUTE_NOT_FOUND - Route not found
- E_UNSYNCABLE_RELATIONSHIP_EXCEPTION - Relationship cannot be synced
- E_INVALID_RELATIONSHIP_EXCEPTION - Invalid relationship operation
OpenAPI Documentation
Automatic Generation
Every resourceful router group automatically generates OpenAPI 3.0 documentation available at the root path:
GET / (Accept: application/json) # OpenAPI JSON spec
GET / (Accept: text/yaml) # OpenAPI YAML spec
GET / (Accept: text/html) # Interactive Swagger UIViewing Documentation
Visit your API root in a browser to see interactive Swagger UI documentation powered by Stoplight Elements:
http://localhost:3333/api/v1/Configuring Documentation
Customize OpenAPI metadata:
start/routes.ts
router.resourceful(
{ /* models */ },
{
prefix: '/api/v1',
info: {
title: 'My Application API',
version: '2.0.0',
description: 'Complete API documentation',
termsOfService: 'https://example.com/terms',
contact: {
name: 'API Support',
email: 'api@example.com',
url: 'https://example.com/support'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
externalDocs: {
description: 'Additional documentation',
url: 'https://docs.example.com'
},
// Customize tag names in documentation
tagMap: {
general: 'Core Operations',
additional: 'Custom Endpoints'
}
}
)Security Schemes
Document authentication requirements:
start/routes.ts
router.resourceful(
{ /* models */ },
{
security: [
{
key: 'bearerAuth',
schema: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Bearer Token Authentication'
},
applyToModels: true, // Apply to all models
applyToAdditional: true // Apply to additional routes
},
{
key: 'apiKey',
schema: {
type: 'apiKey',
name: 'X-API-Key',
in: 'header',
description: 'API Key Authentication'
},
// Selective application
applyToModels: {
users: true, // All user operations
posts: {
index: true,
create: false, // No auth required
update: ['admin', 'moderator'] // Document required scopes
}
},
applyToAdditional: {
'get|/health': false, // Public endpoint
'post|/admin/actions': ['admin']
}
}
]
}
)Advanced Features
Query Scoping
Automatically filter queries based on request context:
start/routes.ts
router.resourceful(
{
posts: {
model: () => import('#models/post'),
scopeRestrictors: [
// Only show published posts to non-admins
async (ctx, app, query, model) => {
if (!ctx.auth.user?.isAdmin) {
query.where('status', 'published')
}
},
// Tenant isolation
async (ctx, app, query, model) => {
const tenantId = ctx.auth.user?.tenantId
if (tenantId) {
query.where('tenantId', tenantId)
}
}
]
}
},
{
// Global scope restrictors apply to all models
scopeRestrictors: [
async (ctx, app, query, model) => {
// Soft delete filter
query.whereNull('deletedAt')
}
]
}
)Payload Validation
Add custom validation rules:
start/routes.ts
router.resourceful({
users: {
model: () => import('#models/user'),
payloadRestrictors: [
// Additional validation for user creation/updates
(ctx, app) => {
return joi.object({
email: joi.string().email().endsWith('@company.com'),
age: joi.number().min(18).max(120),
role: joi.string().valid('user', 'moderator', 'admin')
})
}
]
}
})Response Mutation
Transform responses before sending:
start/routes.ts
router.resourceful(
{
users: {
model: () => import('#models/user'),
mutators: {
// Transform index/list responses
index: [
async (result, ctx) => {
// Add custom metadata
result.meta.serverTime = new Date()
return result
}
],
// Transform single record responses
read: [
async (record, ctx) => {
// Add computed fields
record.fullName = `${record.firstName} ${record.lastName}`
return record
}
]
}
}
},
{
// Global mutators apply to all models
mutators: {
index: [
async (result, ctx) => {
result.meta.requestId = ctx.request.id()
return result
}
]
}
}
)Policies
Combine scope restrictors, payload restrictors, and mutators:
start/routes.ts
router.resourceful(
{ /* models */ },
{
policies: [
{
// Query filtering
scope: async (ctx, app, query, model) => {
query.where('tenantId', ctx.auth.user.tenantId)
},
// Payload validation
payload: async (ctx, app) => {
return joi.object({
tenantId: joi.string().valid(ctx.auth.user.tenantId)
})
},
// Response transformation
mutators: {
index: [
async (result, ctx) => {
result.meta.tenant = ctx.auth.user.tenant
return result
}
]
}
}
]
}
)Excluding Operations
Exclude specific operations from being generated:
start/routes.ts
router.resourceful(
{
// Read-only resource
'audit-logs': {
model: () => import('#models/audit_log'),
except: ['create', 'update', 'delete', 'bulkUpdate', 'syncRelated']
},
// No bulk operations
'sensitive-data': {
model: () => import('#models/sensitive_data'),
except: ['bulkUpdate']
}
},
{
// Globally exclude operations
except: ['bulkUpdate'] // Apply to all models
}
)Grouping Routes
Use router groups for better organization:
start/routes.ts
import router from '@adonisjs/core/services/router'
// Public API
router.resourceful(
{
posts: {
model: () => import('#models/post'),
scopeRestrictors: [
(ctx, app, query) => query.where('published', true)
]
}
},
{
prefix: '/api/public',
except: ['create', 'update', 'delete']
}
)
// Admin API
router.resourceful(
{
posts: {
model: () => import('#models/post')
},
users: {
model: () => import('#models/user')
}
},
{
prefix: '/api/admin',
middleware: ['auth', 'admin']
}
)Domain-Specific Routes
Register routes for specific domains:
start/routes.ts
router.resourceful(
{
blog: {
model: () => import('#models/post')
}
},
{
domain: 'blog.example.com',
prefix: '/api'
}
)
router.resourceful(
{
products: {
model: () => import('#models/product')
}
},
{
domain: 'shop.example.com',
prefix: '/api'
}
)Response Formats
Responses are automatically formatted based on the Accept header:
application/json- JSON format (default)text/yaml- YAML formattext/html- HTML format (for documentation endpoints)
Clients can specify their preference:
GET /users
Accept: text/yamlBest Practices
1. Use Lazy Imports
Always use lazy imports for models to improve startup performance:
// ✅ Good
model: () => import('#models/user')
// ❌ Avoid
import User from '#models/user'
model: User2. Apply Middleware Strategically
Use CRUD action-specific middleware for granular control:
crudActionMiddlewares: {
index: 'ratelimit:high', // Public endpoint
create: ['auth', 'ratelimit:medium'],
update: ['auth', 'owner'], // Only resource owner
delete: ['auth', 'admin'] // Admin only
}3. Document Custom Routes
Always provide OpenAPI metadata for additional routes:
additional: {
'post|/action': {
title: 'Clear action name',
description: 'Detailed description',
handler: async (ctx) => { /* ... */ },
requestPayloadSchema: { /* ... */ },
responsePayloadSchema: { /* ... */ },
tags: ['custom-operations']
}
}4. Enable Error Handling
Always enable catchThrown in production:
{
catchThrown: true,
onError: (error) => logger.error(error)
}5. Use Scope Restrictors for Security
Implement multi-tenancy and soft deletes via scope restrictors:
scopeRestrictors: [
(ctx, app, query) => {
// Tenant isolation
query.where('tenantId', ctx.auth.user.tenantId)
},
(ctx, app, query) => {
// Soft delete
query.whereNull('deletedAt')
}
]See Also
- Model Decorators - Configure field-level behavior
- Validation - Schema validation with Joi
- OpenAPI Generation - Detailed OpenAPI configuration
- CRUD Operations - Deep dive into CRUD functionality