Skip to main content
The Intent.AspNetCore.ODataQuery module adds OData query capabilities to standard ASP.NET Core controllers, enabling clients to filter, sort, page, and expand data using OData query syntax.

Overview

OData (Open Data Protocol) is an ISO/IEC standard that defines best practices for building and consuming RESTful APIs. This module adds OData query support to your existing controllers without requiring a full OData implementation.

What Gets Generated

ODataQuerySwaggerFilter

Documents OData query parameters in Swagger:
public class ODataQuerySwaggerFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        var hasODataAttribute = context.MethodInfo
            .GetCustomAttributes(typeof(EnableQueryAttribute), true)
            .Any();

        if (!hasODataAttribute)
            return;

        operation.Parameters ??= new List<OpenApiParameter>();

        // Add OData query parameters
        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "$filter",
            In = ParameterLocation.Query,
            Description = "Filter the results using OData syntax",
            Schema = new OpenApiSchema { Type = "string" }
        });

        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "$orderby",
            In = ParameterLocation.Query,
            Description = "Order the results using OData syntax",
            Schema = new OpenApiSchema { Type = "string" }
        });

        // ... $top, $skip, $expand, $select
    }
}

Key Features

Filtering

Filter collections using OData filter syntax

Sorting

Order results by one or more properties

Paging

Skip and take results for pagination

Expand

Include related data in responses

Module Settings

Allow Filter Option

Allow Filter Option
boolean
default:"true"
Enable $filter query option for expression-based filtering of data

Allow OrderBy Option

Allow OrderBy Option
boolean
default:"true"
Enable $orderby query option for expression-based ordering of data

Allow Expand Option

Allow Expand Option
boolean
default:"true"
Enable $expand query option to include related data inline

Allow Select Option

Allow Select Option
boolean
default:"false"
Enable $select query option to change the return type projection

Max Top

Max Top
number
default:"200"
Maximum value of $top that a client can request

Usage in Controllers

Apply [EnableQuery] attribute to controller actions:
[HttpGet]
[EnableQuery]
public IQueryable<ProductDto> GetAll()
{
    return _dbContext.Products
        .ProjectTo<ProductDto>(_mapper.ConfigurationProvider);
}
Important: Return IQueryable<T> not Task<List<T>> to enable OData query processing.

OData Query Operations

$filter - Filtering

Filter results using logical expressions:
# Products with price greater than 100
GET /api/products?$filter=price gt 100

# Products in Electronics category
GET /api/products?$filter=category eq 'Electronics'

# Products with name containing 'Laptop'
GET /api/products?$filter=contains(name, 'Laptop')

# Complex filter with AND/OR
GET /api/products?$filter=price gt 100 and (category eq 'Electronics' or category eq 'Computers')

# Filter by date
GET /api/orders?$filter=orderDate ge 2024-01-01

Filter Operators

OperatorDescriptionExample
eqEqualcategory eq 'Electronics'
neNot equalstatus ne 'Deleted'
gtGreater thanprice gt 100
geGreater than or equalprice ge 100
ltLess thanprice lt 1000
leLess than or equalprice le 1000
andLogical andprice gt 100 and inStock eq true
orLogical orcategory eq 'A' or category eq 'B'
notLogical notnot(category eq 'Electronics')

Filter Functions

FunctionDescriptionExample
containsString containscontains(name, 'phone')
startswithString starts withstartswith(name, 'iPhone')
endswithString ends withendswith(name, 'Pro')
tolowerConvert to lowercasetolower(name) eq 'iphone'
toupperConvert to uppercasetoupper(category) eq 'ELECTRONICS'
lengthString lengthlength(name) gt 10
yearYear from dateyear(orderDate) eq 2024
monthMonth from datemonth(orderDate) eq 12
dayDay from dateday(orderDate) eq 25

$orderby - Sorting

Order results by one or more properties:
# Order by price ascending
GET /api/products?$orderby=price

# Order by price descending
GET /api/products?$orderby=price desc

# Order by multiple fields
GET /api/products?$orderby=category asc, price desc

# Order by calculated field
GET /api/products?$orderby=year(createdDate) desc

topandtop and skip - Paging

Implement pagination:
# Get first 10 products
GET /api/products?$top=10

# Skip first 20, take next 10 (page 3)
GET /api/products?$skip=20&$top=10

# Combined with filter and sort
GET /api/products?$filter=inStock eq true&$orderby=price&$skip=0&$top=20
Client-side pagination example:
interface PageRequest {
  pageNumber: number;
  pageSize: number;
}

async function getProducts(page: PageRequest): Promise<ProductDto[]> {
  const skip = (page.pageNumber - 1) * page.pageSize;
  const response = await fetch(
    `/api/products?$skip=${skip}&$top=${page.pageSize}`
  );
  return await response.json();
}
Include related entities:
# Expand single navigation property
GET /api/orders?$expand=customer

# Expand multiple properties
GET /api/orders?$expand=customer,items

# Nested expand
GET /api/orders?$expand=customer,items($expand=product)

# Expand with filter
GET /api/orders?$expand=items($filter=quantity gt 1)

$select - Property Selection

Select specific properties:
# Select specific fields
GET /api/products?$select=id,name,price

# Select with expand
GET /api/orders?$expand=customer($select=name,email)&$select=id,orderDate,totalAmount

$count - Get Total Count

Include total count in response:
# Get products with count
GET /api/products?$count=true

Response:
{
  "@odata.count": 150,
  "value": [ /* products */ ]
}

Advanced Scenarios

Complex Filtering

