Note

Access to this page requires authorization. You can try signing in or .

Access to this page requires authorization. You can try .

How to create responses in Minimal API apps

Note

This isn't the latest version of this article. For the current release, see the .NET 10 version of this article.

Warning

This version of ASP.NET Core is no longer supported. For more information, see the .NET and .NET Core Support Policy. For the current release, see the .NET 10 version of this article.

This article explains how to create responses for Minimal API endpoints in ASP.NET Core. Minimal APIs provide several ways to return data and HTTP status codes.

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

Important

Starting with ASP.NET Core 10, known API endpoints no longer redirect to login pages when using cookie authentication. Instead, they return 401/403 status codes. For details, see API endpoint authentication behavior in ASP.NET Core.

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.

For examples on testing result types, see the Test documentation.

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("/todoitems/{id}", async (int id, TodoDb db) =>
 await db.Todos.FindAsync(id)
 is Todo todo
 ? Results.Ok(todo)
 : Results.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("/todoitems/{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; when the method is asynchronous, the declaration requires wrapping the return type in a Task<>. 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("/todoitems/{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));

Internal Server Error

app.MapGet("/500", () => Results.InternalServerError("Something went wrong!"));

The preceding example returns a 500 status code.

Problem and ValidationProblem

app.MapGet("/problem", () =>
{
 var extensions = new List<KeyValuePair<string, object?>> { new("test", "value") };
 return TypedResults.Problem("This is an error with extensions", 
 extensions: extensions);
});

Customize validation error responses using IProblemDetailsService

Customize error responses from Minimal API validation logic with an IProblemDetailsService implementation. Register this service in your application's service collection to enable more consistent and user-specific error responses. Support for Minimal API validation was introduced in ASP.NET Core in .NET 10.

To implement custom validation error responses:

  • Implement IProblemDetailsService or use the default implementation
  • Register the service in the DI container
  • The validation system automatically uses the registered service to format validation error responses

The following example shows how to register and configure the IProblemDetailsService to customize validation error responses:

using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails(options =>
{
 options.CustomizeProblemDetails = context =>
 {
 if (context.ProblemDetails.Status == 400)
 {
 context.ProblemDetails.Title = "Validation error occurred";
 context.ProblemDetails.Extensions["support"] = "Contact support@example.com";
 context.ProblemDetails.Extensions["traceId"] = Guid.NewGuid().ToString();
 }
 };
});

When a validation error occurs, the IProblemDetailsService will be used to generate the error response, including any customizations added in the CustomizeProblemDetails callback.

For a complete app example, see the Minimal API sample app demonstrating how to customize validation error responses using the IProblemDetailsService in ASP.NET Core Minimal APIs.

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://contoso/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);
});

Server-Sent Events (SSE)

The TypedResults.ServerSentEvents API supports returning a ServerSentEvents result.

Server-Sent Events is a server push technology that allows a server to send a stream of event messages to a client over a single HTTP connection. In .NET, the event messages are represented as SseItem<T> objects, which may contain an event type, an ID, and a data payload of type T.

The TypedResults class has a static method called ServerSentEvents that can be used to return a ServerSentEvents result. The first parameter to this method is an IAsyncEnumerable<SseItem<T>> that represents the stream of event messages to be sent to the client.

The following example illustrates how to use the TypedResults.ServerSentEvents API to return a stream of heart rate events as JSON objects to the client:

app.MapGet("sse-item", (CancellationToken cancellationToken) =>
{
 async IAsyncEnumerable<SseItem<int>> GetHeartRate(
 [EnumeratorCancellation] CancellationToken cancellationToken)
 {
 while (!cancellationToken.IsCancellationRequested)
 {
 var heartRate = Random.Shared.Next(60, 100);
 yield return new SseItem<int>(heartRate, eventType: "heartRate")
 {
 ReconnectionInterval = TimeSpan.FromMinutes(1)
 };
 await Task.Delay(2000, cancellationToken);
 }
 }

 return TypedResults.ServerSentEvents(GetHeartRate(cancellationToken));
});

