ASP.NET Core Web API · Building APIs

Routing

An API’s URLs are its public contract — once mobile apps use them, they are hard to change. Attribute routing lets you design them precisely: /api/categories/3/products, /api/products/by-sku/MOUSE-1, and links that are generated, never hand-written.

Beginner 6 min Lesson 6 of 37Examples target .NET 9.
Before you start:Controllers and Endpoints
0/37 in ASP.NET Core Web API
On this page · 6 min · Beginner

In simple words#

Routes are the counter numbers: api/products/{id} sends the request to the right window with the product number attached.

[Route("api/[controller]")]
Base route from the controller name.
{id:int}
Route parameter with a constraint.
Minimal APIs
app.MapGet("/path", handler).

Learning objectives#

  • Define routes with [Route] and HTTP method templates
  • Use tokens, constraints and optional parameters
  • Model nested resources
  • Generate URLs with named routes and LinkGenerator

Explanation#

API controllers use attribute routing: [Route("api/[controller]")] on the class and [HttpGet("{id:int}")] on actions combine into /api/products/{id}. [controller] and [action] tokens are replaced by names.

Constraints restrict matches: {id:int}, {sku:regex(^[A-Z]+-\d+$)}, {page:min(1)}, {slug:length(3,60)}. A failed constraint means the route does not match (404), so bad ids never reach your code.

Nested resources reflect ownership: [Route("api/categories/{categoryId:int}/products")]. Keep nesting to one level.

Give routes names ([HttpGet("{id:int}", Name = "GetProduct")]) and use CreatedAtRoute or LinkGenerator.GetPathByName to build URLs — they stay correct when templates change.

Minimal APIs use the same templates and constraints: app.MapGroup("/api/categories/{categoryId:int}/products").MapGet("/", …).

Syntax#

C# Illustrative
[Route("api/categories/{categoryId:int}/products")]
public class CategoryProductsController : ControllerBase
{
    [HttpGet] …                                           // GET /api/categories/3/products
    [HttpGet("{id:int}", Name = "GetCategoryProduct")] …  // GET /api/categories/3/products/7
}

Code example: Constraints, nested resources and generated links#

C#Controllers/ProductsController.cs Illustrative
// Program.cs
using System.Net.Http.Json;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                   // keep the demo output clean
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5206");
await app.StartAsync();


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5206") };
string[] paths = ["/api/products/2", "/api/products/abc", "/api/products/by-sku/HUB-2", "/api/products/by-sku/hub",
                  "/api/categories/1/products", "/api/categories/1/products/2", "/api/categories/2/products/2"];
