Skip to main content
CodeOath
← All posts

.NET Core / Web API71 min total · 19 parts

Building REST APIs with ASP.NET Core: Routing, Middleware, and Dependency Injection

Part 4 of 19 · ~2 min

Routing: Attributes Map a URL to a Method

[ApiController]
[Route("api/tools/{toolId:int}/reservations")]
public class ReservationsController : ControllerBase
{
    private readonly IReservationService _reservations;

    public ReservationsController(IReservationService reservations) => _reservations = reservations;

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<Reservation>> GetById(int toolId, Guid id)
    {
        var reservation = await _reservations.GetByIdAsync(toolId, id);
        if (reservation is null) return NotFound();
        return Ok(reservation);
    }

    [HttpPost]
    public async Task<ActionResult<Reservation>> Create(int toolId, CreateReservationRequest request)
    {
        var result = await _reservations.CreateAsync(toolId, request);
        return CreatedAtAction(nameof(GetById), new { toolId, id = result.Id }, result);
    }
}

Two route parameter types show up in that class for a reason. toolId is an int — Ridgeline has a small, fixed inventory of maybe forty tools, and {toolId:int} in the class-level route constrains it so a non-numeric segment 404s before the framework even tries to match an action. id, on the reservation itself, is a {id:guid} — reservation confirmations go out in emails and show up in a URL a member might screenshot, and a sequential integer there would mean anyone could walk the whole reservation history by incrementing a number in the address bar. Constraints aren't just validation; picking the right one is part of picking the right shape of ID for what the URL is exposed to.

Stacked constraints work the same way as a single one: {id:guid} only matches something that actually parses as a GUID; {toolId:int:min(1)} would add "and at least 1" on top of "and an integer," if Bench ever needed to rule out a zero or negative tool ID specifically.

Bench also has a small convenience endpoint that reads like it should collide with the one above:

[HttpGet("/api/tools/available")]
public async Task<ActionResult<IEnumerable<Tool>>> Available() =>
    Ok(await _tools.GetAvailableRightNowAsync());

api/tools/available and api/tools/{id:int} both look, at a glance, like they could both match a request for /api/tools/available. They don't conflict, and the reason is route precedence: a literal segment always outranks a constrained parameter, which outranks an unconstrained one. available matches the literal route outright; it never even gets offered to {id:int}, because "available" wouldn't parse as an int anyway. The two routes only look like they're competing — precedence means they never actually are.