For more information, see the Minimal API sample app using the TypedResults.ServerSentEvents API to return a stream of heart rate events as string, ServerSentEvents, and JSON objects to the client.

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.

File result return values

When an API endpoint returns content other than JSON, or supports HTTP protocol features like conditional or range requests, ASP.NET Core provides result types that handle the necessary HTTP protocol details for you. These result types are referred to as "file results", but the functionality is useful for scenarios beyond serving files on disk. There are file result types for both Minimal APIs and controller-based APIs, and they share a common underlying implementation and behavior.

To access this functionality, the API endpoint creates and returns a file result object - an instance of one of these file result types. The file result object encapsulates the content to be sent, the content type, and any additional parameters like a download file name. The result object implements a method -- ExecuteAsync(HttpContext) for Minimal APIs or ExecuteResultAsync(ActionContext) for controllers -- that the framework calls to write the response. This method sets the Content-Type header and, when a file name is provided, the Content-Disposition header. It also handles conditional request headers and range request headers when the appropriate parameters are set on the result object.

File result types

In Minimal APIs, the most common and recommended way to create a file result is TypedResults.File, which accepts a byte[] or Stream and returns a FileContentHttpResult or FileStreamHttpResult.

Alternatives include TypedResults.Bytes for an explicit byte-array helper, TypedResults.PhysicalFile for serving files by absolute path, or Results.File / Results.Bytes when you only need the IResult interface.

ASP.NET Core also provides static files middleware that serves files relative to the web root (wwwroot) without requiring an explicit endpoint.

app.MapGet("/report", () =>
{
 // TypedResults.File with a byte[] returns a FileContentHttpResult
 byte[] pdf = GenerateReport();
 return TypedResults.File(pdf, "application/pdf", "report.pdf");
});

app.MapGet("/download", () =>
{
 // TypedResults.File with a Stream returns a FileStreamHttpResult
 Stream stream = new MemoryStream("Hello, World!"u8.ToArray());
 return TypedResults.File(stream, "application/octet-stream");
});

In controller-based APIs, the ControllerBase.File() helper method accepts either a byte[] or Stream and returns the appropriate concrete result type (FileContentResult, FileStreamResult).

Alternatives include returning a VirtualFileResult for files relative to the web root, or a PhysicalFileResult for files by absolute path, but these are less common as the static files middleware usually handles those scenarios.

[HttpGet("report")]
public FileContentResult Report()
{
 // File() with a byte[] returns a FileContentResult
 byte[] pdf = GenerateReport();
 return File(pdf, "application/pdf", "report.pdf");
}

[HttpGet("download")]
public FileStreamResult Download()
{
 // File() with a Stream returns a FileStreamResult
 Stream stream = new MemoryStream("Hello, World!"u8.ToArray());
 return File(stream, "application/octet-stream");
}

OpenAPI support for file results

File result types don't automatically contribute response metadata to the generated OpenAPI document. To get a proper response description in the OpenAPI document, you must specify this metadata explicitly.

In Minimal APIs, use the Produces<TResponse>() extension method to provide the OpenAPI metadata for the response. This metadata determines the status code, content type, and schema for the response in the OpenAPI document. For a file result, it's important to specify the content type, such as application/pdf, and to use an appropriate TResponse to get the desired schema. You can also specify the status code in the Produces extension method if it differs from the default of 200 OK. Common status codes are defined in the StatusCodes class, and common content types are defined in the MediaTypeNames class. For example:

app.MapGet("/image", () =>
{
 // A 1x1 red pixel BMP (bitmap header + single pixel)
 byte[] data = [0x42, 0x4D, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
 0x1A, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00,
 0x01, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, 0xFF, 0x00];
 return TypedResults.File(data, MediaTypeNames.Image.Bmp, "pixel.bmp");
})
// Use Stream to produce the correct schema in OpenAPI (format: binary)
.Produces<Stream>(contentType: MediaTypeNames.Image.Bmp);

