Content Types and Negotiation

Return JSON, CSV, files and streams with the right Content-Type, honour or reject Accept headers, and compress JSON safely.

SyntaxTypedResults.File(bytes, "application/pdf", fileDownloadName: "invoice-1001.pdf"); TypedResults.Text(csv, "text/csv"); request.GetTypedHeaders().Accept // parsed with q-values

Content negotiation is the server choosing a representation from the request's Accept header, for example JSON or CSV for the same list of invoices. ASP.NET Core does it in controllers, not in minimal APIs.

Minimal APIs: the result decides

A minimal API has no output formatters. The IResult sets the content type: TypedResults.Ok(x) always writes application/json; charset=utf-8, even for Accept: application/xml, with a 200. If you support more than one format, read request.GetTypedHeaders().Accept, order by quality, pick, and return 406 when nothing fits. .Produces<T>(200, "application/json", "text/csv") only describes the endpoint for OpenAPI; it changes nothing at run time.

On the request side, a parameter bound from the body makes the endpoint accept application/json and +json types; anything else gets 415 from routing before the handler runs.

Controllers: output formatters

  • Out of the box the output formatters cover JSON, text/plain for string results, streams, and 204 for null. AddXmlSerializerFormatters() adds XML.
  • An Accept header no formatter supports falls back to JSON with 200, unless ReturnHttpNotAcceptable = true, which returns 406.
  • RespectBrowserAcceptHeader is false, so a header containing */*, as browsers send, is ignored and JSON is returned.
  • [Produces("application/json")] restricts an action to the listed types.

Files and streams

ResultUse
TypedResults.Text(s, "text/csv")small generated text
TypedResults.File(bytes or stream, type, name)a file already in memory or a readable stream; the stream is disposed after sending
TypedResults.Stream(async s => ...)writing large output as it is produced, such as an export
TypedResults.PhysicalFile(path, ...)a file on disk, without opening the stream yourself

fileDownloadName adds Content-Disposition: attachment; filename=...; filename*=UTF-8''.... File, PhysicalFile and VirtualFile all take lastModified, entityTag and enableRangeProcessing, so each can answer 304 and 206; PhysicalFile also avoids opening the stream yourself. enableRangeProcessing: true answers Range requests with 206 Partial Content, which resumable downloads need. Returning IAsyncEnumerable<T> streams a JSON array without buffering the whole list.

Compression

JSON compresses well. AddResponseCompression() with UseResponseCompression() applies Brotli or gzip by Accept-Encoding, and the default MIME list includes application/json. EnableForHttps is false by default, because compressing secrets next to attacker-controlled text over TLS enables BREACH-style attacks. Offloading compression to the reverse proxy or CDN saves CPU in the app but has the same BREACH exposure. Either way, do not compress responses that contain secrets or tokens next to reflected user input, or add per-response random padding or masking for those secrets.

Production failures

  • A client asks for XML from a minimal API, gets JSON with 200, and fails at parse time; return 406 if the contract matters.
  • Results.Bytes on a 300 MB export puts it all in memory on the large object heap; stream it.
  • A CSV built with $"{total}" on a server in a German culture writes 129,90; format with CultureInfo.InvariantCulture.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Serve GET /invoices as JSON to the web app and as CSV to the finance team's spreadsheet import from the same endpoint.
  • Stream a 500,000-row order export to a client without holding it in memory, and stop reading when the client disconnects.
  • Let a mobile app resume a 40 MB statement PDF download with Range requests instead of starting again.

More examples

Controllers that negotiate JSON and XML

With output formatters the framework does the negotiation, including q-values. <code>XmlSerializer</code> needs a public parameterless constructor and settable properties, which is why <code>InvoiceDto</code> is a class here rather than a positional record. Leave <code>RespectBrowserAcceptHeader</code> false so a browser's <code>*/*</code> still gets JSON.

Example · csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddControllers(options =>
    {
        options.ReturnHttpNotAcceptable = true;   // 406 instead of falling back to JSON
    })
    .AddXmlSerializerFormatters();

var app = builder.Build();
app.MapControllers();
app.Run();

public class InvoiceDto
{
    public int Id { get; set; }
    public string Number { get; set; } = "";
    public decimal Total { get; set; }
}

