Parsing OData queries for decoupled data entities in ASP.NET WebApi

From my archive - originally published on 15 October 2014

OData is a useful way of establishing consistent syntax across an API, though it’s easy to be put off by the sheer range of query functionality offered by the specification. This shouldn’t be an issue – just because you’re adopting OData it doesn’t necessarily follow that you have to implement the full querying syntax for every resource.

OData implementations in .NET tend to be very closely coupled to the IQueryable<T> interface. Typically, data is accessed via the entity framework or a similar collection that implements the interface. This creates a “magic box” where queries are automatically passed down to data sources that expose this generic interface.

This is useful if you want to quickly expose a data model through REST, though less useful if you want to separate your public API definition from your underlying data store. It’s less useful still if your data store does not provide a close fit to the style of query supported by IQueryable<T>. You may be accessing a key\value store or having to work through an external API that does not offer SQL-style query syntax.

Breaking out of the “magic box”

Creating your own implementation of IQueryable<T> is a huge undertaking – the Microsoft guidance alone runs to eighteen long articles. If you want to support a limited sub-set of OData commands then a more realistic option may be to parse the query before mapping it directly onto the underlying data source.

This parsing is best done with the System.Web.OData namespace, though it does involve working with a number of fairly obscure and undocumented syntax classes. This is still easier than trying to parse the input directly as you will likely drown in edge cases.

Exposing an OData method

In a WebApi controller, the ODataQueryOptions<T> class is used to exposes the validation and parsing capability for your resource. You add it to a method signature as shown below:

public IHttpActionResult Get(ODataQueryOptions<Product> options)
{
    // Start parsing options...
}

Validating the input options

The ODataValidationSettings class allows you to validate the options passed in the method call by specifying those features of OData that are not supported by the method. If validation fails then a pretty detailed error message is returned in an ODataException.

The example below switches off pretty much everything except a Filters that use the “equals” operator:

// These validation settings prevent anything except an equals filter
 
var settings = new ODataValidationSettings
{
    AllowedFunctions = AllowedFunctions.None,
    AllowedLogicalOperators = AllowedLogicalOperators.Equal,
    AllowedArithmeticOperators = AllowedArithmeticOperators.None,
    AllowedQueryOptions = AllowedQueryOptions.Filter
};
try
{
    options.Validate(settings);
}
catch (ODataException ex)
{
    return this.ResponseMessage(Request.CreateResponse(HttpStatusCode.BadRequest, ex.Message));
}

Parsing the OData query

Query parsing normally follows the same pattern of navigating through the options class and casting properties into a more suitable syntax class. The examples below show how the main types of OData query clause can be parsed.

Filter clause

// Parsing a filter, e.g. /Products?$filter=Name eq 'beer'        
 
if (options.Filter != null && options.Filter.FilterClause != null)
{
    var binaryOperator = options.Filter.FilterClause.Expression as BinaryOperatorNode;
    if (binaryOperator != null)
    {
        var property = binaryOperator.Left as SingleValuePropertyAccessNode ?? binaryOperator.Right as SingleValuePropertyAccessNode;
        var constant = binaryOperator.Left as ConstantNode ?? binaryOperator.Right as ConstantNode;
 
        if (property != null && property.Property != null && constant != null && constant.Value != null)
        {
            Debug.WriteLine("Property: " + property.Property.Name);
            Debug.WriteLine("Operator: " + binaryOperator.OperatorKind);
            Debug.WriteLine("Value: " + constant.LiteralText);
        }
    }
}

Select list

// Parsing a select list , e.g. /Products?$select=Price,Name       
 
if (options.SelectExpand != null && options.SelectExpand.SelectExpandClause != null)
{
    foreach (var item in options.SelectExpand.SelectExpandClause.SelectedItems) 
    {
        var selectItem = item as PathSelectItem;
        if (selectItem != null && selectItem.SelectedPath != null) 
        {
            var segment = selectItem.SelectedPath.FirstSegment as PropertySegment;
            if (segment != null) 
            {
                Debug.WriteLine("Property: " + segment.Property.Name);
            }
        }
    }
}

Top and skip

// Top and skip, e.g. /Products?$top=10&$skip=20
 
if (options.Top != null)
{
    Debug.WriteLine("Top: " + options.Top.Value);
}
 
if (options.Skip != null)
{
    Debug.WriteLine("Skip: " + options.Skip.Value);
}

Order by

// Order by, e.g. /Products?$orderby=Supplier asc,Price desc
 
if (options.OrderBy != null && options.OrderBy.OrderByClause != null)
{
    foreach(var node in options.OrderBy.OrderByNodes)
    {
        var typedNode = node as OrderByPropertyNode;
        Debug.WriteLine("Property: " + typedNode.Property.Name);
        Debug.WriteLine("Direction: " + typedNode.OrderByClause.Direction);
    }
}