ASP.NET Core ways to handle custom response/output format in Web API

14,593

Okay, after spending some good amount of time with ASP.NET Core there are basically 4 ways I can think of to solve this. The topic itself is quite complex and broad to think of and honestly, I don't think there's a silver bullet or the best practice for this.

For custom Content-Type(let's say you want to implement application/hal+json), the official way and probably the most elegant way is to create custom output formatter. This way your actions won't know anything about the output format but you still can control the formatting behaviour inside your controllers thanks to dependency injection mechanism and scoped lifetime.


1. Custom output formatters

This is the most popular way used by OData official C# libraries and json:api framework for ASP.Net Core. Probably the best way to implement hypermedia formats.

To control your custom output formatter from a controller you either have to create your own "context" to pass data between your controllers and custom formatter and add it to DI container with scoped lifetime:

services.AddScoped<ApiContext>();

This way there will be only one instance of ApiContext per request. You can inject it to both you controllers and output formatters and pass data between them.

You can also use ActionContextAccessor and HttpContextAccessor and access your controller and action inside your custom output formatter. To access controller you have to cast ActionContextAccessor.ActionContext.ActionDescriptor to ControllerActionDescriptor. You can then generate links inside your output formatters using IUrlHelper and action names so the controller will be free from this logic.

IActionContextAccessor is optional and not added to the container by default, to use it in your project you have to add it to the IoC container.

services.AddSingleton<IActionContextAccessor, ActionContextAccessor>()

Using services inside custom output formatter:

You can't do constructor dependency injection in a formatter class. For example, you can't get a logger by adding a logger parameter to the constructor. To access services, you have to use the context object that gets passed in to your methods.

https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/custom-formatters?view=aspnetcore-2.0#read-write

Swashbuckle support:

Swashbuckle obviously won't generate a correct response example with this approach and the approach with filters. You will probably have to create your custom document filter.

Example: How to add pagination links:

Usually paging, filtering is solved with specification pattern you will typically have some common model for the specification in your [Get] actions. You can then identify in your formatter if currently executed action is returning list of elements by it's parameter type or something else:

var specificationParameter = actionContextAccessor.ActionContext.ActionDescriptor.Parameters.SingleOrDefault(p => p.ParameterType == typeof(ISpecification<>));
if (specificationParameter != null)
{
   // add pagination links or whatever
   var urlHelper = new UrlHelper(actionContextAccessor.ActionContext);
   var link = urlHelper.Action(new UrlActionContext()
   {
       Protocol = httpContext.Request.Scheme,
       Host = httpContext.Request.Host.ToUriComponent(),
       Values = yourspecification
   })
}

Advantages (or not):

  • Your actions don't define the format, they know nothing about a format or how to generate links and where to put them. They know only of the result type, not the meta-data describing the result.

  • Re-usable, you can easily add the format to other projects without worrying how to handle it in your actions. Everything related to linking, formatting is handled under the hood. No need for any logic in your actions.

  • Serialization implementation is up to you, you don't have to use Newtonsoft.JSON, you can use Jil for example.

Disadvantages:

  • One disadvantage of this approach that it will only work with specific Content-Type. So to support XML we'd need to create another custom output formatter with Content-Type like vnd.myapi+xml instead of vnd.myapi+json.

  • We're not working directly with the action result

  • Can be more complex to implement

2. Result filters

Result filters allow us to define some kind of behaviour that will execute before our action returns. I think of it as some form of post-hook. I don't think it's the right place for wrapping our response.

They can be applied per action or globally to all actions.

Personally, I wouldn't use it for this kind of thing but use it as a supplement for the 3rd option.

Sample result filter wrapping the output:

public class ResultFilter : IResultFilter
{
    public void OnResultExecuting(ResultExecutingContext context)
    {
        if (context.Result is ObjectResult objectResult)
        {
            objectResult.Value = new ApiResult { Data = objectResult.Value };
        }
    }

    public void OnResultExecuted(ResultExecutedContext context)
    {
    }
}

You can put the same logic in IActionFilter and it should work as well:

public class ActionFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        if (context.Result is ObjectResult objectResult)
        {
            objectResult.Value = new ApiResult { Data = objectResult.Value };
        }
    }
}

This is the easiest way to wrap your responses especially if you already have the existing project with controllers. So if you care about time, choose this one.

3. Explicitly formatting/wrapping your results in your actions

