No matter how you build your APIs, you'll need certain functionality that's more than just in the body of a method. ASP.NET Core allows you to add this functionality through middleware. Let's see how that middleware can interact with Minimal APIs.
Last year, I showed you how to write and structure your own Minimal APIs (https://www.codemag.com/Article/2201081/Minimal-APIs-in-.NET-6). What was missing in that article is how to opt into middleware functionality. Let's dig into middleware and Minimal APIs.
Middleware in ASP.NET Core
Before I dig into using middleware with Minimal APIs, let me explain what middleware is. Sometimes you don't need a twenty-five-cent word to explain a simple concept. Middleware allows ASP.NET Core to execute a piece of code. That code could be part of ASP.NET Core, like Static File support, Authentication support, or even Routing. That code could be some third-party library or even your own code. For example, here's a simple top-level file that new .NET 6 projects use to listen for requests:
var bldr = WebApplication.CreateBuilder(args);
// Add services to the container.
bldr.Services.AddRazorPages();
// Get the web application object
var app = bldr.Build();
// Add Middleware
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
// Start Listening for requests
app.Run();
Once the app
object is created by building the web application (to contain any required services), you can add middleware that will be called, in order, to handle the request. The order of this middleware matters, as this is the specific order that the request is passed into each part of the middleware. As you can see in Figure 1, the order of the middleware is stacked together:
As a request comes in, each middleware looks at the request and tries to determine if it can handle the request. In Figure 2, you'll see that the request can be handled by the Razor Pages middleware.
The request is passed to Static Files but it determines that it isn't a request that Static Files can handle, so it passes it to the Routing middleware. The Routing middleware determines that it can't handle it either, so it passes it to the next middleware: Razor Pages. Once the Razor Pages middleware realizes that it can handle the request, it fulfills the request and returns, which passes the request to the last middleware that called it. This continues the chain until the request is ultimately returned to the user.
At any point, middleware can fulfill the request and, in that case, it doesn't call the next piece of middleware. Instead, it just returns the chain back up, as seen in Figure 3:
Although these pieces of middleware may do a little or quite a lot, it's a black box for most of us developers. But what's really happening in the middleware? Let's write a tiny piece of middleware and find out.
If you call the Use
method on the application, you can include a lambda to be called during a request:
app.Use(async (ctx, next) =>
{
await next.Invoke(ctx); // Pass the context
});
You'll notice that you're passed two parameters when the lambda is called. The first parameter is the context object (HttpContext
) that gives you access to the request and response objects. The second parameter is a RequestDelegate
. This is how you will call the next piece of middleware. Notice that middleware has no idea of the order of middleware; it just gives you a bite at the apple of the request. Not all middleware is there to handle a request. For this example, let's write to the log with the speed of the request:
app.Use(async (ctx, next) =>
{
// Get a starting time
var start = DateTime.UtcNow;
// Call the next piece of middleware
await next.Invoke(ctx);
// Execute code as the chain returns back up the list of middleware.
var totalMs = (DateTime.UtcNow - start).TotalMilliseconds;
app.Logger.LogInformation(@$"Request {ctx.Request.Path}: {totalMs}ms");
});
Now that you've had a brief introduction to middleware, let's talk about how it works with Minimal APIs.
Using Middleware with Minimal APIs
Although some middleware is about answering requests (e.g., Razor Pages, Controllers, and Static Files), other middleware is to provide services to other middleware. To accomplish this, you need to opt-in or provide data to be used by the middleware. You can see this in one of the most common cases with Authorization. If you were writing a controller-based API, you might use an attribute to tell the Authorization middleware that authorization is required by a particular controller or action:
[Route("{moniker}/api/me")]
[Authorize]
[ApiController]
public class MeController : Controller
{
The use of the Authorize
attribute allows the controller to opt into requiring authorization. But how do you accomplish this with Minimal APIs? If you have a Minimal API that requires authorization, you can still use the attribute:
app.MapGet("api/films", [Authorize]async (BechdelDataService ds,
int? page, int? pageSize) =>
{
FilmResult data = await ds.LoadAllFilmsAsync();
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
});
You should notice that the attribute is on the method (the anonymous method in this example). Unlike with controllers, there isn't a good way to specify the attribute for a group of Minimal APIs. I find the attribute method clunky and, evidently, so did Microsoft, as they recommend another way.
Instead of using an attribute, the more common way is to use a fluent syntax for the Minimal API:
app.MapGet("api/films", async (BechdelDataService ds, int?
page, int? pageSize) =>
{
FilmResult data = await ds.LoadAllFilmsAsync();
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
}).RequireAuthorization();
In this way, most middleware supports a fluent syntax (via extension methods) to the call to MapXXX
to add information to the middleware. For example, you can also allow specific APIs to be accessible without authorization by using the AllowAnonymous
method:
app.MapGet("api/years", async (BechdelDataService ds) =>
{
var data = await ds.LoadFilmYears();
if (data is null) Results.NotFound();
return Results.Ok(data);
}).AllowAnonymous();
This is a fluent syntax so you can chain these together:
app.MapGet("api/years", async (BechdelDataService ds) =>
{
var data = await ds.LoadFilmYears();
if (data is null) Results.NotFound();
return Results.Ok(data);
}).AllowAnonymous().RequireHost("localhost");
Let's walk through some common middleware to see how it's used in minimal APIs.
CORS
In the case of CORS (or cross-origin resource sharing), often you'll only have a single policy defined:
app.UseCors(cfg =>
{
cfg.WithMethods("GET");
cfg.AllowAnyHeader();
cfg.AllowAnyOrigin();
});
In some cases, you'll be using CORS policies and want to opt into individual ones. For example, if you configured CORS to have a partner policy for certain APIs, it would look like this:
builder.Services.AddCors(cfg =>
{
cfg.AddDefaultPolicy(cfg =>
{
cfg.WithMethods("GET");
cfg.AllowAnyHeader();
cfg.AllowAnyOrigin();
});
cfg.AddPolicy("partners", cfg =>
{
cfg.WithOrigins("https://somepartnername.com");
cfg.AllowAnyMethod();
});
});
Although the default policy applies to all the MapGet
calls, you might want to support all methods if you're from a partner website (i.e., “partners” policy). To opt into that, make a fluent call on the Minimal API:
app.MapGet("api/films/{year:int}", async (BechdelDataService ds,
int? page, int? pageSize, int year) =>
{
FilmResult data = await ds.LoadAllFilmsByYearAsync(year);
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
}).RequireCors("partners");
Swagger
Using OpenAPI (through Swagger) has become an important way to document and test your APIs. Minimal APIs support this with a set of fluent syntax methods. This allows you to annotate the Minimal API with information that will be available to OpenAPI tooling as well as if you're supporting the Swagger UI plug-in.
First, there are methods for adding information about what the API produces. You could specify just the result codes like so:
app.MapGet("api/films/{year:int}", async (BechdelDataService ds,
int? page, int? pageSize, int year) =>
{
FilmResult data = await ds.LoadAllFilmsByYearAsync(year);
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
}).Produces(200);
You can specify the types it produces as well (FilmResult
in this example):
app.MapGet("api/films/{year:int}", async (BechdelDataService ds,
int? page, int? pageSize, int year) =>
{
FilmResult data = await ds.LoadAllFilmsByYearAsync(year);
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
}).Produces<FilmResult>(200, "application/json");
Notice that you can specify the type, the result code, and the MIME type. In addition, you'll want to explain problem codes as well:
app.MapGet("api/films/{year:int}", async (BechdelDataService ds,
int? page, int? pageSize, int year) =>
{
FilmResult data = await ds.LoadAllFilmsByYearAsync(year);
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
}).Produces<FilmResult>(200, "application/json").ProducesProblem(404);
This produces metadata so that callers can expect certain types of problem results. You can also add metadata for API names and tags (used for grouping):
app.MapGet("api/films/{year:int}",async (BechdelDataService ds,
int? page, int? pageSize, int year) =>
{
FilmResult data = await ds.LoadAllFilmsByYearAsync(year);
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
}).Produces<FilmResult>(200, "application/json")
.ProducesProblem(404)
.WithName("GetAllFilms")
.WithTags("films");
In this way, these methods can be used to add metadata about your API to let your users know what to expect. Although these work with Swagger, there are other methods (e.g., WithMetadata
and WithDisplayName
) that don't show up in the Swagger implementation.
OutputCaching
If you're familiar with ASP.NET before .NET Core, you might be used to using output caching. This allows you to cache the output of a request so that subsequent calls could just return the result. In .NET 7, this comes back and is supported via the CacheOutput
method:
app.MapGet("api/films", async (BechdelDataService ds,
int? page, int? pageSize) =>
{
FilmResult data = await ds.LoadAllFilmsAsync();
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
}).CacheOutput();
// Or .CacheOutput("somepolicy")
// for specific output caching needs
You can see that you can either use the default output caching policy by providing no parameter, or you can supply a named policy. This allows you to handle output caching on an API-by-API basis. This is often only useful on MapGet
APIs.
ResponseCaching
Like output caching, response caching is a way to help decrease the load on a server. It does this by using caching HTTP headers. If you've opted into using Response Caching, you'll need a way to specify the response caching policy for your Minimal APIs. Unfortunately, at least in the .NET 7 previews, there aren't methods for adding response caching. Instead, you'll have to use the attributes, but luckily this still works:
app.MapGet("api/films", [ResponseCache(Duration = 5)] async (
BechdelDataService ds, int? page, int? pageSize) =>
{
FilmResult data = await ds.LoadAllFilmsAsync();
if (data.Results is null)
{
return Results.NotFound();
}
return Results.Ok(data);
});
In this case, you're telling the response caching to add a Cache-control header with a max-age of five seconds. So, to use Response Caching, you'll continue to operate like you did in controller-based APIs from earlier versions of ASP.NET Core.
Where Are We?
Although this article isn't a comprehensive list of how to opt into features of built-in middleware, I hope it provides a hint as to how the interaction of middleware and Minimal APIs can work together. For middleware authors, supporting extension methods to allow Minimal APIs to provide you with information about an API is something you should consider. For middleware users, you've seen that if a type of middleware doesn't directly support Minimal APIs, you can still fall back to using attributes to attain the same functionality.