F-H
F-H

Reputation: 1085

How can I parse ODataQueryOptions from a string?

I have to provide some read endpoints for our EF6 entities on an ASP.NET API that conform to the OData specification. Entity retrieval works well based upon functions that accept a System.Web.Http.OData.Query.ODataQueryOptions<TEntity> instance.

Now, according to the docs, that implementation of OData does not support $count.

We would, however, like to offer at least the capability to retrieve the total count of a (filtered) data set as shown in the docs, like (by slightly combining several of those samples):

http://host/service/Products/$count($filter=Price gt 5.00)

(Based on the spec, I understand that this should be a valid, specification-conformant OData query for the number of products whose price is greater than 5¤. Please correct me if I'm wrong.)

Now, retrieving the count based on the IQueryable returned from ODataQuerySettings.ApplyTo is trivial. So is capturing requests to this route:

[Route("$count({queryOptions})")]
public int Count(ODataQueryOptions<ProductEntity> queryOptions)

The only bit that is missing is that the queryOptions portion of the route should be parsed into the ODataQueryOptions<ProductEntity> instance. On other OData endpoints, this works without any further ado. However, even when I call this endpoint with a $filter, all I am getting is an "empty" (i.e. initialized to default values) ODataQueryOptions<ProductEntity> instance with no filter set.

Alternatively, I can declare my web API endpoint like this:

[Route("$count({queryOptions})")]
public int Count(string rawQueryOptions)

Within this method, rawQueryOptions contains the query options that I wish to pass to OData, that is, parse to populate an ODataQueryOptions<ProductEntity> instance.

This must be very straightforward as the very same thing happens for any other OData endpoint. For a comparison:

[Route("")]
public IEnumerable<object> Filter(ODataQueryOptions<ProductEntity> queryOptions)

This works; the query options are populated as expected, unlike it is the case with my above endpoint.

How can I populate my OData query options instance based on the string extracted from my route?

Some more things I have tried:

Upvotes: 3

Views: 4456

Answers (1)

Chris Schaller
Chris Schaller

Reputation: 16679

Although the syntax is a little bit different to your request, the .Net OData Implementation has the support you need OOTB, if you're asking this question at all, that indicates that you are trying to add OData support to your standard API.

Given that you have EF6 already on an ASP.Net API... Why not just expose the OData controllers on another route? In this way you get the full implementation of query support without ever needing to parse the QueryOptions at all.

Updated

If adding new controllers in a separate route is not suitable you can easily upgrade your existing ApiControllers and Implement OData routes in place of the existing ones.

ODataController inherits from ApiController but includes some helper methods that simplify working with OData response conventions, so upgrading in place is generally non-breaking.

As an example, the following is the only controller code that is needed to allow all the supported OData Query Options to return data from an EF DbSet, this includes full support for $select, $expand, $filter, $apply and even $count across a nested $filter.

public partial class ResidentsController : ODataController
{
    protected MyEF.Context db = new MyEF.Context();    

    [EnableQuery]
    public async Task<IHttpActionResult> Get(ODataQueryOptions<MyEF.Resident> options)
    { 
        return Ok(db.Residents);
    }
}

The magic that allows this is not the ODataController itself, the EnableQueryAttribute parses/translates the QueryOptions and applies this to the Linq to Entities expression that is returned from the method.

The final component to make this work is to register the routes, this is a little bit more involved than standard API because you need to define the EdmModel first, but in doing so we never need to parse the incoming query parameters.

a minimal example to configure the model and routes for the above controller might look like this:

public static void Register(HttpConfiguration config)
{
    var builder = new ODataConventionModelBuilder();
    builder.EntitySet<Resident>("Residents");
    IEdmModel model = builder.GetEdmModel();

    // To enable $select and $filter on all fields by default
    config.Count().Filter().OrderBy().Expand().Select().MaxTop(null);
    // can also be configured like this
    config.SetDefaultQuerySettings(new Microsoft.AspNet.OData.Query.DefaultQuerySettings() 
    { 
        EnableCount = true, 
        EnableExpand = true, 
        EnableFilter = true, 
        EnableOrderBy = true, 
        EnableSelect = true, 
        MaxTop = null 
    });
    // Map the routes from the model using OData Conventions
    config.MapODataServiceRoute("odata", "odata", model);
}

How to Configure the $count syntax you desire

although your expected syntax for count of filtered collections is not supported OOTB, the syntax that is supported is very close, so you could easily manipulate the query with a URL re-write module

  • Your expected syntax:
    http://host/service/Products/$count($filter=Price gt 5.00)
  • .Net Supported syntax
    http://host/service/Products/$count?$filter=Price gt 5.00

OwinMiddleware:

/// <summary>
/// Rewrite incoming OData requests that are implemented differently in the .Net pipeline
/// </summary>
public class ODataConventionUrlRewriter : OwinMiddleware
{
    private static readonly PathString CountUrlSegments = PathString.FromUriComponent("/$count");

    public ODataConventionUrlRewriter(OwinMiddleware next)
    : base(next)
    {
    }

    public override async Task Invoke(IOwinContext context)
    {
        // OData spec says this should work:                http://host/service/Products/$count($filter=Price gt 5.00)
        // But in .Net the filter needs to be in the query: http://host/service/Products/$count?$filter=Price gt 5.00
        var regex = new System.Text.RegularExpressions.Regex(@"\/\$count\((.+)\)$");
        var match = regex.Match(context.Request.Path.Value);
        if(match != null && match.Success)
        {
            // So move the $filter expression to a query option
            // We have to use redirect here, we can't affect the query inflight
            context.Response.Redirect($"{context.Request.Uri.GetLeftPart(UriPartial.Authority)}{regex.Replace(context.Request.Path.Value, "/$count")}?{match.Groups[1].Value}");
        }
        else
            await Next.Invoke(context);
    }
}

Add to Startup.cs, before registering OData routes

app.Use(typeof(ODataConventionUrlRewriter));

Upvotes: 2

Related Questions