(The way I do it in my question)

This is also used here: https://github.com/nbarbettini/BeautifulRestApi/tree/master/src to implement https://github.com/ionwg/ion-doc/blob/master/index.adoc personally I think this would be better suited in custom output formatter.

This is probably the easiest way but it's also "sealing" your API to that specific format. There are advantages to this approach but there can be some disadvantages too. For example, if you wanted to change the format of your API, you can't do it easily because your actions are coupled with that specific response model, and if you have some logic on that model in your actions, for example, you're adding pagination links for next and prev. You practically have to rewrite all your actions and formatting logic to support that new format. With custom output formatter you can even support both formats depending on the Content-Type header.

Advantages:

  • Works with all Content-Types, the format is an integral part of your API.
  • Swashbuckle works out of the box, when using ActionResult<T> (2.1+), you can also add [ProducesResponseType] attribute to your actions.

Disadvantages:

  • You can't control the format with Content-Type header. It always remains the same for application/json and application/xml. (maybe it's advantage?)
  • Your actions are responsible for returning the correctly formatted response. Something like: return new ApiResponse(obj); or you can create extension method and call it like obj.ToResponse() but you always have to think about the correct response format.
  • Theoretically custom Content-Type like vnd.myapi+json doesn't give any benefit and implementing custom output formatter just for the name doesn't make sense as formatting is still responsibility of controller's actions.

I think this is more like a shortcut for properly handling the output format. I think following the single responsibility principle it should be the job for output formatter as the name suggests it formats the output.

4. Custom middleware

The last thing you can do is a custom middleware, you can resolve IActionResultExecutor from there and return IActionResult like you would do in your MVC controllers.

https://github.com/aspnet/Mvc/issues/7238#issuecomment-357391426

You could also resolve IActionContextAccessor to get access to MVC's action context and cast ActionDescriptor to ControllerActionDescriptor if you need to access controller info.

Docs say:

Resource filters work like middleware in that they surround the execution of everything that comes later in the pipeline. But filters differ from middleware in that they're part of MVC, which means that they have access to MVC context and constructs.

But it's not entirely true, because you can access action context and you can return action results which is part of MVC from your middleware.


If you have anything to add, share your own experiences and advantages or disadvantages feel free to comment.

Share:
14,593
Konrad
Author by

Konrad

Updated on June 13, 2022

Comments

  • Konrad
    Konrad almost 2 years

    I'd like to create custom JSON format, that would wrap the response in data and would return Content-Type like

    vnd.myapi+json

    Currently I have created like a wrapper classes that I return in my controllers but it would be nicer if that could be handled under the hood:

    public class ApiResult<TValue>
    {
        [JsonProperty("data")]
        public TValue Value { get; set; }
    
        [JsonExtensionData]
        public Dictionary<string, object> Metadata { get; } = new Dictionary<string, object>();
    
        public ApiResult(TValue value)
        {
            Value = value;
        }
    }
    
    [HttpGet("{id}")]
    public async Task<ActionResult<ApiResult<Bike>>> GetByIdAsync(int id)
    {
        var bike = _dbContext.Bikes.AsNoTracking().SingleOrDefault(e => e.Id == id);
        if (bike == null)
        {
            return NotFound();
        }
        return new ApiResult(bike);
    }
    
    public static class ApiResultExtensions
    {
        public static ApiResult<T> AddMetadata<T>(this ApiResult<T> result, string key, object value)
        {
            result.Metadata[key] = value;
            return result;
        }
    }
    

    I'd like to return response like:

    {
        "data": { ... },
        "pagination": { ... },
        "someothermetadata": { ... }
    }
    

    But the pagination would have to be added somehow to the metadata in my controller's action, of course there's some article about content negotiation here: https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/formatting?view=aspnetcore-2.1 but still I'd like to be sure I'm on the right track.

    If that would be handled under the hood with my custom formatter then how would I add metadata like a pagination to it, to be aside of "data" and not inside of it?

    When having a custom formatter I'd like to still have some way to add metadata to it from my controllers or by some mechanism so the format could be extensible.

    One advantage or disadvantage with the approach above is that it works with all serializers xml, json, yaml etc. By having custom formatter it would probably work only for json, and I will need to create few different formatters to support all the formats that I want.

  • Christian Sauer
    Christian Sauer about 4 years
    Very well researched and good reasoning. Really sad that this has so few upvotes