Skip to content

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

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:

http
GET /odata/users(123)           # User with ID 123
GET /odata/users('abc-uuid')    # User with UUID key

Access related entities through navigation properties:

http
GET /odata/users(1)/posts                    # User's posts
GET /odata/users(1)/posts(5)/comments        # Nested navigation

Service Root Documents

The root endpoint returns the service document listing all entity sets:

http
GET /odata
Accept: application/json

Response:

json
{
  "@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:

http
GET /odata/$metadata
Accept: application/xml

Returns 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

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:

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

http
GET /odata/users?$filter=age gt 25 and status eq 'active'

Supported operators:

Logical Operators:

  • and - Logical AND
  • or - Logical OR
  • not - Logical NOT

Comparison Operators:

  • eq - Equal
  • ne - Not equal
  • gt - Greater than
  • ge - Greater than or equal
  • lt - Less than
  • le - Less than or equal

String Functions:

  • contains(field, 'value') - Contains substring
  • startswith(field, 'value') - Starts with
  • endswith(field, 'value') - Ends with

Date/Time Functions:

  • year(field) - Extract year
  • month(field) - Extract month
  • day(field) - Extract day

Examples:

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

http
GET /odata/users?$select=id,name,email

Only id, name, and email are included in the response.

Preload navigation properties:

http
GET /odata/users?$expand=posts,profile

Include nested expansions:

http
GET /odata/users?$expand=posts($expand=comments)

$orderby - Sorting

Sort results by one or more fields:

http
GET /odata/products?$orderby=price desc,name asc

$top and $skip - Pagination

Limit and offset results:

http
GET /odata/users?$top=10&$skip=20     # Page 3 (records 21-30)

$count - Include Count

Get total count alongside results:

http
GET /odata/users?$count=true

Response includes @odata.count:

json
{
  "@odata.context": "/odata/$metadata#users",
  "@odata.count": 150,
  "value": [ /* ... */ ]
}

Combining Query Options

http
GET /odata/users?$filter=status eq 'active'&$orderby=createdAt desc&$top=20&$select=id,name&$expand=profile

OData Operations

Collection Queries

Query entity sets:

http
GET /odata/users
GET /odata/users?$filter=age gt 21&$orderby=name

Single Entity Retrieval

Access by key using parentheses:

http
GET /odata/users(123)
GET /odata/users('550e8400-e29b-41d4-a716-446655440000')

Creating Entities

POST to the collection:

http
POST /odata/users
Content-Type: application/json

{
  "name": "John Doe",
  "email": "john@example.com",
  "age": 30
}

Updating Entities

Use PUT (replace) or PATCH (merge):

http
PUT /odata/users(123)
Content-Type: application/json

{
  "name": "Jane Doe",
  "email": "jane@example.com",
  "age": 28
}
http
PATCH /odata/users(123)
Content-Type: application/json

{
  "age": 29
}

Deleting Entities

http
DELETE /odata/users(123)
http
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 posts

Collection Count

Get just the count without data:

http
GET /odata/users/$count

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

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 ()):

http
GET /odata/users(123)/Default.GetAvatar()

Bound Actions (no suffix):

http
POST /odata/users(123)/Default.PostResetPassword
Content-Type: application/json

{ "reason": "user_requested" }

Unbound Functions (collection-level with ()):

http
GET /odata/users/Default.GetActiveCount()

Unbound Actions (collection-level, no suffix):

http
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|/avatarDefault.GetAvatar()
  • post|/reset-passwordDefault.PostResetPassword
  • patch|/update-statusDefault.PatchUpdateStatus

Method prefix + PascalCase path segments.

Relationship Syncing

Sync many-to-many relationships using the Default.Sync action:

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

http
GET /odata/users
Accept: application/json

Response:

json
{
  "@odata.context": "/odata/$metadata#users",
  "value": [
    { "id": 1, "name": "John", "email": "john@example.com" }
  ]
}

XML Format

http
GET /odata/users
Accept: application/xml

Returns OData-compliant XML/Atom feed format.

Metadata Levels

Control OData metadata verbosity via Accept header:

Minimal (default):

http
Accept: application/json;odata.metadata=minimal

None (no metadata):

http
Accept: application/json;odata.metadata=none

Full (verbose metadata):

http
Accept: application/json;odata.metadata=full

Configuration Options

All options from router.resourceful() are supported:

start/routes.ts

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

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 TypeEDM Type
stringEdm.String
integerEdm.Int32
bigIntegerEdm.Int64
booleanEdm.Boolean
dateEdm.Date
dateTimeEdm.DateTimeOffset
decimalEdm.Decimal
floatEdm.Double
uuidEdm.Guid

Lucid relationships become navigation properties:

  • belongsTo → Single-valued navigation
  • hasOne → Single-valued navigation
  • hasMany → Collection-valued navigation
  • manyToMany → Collection-valued navigation

Best Practices

1. Use Simple Entity Set Names

OData has strict identifier rules. Avoid hyphens and special characters:

ts
// ✅ Good
resourcefulOdata({ users: ..., blogPosts: ... })

// ❌ Avoid
resourcefulOdata({ 'blog-posts': ... })  // Will throw E_INVALID_ENTITYSET_IDENTIFIERS

2. Leverage $expand for Performance

Instead of multiple round trips:

ts
// ❌ Multiple requests
GET /odata/users(1)
GET /odata/users(1)/posts
GET /odata/users(1)/profile

// ✅ Single request
GET /odata/users(1)?$expand=posts,profile

3. Use $select to Reduce Payload

Only request needed fields:

http
GET /odata/users?$select=id,name,email&$expand=profile($select=avatar)

4. Implement $count for Pagination UI

Always include count when paginating:

http
GET /odata/users?$top=20&$skip=0&$count=true

5. Test with OData Clients

Use OData-aware clients for testing:

6. Document Custom Operations

Provide OpenAPI metadata for custom actions/functions:

ts
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 middleware option
  • Model-specific middleware - Applied to all operations for a specific model

start/routes.ts

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

FeatureResourcefulOData
ListGET /usersGET /odata/users
GetGET /users/:idGET /odata/users(id)
CreatePOST /usersPOST /odata/users
UpdatePUT /users/:idPUT /odata/users(id)
DeleteDELETE /users/:idDELETE /odata/users(id)
RelatedGET /users/:id/postsGET /odata/users(id)/posts

Query Syntax

FeatureResourceful (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
IncludesAutomatic preload?$expand=posts

Response Format

Resourceful:

json
{
  "data": [...],
  "meta": { "page": 1, "perPage": 20, "total": 100 }
}

OData:

json
{
  "@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:

  1. Change the macro:

    ts
    // Before
    router.resourceful({ users: ... })
    
    // After
    router.resourcefulOdata({ users: ... })
  2. Update client URLs:

    • /users/123/users(123)
    • /users/123/posts/users(123)/posts
  3. Update query parameters:

    • ?filter=...?$filter=...
    • ?sort[field]=asc?$orderby=field asc
    • ?page=2&perPage=20?$skip=20&$top=20
  4. Update response parsing:

    • response.dataresponse.value
    • response.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:

json
{
  "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:

ts
ctx.response.oXml(200, xmlData)

This is automatically used for metadata documents and XML format responses.

See Also