Building the API

Wiring one project: EF Core on Postgres, bearer authentication, automatic validation, problem details and endpoints grouped by feature.

SyntaxRouteGroupBuilder links = app.MapGroup("/links").RequireAuthorization();

The service is one project, Shortly.Api, and its Program.cs does nothing but wiring. Everything else lives in folders named after what it does: Endpoints, Data, Domain, Auth. Someone looking for the redirect handler should not have to guess.

Registrations, then pipeline, then routes

Program.cs has three parts in a fixed order. First the registrations on builder.Services, which say how to build each service when something asks for it. Then the middleware: the app.Use... calls that every request passes through, in the order written. Then the routes.

That order is not a matter of taste. UseExceptionHandler goes first, because it can only catch what runs after it. UseAuthentication reads the token and builds the user; UseAuthorization then decides whether that user may proceed, so it must come second.

Validation and errors

AddValidation(), new in .NET 10, reads the data annotation attributes on a request type and rejects a bad body before the handler runs, returning 400 with the failures listed per field. AddProblemDetails() gives every other failure that same single JSON shape, so a client parses one thing whether it received 401, 404 or 500.

Against the example below: a POST to /links with no bearer token returns 401; with a token and a body of {"targetUrl": "not-a-url"} it returns 400 naming TargetUrl; with a real address, 201 and a Location header. Adding AddOpenApi() and app.MapOpenApi() serves the generated description at /openapi/v1.json.

Shorthand and longhand

class ShortlyDb(DbContextOptions<ShortlyDb> options) : DbContext(options) is a primary constructor, from C# 12. It is the same code as writing public ShortlyDb(DbContextOptions<ShortlyDb> options) : base(options) { } in the class body. Likewise TypedResults.Created(...) and Results.Created(...) send an identical response; TypedResults just returns a precise type, which is what the OpenAPI document is generated from.

Example

Example · csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ShortlyDb>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Shortly")));
builder.Services.AddAuthentication().AddJwtBearer();  // reads Authentication:Schemes:Bearer
builder.Services.AddAuthorization();
builder.Services.AddValidation();                     // .NET 10: 400 before the handler
builder.Services.AddProblemDetails();                 // one error shape for everything

WebApplication app = builder.Build();

app.UseExceptionHandler();  // unhandled exception -> 500 with a problem details body
app.UseStatusCodePages();   // a bare 401 or 404 gets a body too
app.UseAuthentication();    // read the token, build the user
app.UseAuthorization();     // decide if that user may continue

RouteGroupBuilder links = app.MapGroup("/links").RequireAuthorization();

links.MapPost("/", async (CreateLinkRequest request, ShortlyDb db) =>
{
    Link link = new() { Code = "a7Kd92", TargetUrl = request.TargetUrl };
    db.Links.Add(link);
    await db.SaveChangesAsync();
    return TypedResults.Created($"/links/{link.Code}", new { link.Code });
});

app.Run();

public class CreateLinkRequest
{
    [Required]
    [Url]
    public string TargetUrl { get; set; } = string.Empty;
}

public class Link
{
    public int Id { get; set; }
    public required string Code { get; set; }
    public required string TargetUrl { get; set; }
}

public class ShortlyDb(DbContextOptions<ShortlyDb> options) : DbContext(options)
{
    public DbSet<Link> Links => Set<Link>();
}

When to use it

  • Starting a new service and needing the registration and middleware order right first time.
  • Giving every failure in an API one JSON shape so clients parse one thing.
  • Keeping Program.cs readable once the service has thirty endpoints.

More examples

The endpoints moved out of Program.cs

Do this as soon as a service has more than about five endpoints. Program.cs stays a list of features rather than a wall of lambdas, and each group can carry its own authorisation and tags in one place. The handler as a named static method is the version you can call directly from a unit test.

Example · csharp
// Shortly.Api/Endpoints/LinkEndpoints.cs
using Microsoft.EntityFrameworkCore;
using Shortly.Api.Data;

namespace Shortly.Api.Endpoints;

// An extension method must live in a static class. This one adds a method to
// IEndpointRouteBuilder, so Program.cs can say app.MapLinkEndpoints().
public static class LinkEndpoints
{
    public static RouteGroupBuilder MapLinkEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder group = app.MapGroup("/links")
            .RequireAuthorization()
            .WithTags("links");          // groups them in the OpenAPI document

        group.MapGet("/{code}", GetByCode);
        return group;
    }

    // A named method, not a lambda. It can be unit tested and it gets a name
    // in a stack trace. Minimal APIs accept either.
    private static async Task<IResult> GetByCode(string code, ShortlyDb db)
    {
        Link? link = await db.Links.SingleOrDefaultAsync(l => l.Code == code);
        return link is null
            ? TypedResults.NotFound()
            : TypedResults.Ok(new { link.Code, link.TargetUrl });
    }
}

What a validation failure returns

This is the body AddValidation produces, and it is worth copying into your API documentation. The errors object is keyed by the path to the member that failed, so a nested property arrives as Customer.Email and a collection item as Items[0].Quantity. Clients can attach each message to the right input without parsing prose.

Example · json
// POST /links with body {"targetUrl": ""}
// HTTP/1.1 400 Bad Request
// Content-Type: application/problem+json
{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "TargetUrl": [
      "The TargetUrl field is required."
    ]
  }
}

Reading who the caller is

Any endpoint that touches a row belonging to one user needs this. Putting the owner condition in the Where clause means a caller asking for someone else's code gets 404 rather than 403, which tells them nothing about whether the code exists. ExecuteDeleteAsync sends one DELETE statement instead of loading the entity first.

Example · csharp
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Shortly.Api.Data;

namespace Shortly.Api.Endpoints;

public static class OwnedLinkEndpoints
{
    // ClaimsPrincipal is supplied by the framework, like ShortlyDb is. A claim
    // is one statement the token makes about the caller, such as their id.
    public static async Task<IResult> Delete(
        string code, ClaimsPrincipal user, ShortlyDb db)
    {
        string? ownerId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (ownerId is null)
        {
            return TypedResults.Unauthorized();
        }

        // The owner check is part of the query. Loading the row first and
        // comparing afterwards leaks the fact that the code exists.
        int removed = await db.Links
            .Where(l => l.Code == code && l.OwnerId == Guid.Parse(ownerId))
            .ExecuteDeleteAsync();

        return removed == 0 ? TypedResults.NotFound() : TypedResults.NoContent();
    }
}

Discussion

  • Be the first to comment on this lesson.