ASP.NET CORE Minimal API - Content Negotiation

Primárny cieľ ASP.NET Core Minimal API je priniesť priamočiarý, jednoduchý a hlavne veľmi výkonný framework na tvorbu API. Miesto toho aby prinášal features, ktoré umožnia pokryť každý možný scenár použitia (na rozdiel od MVC) je navrhnutý s ohľadom na určité špecifické patterny. Jedným z týchto prípadov je aj to, že Minimal API konzumuje a produkuje iba JSON formát (Nepodporuje teda ani Content Negotiation ktorý sa v prípade MVC rieši pomocou Output formatters).

Sú ale situácie, keď naozaj potrebujete buď iný typ výstupu (napríklad štandardný XML), prípadne potrebujete mať proces serializácie viac pod kontrolou a zároveň chcete využiť jednoduchosť a silu Minimal API.
V tomto článku sa pokúsim ukázať ako je možné si vytvoriť vlastnú podporu pre Content Negotiation v Minimal API.

⚠️ Je to síce funkčná implementácia, ale nepokrýva všetky scenáre a je to len základná ukážka toho ako by mohla takáto podpora vyzerať.

Celé riešenie je založené na vlastnej implementácií pre IResult. Viac info v dokumentácií.

IResponseNegotiator

Definujme si najskôr rozhranie, ktorého implementácia bude zodpovedná za serializovanie odpovede.

public interface IResponseNegotiator
{
    string ContentType { get; }

    bool CanHandle(MediaTypeHeaderValue accept);

    Task Handle<TResult>(HttpContext httpContext, TResult result, CancellationToken cancellationToken);
}

Implementácia pre JSON

Pre JSON môžeme využiť priamo Ok<TResult> result, ktorý je súčasťou 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);
    }
}

Implementácia pre XML

Pre XML môžeme vytvoriť vlastnú implementáciu, ktorá bude využívať napríklad 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);
    }
}

Registrovanie

Bohužiaľ vzhľadom na fungovanie IEndpointMetadataProvider a spôsobu ako funguje serializácia priamo v Minimal API sa mi nepodarilo nájsť elegantný spôsob ako využiť DI kontajner (tých neelegantných by zopár bolo 😊).
Preto som sa uchýlil k vlastnému registrátoru pre negotiatorov.

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

Registrujeme negotiatorov v Program.cs alebo v 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));
        });
    }

    //...
}

Aby dokumentácia bola pekne vygenerovaná a obsahovala informácie o možných formátoch, môžeme implementovať 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

Vytvorme si statický helper pre jednoduchšie použitie.

public static class Negotiation
{
    public static ContentNegotiationResult<T> Negotiate<T>(T result)
        => new(result);
}

Použitie

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

A je to ✅.

### 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>

⚠️ Toto riešenie je len základný návrh a nepokrýva všetky možné scenáre.
Napríklad takto ako je to spravené to môžete použiť len pre 200 OK odpovede (dá sa to ale rozšíriť).

Celý príklad je na GitHub.