Is it possible to include total count on response header instead of body using $count=true
I would like to send total count of entries on response header (eg: X-Total-Count: 34) instead of response body.
Assemblies affected
*Which assemblies and versions are known to be affected e.g. OData WebApi lib 6.1.0
Reproduce steps
Code
public class ProductsController : ControllerBase
{
private readonly MyDbContext _context;
public ProductsController(MyDbContext context)
{
_context = context;
}
[HttpGet]
[EnableQuery]
public IQueryable GetProducts()
{
return _context.Products.AsNoTracking();
}
}
Request
GET /api/products/?$top=300&$skip=10&$count=true
Expected result
Headers
X-Total-Count: 356
Body
[
{ "id": 1 },
{ "id": 2 },
]
Actual result
Headers
There is not any header about total entry count
Body
[
{ "id": 1 },
{ "id": 2 },
]
Additional detail
I'm not using /odata routing instead I want to use Asp.Net Core endpoint routing.
@TheMisir The current behaviour is based on the current odata protocol. If we wanted to support this we would probably use a different mechanism (e.g. preference header)
@TheMisir In addition, consider this perfectly valid OData Uri:
{{ServiceRoot}}/Customers?$expand=Orders($count=true)&$count=true
It will give you a result like the one below as an example:
{
"@odata.context": "{{ServiceRoot}}/$metadata#Customers(Orders())",
"@odata.count": 2,
"value": [
{
"Id": 1,
"Name": "Customer 1",
"[email protected]": 2,
"Orders": [
{
"Id": 1,
"Total": 100
},
{
"Id": 3,
"Total": 300
}
]
},
{
"Id": 2,
"Name": "Customer 2",
"[email protected]": 3,
"Orders": [
{
"Id": 2,
"Total": 200
},
{
"Id": 4,
"Total": 400
},
{
"Id": 5,
"Total": 500
}
]
}
]
}
You will notice from the above response that @odata.count appears at multiple levels. This is where returning the count in the header becomes a dicey affair
I ended up with creating custom action result to execute $skip and $top queries.
MyControllerBase.cs
using Microsoft.AspNetCore.Mvc;
using System;
using System.Globalization;
using System.Linq;
namespace My.Controllers
{
public class MyControllerBase : ControllerBase
{
protected PaginationQuery Pagination { get; private set; }
protected void ParseQuery()
{
Pagination = new PaginationQuery();
if (Request.Query.TryGetValue("$skip", out var skipValue))
{
Pagination.Skip = int.Parse(skipValue.ToString(), CultureInfo.InvariantCulture);
}
if (Request.Query.TryGetValue("$top", out var topValue))
{
Pagination.Top = int.Parse(topValue.ToString(), CultureInfo.InvariantCulture);
}
}
public bool TryParseQuery()
{
try
{
ParseQuery();
return true;
}
catch (FormatException)
{
return false;
}
}
protected ActionResult Query<T>(IQueryable<T> query)
{
if (Pagination == null)
{
if (!TryParseQuery())
{
return BadRequest();
}
}
return new QueryResult<T>(query, Pagination);
}
}
}
MyControllerBase.QueryResult.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
namespace My.Controllers
{
public class QueryResult<T> : ActionResult, IActionResult
{
private readonly IQueryable<T> _queryable;
private readonly PaginationQuery _pagination;
public QueryResult(IQueryable<T> queryable, PaginationQuery pagination)
{
_queryable = queryable;
_pagination = pagination;
}
public override async Task ExecuteResultAsync(ActionContext context)
{
IQueryable<T> query = _queryable;
if (_pagination.Skip.HasValue)
{
query = query.Skip(_pagination.Skip.Value);
}
if (_pagination.Top.HasValue)
{
query = query.Take(_pagination.Top.Value);
}
var count = await query.CountAsync();
context?.HttpContext.Response.Headers.Add("X-Total-Count",
new StringValues(count.ToString(CultureInfo.InvariantCulture)));
var result = new OkObjectResult(await query.ToListAsync());
await result.ExecuteResultAsync(context);
}
}
}
MyControllerBase.Pagination.cs
namespace My.Controllers
{
public class PaginationQuery
{
public int? Skip { get; set; }
public int? Top { get; set; }
}
}
@gathogojr - Is there a way to setup a route similar to what @TheMisir has (class extends ControllerBase and the method is decorated with EnableQuery), and have it return the full OData payload like you've indicated? Opposed to just returning the array of values?
@gathogojr - Is there a way to setup a route similar to what @TheMisir has (class extends
ControllerBaseand the method is decorated withEnableQuery), and have it return the full OData payload like you've indicated? Opposed to just returning the array of values?
I don't think so. I ended up not using odata at all cause it makes harder to separate domain layers.
I've used OData for several years without issue for the most part. Regarding separating the layers, generally I had it setup like so:
public IQueryable<MyFacade> Get(ODataQueryOptions<MyTable> queryOptions)
=> apply queryOptions.ApplyTo to your context table, then select into MyFacade
Which was awkward since the odata query was against the EF model, but I was returning a different type (which also didn't allow $select). I just realized after 5 years I could have been doing this:
[HttpGet, EnableQuery]
public IQueryable<MyFacade> Get([FromServices] MyContext context)
=> context.MyTable.Select(t=> new MyFacade {...});
And OData + EFCore will handle querying/selecting against the facade/viewmodel class appropriately, so you can hide or transform things as needed.
My issue is from a backwards combability standpoint, it would be nice if there was a way to return the full odata payload from this opposed to just the array, but if not I can probably make this work.
On a side note, regarding the original question of this issue, returning the count can also be handled via a result filter:
public class ODataResultFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
var odataDetails = context.HttpContext.Request.ODataFeature();
if (odataDetails is object && odataDetails.TotalCount.HasValue)
{
context.HttpContext.Response.Headers.Add("x-odata-count", odataDetails.TotalCount.ToString());
}
}
public void OnResultExecuted(ResultExecutedContext context) { }
}
What if a $top param is included and still need the total count?