There're several ways to achieve this, but before I jump straight to the answer, I have to mention that if you customize the response returned by the API. Some OData integrations (e.g., PowerBI, OData clients, ... etc.) might not work as expected because of the unconventional response format. But if you're not planning on integrating any OData client with your API this shouldn't be a problem for you.
Another thing is maybe you should have a discussion with the front-end developers at your company to look for errors only when they receive a non-success status code in the response. And they should expect the REST standard error response (i.e., ProblemDetails). The response format they're suggesting is more common in the GraphQL world
Back to the topic, to customize the OData response, You can do that via the controller's action method.
public IActionResult Get(ODataQueryOptions<WeatherForecast> odataOptions)
{
var data = odataOptions.ApplyTo(_dbContext.Forecasts);
var odataFeature = HttpContext.ODataFeature();
var response = new WeatherApiEnvelope()
{
Data = new WeatherApiDataEnvelope()
{
Count = odataFeature.TotalCount,
Items = data,
Type = odataFeature.Path.GetEdmType().AsElementType().FullTypeName()
}
};
return Ok(response);
}
In this approach, you'll have to remove the EnableQueryAttribute if present and return IActionResult instead of IQueryable, And add ODataQueryOptions as a parameter to the action method). ODataQueryOptions is an OData-specific model that'll carry the query information from the query string, and it has the method ApplyTo() that'll apply the OData Query (e.g., filters, projections, ... etc.) to an IQueryable.
NOTE: OData does an extra job to get the total count (if you apply $count=true), Because it has to do it without considering data pagination or ($skip and $top) query options. So when you call the ApplyTo() function it'll set the total count in IODataFeature within HttpContext features.
Personally, I like this approach because it gives more control over what I can return from the method (e.g., error response). There's another approach if you want to keep IQueryable as the return type by leveraging OutputFormatters. In short, OutputFormatters executes after the action method returns (including filters), and its sole purpose is to format the response and write it in the response stream.
When you call AddOData() in the Startup, it injects the necessary services for OData, including ODataOutputFormatter, which will format and serialize OData responses. In the example below I will override the default behavior by adding a new ODataOutputFormatter.
//Action Method
[EnableQuery]
public IQueryable<WeatherForecast> Get()
{
return _dbContext.Forecasts;
}
The action method is annotated by EnableQueryAttribute which does exactly what we've done in the previous example (Applying the query to IQueryable).
NOTE: This annotation is required for my implementation to run but you can take it a mile further and apply the query yourself in the output formatter too.
public class WeatherforecastCustomOutputFormatter : ODataOutputFormatter
{
public WeatherforecastCustomOutputFormatter() : base(new List<ODataPayloadKind>
{
ODataPayloadKind.ResourceSet,
ODataPayloadKind.Resource
})
{
SupportedMediaTypes.Add(MediaTypeNames.Application.Json);
SupportedEncodings.Add(Encoding.UTF8);
}
public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
var odataFeature = context.HttpContext.ODataFeature();
var response = new WeatherApiEnvelope()
{
Data = new WeatherApiDataEnvelope()
{
Count = odataFeature.TotalCount,
Items = context.Object,
Type = odataFeature.Path.GetEdmType().AsElementType().FullTypeName()
}
};
return context.HttpContext.Response.WriteAsync(JsonSerializer.Serialize(response));
}
}
This custom output formatter inherits from ODataOutputFormatter and overrides WriteResponseBodyAsync to achieve the desired behavior.
NOTE: In OData there's this concept of ODataPayloadKind that you can read about here, For the scope of this formatter I included only ResourceSet and Resource which should cover the need to return a list of objects or a single object. Now we should inject this output formatter in our AddControllers().
public void ConfigureServices(IServiceCollection services)
{
services.AddOData(o => o.AddModel(GetEdmModel()).Filter().Select().OrderBy().Expand().SkipToken().Count());
services.AddControllers(options =>
{
options.OutputFormatters.Insert(0, new WeatherforecastCustomOutputFormatter());
});
}
Here I insert my custom OutputFormatter in the first index to make it run before the existing which will not remove the existing ODataOutputFormatter but will override it because it'll run first to handle the request.
NOTE: It's important to call AddOData() before AddControllers in this case. And also my implementation of the formatter is for demonstration only you should handle more cases (e.g., check for nulls, handle errors, use Serialization options, ...etc.)