OData Routing
The OData router macro provides full OData v4 protocol support for your Lucid models, enabling enterprise-grade RESTful APIs with standardized querying, metadata discovery, and XML/JSON formatting.
Introduction
The router.resourcefulOdata() macro extends the standard resourceful routing with complete OData v4 compliance. It creates endpoints that follow OData conventions for:
- Service Documents - Discover available entity sets
- Metadata Documents - Full EDMX/CSDL schema definitions
- Entity Collections - Query, filter, sort, and page collections
- Single Entities - Access records by key with parentheses syntax
- Navigation Properties - Traverse relationships
- Actions & Functions - Custom operations bound to entities or collections
- System Query Options -
$filter,$select,$expand,$top,$skip,$orderby,$count - Format Negotiation - JSON, XML, and Atom formats via Accept headers
- Metadata Levels - Control verbosity with
odata.metadata=none|minimal|full
start/routes.ts
import router from '@adonisjs/core/services/router'
router.resourcefulOdata({
users: {
model: () => import('#models/user')
},
posts: {
model: () => import('#models/post')
}
}, {
prefix: '/odata'
})This creates an OData-compliant API accessible at /odata with full metadata at /odata/$metadata.
OData Protocol Basics
Entity Keys
Unlike REST APIs, OData uses parentheses notation for entity keys:
GET /odata/users(123) # User with ID 123
GET /odata/users('abc-uuid') # User with UUID keyNavigation Properties
Access related entities through navigation properties:
GET /odata/users(1)/posts # User's posts
GET /odata/users(1)/posts(5)/comments # Nested navigationService Root Documents
The root endpoint returns the service document listing all entity sets:
GET /odata
Accept: application/jsonResponse:
{
"@odata.context": "$metadata",
"value": [
{ "name": "users", "kind": "EntitySet", "url": "users" },
{ "name": "posts", "kind": "EntitySet", "url": "posts" }
]
}Metadata Document
The $metadata endpoint returns the complete EDMX schema:
GET /odata/$metadata
Accept: application/xmlReturns full CSDL/EDMX schema with entity types, properties, relationships, and annotations.
Basic Usage
Registering Models
The API is identical to router.resourceful() but generates OData-compliant endpoints:
start/routes.ts
import router from '@adonisjs/core/services/router'
router.resourcefulOdata(
{
users: {
model: () => import('#models/user')
},
blogPosts: {
model: () => import('#models/post')
}
},
{
prefix: '/odata/v1',
middleware: ['auth']
}
)Important: Entity set names must be valid OData identifiers (alphanumeric, no special characters except underscore).
Entity Set Naming
OData uses camelCase for entity sets in the service document, but your routes use the exact names provided:
router.resourcefulOdata({
blogPosts: { model: () => import('#models/post') } // Route: /odata/blogPosts
})Service document shows "name": "blogPosts" and "url": "blogPosts".
OData System Query Options
$filter - Filtering
OData uses a powerful filtering syntax:
GET /odata/users?$filter=age gt 25 and status eq 'active'Supported operators:
Logical Operators:
and- Logical ANDor- Logical ORnot- Logical NOT
Comparison Operators:
eq- Equalne- Not equalgt- Greater thange- Greater than or equallt- Less thanle- Less than or equal
String Functions:
contains(field, 'value')- Contains substringstartswith(field, 'value')- Starts withendswith(field, 'value')- Ends with
Date/Time Functions:
year(field)- Extract yearmonth(field)- Extract monthday(field)- Extract day
Examples:
# Complex filter
GET /odata/products?$filter=price gt 100 and category eq 'Electronics'
# String functions
GET /odata/users?$filter=contains(email, '@gmail.com')
# Date filtering
GET /odata/orders?$filter=year(orderDate) eq 2024$select - Field Selection
Limit returned fields:
GET /odata/users?$select=id,name,emailOnly id, name, and email are included in the response.
$expand - Include Related Data
Preload navigation properties:
GET /odata/users?$expand=posts,profileInclude nested expansions:
GET /odata/users?$expand=posts($expand=comments)$orderby - Sorting
Sort results by one or more fields:
GET /odata/products?$orderby=price desc,name asc$top and $skip - Pagination
Limit and offset results:
GET /odata/users?$top=10&$skip=20 # Page 3 (records 21-30)$count - Include Count
Get total count alongside results:
GET /odata/users?$count=trueResponse includes @odata.count:
{
"@odata.context": "/odata/$metadata#users",
"@odata.count": 150,
"value": [ /* ... */ ]
}Combining Query Options
GET /odata/users?$filter=status eq 'active'&$orderby=createdAt desc&$top=20&$select=id,name&$expand=profileOData Operations
Collection Queries
Query entity sets:
GET /odata/users
GET /odata/users?$filter=age gt 21&$orderby=nameSingle Entity Retrieval
Access by key using parentheses:
GET /odata/users(123)
GET /odata/users('550e8400-e29b-41d4-a716-446655440000')Creating Entities
POST to the collection:
POST /odata/users
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com",
"age": 30
}Updating Entities
Use PUT (replace) or PATCH (merge):
PUT /odata/users(123)
Content-Type: application/json
{
"name": "Jane Doe",
"email": "jane@example.com",
"age": 28
}PATCH /odata/users(123)
Content-Type: application/json
{
"age": 29
}Deleting Entities
DELETE /odata/users(123)Navigating Relationships
GET /odata/users(1)/posts # All posts by user 1
GET /odata/users(1)/posts?$top=5 # First 5 posts
GET /odata/users(1)/posts(42) # Specific post
GET /odata/users(1)/posts?$count=true # Count of postsCollection Count
Get just the count without data:
GET /odata/users/$countReturns: 150 (plain integer)
Custom Actions and Functions
Functions vs Actions
- Functions - Read-only operations, no side effects (GET)
- Actions - Operations that modify state (POST)
Defining Custom Operations
Custom routes in OData are exposed as bound or unbound operations:
start/routes.ts
router.resourcefulOdata({
users: {
model: () => import('#models/user'),
additional: {
// Bound function (per instance) - GET
'get|/:uid/avatar': async ({ params, response }) => {
return response.ok({ url: `/avatars/${params.uid}.png` })
},
// Bound action (per instance) - POST
'post|/:uid/reset-password': async ({ params }) => {
// Send password reset email
return { message: 'Reset email sent' }
},
// Unbound function (collection-level) - GET
'get|/active-count': async () => {
// Return count of active users
return { count: 42 }
},
// Unbound action (collection-level) - POST
'post|/bulk-notify': async ({ request }) => {
// Notify multiple users
return { notified: 100 }
}
}
}
})Calling Custom Operations
Bound Functions (suffix with ()):
GET /odata/users(123)/Default.GetAvatar()Bound Actions (no suffix):
POST /odata/users(123)/Default.PostResetPassword
Content-Type: application/json
{ "reason": "user_requested" }Unbound Functions (collection-level with ()):
GET /odata/users/Default.GetActiveCount()Unbound Actions (collection-level, no suffix):
POST /odata/users/Default.PostBulkNotify
Content-Type: application/json
{ "userIds": [1, 2, 3] }Operation Naming Convention
The library automatically converts your route names to OData operation names:
get|/avatar→Default.GetAvatar()post|/reset-password→Default.PostResetPasswordpatch|/update-status→Default.PatchUpdateStatus
Method prefix + PascalCase path segments.
Relationship Syncing
Sync many-to-many relationships using the Default.Sync action:
POST /odata/users(1)/Default.SyncPosts
Content-Type: application/json
[
{ "ruid": 10, "pivotData": "value" },
{ "ruid": 20 }
]The ruid (related unique identifier) field specifies the related entity's key.
Format Negotiation
JSON Format (default)
GET /odata/users
Accept: application/jsonResponse:
{
"@odata.context": "/odata/$metadata#users",
"value": [
{ "id": 1, "name": "John", "email": "john@example.com" }
]
}XML Format
GET /odata/users
Accept: application/xmlReturns OData-compliant XML/Atom feed format.
Metadata Levels
Control OData metadata verbosity via Accept header:
Minimal (default):
Accept: application/json;odata.metadata=minimalNone (no metadata):
Accept: application/json;odata.metadata=noneFull (verbose metadata):
Accept: application/json;odata.metadata=fullConfiguration Options
All options from router.resourceful() are supported:
start/routes.ts
router.resourcefulOdata(
{ /* models */ },
{
// URL prefix
prefix: '/odata/v1',
// Domain restriction
domain: 'api.example.com',
// Global middleware
middleware: ['auth', 'cors'],
// Exclude operations
except: ['delete', 'bulkUpdate'],
// Custom headers
headers: {
'OData-Version': '4.0',
'X-API-Version': '1.0.0'
},
// Error handling
catchThrown: true,
onError: (error) => logger.error(error),
// OpenAPI/metadata info
info: {
title: 'OData API',
version: '1.0.0',
description: 'OData v4 compliant API'
}
}
)Per-Model Configuration
start/routes.ts
router.resourcefulOdata({
users: {
model: () => import('#models/user'),
// Model-specific middleware
middleware: ['permission:users.access'],
// Exclude operations for this model
except: ['delete'],
// Query scoping
scopeRestrictors: [
(ctx, app, query) => {
query.where('tenantId', ctx.auth.user.tenantId)
}
],
// Payload validation
payloadRestrictors: [
(ctx, app) => {
return joi.object({
email: joi.string().email().endsWith('@company.com')
})
}
]
}
})OData-Specific Features
Entity Type Discovery
Models are automatically converted to OData EntityTypes in the metadata document with:
- Property types mapped to EDM types
- Navigation properties for relationships
- Key definitions
- Nullability constraints
Automatic Type Mapping
Lucid column types are mapped to OData EDM types:
| Lucid Type | EDM Type |
|---|---|
string | Edm.String |
integer | Edm.Int32 |
bigInteger | Edm.Int64 |
boolean | Edm.Boolean |
date | Edm.Date |
dateTime | Edm.DateTimeOffset |
decimal | Edm.Decimal |
float | Edm.Double |
uuid | Edm.Guid |
Navigation Property Binding
Lucid relationships become navigation properties:
belongsTo→ Single-valued navigationhasOne→ Single-valued navigationhasMany→ Collection-valued navigationmanyToMany→ Collection-valued navigation
Best Practices
1. Use Simple Entity Set Names
OData has strict identifier rules. Avoid hyphens and special characters:
// ✅ Good
resourcefulOdata({ users: ..., blogPosts: ... })
// ❌ Avoid
resourcefulOdata({ 'blog-posts': ... }) // Will throw E_INVALID_ENTITYSET_IDENTIFIERS2. Leverage $expand for Performance
Instead of multiple round trips:
// ❌ Multiple requests
GET /odata/users(1)
GET /odata/users(1)/posts
GET /odata/users(1)/profile
// ✅ Single request
GET /odata/users(1)?$expand=posts,profile3. Use $select to Reduce Payload
Only request needed fields:
GET /odata/users?$select=id,name,email&$expand=profile($select=avatar)4. Implement $count for Pagination UI
Always include count when paginating:
GET /odata/users?$top=20&$skip=0&$count=true5. Test with OData Clients
Use OData-aware clients for testing:
- OData Explorer
- Postman OData Collection
- LinqPad for .NET
- Simple.OData.Client library
6. Document Custom Operations
Provide OpenAPI metadata for custom actions/functions:
additional: {
'post|/notify': {
title: 'Notify User',
description: 'Send notification to user',
handler: async (ctx) => { /* ... */ },
requestPayloadSchema: { /* ... */ },
responsePayloadSchema: { /* ... */ },
tags: ['notifications']
}
}Differences from Standard Resourceful Router
CRUD Action-Specific Middleware
Not Supported: The OData router does not support crudActionMiddlewares configuration. Unlike the standard resourceful router, you cannot apply different middleware to specific CRUD operations (index, create, read, update, delete, etc.).
For OData endpoints, use:
- Global middleware - Applied to all routes via the
middlewareoption - Model-specific middleware - Applied to all operations for a specific model
start/routes.ts
router.resourcefulOdata({
users: {
model: () => import('#models/user'),
// ✅ Supported - applies to ALL operations on users
middleware: ['auth', 'permission:users.access'],
// ❌ NOT supported in OData
// crudActionMiddlewares: {
// index: 'public',
// create: ['auth', 'admin']
// }
}
}, {
// ✅ Supported - applies to ALL routes
middleware: ['cors']
})If you need operation-specific middleware, consider using the standard router.resourceful() instead, or implement access control through scopeRestrictors and payloadRestrictors.
URL Patterns
| Feature | Resourceful | OData |
|---|---|---|
| List | GET /users | GET /odata/users |
| Get | GET /users/:id | GET /odata/users(id) |
| Create | POST /users | POST /odata/users |
| Update | PUT /users/:id | PUT /odata/users(id) |
| Delete | DELETE /users/:id | DELETE /odata/users(id) |
| Related | GET /users/:id/posts | GET /odata/users(id)/posts |
Query Syntax
| Feature | Resourceful (Lucene) | OData |
|---|---|---|
| Filter | ?filter=age:25 | ?$filter=age eq 25 |
| Sort | ?sort[name]=asc | ?$orderby=name asc |
| Pagination | ?page=2&perPage=20 | ?$skip=20&$top=20 |
| Fields | ?fields=id,name | ?$select=id,name |
| Includes | Automatic preload | ?$expand=posts |
Response Format
Resourceful:
{
"data": [...],
"meta": { "page": 1, "perPage": 20, "total": 100 }
}OData:
{
"@odata.context": "/odata/$metadata#users",
"@odata.count": 100,
"value": [...]
}Metadata Discovery
- Resourceful: OpenAPI/Swagger documentation
- OData: EDMX/CSDL metadata document at
/$metadata
Migration from Resourceful to OData
To migrate an existing resourceful API to OData:
Change the macro:
ts// Before router.resourceful({ users: ... }) // After router.resourcefulOdata({ users: ... })Update client URLs:
/users/123→/users(123)/users/123/posts→/users(123)/posts
Update query parameters:
?filter=...→?$filter=...?sort[field]=asc→?$orderby=field asc?page=2&perPage=20→?$skip=20&$top=20
Update response parsing:
response.data→response.valueresponse.meta→@odata.count, etc.
OData Protocol Compliance
This implementation follows OData v4 specification:
- ✅ Service documents (JSON)
- ✅ Metadata documents (XML/EDMX)
- ✅ Entity sets and entity retrieval
- ✅ Navigation properties
- ✅ System query options (
$filter,$select,$expand,$orderby,$top,$skip,$count) - ✅ CRUD operations
- ✅ Custom actions and functions
- ✅ Format negotiation (JSON/XML)
- ✅ Metadata level control
- ⚠️ Batch requests (not implemented)
- ⚠️ Delta queries (not implemented)
- ⚠️ Async operations (not implemented)
Error Responses
OData errors follow the standard format:
{
"error": {
"code": "404",
"message": "Entity not found",
"details": [
{
"code": "E_RECORD_NOT_FOUND",
"message": "User with ID 123 not found"
}
]
}
}XML Response Macro
The OData router includes a Response macro for XML formatting:
ctx.response.oXml(200, xmlData)This is automatically used for metadata documents and XML format responses.
See Also
- Resourceful Routing - Standard RESTful routing
- Model Decorators - Configure field-level behavior
- Validation - Schema validation
- OData v4 Specification - Official OData docs
- OASIS OData TC - Standards body