How to create responses in Minimal API apps

Minimal endpoints support the following types of return values:

  1. string - This includes Task<string> and ValueTask<string>.
  2. T (Any other type) - This includes Task<T> and ValueTask<T>.
  3. IResult based - This includes Task<IResult> and ValueTask<IResult>.

string return values

Behavior Content-Type
The framework writes the string directly to the response. text/plain

Consider the following route handler, which returns a Hello world text.

app.MapGet("/hello", () => "Hello World");

The 200 status code is returned with text/plain Content-Type header and the following content.

Hello World

T (Any other type) return values

Behavior Content-Type
The framework JSON-serializes the response. application/json

Consider the following route handler, which returns an anonymous type containing a Message string property.

app.MapGet("/hello", () => new { Message = "Hello World" });

The 200 status code is returned with application/json Content-Type header and the following content.

{"message":"Hello World"}

IResult return values

Behavior Content-Type
The framework calls IResult.ExecuteAsync. Decided by the IResult implementation.

The IResult interface defines a contract that represents the result of an HTTP endpoint. The static Results class and the static TypedResults are used to create various IResult objects that represent different types of responses.

TypedResults vs Results

The Results and TypedResults static classes provide similar sets of results helpers. The TypedResults class is the typed equivalent of the Results class. However, the Results helpers' return type is IResult, while each TypedResults helper's return type is one of the IResult implementation types. The difference means that for Results helpers a conversion is needed when the concrete type is needed, for example, for unit testing. The implementation types are defined in the Microsoft.AspNetCore.Http.HttpResults namespace.

Returning TypedResults rather than Results has the following advantages:

Consider the following endpoint, for which a 200 OK status code with the expected JSON response is produced.

app.MapGet("/hello", () => Results.Ok(new Message() { Text = "Hello World!" }))
    .Produces<Message>();

In order to document this endpoint correctly the extensions method Produces is called. However, it's not necessary to call Produces if TypedResults is used instead of Results, as shown in the following code. TypedResults automatically provides the metadata for the endpoint.

app.MapGet("/hello2", () => TypedResults.Ok(new Message() { Text = "Hello World!" }));

For more information about describing a response type, see OpenAPI support in minimal APIs.

As mentioned previously, when using TypedResults, a conversion is not needed. Consider the following minimal API which returns a TypedResults class

public static async Task<Ok<Todo[]>> GetAllTodos(TodoGroupDbContext database)
{
    var todos = await database.Todos.ToArrayAsync();
    return TypedResults.Ok(todos);
}

The following test checks for the full concrete type:

[Fact]
public async Task GetAllReturnsTodosFromDatabase()
{
    // Arrange
    await using var context = new MockDb().CreateDbContext();

    context.Todos.Add(new Todo
    {
        Id = 1,
        Title = "Test title 1",
        Description = "Test description 1",
        IsDone = false
    });

    context.Todos.Add(new Todo
    {
        Id = 2,
        Title = "Test title 2",
        Description = "Test description 2",
        IsDone = true
    });

    await context.SaveChangesAsync();

    // Act
    var result = await TodoEndpointsV1.GetAllTodos(context);

    //Assert
    Assert.IsType<Ok<Todo[]>>(result);
    
    Assert.NotNull(result.Value);
    Assert.NotEmpty(result.Value);
    Assert.Collection(result.Value, todo1 =>
    {
        Assert.Equal(1, todo1.Id);
        Assert.Equal("Test title 1", todo1.Title);
        Assert.False(todo1.IsDone);
    }, todo2 =>
    {
        Assert.Equal(2, todo2.Id);
        Assert.Equal("Test title 2", todo2.Title);
        Assert.True(todo2.IsDone);
    });
}

Because all methods on Results return IResult in their signature, the compiler automatically infers that as the request delegate return type when returning different results from a single endpoint. TypedResults requires the use of Results<T1, TN> from such delegates.

The following method compiles because both Results.Ok and Results.NotFound are declared as returning IResult, even though the actual concrete types of the objects returned are different:

app.MapGet("/todos/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
   await db.Todos.FindAsync(id)
    is Todo todo
       ? TypedResults.Ok(todo)
       : TypedResults.NotFound());

The following method does not compile, because TypedResults.Ok and TypedResults.NotFound are declared as returning different types and the compiler won't attempt to infer the best matching type:

app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
     await db.Todos.FindAsync(id)
     is Todo todo
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound());

To use TypedResults, the return type must be fully declared, which when asynchronous requires the Task<> wrapper. Using TypedResults is more verbose, but that's the trade-off for having the type information be statically available and thus capable of self-describing to OpenAPI:

app.MapGet("/todos/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
   await db.Todos.FindAsync(id)
    is Todo todo
       ? TypedResults.Ok(todo)
       : TypedResults.NotFound());

Results<TResult1, TResultN>

Use Results<TResult1, TResultN> as the endpoint handler return type instead of IResult when:

  • Multiple IResult implementation types are returned from the endpoint handler.
  • The static TypedResult class is used to create the IResult objects.