foreach (var path in paths)
{
    var response = await http.GetAsync(path);
    Console.WriteLine(
quot;GET
{path,-30} {(int)response.StatusCode} {(response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync() : "")}".TrimEnd());
} var created = await http.PostAsJsonAsync("/api/categories/1/products", new { name = "Mouse Pad" }); Console.WriteLine(
quot;POST
{"/api/categories/1/products",-30} {(int)created.StatusCode} Location: {created.Headers.Location}");
await app.StopAsync(); // Controllers/Catalog.cs public record ProductDto(int Id, int CategoryId, string Sku, string Name); public static class Catalog { public static readonly List<ProductDto> Products = [new(1, 1, "MOUSE-1", "Wireless Mouse"), new(2, 1, "HUB-2", "USB-C Hub"), new(3, 2, "SPK-3", "Bluetooth Speaker")]; } // Controllers/ProductsController.cs using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { [HttpGet("{id:int}")] // /api/products/2 (but not /abc) public ActionResult<ProductDto> Get(int id) => Catalog.Products.FirstOrDefault(p => p.Id == id) is { } p ? p : NotFound(); [HttpGet("by-sku/{sku:regex(^[[A-Z]]+-\\d+$)}")] // /api/products/by-sku/HUB-2 public ActionResult<ProductDto> GetBySku(string sku) => Catalog.Products.FirstOrDefault(p => p.Sku == sku) is { } p ? p : NotFound(); } // Controllers/CategoryProductsController.cs using Microsoft.AspNetCore.Mvc; public record NewProductDto(string Name); [ApiController] [Route("api/categories/{categoryId:int}/products")] // nested resource public class CategoryProductsController : ControllerBase { [HttpGet] public IEnumerable<string> GetAll(int categoryId) => Catalog.Products.Where(p => p.CategoryId == categoryId).Select(p => p.Name); [HttpGet("{id:int}", Name = "GetCategoryProduct")] public ActionResult<ProductDto> Get(int categoryId, int id) => Catalog.Products.FirstOrDefault(p => p.Id == id && p.CategoryId == categoryId) is { } p ? p : NotFound(); [HttpPost] public IActionResult Create(int categoryId, NewProductDto dto) { var product = new ProductDto(Catalog.Products.Max(p => p.Id) + 1, categoryId,
quot;NEW-
{Catalog.Products.Count + 1}", dto.Name);
Catalog.Products.Add(product); return CreatedAtRoute("GetCategoryProduct", new { categoryId, id = product.Id }, product); } }
Expected output
GET  /api/products/2                200 {"id":2,"categoryId":1,"sku":"HUB-2","name":"USB-C Hub"}
GET  /api/products/abc              404
GET  /api/products/by-sku/HUB-2     200 {"id":2,"categoryId":1,"sku":"HUB-2","name":"USB-C Hub"}
GET  /api/products/by-sku/hub       404
GET  /api/categories/1/products     200 ["Wireless Mouse","USB-C Hub"]
GET  /api/categories/1/products/2   200 {"id":2,"categoryId":1,"sku":"HUB-2","name":"USB-C Hub"}
GET  /api/categories/2/products/2   404
POST /api/categories/1/products     201 Location: http://127.0.0.1:5206/api/categories/1/products/4

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. {id:int} matches /api/products/2 but not /abc, which returns 404 before any code runs.
  2. The regex constraint only accepts SKUs like HUB-2; in route templates, [ and ] must be doubled ([[A-Z]]) and the backslash escaped.
  3. The nested controller reads categoryId from the URL for every action.
  4. Product 2 belongs to category 1, so /api/categories/2/products/2 is a 404 — nested lookups must check the parent too.
  5. CreatedAtRoute uses the route name to build the Location header for the new product.

Real-world use case#

ShopSmart’s public URLs follow a reviewed design: /api/products/{id}, /api/products/by-sku/{sku}, /api/categories/{id}/products and /api/orders/{id}/lines. Every Location header and HATEOAS-style link is generated from route names.

Common mistakes#

Regex constraints without escaping

In route templates, [ ] { } are special. Write [[A-Z]] for a character class and escape backslashes.

Not checking the parent in nested routes

Finding product 2 by id alone makes /api/categories/9/products/2 return it — misleading at best, a data leak at worst.

Constraints as validation

Constraints decide matching (404). Real validation (ranges, business rules) belongs in DTO validation (400/422).

Best practices#

  • Design URLs before coding
  • Type constraints on every id
  • Named routes + CreatedAtRoute
  • One level of nesting

Remember this#

[Route("api/[controller]")] + [HttpGet("{id:int}")].

Constraint fails → 404 (route does not match).

Name routes; generate URLs.

Practice exercise#

Your task: Add GET /api/products/search/{term:minlength(2)} returning product names containing the term (case-insensitive).

Need a hint?
[HttpGet("search/{term:minlength(2)}")] and Contains(term, StringComparison.OrdinalIgnoreCase).
Show solution
C# Illustrative
// Controllers/SearchController.cs
using Microsoft.AspNetCore.Mvc;


[ApiController]
[Route("api/products")]
public class SearchController : ControllerBase
{
    private static readonly string[] Names = ["Wireless Mouse", "Mouse Pad", "USB-C Hub"];


    [HttpGet("search/{term:minlength(2)}")]
    public IEnumerable<string> Search(string term) =>
        Names.Where(n => n.Contains(term, StringComparison.OrdinalIgnoreCase));
}

Mini quiz#

Routing quizMultiple choice Question 1 of 3

What happens when a request to /api/products/abc hits [HttpGet("{id:int}")]?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.