Minimal APIs
Build HTTP APIs in ASP.NET Core with MapGet, MapPost and friends — routing, binding, results and validation.
15 min read
You will learn
- Create and run an ASP.NET Core web API
- Map endpoints with route templates and parameters
- Bind inputs from the route, query, body and DI
- Return proper results and organize endpoints with route groups
From concepts to code
Track 7 covered how backends work in general. Now let's build one with ASP.NET Core — .NET's high-performance, cross-platform web framework.
dotnet new web -o Shop.Api
cd Shop.Api
dotnet run # Now listening on: http://localhost:5xxxThe whole app is one file:
var builder = WebApplication.CreateBuilder(args); // configuration, logging, DI
var app = builder.Build(); // the request pipeline
app.MapGet("/", () => "Hello, .NET Academy!");
app.Run();This is a minimal API: endpoints are mapped directly to lambdas or methods — no controllers needed.
Mapping endpoints
app.MapGet("/products", (ShopDb db) => db.Products.ToListAsync());
app.MapGet("/products/{id:int}", GetProduct);
app.MapPost("/products", CreateProduct);
app.MapPut("/products/{id:int}", UpdateProduct);
app.MapDelete("/products/{id:int}", DeleteProduct);A route template like /products/{id:int} has literal segments and parameters in braces, optionally with constraints (int, guid, min(1), alpha…). When a request arrives, routing compares the path against all templates and picks the endpoint. In this lesson's exercise you'll implement that matching yourself.
Binding inputs
Handler parameters are filled from the request automatically:
app.MapGet("/products", async (
string? category, // query string: ?category=books
int page, // query string: &page=2
ShopDb db, // dependency injection
CancellationToken ct) => // cancelled when the client disconnects
{
...
});
app.MapPost("/products", async (CreateProductRequest request, ShopDb db) => // JSON body
{
...
});
app.MapGet("/me", (ClaimsPrincipal user, [FromHeader(Name = "X-Request-Id")] string? requestId) => ...);Simple types come from the route or query string, complex types from the JSON body, and registered services from DI. Attributes like [FromQuery], [FromHeader] and [FromBody] make it explicit.
Returning results
Return objects (serialized to JSON with 200 OK) or explicit results:
static async Task<Results<Ok<ProductDto>, NotFound>> GetProduct(int id, ShopDb db)
{
var p = await db.Products.FindAsync(id);
return p is null ? TypedResults.NotFound() : TypedResults.Ok(new ProductDto(p.Id, p.Name, p.Price));
}
static async Task<Created<ProductDto>> CreateProduct(CreateProductRequest req, ShopDb db)
{
var product = new Product { Name = req.Name, Price = req.Price };
db.Products.Add(product);
await db.SaveChangesAsync();
return TypedResults.Created($"/products/{product.Id}", new ProductDto(product.Id, product.Name, product.Price));
}TypedResults + Results<...> document every possible response, which also feeds the generated OpenAPI description.
Validation
Reject bad input early with a 400 and Problem Details. Since .NET 10 minimal APIs validate DataAnnotations automatically (builder.Services.AddValidation()); in .NET 8 you validate in the handler, in an endpoint filter, or with FluentValidation:
public record CreateProductRequest([Required, MaxLength(200)] string Name, [Range(0.01, 100_000)] decimal Price);Organizing endpoints
// Program.cs
app.MapProductEndpoints();
// ProductEndpoints.cs
public static class ProductEndpoints
{
public static void MapProductEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products").WithTags("Products");
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetProduct);
group.MapPost("/", CreateProduct).RequireAuthorization();
}
...
}OpenAPI and testing your API
builder.Services.AddOpenApi();
app.MapOpenApi(); // /openapi/v1.json describes every endpointTools like Swagger UI, Scalar, Postman or the .http files in Visual Studio/VS Code let you call your endpoints interactively.
Key takeaways
WebApplication.CreateBuilder+Build+Map*+Runis a complete API.- Route templates (
/products/{id:int}) are matched by routing to pick an endpoint. - Parameters bind from route, query, headers, JSON body and DI automatically.
- Return
TypedResultsfor correct status codes; organize with route groups and extension methods; describe with OpenAPI.
Exercise
Routing is how ASP.NET Core picks an endpoint. Implement Match: given a route template like "/users/{userId}/orders/{orderId}" and a request path, return the captured values (or null if the path doesn't match). Literal segments match case-insensitively, the number of segments must be equal, and a parameter with the :int constraint (e.g. {id:int}) only matches whole numbers.
C# runtime not loaded
Hint 1
Split both strings: template.Split('/', StringSplitOptions.RemoveEmptyEntries). If the lengths differ, return null.
Hint 2
For each segment: if it starts with '{' and ends with '}', it's a parameter — take the text inside the braces, and split off an optional ':int' constraint. Otherwise compare with string.Equals(a, b, StringComparison.OrdinalIgnoreCase).
Hint 3
Use int.TryParse(value, out _) to enforce the int constraint.
Show solution
var values = Match("/orders/{id}", "/orders/42");
Console.WriteLine(values is null ? "no match" : string.Join(", ", values.Select(kv => $"{kv.Key}={kv.Value}")));
Dictionary<string, string>? Match(string template, string path)
{
var templateParts = template.Split('/', StringSplitOptions.RemoveEmptyEntries);
var pathParts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (templateParts.Length != pathParts.Length) return null;
var values = new Dictionary<string, string>();
for (int i = 0; i < templateParts.Length; i++)
{
string segment = templateParts[i];
string actual = pathParts[i];
if (segment.StartsWith('{') && segment.EndsWith('}'))
{
var parts = segment[1..^1].Split(':');
string name = parts[0];
if (parts.Length > 1 && parts[1] == "int" && !int.TryParse(actual, out _)) return null;
values[name] = actual;
}
else if (!string.Equals(segment, actual, StringComparison.OrdinalIgnoreCase))
{
return null;
}
}
return values;
}Quiz
4 questions · 3 correct to pass1.In `app.MapGet("/products/{id:int}", (int id, ShopDb db) => ...)`, where do id and db come from?
2.What does `Results.Created($"/products/{p.Id}", p)` produce?
3.What are route groups for?
4.A request matches no endpoint. What does ASP.NET Core return by default?
To complete this lesson
- ○ Solve the exercise
- ○ Pass the quiz (3/4 correct)