.NET Academy

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:5xxx

The 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 endpoint

Tools 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* + Run is 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 TypedResults for 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.

Program.cs
Loading editor…

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 pass
  1. 1.In `app.MapGet("/products/{id:int}", (int id, ShopDb db) => ...)`, where do id and db come from?

  2. 2.What does `Results.Created($"/products/{p.Id}", p)` produce?

  3. 3.What are route groups for?

  4. 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)