# Products that are either:
# - In Electronics category with price > 500, OR
# - In Computers category regardless of price
GET /api/products?$filter=(category eq 'Electronics' and price gt 500) or (category eq 'Computers')

# Orders placed in the last 30 days by active customers
GET /api/orders?$filter=orderDate ge 2024-01-01 and customer/isActive eq true

# Products with any tag containing 'sale'
GET /api/products?$filter=tags/any(t: contains(t, 'sale'))

Collection Filtering

# Orders that have any item with quantity > 5
GET /api/orders?$filter=items/any(i: i/quantity gt 5)

# Orders where all items are in stock
GET /api/orders?$filter=items/all(i: i/inStock eq true)

# Products with at least 3 reviews
GET /api/products?$filter=reviews/count() ge 3

Combining Operations

# Complete example: filtered, sorted, paged
GET /api/products?
  $filter=category eq 'Electronics' and price gt 100
  &$orderby=price desc
  &$skip=0
  &$top=20
  &$expand=reviews
  &$count=true

Configuration

Query Options

Configure allowed query options:
[HttpGet]
[EnableQuery(
    AllowedQueryOptions = AllowedQueryOptions.Filter | 
                          AllowedQueryOptions.OrderBy | 
                          AllowedQueryOptions.Top | 
                          AllowedQueryOptions.Skip
)]
public IQueryable<ProductDto> GetAll()
{
    return _repository.GetAll();
}

Max Results

Limit maximum results:
[HttpGet]
[EnableQuery(PageSize = 50, MaxTop = 100)]
public IQueryable<ProductDto> GetAll()
{
    return _repository.GetAll();
}

Allowed Properties

Restrict filterable/sortable properties:
[HttpGet]
[EnableQuery(
    AllowedOrderByProperties = "name,price,createdDate",
    AllowedFilterProperties = "category,inStock,price"
)]
public IQueryable<ProductDto> GetAll()
{
    return _repository.GetAll();
}

Entity Framework Integration

OData queries are translated to SQL:
[HttpGet]
[EnableQuery]
public IQueryable<OrderDto> GetOrders()
{
    return _dbContext.Orders
        .Include(x => x.Customer)
        .Include(x => x.Items)
        .ThenInclude(x => x.Product)
        .ProjectTo<OrderDto>(_mapper.ConfigurationProvider);
}
Query:
GET /api/orders?$filter=customer/name eq 'Acme Corp'&$orderby=orderDate desc&$top=10
Generated SQL:
SELECT TOP 10 *
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
WHERE c.Name = 'Acme Corp'
ORDER BY o.OrderDate DESC

Cosmos DB Integration

Works with Cosmos DB repositories:
[HttpGet]
[EnableQuery]
public IQueryable<ProductDto> GetProducts()
{
    return _cosmosRepository.Query()
        .ProjectTo<ProductDto>(_mapper.ConfigurationProvider);
}

Client Examples

TypeScript/JavaScript

class ProductService {
  async getProducts(options: {
    filter?: string;
    orderBy?: string;
    skip?: number;
    top?: number;
  }): Promise<ProductDto[]> {
    const params = new URLSearchParams();
    
    if (options.filter) params.append('$filter', options.filter);
    if (options.orderBy) params.append('$orderby', options.orderBy);
    if (options.skip) params.append('$skip', options.skip.toString());
    if (options.top) params.append('$top', options.top.toString());

    const response = await fetch(`/api/products?${params}`);
    return await response.json();
  }

  async getElectronicsUnder500(): Promise<ProductDto[]> {
    return this.getProducts({
      filter: "category eq 'Electronics' and price lt 500",
      orderBy: 'price desc',
      top: 20
    });
  }
}

C# Client

public class ProductClient
{
    private readonly HttpClient _httpClient;

    public async Task<List<ProductDto>> GetProductsAsync(
        string? filter = null,
        string? orderBy = null,
        int? skip = null,
        int? top = null)
    {
        var query = new List<string>();
        
        if (filter != null) query.Add($"$filter={Uri.EscapeDataString(filter)}");
        if (orderBy != null) query.Add($"$orderby={Uri.EscapeDataString(orderBy)}");
        if (skip.HasValue) query.Add($"$skip={skip}");
        if (top.HasValue) query.Add($"$top={top}");

        var url = $"/api/products?{string.Join("&", query)}";
        return await _httpClient.GetFromJsonAsync<List<ProductDto>>(url);
    }
}

Best Practices

  • Validate and sanitize filter inputs
  • Limit maximum page size
  • Restrict allowed properties
  • Implement authorization checks
  • Add database indexes for filterable properties
  • Use projections (ProjectTo) to limit data
  • Set reasonable MaxTop limits
  • Monitor slow queries
  • Document supported OData operations
  • Provide examples in Swagger/Scalar
  • Return meaningful error messages
  • Support both OData and standard queries

Troubleshooting

  • Ensure return type is IQueryable<T> not List<T>
  • Verify [EnableQuery] attribute is present
  • Check query option is allowed in settings
  • Review query syntax for typos
  • Add database indexes
  • Reduce max page size
  • Limit expand depth
  • Use Select to reduce payload
  • Check property names match DTO
  • Verify data types in filter
  • Ensure properties are allowed
  • Review operator syntax

Installation

Intent.AspNetCore.ODataQuery

Dependencies

  • Intent.Application.AutoMapper
  • Intent.Common.CSharp
  • Intent.Modelers.Services
  • Intent.Modelers.Services.CQRS

Next Steps

Controllers

Create OData-enabled controllers

Entity Framework

Optimize queries for EF Core

AutoMapper

Project entities to DTOs

Build docs developers (and LLMs) love