Lab 1: The Public Menu

45 minutes · Ends at checkpoint-1

In this lab you build the read side of Roastery: a product catalog served from an in-memory list. Persistence comes in Module 2; the HTTP contract you define here will not change when it does.

Starting point

cd src/00-start
dotnet run

Open http/module-1.http in your editor. Every request in this lab is already written there.

Step 1: The domain and a catalog service

Create Models/Product.cs:

namespace Roastery.Api.Models;

public enum Roast { Light, Medium, Dark }

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public Roast Roast { get; set; }
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
}

Create Contracts/ProductDto.cs and a query record:

namespace Roastery.Api.Contracts;

public record ProductDto(int Id, string Name, string Roast, decimal Price, bool InStock);
public record CreateProductRequest(string Name, string Roast, decimal Price, int StockQuantity);
public record ProductQuery(string? Roast, bool? InStock);

Create Services/IProductCatalog.cs with an in-memory implementation seeded with four or five products (the starter has a seed list in Data/Seed.cs you can copy). Register it:

builder.Services.AddSingleton<IProductCatalog, InMemoryProductCatalog>();

Step 2: The products group

Create Endpoints/ProductEndpoints.cs:

public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/products").WithTags("Products");

        group.MapGet("/", GetProducts).WithName("GetProducts")
             .WithSummary("List products, optionally filtered by roast and stock.");
        group.MapGet("/{id:int}", GetProduct).WithName("GetProduct");
        group.MapPost("/", CreateProduct).WithName("CreateProduct");

        return group;
    }

    static async Task<Ok<List<ProductDto>>> GetProducts(
        [AsParameters] ProductQuery query, IProductCatalog catalog, CancellationToken ct)
    {
        var products = await catalog.ListAsync(query.Roast, query.InStock, ct);
        return TypedResults.Ok(products.Select(p => p.ToDto()).ToList());
    }

    static async Task<Results<Ok<ProductDto>, NotFound>> GetProduct(
        int id, IProductCatalog catalog, CancellationToken ct)
    {
        var product = await catalog.FindAsync(id, ct);
        return product is null ? TypedResults.NotFound() : TypedResults.Ok(product.ToDto());
    }

    static async Task<CreatedAtRoute<ProductDto>> CreateProduct(
        CreateProductRequest request, IProductCatalog catalog, CancellationToken ct)
    {
        var product = await catalog.AddAsync(request, ct);
        return TypedResults.CreatedAtRoute(product.ToDto(), "GetProduct", new { id = product.Id });
    }
}

Add the ToDto() extension in Contracts/Mapping.cs, then call app.MapProductEndpoints(); in Program.cs.

Run the app and send the first three requests in module-1.http. You should see a list, a single product, and a 404 for id 999.

Send the POST request. Confirm the response is 201 Created with a Location header that points at the new product, then GET that location.

Step 4: A timing filter on the group

Add this to MapProductEndpoints before return group;:

group.AddEndpointFilter(async (context, next) =>
{
    var sw = Stopwatch.StartNew();
    var result = await next(context);
    context.HttpContext.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
    return result;
});

Send any request and look for X-Elapsed-Ms in the response headers. Then add a Console.WriteLine at the top of the filter and another in a small inline middleware in Program.cs (app.Use(async (ctx, next) => { … await next(); })) and watch the order in which they print.

Step 5: Read the OpenAPI document

Add the packages and wiring:

dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore
builder.Services.AddOpenApi();
// …
app.MapOpenApi();
app.MapScalarApiReference();

Browse to https://localhost:7181/scalar/v1. Find GetProduct and confirm it shows both 200 (with the ProductDto schema) and 404.

Now find the endpoint in the starter marked // BUG: wrong Produces. It declares .Produces<Product>(200) on an endpoint that returns a ProductDto. Remove the attribute, since TypedResults already describes the response, and refresh the document to see the schema correct itself.

Step 6: Exercise everything

Run through every request in module-1.http one more time. The expected status for each is written in a comment above the request.

Stretch goals

  • Add GET /api/products/{id}/image that returns TypedResults.PhysicalFile(...) for an image in wwwroot/images, with 404 if the file is missing.
  • Add app.MapFallback(...) that returns a ProblemDetails with status 404 and a friendly message for any unmatched route.
  • Add a route constraint so /{id:int:min(1)} rejects zero and negative ids, and see what the client receives.

Common pitfalls

  • Ambiguous routes. If you add /featured alongside /{id}, the int constraint on id is what keeps them apart. Without it, /featured fails to bind and returns 400.
  • Results versus TypedResults. Both compile; only the typed version populates OpenAPI and gives you a compile-time contract.
  • Filters versus middleware. Filters run after binding and can see arguments; middleware runs before routing and cannot. Choose based on what you need to see.

Checkpoint

git add -A && git commit -m "Lab 1 complete"
# or, if you fell behind:
git checkout checkpoint-1

Next: Module 2: Data, Validation, and the Shape of a Good API