2

I added Odata to my project so I can use the url-query-parameters like $filter. With the demo-class/controller, the output now looks like this:

{
  "@odata.context": "https://localhost:5001/api/v1/$metadata#WeatherForecast",
  "value": [
    {
      "Id": 1,
      "Date": "2021-05-22T14:00:18.9513586+02:00",
      "TemperatureC": 36,
      "Summary": "Sweltering"
    },
    {
      "Id": 2,
      "Date": "2021-05-23T14:00:21.6231763+02:00",
      "TemperatureC": 44,
      "Summary": "Chilly"
    }
  ]
}

So far, so good, that works.

The frontend-teams that consumes my API now wants something like an envelope.. (is it called so?) They want to get the result like this:

{
    "data": {
        "type": "WeatherForecast",
        "count": 2,
        "items" : [
            {
              "Id": 1,
              "Date": "2021-05-22T14:00:18.9513586+02:00",
              "TemperatureC": 36,
              "Summary": "Sweltering"
            },
            {
              "Id": 2,
              "Date": "2021-05-23T14:00:21.6231763+02:00",
              "TemperatureC": 44,
              "Summary": "Chilly"
            }
        ]
    },
    "error": null
}

Is there a possibility on how I can do this? Either with OData directly or somehow else? I can't simply change the return-type since OData wants a IQueryable<> as return-type. That's what I've tried, but Odata cant filter then.

Matthias Burger
  • 5,549
  • 7
  • 49
  • 94
  • OData is already in an _envelope_ form, it is not a GraphQL envelope but it is already wrapped. A simple transform would normally be applied at the consumer end. However you could add middleware to transform the response _after_ OData has generated it's response message. Ultimately you're being asked to implement a different media type format, so whatever solution you use should require the client to send a specific header to enable this behaviour – Chris Schaller May 30 '21 at 03:35

2 Answers2

5

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

  • Thanks for your detailed response. This is the way I went, too. Wasn't sure if this is a good way, but it worked. Great thing with that `ODataFeature` didn't knew that. Thanks, so I accept this and you earn that bounty :) – Matthias Burger Jun 01 '21 at 10:25
0

The thing is, what you are posted above that works - this is the Envelop response of Odata. The Envelop means that the object is wrapped in the odata or $odata tags and contains the values of the response inside json array:

"Value": [{..}{..}]

What your client is expecting is rather an Envelop but plain list of data objects in the form of

{"data":{[{..}{..}]},"error":..}

if you want to get plain list, you will have to use the $select attr accordingly, for example:

../api/home/GetSomeList?$select=param1,param2,param3

Barr J
  • 10,636
  • 1
  • 28
  • 46
  • ehm.. this just reduces the properties in the odata. the $select doesnt add the "data" and "error" around. looks like I need to fork their open-source project and do it myself... – Matthias Burger May 26 '21 at 22:42