Conceptually, TResponse represents the type of the response body, but the appropriate schema for a file result depends more on the body content than the CLR type. Therefore, it may be necessary to specify a TResponse that doesn't match the CLR type of the content in the file result in order to get the desired OpenAPI schema.

In particular, there are three common schemas for file results:

  • binary content, such as PDFs, images, or videos, where the schema should be type: string, format: binary. The recommended TResponse for this case is Stream. The framework has special logic to map this type to the binary format in the OpenAPI schema.
  • text content, such as CSV or plain text. Here the schema should be simply type: string with no format. Use string as the TResponse for this case.
  • base64-encoded content, where the schema should be type: string, format: byte. It's uncommon to base64-encode file content in an API response, but for legacy reasons this is the schema produced when the TResponse is byte[].

In a controller-based app, use the [Produces] or the [ProducesResponseType] attribute:

[HttpGet("image")]
// Use Stream to produce the correct schema in OpenAPI (format: binary)
[ProducesResponseType<Stream>(StatusCodes.Status200OK, MediaTypeNames.Image.Bmp)]
public FileContentResult Image()
{
 // A 1x1 red pixel BMP (bitmap header + single pixel)
 byte[] data = [0x42, 0x4D, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
 0x1A, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00,
 0x01, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, 0xFF, 0x00];
 return File(data, "image/bmp", "pixel.bmp");
}

As with Minimal APIs, you should choose the Type parameter of the [Produces] and [ProducesResponseType] attributes that corresponds to the desired OpenAPI schema for your file result responses:

  • binary content: Use FileContentResult or FileStreamResult to get the binary format in the OpenAPI schema.
  • text content: Use string to get a simple type: string schema.
  • base64-encoded content: Use byte[] to get the byte format in the OpenAPI schema, though this is uncommon for file results.

File result support for conditional requests

File results support conditional requests for cache validation. Set the lastModified and/or entityTag parameters when creating the result object, and the framework automatically inspects incoming If-None-Match and If-Modified-Since headers. If the resource hasn't changed, the framework returns 304 Not Modified with no body — no additional code is needed.

Parameter Purpose
lastModified Sets the Last-Modified response header. If the client sends If-Modified-Since and the file hasn't changed, the framework returns 304 Not Modified with no body.
entityTag Sets the ETag response header. If the client sends If-None-Match with a matching ETag, the framework returns 304 Not Modified with no body.

Note

Precondition checks (If-Match, If-Unmodified-Since) typically require custom logic in the endpoint to verify preconditions before performing their function.

The following example demonstrates how to use file results to enable cache validation for a configuration endpoint.

app.MapGet("/config", (
 [FromHeader(Name = "If-None-Match")] string? ifNoneMatch,
 [FromHeader(Name = "If-Modified-Since")] string? ifModifiedSince) =>
{
 byte[] data = File.ReadAllBytes("Data/config.json");
 var lastModified = File.GetLastWriteTimeUtc("Data/config.json");
 var etag = new EntityTagHeaderValue($"\"{Convert.ToHexString(SHA256.HashData(data))}\"");

 return TypedResults.File(
 data,
 contentType: MediaTypeNames.Application.Json,
 lastModified: lastModified,
 entityTag: etag);
})
.Produces<object>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status304NotModified);

This example also illustrates how to document the 304 Not Modified response in the OpenAPI document using the Produces extension method, and including the if-none-match and if-modified-since headers as parameters so they're included in the OpenAPI schema for the endpoint.

A controller-based API can achieve the same behavior using the File helper method and the [ProducesResponseType] attribute:

[HttpGet("config")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]
public FileContentResult Config(
 [FromHeader(Name = "If-None-Match")] string? ifNoneMatch,
 [FromHeader(Name = "If-Modified-Since")] string? ifModifiedSince)
{
 byte[] data = System.IO.File.ReadAllBytes("Data/config.json");
 var lastModified = System.IO.File.GetLastWriteTimeUtc("Data/config.json");
 var etag = new EntityTagHeaderValue($"\"{Convert.ToHexString(SHA256.HashData(data))}\"");

 return File(
 data,
 MediaTypeNames.Application.Json,
 lastModified: lastModified,
 entityTag: etag);
}

