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.
Step 3: Create and link
Send the POST request. Confirm the response is 201 Created with a Location header that points at the new product, then GET that location.
warning
If you get an exception mentioning a route named GetProduct, the .WithName("GetProduct") call is missing or misspelled. CreatedAtRoute resolves the name at runtime.
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}/imagethat returnsTypedResults.PhysicalFile(...)for an image inwwwroot/images, with404if the file is missing. - Add
app.MapFallback(...)that returns aProblemDetailswith status404and 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
/featuredalongside/{id}, theintconstraint onidis what keeps them apart. Without it,/featuredfails to bind and returns400. ResultsversusTypedResults. 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