Skip to content

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

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:

  1. Models map - Object mapping resource names to model configurations
  2. Options - Global configuration for all routes (optional)

start/routes.ts

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

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

MethodPathActionDescription
GET/{resource}indexList all records with pagination
GET/{resource}/s/:encodedindexList records using encoded query parameters
GET/{resource}/$meta.indexindex:metaGet metadata for list operation

Create Operations

MethodPathActionDescription
POST/{resource}createCreate a new record
GET/{resource}/$meta.createcreate:metaGet validation schema for creation

Read Operations

MethodPathActionDescription
GET/{resource}/:idreadRetrieve a single record
GET/{resource}/:id/:relationshipreadRelatedRetrieve related records
GET/{resource}/:id/:relationship/s/:encodedreadRelatedRetrieve related records (encoded)
GET/{resource}/:id/:relationship/$meta.indexreadRelated:metaGet metadata for related records

Update Operations

MethodPathActionDescription
PUT/{resource}updateUpdate record (ID in body)
PUT/{resource}/:idupdateUpdate record by ID
PATCH/{resource}/:idupdatePartially update record
GET/{resource}/$meta.updateupdate:metaGet validation schema for updates

Bulk Update Operations

MethodPathActionDescription
PUT/{resource}/$bulkbulkUpdateUpdate multiple records (IDs in body)
PUT/{resource}/$bulk/:idsbulkUpdateUpdate multiple records by ID list
PATCH/{resource}/$bulkbulkUpdatePartially update multiple records
PATCH/{resource}/$bulk/:idsbulkUpdatePartially update multiple records
GET/{resource}/$meta.$bulk.updatebulkUpdate:metaGet validation schema for bulk updates
MethodPathActionDescription
PUT/{resource}/:id/:relationshipsyncRelatedSync many-to-many relationship
PATCH/{resource}/:id/:relationshipsyncRelatedSync many-to-many relationship

Delete Operations

MethodPathActionDescription
DELETE/{resource}/:iddeleteDelete a record

Query Parameters

Pagination

All list endpoints support pagination via query parameters:

http
GET /users?page=2&perPage=50
  • page - Page number (default: 1, min: 1)
  • perPage - Records per page (default: 20, min: 1, max: 100)

Filtering

Use Lucene-style syntax for complex filtering:

http
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:

http
GET /posts?filter=createdAt:[2024-01-01T00:00:00Z TO 2024-12-31T23:59:59Z]

Sorting

Specify sort fields and direction:

http
GET /users?sort[name]=asc&sort[createdAt]=desc

Default sort is by id ascending.

Field Selection

Request specific fields to reduce payload size:

http
GET /users?fields=id,name,email

Fields are subject to field-level access control.

Aggregations

Request aggregate calculations on numeric fields:

http
GET /products?aggregations[price][]=avg&aggregations[price][]=max

Supported aggregations:

  • avg - Average value
  • min - Minimum value
  • max - Maximum value
  • sum - Sum of values
  • countDistinct - Count of unique values
  • sumDistinct - Sum of unique values
  • avgDistinct - Average of unique values

Configuration Options

Global Options

Configure behavior for all models:

start/routes.ts

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

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

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

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

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:

http
GET / (Accept: application/json)  # OpenAPI JSON spec
GET / (Accept: text/yaml)          # OpenAPI YAML spec
GET / (Accept: text/html)          # Interactive Swagger UI

Viewing 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

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

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

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

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

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

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

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

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

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 format
  • text/html - HTML format (for documentation endpoints)

Clients can specify their preference:

http
GET /users
Accept: text/yaml

Best Practices

1. Use Lazy Imports

Always use lazy imports for models to improve startup performance:

ts
// ✅ Good
model: () => import('#models/user')

// ❌ Avoid
import User from '#models/user'
model: User

2. Apply Middleware Strategically

Use CRUD action-specific middleware for granular control:

ts
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:

ts
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:

ts
{
  catchThrown: true,
  onError: (error) => logger.error(error)
}

5. Use Scope Restrictors for Security

Implement multi-tenancy and soft deletes via scope restrictors:

ts
scopeRestrictors: [
  (ctx, app, query) => {
    // Tenant isolation
    query.where('tenantId', ctx.auth.user.tenantId)
  },
  (ctx, app, query) => {
    // Soft delete
    query.whereNull('deletedAt')
  }
]

See Also