File result support for range requests

Range requests allow clients to request only a portion of a file rather than the entire content. A client sends a Range header specifying byte offsets, and the server responds with 206 Partial Content containing just those bytes. This enables resumable downloads, parallel chunked downloads, and efficient seeking in media players.

Range requests can also be conditional: the client sends an If-Range header containing an ETag or date alongside the Range header, and the server returns the partial content only if the resource hasn't changed. If it has changed, the server ignores the range and returns the full resource instead. If-Range is only evaluated when entityTag or lastModified is also set on the file result.

Set enableRangeProcessing to true to enable range processing. The following examples enable range processing for a video streaming endpoint, and document the additional response types in the OpenAPI metadata.

Minimal API:

app.MapGet("/video/{id}", (string id,
 [FromHeader(Name = "Range")] string? range) =>
{
 var bytes = GetVideo(id);

 return TypedResults.File(
 bytes,
 contentType: "video/mp4",
 fileDownloadName: "video.mp4",
 enableRangeProcessing: true);
})
.Produces<Stream>(StatusCodes.Status200OK, "video/mp4")
.Produces<Stream>(StatusCodes.Status206PartialContent, "video/mp4")
.Produces(StatusCodes.Status416RangeNotSatisfiable);

Controller:

[HttpGet("video/{id}")]
[ProducesResponseType<Stream>(StatusCodes.Status200OK, "video/mp4")]
[ProducesResponseType<Stream>(StatusCodes.Status206PartialContent, "video/mp4")]
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
public FileContentResult Video(string id,
 [FromHeader(Name = "Range")] string? range)
{
 var bytes = GetVideo(id);

 return File(
 bytes,
 contentType: "video/mp4",
 fileDownloadName: "video.mp4",
 enableRangeProcessing: true);
}

With this configuration, the framework automatically handles the following HTTP interactions:

  • Full request: Returns 200 OK with the complete file.
  • Range request (Range: bytes=0-1023): Returns 206 Partial Content with the Content-Range header and only the requested bytes.
  • Invalid range: Returns 416 Range Not Satisfiable.

Modifying Headers

Use the HttpResponse object to modify response headers:

app.MapGet("/", (HttpContext context) => {
 // Set a custom header
 context.Response.Headers["X-Custom-Header"] = "CustomValue";

 // Set a known header
 context.Response.Headers.CacheControl = $"public,max-age=3600";

 return "Hello World";
});

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

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("/todoitems/{id}", async (int id, TodoDb db) =>
 await db.Todos.FindAsync(id)
 is Todo todo
 ? Results.Ok(todo)
 : Results.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("/todoitems/{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("/todoitems/{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));

Internal Server Error

app.MapGet("/500", () => Results.InternalServerError("Something went wrong!"));

The preceding example returns a 500 status code.

Problem and ValidationProblem

app.MapGet("/problem", () =>
{
 var extensions = new List<KeyValuePair<string, object?>> { new("test", "value") };
 return TypedResults.Problem("This is an error with extensions", 
 extensions: extensions);
});

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://contoso/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.

Modifying Headers

Use the HttpResponse object to modify response headers:

app.MapGet("/", (HttpContext context) => {
 // Set a custom header
 context.Response.Headers["X-Custom-Header"] = "CustomValue";

 // Set a known header
 context.Response.Headers.CacheControl = $"public,max-age=3600";

 return "Hello World";
});

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

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("/todoitems/{id}", async (int id, TodoDb db) =>
 await db.Todos.FindAsync(id)
 is Todo todo
 ? Results.Ok(todo)
 : Results.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("/todoitems/{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("/todoitems/{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://contoso/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


Feedback

Was this page helpful?

Additional resources