This alternative is better than returning IResult because the generic union types automatically retain the endpoint metadata. And since the Results<TResult1, TResultN> union types implement implicit cast operators, the compiler can automatically convert the types specified in the generic arguments to an instance of the union type.

This has the added benefit of providing compile-time checking that a route handler actually only returns the results that it declares it does. Attempting to return a type that isn't declared as one of the generic arguments to Results<> results in a compilation error.

Consider the following endpoint, for which a 400 BadRequest status code is returned when the orderId is greater than 999. Otherwise, it produces a 200 OK with the expected content.

app.MapGet("/orders/{orderId}", IResult (int orderId)
    => orderId > 999 ? TypedResults.BadRequest() : TypedResults.Ok(new Order(orderId)))
    .Produces(400)
    .Produces<Order>();

In order to document this endpoint correctly the extension method Produces is called. However, since the TypedResults helper automatically includes the metadata for the endpoint, you can return the Results<T1, Tn> union type instead, as shown in the following code.

app.MapGet("/orders/{orderId}", Results<BadRequest, Ok<Order>> (int orderId) 
    => orderId > 999 ? TypedResults.BadRequest() : TypedResults.Ok(new Order(orderId)));

Built-in results

Common result helpers exist in the Results and TypedResults static classes. Returning TypedResults is preferred to returning Results. For more information, see TypedResults vs Results.

The following sections demonstrate the usage of the common result helpers.

JSON

app.MapGet("/hello", () => Results.Json(new { Message = "Hello World" }));

WriteAsJsonAsync is an alternative way to return JSON:

app.MapGet("/", (HttpContext context) => context.Response.WriteAsJsonAsync
    (new { Message = "Hello World" }));

Custom Status Code

app.MapGet("/405", () => Results.StatusCode(405));

Text

app.MapGet("/text", () => Results.Text("This is some text"));

Stream

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

var proxyClient = new HttpClient();
app.MapGet("/pokemon", async () => 
{
    var stream = await proxyClient.GetStreamAsync("http://consoto/pokedex.json");
    // Proxy the response as JSON
    return Results.Stream(stream, "application/json");
});

app.Run();

Results.Stream overloads allow access to the underlying HTTP response stream without buffering. The following example uses ImageSharp to return a reduced size of the specified image:

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/process-image/{strImage}", (string strImage, HttpContext http, CancellationToken token) =>
{
    http.Response.Headers.CacheControl = $"public,max-age={TimeSpan.FromHours(24).TotalSeconds}";
    return Results.Stream(stream => ResizeImageAsync(strImage, stream, token), "image/jpeg");
});

async Task ResizeImageAsync(string strImage, Stream stream, CancellationToken token)
{
    var strPath = $"wwwroot/img/{strImage}";
    using var image = await Image.LoadAsync(strPath, token);
    int width = image.Width / 2;
    int height = image.Height / 2;
    image.Mutate(x =>x.Resize(width, height));
    await image.SaveAsync(stream, JpegFormat.Instance, cancellationToken: token);
}

The following example streams an image from Azure Blob storage:

app.MapGet("/stream-image/{containerName}/{blobName}", 
    async (string blobName, string containerName, CancellationToken token) =>
{
    var conStr = builder.Configuration["blogConStr"];
    BlobContainerClient blobContainerClient = new BlobContainerClient(conStr, containerName);
    BlobClient blobClient = blobContainerClient.GetBlobClient(blobName);
    return Results.Stream(await blobClient.OpenReadAsync(cancellationToken: token), "image/jpeg");
});

The following example streams a video from an Azure Blob:

// GET /stream-video/videos/earth.mp4
app.MapGet("/stream-video/{containerName}/{blobName}",
     async (HttpContext http, CancellationToken token, string blobName, string containerName) =>
{
    var conStr = builder.Configuration["blogConStr"];
    BlobContainerClient blobContainerClient = new BlobContainerClient(conStr, containerName);
    BlobClient blobClient = blobContainerClient.GetBlobClient(blobName);
    
    var properties = await blobClient.GetPropertiesAsync(cancellationToken: token);
    
    DateTimeOffset lastModified = properties.Value.LastModified;
    long length = properties.Value.ContentLength;
    
    long etagHash = lastModified.ToFileTime() ^ length;
    var entityTag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
    
    http.Response.Headers.CacheControl = $"public,max-age={TimeSpan.FromHours(24).TotalSeconds}";

    return Results.Stream(await blobClient.OpenReadAsync(cancellationToken: token), 
        contentType: "video/mp4",
        lastModified: lastModified,
        entityTag: entityTag,
        enableRangeProcessing: true);
});

Redirect

app.MapGet("/old-path", () => Results.Redirect("/new-path"));

File

app.MapGet("/download", () => Results.File("myfile.text"));

HttpResult interfaces

The following interfaces in the Microsoft.AspNetCore.Http namespace provide a way to detect the IResult type at runtime, which is a common pattern in filter implementations:

Here's an example of a filter that uses one of these interfaces:

