Minimal APIs in ASP.NET Core
Let's start by creating a new web API project.
dotnet new webapi -n TheEmployeeAPI
This will create a new web API project in a folder called TheEmployeeAPI
.
Let's delete some parts of the Program.cs that we don't need. We're left with:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.Run();
What is a Minimal API?
Minimal APIs are a relatively new way to define routes and handlers in ASP.NET Core. They are a simple and lightweight way to define routes and handlers.
They have two basic ways to define endpoints:
Delegate
e.g. any ol' function.RequestDelegate
e.g. (HttpContext context) => { ... }
. In practice, this will rarely be used.
Let's focus on adding our Employee class and then some endpoints to create and get them. Add a new file called Employee.cs and write an Employee class with two properties: FirstName
and LastName
.
public class Employee
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
Wait, why is the compiler complaining about FirstName
and LastName
? (Hover over to see the error message - it's saying that we need FirstName
and LastName
to be initialized before exiting the constructor.)
We could handle this by adding ?
to the end of the types to mark them as nullable, but in the real world employees have names and they're not nullable, so let's mark them as required (which, again, is a relatively new keyword in .NET):
public class Employee
{
public int Id { get; set; }
public required string FirstName { get; set; }
public required string LastName { get; set; }
}
🌶️🌶️🌶️ Spicy take incoming. I like what nullability represents long-term, but it's relatively new to .NET and the real world is filled with projects where it's simply impractical to rewrite all the code to be non-nullable. I'm using nullability in this course because I want to get you to think about handling compiler warnings and to make the code as clean as possible, but know that I'm making this decision with eyes wide open knowing that not all projects in the real world use nullability.
Let's drive the point home by editing the csproj file to treat all warnings as errors:
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Ok, let's keep going! Let's create a list to store our employees:
var employees = new List<Employee>();
We'll create one with a couple of hardcoded employees:
employees.Add(new Employee { Id = 1, FirstName = "John", LastName = "Doe" });
employees.Add(new Employee { Id = 2, FirstName = "Jane", LastName = "Doe" });
Now we'll add a few endpoints to our API. We'll start with the GetAllEmployees
endpoint.
app.MapGet("/employees", () => {
return employees;
});
While we can return the list directly, it's often better to return a 200 OK response with the list of employees. We can do this by using the Results.Ok
method.
app.MapGet("/employees", () => {
return Results.Ok(employees);
});
Results
is a handy class that contains a bunch of methods representing HTTP status codes you can return from your APIs.
🌶️🌶️🌶️ Spencer NEVER EVER returns anything that isn't tied directly to an HTTP status code.
We'll add a get single employee endpoint next:
app.MapGet("/employees/{id}", (int id) => {
var employee = employees.SingleOrDefault(e => e.Id == id);
if (employee == null)
{
return Results.NotFound();
}
return Results.Ok(employee);
});
Wait, what's that int id
parameter?
That's a route parameter. We can define as many route parameters as we want, though I usually only stick to a couple.
Note in the route that we have {id}
- that's the route parameter - and it's bound to the id
parameter in the lambda expression.
We can also define route constraints. For instance, we can define that the id
parameter must be an integer:
app.MapGet("/employees/{id:int}", (int id) => {
var employee = employees.SingleOrDefault(e => e.Id == id);
if (employee == null)
{
return Results.NotFound();
}
return Results.Ok(employee);
});
Run the app and use the Swagger UI and/or Postman to test the endpoints.
Alright, now let's add an endpoint to create an employee.
app.MapPost("/employees", (Employee employee) => {
employee.Id = employees.Max(e => e.Id) + 1; // We're not using a database, so we need to manually assign an ID
employees.Add(employee);
return Results.Created($"/employees/{employee.Id}", employee);
});
Run the app and use the Swagger UI and/or Postman to test the endpoints. An empty Employee object (e.g. no first and last name) in your POST results in a 400 Bad Request response:
400 Bad Request
Microsoft.AspNetCore.Http.BadHttpRequestException: Implicit body inferred for parameter "employee" but no body was provided. Did you mean to use a Service instead?
at Microsoft.AspNetCore.Http.RequestDelegateFactory.Log.ImplicitBodyNotProvided(HttpContext httpContext, String parameterName, Boolean shouldThrow)
at lambda_method4(Closure, Object, HttpContext, Object)
at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass102_2.<<HandleRequestBodyAndCompileRequestDelegateForJson>b__2>d.MoveNext()
--- End of stack trace from previous location ---
at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
This is kind of ugly. Our clients would much prefer that we return details that are more friendly to interpret. We'll handle that as we go further down the course.
A final refactor for now: route groups
Let's refactor our code to use route groups. Route groups allow us to group related routes together. We can even add different behaviors to our APIs based on what route group they're in (more on that later):
var employeeRoute = app.MapGroup("employees");
Now we can change our three endpoints to be children of the employeeRoute
group:
employeeRoute.MapGet(string.Empty, () => {
return employees;
});
employeeRoute.MapGet("{id:int}", (int id) => {
var employee = employees.SingleOrDefault(e => e.Id == id);
if (employee == null)
{
return Results.NotFound();
}
return Results.Ok(employee);
});
employeeRoute.MapPost(string.Empty, (Employee employee) => {
employee.Id = employees.Max(e => e.Id) + 1; // We're not using a database, so we need to manually assign an ID
employees.Add(employee);
return Results.Created($"/employees/{employee.Id}", employee);
});
Run the app and use the Swagger UI and/or Postman to test the endpoints.
Parameter binding in minimal APIs
In the second argument of MapGet
/MapPost
/etc. functions, you can pretty much pass any kind of delegate you want. That said, there is a specific order that they follow when trying to bind parameters:
- Parameters using [Bind/From] attributes
- Special types like HttpContext and CancellationToken
- Route parameters/query string
- Services from the DI container
- Body of the request
Wait, what are the [Bind/From]
attributes?
They're useful attributes that allow you to tell ASP.NET Core explicitly how to bind parameters from your request.
Let's give a brief example:
app.MapPost("/employees/{id}", ([FromRoute] int id, [FromBody] Employee employee, [FromServices] ILogger<Program> logger, [FromQuery] string search) => {
return Results.Ok(employee);
});
Each of these attributes are used to bind parameters from different parts of the request:
[FromRoute] int id
: Binds theid
parameter from the route.[FromBody] Employee employee
: Binds theemployee
parameter from the body of the request.[FromServices] ILogger<Program> logger
: Binds thelogger
parameter from the DI container.[FromQuery] string search
: Binds thesearch
parameter from the query string.
While they aren't required strictly speaking, they are highly recommended as they make your code more explicit and easier to understand - plus it takes some of the guesswork out of how ASP.NET binds parameters.
🌶️🌶️🌶️ Spencer always recommends using binding attributes. We'll use them much more when we get to controllers - for now, we'll just keep our APIs simple. Besides which, for the APIs we're building it's pretty easy to see how parameters are bound.