ASP.NET CORE Minimal API - Content Negotiation
The primary goal of ASP.NET Core Minimal API is to deliver a straightforward, simple, and most importantly, very powerful framework for creating APIs. Rather than delivering features to cover every possible usage scenario (unlike MVC) it is designed with certain specific patterns in mind. One of these cases is that the Minimal API consumes and produces only the JSON
format (Thus, it does not support Content Negotiation which is handled by Output formatters in the case of MVC).
However, there are situations where you really need either a different type of output (for example, standard XML
), or you need to be more in control of the serialization process, and you want to take advantage of the simplicity and power of the Minimal API. In this article, I will try to show how you can create your own support for Content Negotiation in the Minimal API.
⚠️ This is a functional implementation, but it doesn’t cover all scenarios and is just a basic example of what such support could look like.
The whole solution is based on a custom implementation for IResult
. More info in the documentation.
IResponseNegotiator
Let’s first define the interface whose implementation will be responsible for serializing the response.
public interface IResponseNegotiator
{
string ContentType { get; }
bool CanHandle(MediaTypeHeaderValue accept);
Task Handle<TResult>(HttpContext httpContext, TResult result, CancellationToken cancellationToken);
}
Implementation for JSON
For JSON, we can directly use Ok<TResult>
result, which is part of the Minimal API.
public class JsonNegotiator : IResponseNegotiator
{
public string ContentType => MediaTypeNames.Application.Json;
public bool CanHandle(MediaTypeHeaderValue accept)
=> accept.MediaType == ContentType;
public Task Handle<TResult>(HttpContext httpContext, TResult result, CancellationToken cancellationToken)
{
// 👇 Use original Ok<TResult> type for JSON serialization
return TypedResults.Ok(result).ExecuteAsync(httpContext);
}
}
Implementation for XML
For XML, we can create a custom implementation that uses, for example, DataContractSerializer
.
public class XmlNegotiator : IResponseNegotiator
{
public string ContentType => MediaTypeNames.Application.Xml;
public bool CanHandle(MediaTypeHeaderValue accept)
=> accept.MediaType == ContentType;
public async Task Handle<TResult>(HttpContext httpContext, TResult result, CancellationToken cancellationToken)
{
httpContext.Response.ContentType = ContentType;
// 👇 Use DataContractSerializer for XML serialization
using var stream = new FileBufferingWriteStream();
using var streamWriter = new StreamWriter(stream);
var serializer = new DataContractSerializer(result.GetType());
serializer.WriteObject(stream, result);
await stream.DrainBufferAsync(httpContext.Response.Body, cancellationToken);
}
}
Registration
Unfortunately, due to the way IEndpointMetadataProvider
works and the way serialization works directly in the Minimal API, I couldn’t find an elegant way to use the DI container (there would be a few inelegant ones 😊). So I resorted to a custom registrar for negotiators.
public static class ContentNegotiationProvider
{
private static readonly List<IResponseNegotiator> _negotiators = [];
// 👇 Internal list of negotiators
internal static IReadOnlyList<IResponseNegotiator> Negotiators => _negotiators;
// 👇 Add negotiator to the list
public static void AddNegotiator<TNegotiator>()
where TNegotiator : IResponseNegotiator, new()
{
_negotiators.Add(new TNegotiator());
}
}
We register the negotiators in Program.cs
or Startup.cs
.
ContentNegotiationProvider.AddNegotiator<JsonNegotiator>();
ContentNegotiationProvider.AddNegotiator<XmlNegotiator>();
ContentNegotiationResult<TResult>
public class ContentNegotiationResult<TResult>(TResult result)
: IResult, IEndpointMetadataProvider, IStatusCodeHttpResult, IValueHttpResult
{
private readonly TResult _result = result;
// ...
public Task ExecuteAsync(HttpContext httpContext)
{
if (_result == null)
{
httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
}
// 👇 Get negotiator based on Accept header
var negotiator = GetNegotiator(httpContext);
if (negotiator == null)
{
httpContext.Response.StatusCode = StatusCodes.Status406NotAcceptable;
return Task.CompletedTask;
}
// 👇 Set status code
httpContext.Response.StatusCode = StatusCode;
// 👇 Handle the result
return negotiator.Handle(httpContext, _result, httpContext.RequestAborted);
}
private static IResponseNegotiator? GetNegotiator(HttpContext httpContext)
{
var accept = httpContext.Request.GetTypedHeaders().Accept;
// 👇 Get negotiator based on Accept header (use ContentNegotiationProvider)
return ContentNegotiationProvider.Negotiators.FirstOrDefault(n =>
{
return accept.Any(a => n.CanHandle(a));
});
}
//...
}
To make the documentation nicely generated and contain information about possible formats, we can implement IEndpointMetadataProvider
.
static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
{
// 👇 Add produces response type metadata
builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TResult),
ContentNegotiationProvider.Negotiators.Select(n => n.ContentType).ToArray()));
}
Helper method
Let’s create a static helper for ease of use.
public static class Negotiation
{
public static ContentNegotiationResult<T> Negotiate<T>(T result)
=> new(result);
}
Usage
app.MapGet("/products", () =>
{
// 👇 Use Negotiation
return Negotiation.Negotiate(new List<Product>() { new(1, "Product 1", 100) });
});
app.MapGet("/products/{id}", GetProduct);
static Results<ContentNegotiationResult<Product>, NotFound> GetProduct(int id)
{
if (id == 1)
{
// 👇 Use Negotiation
return Negotiation.Negotiate(new Product(1, "Product 1", 100));
}
else
{
return TypedResults.NotFound();
}
}
That’s it ✅.
### GET products as XML
GET http://localhost:5210/product/
Accept: application/xml
### Response
<ArrayOfProduct xmlns="http://schemas.datacontract.org/2004/07/" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<Product>
<Id>1</Id>
<Name>Product 1</Name>
<Price>100</Price>
</Product>
</ArrayOfProduct>
⚠️ This solution is just a basic suggestion and does not cover all possible scenarios. For example, the way it is done you can only use it for
200 OK
answers (but it can be extended).
The full example is on GitHub.