[ApiController]
[Route("api/invoices")]
public class InvoicesController : ControllerBase
{
    // Accept: application/xml -> XML; application/json or */* -> JSON; text/csv -> 406
    [HttpGet("{id:int}")]
    [Produces("application/json", "application/xml")]
    public ActionResult<InvoiceDto> Get(int id) =>
        new InvoiceDto { Id = id, Number = "INV-2026-0042", Total = 249.00m };
}

Streaming a large export instead of buffering it

<code>TypedResults.Stream</code> with a callback writes straight to the response body, so headers go out first and the client starts receiving data at once; an exception after that point cannot become a ProblemDetails, only an aborted response. The cancellation token stops the database read when the client disconnects. Returning <code>IAsyncEnumerable</code> gives the same flat memory for JSON.

Example · csharp
using System.Globalization;
using System.Runtime.CompilerServices;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// CSV written row by row as it is read; memory stays flat whatever the size.
app.MapGet("/exports/orders.csv", (CancellationToken ct) =>
    TypedResults.Stream(async body =>
    {
        await using var writer = new StreamWriter(body);
        await writer.WriteLineAsync("id,customer_id,total,placed_at");
        await foreach (var o in OrderSource.ReadAllAsync(ct))
            await writer.WriteLineAsync(string.Create(CultureInfo.InvariantCulture,
                $"{o.Id},{o.CustomerId},{o.Total},{o.PlacedAt:O}"));
    }, contentType: "text/csv", fileDownloadName: "orders.csv"));

// IAsyncEnumerable<T> is written as a JSON array while items are still being produced.
app.MapGet("/exports/orders.json", (CancellationToken ct) => OrderSource.ReadAllAsync(ct));

app.Run();

public record OrderRow(int Id, int CustomerId, decimal Total, DateTimeOffset PlacedAt);

public static class OrderSource
{
    public static async IAsyncEnumerable<OrderRow> ReadAllAsync([EnumeratorCancellation] CancellationToken ct)
    {
        var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
        for (var i = 1; i <= 500_000; i++)
        {
            if (i % 10_000 == 0) await Task.Yield();
            ct.ThrowIfCancellationRequested();
            yield return new OrderRow(i, 1000 + i % 977, 19.99m + i % 300, start.AddMinutes(i));
        }
    }
}

Serving stored invoices with ranges and caching

<code>PhysicalFile</code> streams from disk and, given <code>lastModified</code> and an <code>entityTag</code>, answers <code>If-None-Match</code> with 304 and <code>Range</code> with 206, so a resumed download fetches only the missing bytes. The full-path check blocks <code>../</code> in the invoice number, the classic path traversal bug in download endpoints, and <code>TrimEndingDirectorySeparator</code> keeps a configured root with a trailing slash working. The traversal check does not replace an authorization check: invoice numbers are sequential, so the endpoint requires a signed-in caller and returns 404 for an invoice that belongs to another customer.

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddSingleton<InvoiceOwnership>();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

var invoiceRoot = Path.TrimEndingDirectorySeparator(
    Path.GetFullPath(builder.Configuration["Invoices:Root"] ?? "/var/lib/shop/invoices"));

app.MapGet("/invoices/{number}/pdf", Results<PhysicalFileHttpResult, NotFound> (
    string number, ClaimsPrincipal user, InvoiceOwnership owners) =>
{
    // Invoice numbers are sequential and guessable: answer 404 unless this caller owns it.
    if (!owners.BelongsTo(number, user.FindFirstValue("customer_id")))
        return TypedResults.NotFound();

    // The route value is user input: never let it climb out of the invoice folder.
    var path = Path.GetFullPath(Path.Combine(invoiceRoot, $"{number}.pdf"));
    if (!path.StartsWith(invoiceRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) || !File.Exists(path))
        return TypedResults.NotFound();

    var info = new FileInfo(path);
    var etag = new EntityTagHeaderValue($"\"{info.Length:x}-{info.LastWriteTimeUtc.Ticks:x}\"");
    return TypedResults.PhysicalFile(path, "application/pdf", $"{number}.pdf",
        lastModified: info.LastWriteTimeUtc, entityTag: etag, enableRangeProcessing: true);
}).RequireAuthorization();

app.Run();

// Stand-in for the invoices table lookup: which customer an invoice number belongs to.
public sealed class InvoiceOwnership
{
    private readonly Dictionary<string, string> owners = new() { ["INV-2026-0042"] = "1001" };

    public bool BelongsTo(string number, string? customerId) =>
        customerId is not null && owners.TryGetValue(number, out var owner) && owner == customerId;
}

Discussion

  • Be the first to comment on this lesson.