WebApi icon indicating copy to clipboard operation
WebApi copied to clipboard

Is it possible to include total count on response header instead of body using $count=true

Open themisir opened this issue 5 years ago • 7 comments

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 avatar Apr 04 '20 18:04 themisir

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

gathogojr avatar Apr 07 '20 16:04 gathogojr

@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

gathogojr avatar Apr 08 '20 12:04 gathogojr

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; }
    }
}

themisir avatar Apr 21 '20 15:04 themisir

@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?

JohnYoungers avatar Jun 25 '21 20:06 JohnYoungers

@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?

I don't think so. I ended up not using odata at all cause it makes harder to separate domain layers.

themisir avatar Jun 25 '21 22:06 themisir

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) { }
    }

JohnYoungers avatar Jun 26 '21 00:06 JohnYoungers

What if a $top param is included and still need the total count?

blanks88 avatar Jun 19 '23 22:06 blanks88