This post was originally published on Variant’s blog.
In my previous post on moving from Controllers to Minimal API in .NET, one question popped up in the comments asking if one could add attributes in Minimal API to generate an OpenAPI document for Swagger UI, like one would do with MVC Controllers.
And the short answer here is; no. Minimal API does not support any of the old attributes you might have used in Controllers.
Minimal API forces you to be more explicit about the structure and routing of your API, and also with any metadata you might want to add to each endpoint. Instead of adding attributes to endpoints, most of this is done through calling extension methods when building the routes. As with for example authorization, where you would call RequireAuthorization() on the endpoint, instead of adding an [Authorize]-attribute:
app.MapGet("/hello", () => "Hello world!")
.RequireAuthorization();
For some parts of the OpenAPI documentation though, I would argue there are better ways than doing this through extension methods, and that is what this post is about.
For the impatient, here’s the code
The gist of this post is, use the Results<> and TypedResults types to define the responses on Minimal API endpoints:
app.MapGet("/hello", Results<Ok<GetHelloWorldResponse>, NotFound> (IHelloService helloService) =>
{
var hello = helloService.FetchHello();
return hello != null ? TypedResults.Ok(hello) : TypedResults.NotFound();
});
This will give you OpenAPI documentation out of the box without having to use attributes or extension methods, and it will force you to adhere to that document when compiling the code (which is a good thing!)
I’ve also created an example API project that demonstrates the usage of these types, which you can find at https://github.com/varianter/dotnet-template (see the endpoint handlers in the Api).
What follows is a detailed explanation of why this is far superior to how we “used to do it before”.
A comparison with Controllers
To give an example so you may better understand, I want to do a comparison to how one would do it with Controllers. There you might be used to adding a ProducesResponseType attribute to the endpoint, in order to tell the consumer what the response will look like:
[ApiController]
[Route("[controller]")]
public class HelloController : ControllerBase
{
[HttpGet]
[ProducesResponseType<GetHelloWorldResponse>(StatusCodes.Status200OK)]
public ActionResult<GetHelloWorldResponse> Get()
{
return Ok(new GetHelloWorldResponse { Message = "Hello World!" });
}
}
Here the attribute will say that one possible response from this endpoint is that you will get a 200 OK, and the response body will be equal to that of GetHelloWorldResponse.
Again, Minimal API does not respect these attributes, and prefers to be explicit about adding metadata to an endpoint. This is done by calling extension methods on the endpoint builder, and in this case that would be the Produces-method, which is the counterpart of ProducesResponseType:
app.MapGet("/hello", () => new GetHelloWorldResponse { Message = "Hello World!" })
.Produces<GetHelloWorldResponse>(StatusCodes.Status200OK);
But this becomes way more interesting when we look at endpoints that can return multiple types of responses.
It can do more than one thing?
In the previous Controller-example you tell the compiler that the return type of the endpoint method is ActionResult<GetHelloWorldResponse>, because that is all the method actually returns. But if you have several response types, then you are forced to use IActionResult as the return type in the method signature. This is because the different result types like Ok, NotFound and BadRequest all inherit from IActionResult.
Say you have an endpoint that can return either a 200 OK with a GetHelloWorldResponse or a 404 Not Found, depending on if some data is found when calling a service. To properly document this in the OpenAPI, you would have to use the ProducesResponseType attribute for both cases since the framework cannot infer the response type from the return type of the method:
[ApiController]
[Route("[controller]")]
public class HelloController(IHelloService helloService) : ControllerBase
{
[HttpGet]
[ProducesResponseType<GetHelloWorldResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Get()
{
var hello = helloService.FetchHello();
return hello != null ? Ok(hello) : NotFound();
}
}
The Minimal API equivalent would be forced to do the same, but with the Produces-method:
app.MapGet("/hello", (IHelloService helloService) =>
{
var hello = helloService.FetchHello();
return hello != null ? Results.Ok(hello) : Results.NotFound();
})
.Produces<GetHelloWorldResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
The problem we all face
The issue with both the ProducesResponseType-attributes and the Produces-extension methods is that they do not guarantee that you actually adhere to them in the endpoint.
There is nothing forcing you to fix the OpenAPI metadata as you do changes to the endpoint methods. Over time it can become increasingly difficult to keep the OpenAPI document in sync with what the endpoints actually do.
There is also nothing stopping you from just making stuff up, and the code will still compile and run just fine. Let’s say we create another endpoint to fetch the current weather forecast:
app.MapPost("/weatherforecast", () =>
{
// TODO: I had to go to lunch, lets implement this later
return (IResult)null;
})
.Produces<WeatherForecast>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status418ImATeapot);
Here the compiler will nod and give you two thumbs up, and the Swagger UI will happily render what you told it — even though the endpoint doesn’t actually do any of that. This is not ideal, and here we finally arrive at the point where TypedResults and Results enter the story.
Combining TypedResults and Results
The Results and TypedResults types are the solution to this long-standing problem. They will give you an OpenAPI document out of the box if you use them in your endpoints, while also forcing you to adhere to that document when compiling the code.
The above Minimal API example can be rewritten as:
app.MapGet("/hello", Results<Ok<GetHelloWorldResponse>, NotFound> (IHelloService helloService) =>
{
var hello = helloService.FetchHello();
return hello != null ? TypedResults.Ok(hello) : TypedResults.NotFound();
});
This will give you the same OpenAPI document as the previous examples. But note here that there are no calls to the Produces-method. In its place the endpoint method has been rewritten to say that the return type is Results<Ok<GetHelloWorldResponse>, NotFound>.
By doing this, you tell the endpoint builder that this method can return multiple types of Results, where the first type is the Ok-type with a GetHelloWorldResponse as the response body, and the second type is the NotFound-type with no response body.
To get a bit technical here, both the Results-type, and the Ok- and NotFound-types all implement the IEndpointMetadataProvider-interface. This interface defines a static method called PopulateMetadata, which all of them implement for their respective type.
For example the implementation in the Ok-type looks like this:
static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
{
builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TValue), new[] { "application/json" }));
}
This static method is called when the endpoints are built, so that the correct OpenAPI metadata is added to the endpoint for each of the response types. No magic here, just a bit of clever design.
Why use TypedResults though?
Why use TypedResults.Ok rather than just Results.Ok? The main reason is they serve different purposes in terms of flexibility and type safety.
When the return type of the endpoint method is Results<Ok<GetHelloWorldResponse>, NotFound>, you are forced by the compiler to only return either an Ok<GetHelloWorldResponse> or a NotFound-type, and nothing else.
The reason you cannot use Results.Ok here is because it returns an IResult:
// Results.cs
public static IResult Ok<TValue>(TValue? value)
=> value is null ? TypedResults.Ok() : TypedResults.Ok(value);
While TypedResults.Ok specifically returns the Ok-type:
// TypedResults.cs
public static Ok<TValue> Ok<TValue>(TValue? value) => new(value);
Which satisfies as a valid return type for what is defined in the endpoint.
Another example that will not compile is if you try to return a BadRequest-type instead of a NotFound-type for this endpoint:
app.MapGet("/hello", Results<Ok<GetHelloWorldResponse>, NotFound> (IHelloService helloService) =>
{
var hello = helloService.FetchHello();
return hello != null ? TypedResults.Ok(hello) : TypedResults.BadRequest();
});
The compiler will complain:
Cannot convert expression type 'BadRequest' to return type 'Results<Ok<GetHelloWorldResponse>, NotFound>
Some caveats
First off, nothing is forcing you to actually have code paths which will return all the things you’ve said you might. It does not force you to have code paths that ensure you actually return all the defined possible responses.
Also, not every possible response type has been implemented as its own TypedResults type. As of this writing there is no TypedResults.ImATeapot() — which is obviously a tragedy. For non-standard status codes you would still have to add Produces(StatusCodes.Status418ImATeapot) on the endpoint and use the generic StatusCodeHttpResult.
Second off, you can use TypedResults and Results in your Controller endpoints too:
[ApiController]
[Route("[controller]")]
public class HelloController(IHelloService helloService) : ControllerBase
{
[HttpGet]
public Results<Ok<GetHelloWorldResponse>, NotFound> Get()
{
var hello = helloService.FetchHello();
return hello != null ? TypedResults.Ok(hello) : TypedResults.NotFound();
}
}
But! This does not yet actually infer metadata which would generate the appropriate OpenAPI document in Controller-endpoints. This is a known problem, and it will hopefully be fixed in a later version.
And lastly, this does not fix all your OpenAPI needs. For Minimal API, if you want to add description texts or other metadata to the endpoint, this will still require calling extension methods:
app.MapGet("/hello", Results<Ok<GetHelloWorldResponse>, NotFound> (IHelloService helloService) =>
{
var hello = helloService.FetchHello();
return hello != null ? TypedResults.Ok(hello) : TypedResults.NotFound();
})
.WithOpenApi()
.WithDescription("This endpoint will give you a warm greeting!");
Conclusion
As a long time developer using .NET, I am really excited at where we are right now with the framework and where it is going. Small quality-of-life additions like this makes it fun to continue using the framework.
As noted earlier in the post, I have an example API project at https://github.com/varianter/dotnet-template. And if you are someone who is coming from using Controllers, but is unfamiliar with Minimal API, I’d recommend reading my other post about moving from Controllers to Minimal API.
Hope this post has been of help! ✌️