app.MapGet("/weatherforecast", (int days) =>
{
    if (days <= 0)
    {
        return Results.BadRequest();
    }

    var forecast = Enumerable.Range(1, days).Select(index =>
       new WeatherForecast(DateTime.Now.AddDays(index), Random.Shared.Next(-20, 55), "Cool"))
        .ToArray();
    return Results.Ok(forecast);
}).
AddEndpointFilter(async (context, next) =>
{
    var result = await next(context);

    return result switch
    {
        IValueHttpResult<WeatherForecast[]> weatherForecastResult => new WeatherHttpResult(weatherForecastResult.Value),
        _ => result
    };
});

For more information, see Filters in Minimal API apps and IResult implementation types.

Customizing responses

Applications can control responses by implementing a custom IResult type. The following code is an example of an HTML result type:

using System.Net.Mime;
using System.Text;
static class ResultsExtensions
{
    public static IResult Html(this IResultExtensions resultExtensions, string html)
    {
        ArgumentNullException.ThrowIfNull(resultExtensions);

        return new HtmlResult(html);
    }
}

class HtmlResult : IResult
{
    private readonly string _html;

    public HtmlResult(string html)
    {
        _html = html;
    }

    public Task ExecuteAsync(HttpContext httpContext)
    {
        httpContext.Response.ContentType = MediaTypeNames.Text.Html;
        httpContext.Response.ContentLength = Encoding.UTF8.GetByteCount(_html);
        return httpContext.Response.WriteAsync(_html);
    }
}

We recommend adding an extension method to Microsoft.AspNetCore.Http.IResultExtensions to make these custom results more discoverable.

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

app.MapGet("/html", () => Results.Extensions.Html(@$"<!doctype html>
<html>
    <head><title>miniHTML</title></head>
    <body>
        <h1>Hello World</h1>
        <p>The time on the server is {DateTime.Now:O}</p>
    </body>
</html>"));

app.Run();

Also, a custom IResult type can provide its own annotation by implementing the IEndpointMetadataProvider interface. For example, the following code adds an annotation to the preceding HtmlResult type that describes the response produced by the endpoint.

class HtmlResult : IResult, IEndpointMetadataProvider
{
    private readonly string _html;

    public HtmlResult(string html)
    {
        _html = html;
    }

    public Task ExecuteAsync(HttpContext httpContext)
    {
        httpContext.Response.ContentType = MediaTypeNames.Text.Html;
        httpContext.Response.ContentLength = Encoding.UTF8.GetByteCount(_html);
        return httpContext.Response.WriteAsync(_html);
    }

    public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
    {
        builder.Metadata.Add(new ProducesHtmlMetadata());
    }
}

The ProducesHtmlMetadata is an implementation of IProducesResponseTypeMetadata that defines the produced response content type text/html and the status code 200 OK.

internal sealed class ProducesHtmlMetadata : IProducesResponseTypeMetadata
{
    public Type? Type => null;

    public int StatusCode => 200;

    public IEnumerable<string> ContentTypes { get; } = new[] { MediaTypeNames.Text.Html };
}

An alternative approach is using the Microsoft.AspNetCore.Mvc.ProducesAttribute to describe the produced response. The following code changes the PopulateMetadata method to use ProducesAttribute.

public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
{
    builder.Metadata.Add(new ProducesAttribute(MediaTypeNames.Text.Html));
}

Configure JSON serialization options

By default, minimal API apps use Web defaults options during JSON serialization and deserialization.

Configure JSON serialization options globally

Options can be configured globally for an app by invoking ConfigureHttpJsonOptions. The following example includes public fields and formats JSON output.

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options => {
    options.SerializerOptions.WriteIndented = true;
    options.SerializerOptions.IncludeFields = true;
});

var app = builder.Build();

app.MapPost("/", (Todo todo) => {
    if (todo is not null) {
        todo.Name = todo.NameField;
    }
    return todo;
});

app.Run();

class Todo {
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "nameField":"Walk dog",
//    "isComplete":false
// }

Since fields are included, the preceding code reads NameField and includes it in the output JSON.

Configure JSON serialization options for an endpoint

To configure serialization options for an endpoint, invoke Results.Json and pass to it a JsonSerializerOptions object, as shown in the following example:

using System.Text.Json;

var app = WebApplication.Create();

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
    { WriteIndented = true };

app.MapGet("/", () => 
    Results.Json(new Todo { Name = "Walk dog", IsComplete = false }, options));

app.Run();

class Todo
{
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}
// The endpoint returns the following JSON:
//
// {
//   "name":"Walk dog",
//   "isComplete":false
// }

As an alternative, use an overload of WriteAsJsonAsync that accepts a JsonSerializerOptions object. The following example uses this overload to format the output JSON:

using System.Text.Json;

var app = WebApplication.Create();

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) {
    WriteIndented = true };

app.MapGet("/", (HttpContext context) =>
    context.Response.WriteAsJsonAsync<Todo>(
        new Todo { Name = "Walk dog", IsComplete = false }, options));

app.Run();

class Todo
{
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}
// The endpoint returns the following JSON:
//
// {
//   "name":"Walk dog",
//   "isComplete":false
// }

Additional Resources