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