- Published on
How to Define Response Types in ASP.NET Core Minimal APIs
- Authors
- Name
- Ivan Gechev
In this article, we'll explore how we can define the responses types of our endpoints. In .NET there are two distinct approached of doing this and we'll cover both of them.
Let's dive in!
Setting up our ASP.NET Core Minimal API
We’ll use a Minimal API that deals with solar systems, with the following endpoints:
app.MapGet("/solar-systems", async (
[FromServices] ISolarSystemService solarSystemService) =>
{
var solarSystems = await solarSystemService.GetAllSolarSystemsAsync();
return Results.Ok(solarSystems);
})
app.MapGet("/solar-systems/{id}", async (
[FromRoute] int id,
[FromServices] ISolarSystemService solarSystemService) =>
{
var solarSystem = await solarSystemService.GetSolarSystemByIdAsync(id);
return Results.Ok(solarSystem);
});
app.MapPost("/solar-systems", async (
[FromBody] CreateSolarSystemRequest request,
[FromServices] IValidator<CreateSolarSystemRequest> validator,
[FromServices] ISolarSystemService solarSystemService) =>
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(
validationResult.ToDictionary(),
statusCode: StatusCodes.Status422UnprocessableEntity);
}
var solarSystem = await solarSystemService.CreateSolarSystemAsync(request);
return Results.Created($"/solar-systems/{solarSystem.Id}", solarSystem);
});
app.MapDelete("/solar-systems/{id}", async (
[FromRoute] int id,
[FromServices] ISolarSystemService solarSystemService) =>
{
await solarSystemService.DeleteSolarSystemAsync(id);
return Results.NoContent();
});
Here, we have four endpoint operations for creating, reading, and deleting solar systems. We've skipped the PUT
endpoint as it's not vital for our example.
Our API also relies on FluentValidation
to validate incoming POST
requests. We also have a global exception handler that implements the IExceptionHandler
interface. Inside it, we process all NotFoundExceptions
to return a 404 Not Found
response.
We need a way to examine what our endpoints are returning. We'll opt for Scalar
. To install the package, we use the dotnet add package
command in the terminal to install the Scalar.AspNetCore
library.
Once this is done, let’s configure it in the Program
class:
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference(config =>
{
config.WithTitle("Solar System API");
config.WithTheme(ScalarTheme.Kepler);
config.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
});
}
We start by using the MapScalarApiReference()
method to map the Scalar API reference page into our ASP.NET application's endpoint pipeline (usually at /scalar/v1 by default). We also set the name, theme and default HTTP
client for our API documentation page.
When we examine the POST
endpoint, we see that according to the documentation, it only returns a 200 OK
response. This is far from the truth.
Let’s see how we can improve our documentation!
Defining Response Types Manually in Minimal APIs
When we define response types manually, we are responsible for documenting what each endpoint returns for different scenarios. This approach gives us full control, making it especially convenient when our endpoints need to return varying response types based on the outcome of a request.
As full control is in our hands, we must be diligent and ensure we properly document our endpoints.
Let’s start with a simple example:
app.MapGet("/solar-systems", async (ISolarSystemService solarSystemService) =>
{
var solarSystems = await solarSystemService.GetAllSolarSystemsAsync();
return Results.Ok(solarSystems);
})
.Produces<IEnumerable<SolarSystemResponse>>();
Here, we use the Produces<T>()
method and specify that T will be of the IEnumerable<SolarSystemResponse>
type. Here, we are not additionally specifying a status code as, by default, it’s 200 OK
.
Let’s go back to Scalar and check this endpoint:
Now, alongside the 200 OK
response description, we can also see a JSON
representation of the response body. Originally, this was not the case. This further simplifies the use and integration with our API as our users can clearly see the data structure that we send them.
Let’s now focus on an endpoint with several outcomes:
app.MapPost("/solar-systems", async (
[FromBody] CreateSolarSystemRequest request,
[FromServices] IValidator<CreateSolarSystemRequest> validator,
[FromServices] ISolarSystemService solarSystemService) =>
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(
validationResult.ToDictionary(),
statusCode: StatusCodes.Status422UnprocessableEntity);
}
var solarSystem = await solarSystemService.CreateSolarSystemAsync(request);
return Results.Created($"/solar-systems/{solarSystem.Id}", solarSystem);
})
.Produces<SolarSystemResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
With the POST
endpoint, we have two possible outcomes. When everything goes as expected, we return 201 Created
. We create documentation by calling the Produces<T>()
method and passing the status code using the StatusCodes
static class.
For the validation problem outcome, we use the ProducesValidationProblem()
method. By default, the status code will be documented as 400 Bad Request
, but we can override that by passing StatusCodes.Status422UnprocessableEntity
as an argument.
Once we are done, let’s go to Scalar and check how this looks:
Great, we can see the two possible outcomes properly documented. In addition, we have the JSON
representation of the validation problem.
Using a combination of calls to the Produces<T>()
and ProducesValidationProblem()
methods, we can document a wide range of possible outcomes from our endpoints.
There is also another method we can use. This is the ProducesProblem()
method. It’s similar to the ProducesValidationProblem()
one. The only difference is that the former method returns a ProblemDetails
body, whereas the latter uses the HttpValidationProblemDetails
type.
Using TypedResults to Define Response Types Automatically
Manually defining response types gives us precise control, but it can become repetitive, especially in large projects. To streamline this process, .NET offers a feature called TypedResults
, which automatically determines response types based on a method’s return type.
TypedResults
is a strongly-typed counterpart to the Results
class, but there are key differences between them. The methods in the Results
class have a return type of IResult
, whereas those in the TypedResults
class return specific implementations of the IResult
type.
As a result, when working with the Results
class, additional conversion is required to access the concrete type—something that can be particularly relevant in unit testing.
Another advantage of using TypedResults
is that it automatically provides endpoint descriptions, including status codes.
To see this in practice, let’s go back to our Program
class:
app.MapGet("/solar-systems/{id}", async (
[FromRoute] int id,
[FromServices] ISolarSystemService solarSystemService) =>
{
var solarSystem = await solarSystemService.GetSolarSystemByIdAsync(id);
return TypedResults.Ok(solarSystem);
});
We start by updating the GET /solar-systems/{id}
endpoint. The only change we have to do is to replace the Results
class with the TypedResults
one. This will produce the same result as if we used the Produces<SolarSystemResponse>()
method.
Let’s go to Scalar and check how the endpoint is documented:
Here, we still get the 200 OK
status code, but we also have the JSON
representation of what the endpoint returns.
This works great when we deal with single-outcome endpoints, but let’s see how we can take multiple outcomes:
app.MapDelete("/solar-systems/{id}", async Task<Results<NoContent, NotFound>> (
[FromRoute] int id,
[FromServices] ISolarSystemService solarSystemService) =>
{
await solarSystemService.DeleteSolarSystemAsync(id);
return TypedResults.NoContent();
});
The first thing we do in the DELETE
endpoint is to update the return type of our endpoint. We use a Task of the Results<TResult1, TResultN>
type. This is required because we use the TypedResult
type and our endpoint can return multiple status codes.
It’s also vital to get the proper endpoint metadata. Here, we will return either 204 No Content
, or 404 Not Found
.
For our second and final change, we replace the Results
class with the TypedResults
class to call the NoContent()
method.
With all these changes in place, let’s check the UI and see what we have:
We have the two expected status codes. This is great!
Conclusion
By defining response types in our Minimal APIs, we can improve both API documentation and usability. Whether using manual methods like the Produces<T>()
method or leveraging the TypedResults
class for automation, properly documenting responses ensures clarity for our consumers. By implementing these practices, we create more reliable and user-friendly APIs.