Link Search Menu Expand Document

5. Introducing Hypermedia

This module rebuilds the Autobarn API around hypermedia. Every response now includes a _links object that points to related resources. There’s a discovery endpoint at the API root, and every collection is paginated with first, last, prev and next links.

With these links a client needs only one URL, the API root. It can find everything else by following links instead of building URLs itself. Module 06 builds a client that works this way.

What’s in this module

Autobarn.Website/Api/
├── AutobarnApiEndpointRouteBuilderExtensions.cs   (rewritten)
├── Endpoints.cs                                    (new)
├── HypermediaExtensions.cs                         (new)
└── Resources/
    └── Resources.cs                                (new)

Changes from Module 04

File Change
Autobarn.Website/Api/Resources/Resources.cs Added. Resource types for API responses, kept separate from the EF entities
Autobarn.Website/Api/Endpoints.cs Added. Constants for the endpoint names used to generate links
Autobarn.Website/Api/HypermediaExtensions.cs Added. Link generation, plus mapping from entity to resource
Autobarn.Website/Api/AutobarnApiEndpointRouteBuilderExtensions.cs Rewritten. Adds the discovery endpoint, pagination, new endpoints and validation

Step 1: Define resource types separate from the entities

File: Autobarn.Website/Api/Resources/Resources.cs (new)

using System.Text.Json.Serialization;

namespace Autobarn.Website.Api.Resources;

///<summary>A hypermedia link to a related resource.</summary>
public record Hyperlink(string Href);

///<summary>A set of hypermedia links, keyed by link relation, e.g. "self" or "next".</summary>
public class LinkList : Dictionary<string, Hyperlink>;

public abstract record Resource(
	[property: JsonPropertyName("_links"), JsonPropertyOrder(-1)]
	LinkList Links);

public record ApiRootResource(LinkList Links) : Resource(Links);

public record VehicleMakeResource(LinkList Links, string Code, string Name) : Resource(Links);

public record VehicleModelResource(LinkList Links, string Code, string Name) : Resource(Links);

public record VehicleResource(LinkList Links, string Registration, int Year, string Color) : Resource(Links);

public record ResourceList<T>(LinkList Links, int Index, int Count, int Total, IReadOnlyList<T> Items) : Resource(Links);

Why:

  • Decouple the API from the database. Module 04 serialised EF entities directly, so the JSON exposed navigation properties and followed any change to the schema. The API now controls exactly what goes over the wire. For example, VehicleResource has no Model or ModelCode, only a link to the model.
  • HAL-style _links. Every resource inherits Links from Resource. [JsonPropertyName("_links")] follows the convention from HAL (Hypertext Application Language). [JsonPropertyOrder(-1)] puts the links first in each JSON object, which makes responses easier to read.
  • Link relations as keys. LinkList is a dictionary keyed by relation name (self, next, models and so on). Clients look links up by what they mean, not by where they are.
  • ResourceList<T> wraps a page of results with the metadata a client needs: index, count, total, the items, and pagination links.
  • The records carry XML doc comments (<param name="...">), so the resource schemas are described in the OpenAPI document from Module 04.

Step 2: Name every endpoint

File: Autobarn.Website/Api/Endpoints.cs (new)

/// <summary>Names of the Autobarn API endpoints, used to generate hypermedia links.</summary>
public static class Endpoints {
	public const string GET_API_ROOT = nameof(GET_API_ROOT);
	public const string GET_VEHICLES = nameof(GET_VEHICLES);
	public const string GET_VEHICLE = nameof(GET_VEHICLE);
	public const string GET_MAKES = nameof(GET_MAKES);
	public const string GET_MAKE = nameof(GET_MAKE);
	public const string GET_MODELS_BY_MAKE = nameof(GET_MODELS_BY_MAKE);
	public const string GET_MODEL = nameof(GET_MODEL);
	public const string GET_VEHICLES_BY_MODEL = nameof(GET_VEHICLES_BY_MODEL);
}

Why: ASP.NET Core’s LinkGenerator can build a URL for any endpoint registered with .WithName(...). The name has to match in two places: where the endpoint is registered and where a link to it is generated. Constants turn a typo into a compile error. (Module 04 used string literals like "GetVehicles".)

File: Autobarn.Website/Api/HypermediaExtensions.cs (new)

public static class HypermediaExtensions {

	/// <summary>Creates a link to a named API endpoint, honouring the request's PathBase.</summary>
	public static Hyperlink LinkTo(this LinkGenerator links, HttpContext http, string endpointName, object? values = null)
		=> new(links.GetPathByName(http, endpointName, values)
			?? throw new InvalidOperationException($"Could not create a link to endpoint '{endpointName}'"));

	public static VehicleMakeResource ToResource(this VehicleMake make, LinkGenerator links, HttpContext http)
		=> new(
			Links: new() {
				["self"] = links.LinkTo(http, Endpoints.GET_MAKE, new { make = make.Code }),
				["models"] = links.LinkTo(http, Endpoints.GET_MODELS_BY_MAKE, new { make = make.Code })
			},
			Code: make.Code,
			Name: make.Name
		);

	public static VehicleModelResource ToResource(this VehicleModel model, LinkGenerator links, HttpContext http)
		=> new(
			Links: new() {
				["self"] = links.LinkTo(http, Endpoints.GET_MODEL, new { make = model.MakeCode, model = model.ModelCode }),
				["make"] = links.LinkTo(http, Endpoints.GET_MAKE, new { make = model.MakeCode }),
				["vehicles"] = links.LinkTo(http, Endpoints.GET_VEHICLES_BY_MODEL, new { make = model.MakeCode, model = model.ModelCode })
			},
			Code: model.Code,
			Name: model.Name
		);

	public static VehicleResource ToResource(this Vehicle vehicle, LinkGenerator links, HttpContext http)
		=> new(
			Links: new() {
				["self"] = links.LinkTo(http, Endpoints.GET_VEHICLE, new { registration = vehicle.Registration }),
				["model"] = links.LinkTo(http, Endpoints.GET_MODEL, new { make = vehicle.Model.MakeCode, model = vehicle.Model.ModelCode }),
				["make"] = links.LinkTo(http, Endpoints.GET_MAKE, new { make = vehicle.Model.MakeCode })
			},
			Registration: vehicle.Registration,
			Year: vehicle.Year,
			Color: vehicle.Color
		);
}

Why:

  • Never hand-build URLs. LinkGenerator.GetPathByName uses the routing table, so links stay correct if a route template or the /api prefix changes. It also respects the request’s PathBase, so links still work when the app is hosted in a virtual directory or behind a reverse proxy.
  • Relative links. GetPathByName returns a path such as /api/makes/nissan, not an absolute URL. Clients resolve it against the URL they called.
  • VehicleModel.ModelCode in use. Model URLs use the short code, so nissan-note becomes /api/makes/nissan/models/note. The computed ModelCode property from Module 01 is what makes this possible.
  • Vehicles need their model loaded. A vehicle’s model and make links read vehicle.Model.MakeCode. Every query that returns vehicles therefore has to .Include(v => v.Model), as shown below.

Step 4: Add a pagination helper

File: Autobarn.Website/Api/AutobarnApiEndpointRouteBuilderExtensions.cs

private const int DEFAULT_COUNT = 10;
private const int MAX_COUNT = 100;

private static LinkList Paginate(LinkGenerator links, HttpContext http, string endpointName, object? routeValues,
	int index, int count, int total) {
	ArgumentOutOfRangeException.ThrowIfNegative(index);
	ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
	var result = new LinkList {
		["self"] = Page(index),
		["first"] = Page(0),
		["last"] = Page(Math.Max(0, (total - 1) / count * count))
	};
	if (index + count < total) result["next"] = Page(index + count);
	if (index > 0) result["prev"] = Page(Math.Max(0, index - count));
	return result;

	Hyperlink Page(int pageIndex) => links.LinkTo(http, endpointName,
		new RouteValueDictionary(routeValues) { ["index"] = pageIndex, ["count"] = count });
}
  • Paging uses offset and page size (index, count) passed as query-string parameters.
  • self, first and last are always present. next appears only when there are more items, and prev only when you aren’t on the first page. A client knows it has reached the end when there’s no next link. It never needs to calculate page numbers.
  • last rounds down to the start of the final page: (total - 1) / count * count.
  • Page is a local function, so it captures links, http, endpointName, routeValues and count.
  • new RouteValueDictionary(routeValues) copies route values such as { make } and adds index and count. Values that don’t match a route segment become query-string parameters.
  • The class was also renamed from AutobarnApiRouteBuilderExtensions to AutobarnApiEndpointRouteBuilderExtensions, to match the file name.

Step 5: The discovery endpoint

api.MapGet("/",
	(LinkGenerator links, HttpContext http) => TypedResults.Ok(new ApiRootResource(new() {
		["self"] = links.LinkTo(http, Endpoints.GET_API_ROOT),
		["vehicles"] = links.LinkTo(http, Endpoints.GET_VEHICLES),
		["makes"] = links.LinkTo(http, Endpoints.GET_MAKES)
	})))
	.WithName(Endpoints.GET_API_ROOT)
	.WithSummary("Autobarn API Discovery Endpoint")
	.WithDescription("Returns a list of links to the other endpoints in the Autobarn API.");

GET /api is now the one well-known entry point. It returns no data, only links to the top-level collections:

{"_links":{"self":{"href":"/api"},"vehicles":{"href":"/api/vehicles"},"makes":{"href":"/api/makes"}}}

Step 6: Paginate the collection endpoints

Before (Module 04):

api.MapGet("/makes",
	async (AutobarnDbContext db) => await db.Makes.ToListAsync())
	.WithName("GetMakes")
	// ...

After (Module 05):

api.MapGet("/makes",
	async Task<Ok<ResourceList<VehicleMakeResource>>> (AutobarnDbContext db, LinkGenerator links, HttpContext http,
		[Range(0, int.MaxValue)] int index = 0,
		[Range(1, MAX_COUNT)] int count = DEFAULT_COUNT) => {
		var total = await db.Makes.CountAsync(http.RequestAborted);
		var makes = await db.Makes.AsNoTracking()
			.OrderBy(m => m.Code)
			.Skip(index).Take(count)
			.ToListAsync(http.RequestAborted);
		var items = makes.Select(m => m.ToResource(links, http)).ToList();
		var pageLinks = Paginate(links, http, Endpoints.GET_MAKES, null, index, count, total);
		return TypedResults.Ok(new ResourceList<VehicleMakeResource>(pageLinks, index, count, total, items));
	})
	.WithName(Endpoints.GET_MAKES)
	.WithSummary("List vehicle makes")
	.WithDescription("Returns a page of the manufacturers whose vehicles appear in the Autobarn catalogue.")
	.ProducesValidationProblem();

/vehicles follows the same pattern, with .Include(v => v.Model) and .OrderBy(v => v.Registration).

Why each part is there:

Code Reason
int index = 0, int count = DEFAULT_COUNT Optional query-string parameters with sensible defaults
[Range(0, int.MaxValue)], [Range(1, MAX_COUNT)] Rejects negative offsets and very large pages. builder.Services.AddValidation() (in Program.cs since Module 01) enforces DataAnnotations on minimal API parameters and returns a 400 validation problem
.ProducesValidationProblem() Documents that 400 response in OpenAPI
CountAsync Gets total, which the last link needs
.OrderBy(...) Skip and Take without an order give unpredictable pages. Sorting on the key makes paging stable
.AsNoTracking() These are read-only queries, so EF Core doesn’t need change tracking
http.RequestAborted If the client disconnects, the database query is cancelled
.Select(m => m.ToResource(...)) after ToListAsync The mapping calls LinkGenerator, which EF Core can’t translate to SQL, so it runs in memory on the page of results

A paginated response (GET /api/makes?count=2) looks like this:

{
  "_links": {
    "self":  { "href": "/api/makes?index=0&count=2" },
    "first": { "href": "/api/makes?index=0&count=2" },
    "last":  { "href": "/api/makes?index=88&count=2" },
    "next":  { "href": "/api/makes?index=2&count=2" }
  },
  "index": 0, "count": 2, "total": 90,
  "items": [
    { "_links": { "self": { "href": "/api/makes/abarth" }, "models": { "href": "/api/makes/abarth/models" } },
      "code": "abarth", "name": "ABARTH" },
    { "_links": { "self": { "href": "/api/makes/aixam" }, "models": { "href": "/api/makes/aixam/models" } },
      "code": "aixam", "name": "AIXAM" }
  ]
}

A request outside the allowed range (GET /api/makes?count=500) returns:

HTTP/1.1 400 Bad Request
{"title":"One or more validation errors occurred.","errors":{"count":["The field count must be between 1 and 100."]}}

A link is no use if nothing is at the other end. Module 04 had no endpoint for a single make or model, so this module adds them, and renames the route parameter {code} to {make} for consistency.

Method URL Name New?
GET /api GET_API_ROOT ✅ new
GET /api/vehicles?index&count GET_VEHICLES paginated
GET /api/vehicles/{registration} GET_VEHICLE returns VehicleResource
GET /api/makes?index&count GET_MAKES paginated
GET /api/makes/{make} GET_MAKE ✅ new
GET /api/makes/{make}/models?index&count GET_MODELS_BY_MAKE paginated, {code} renamed to {make}
GET /api/makes/{make}/models/{model} GET_MODEL ✅ new
GET /api/makes/{make}/models/{model}/vehicles?index&count GET_VEHICLES_BY_MODEL ✅ new

Here’s the new single-model endpoint:

api.MapGet("/makes/{make}/models/{model}",
	async Task<Results<Ok<VehicleModelResource>, NotFound>> (AutobarnDbContext db, LinkGenerator links, HttpContext http, string make, string model)
		=> await db.Models.AsNoTracking()
			.FirstOrDefaultAsync(m => m.MakeCode == make && m.Code == $"{make}-{model}", http.RequestAborted) is { } result
			? TypedResults.Ok(result.ToResource(links, http))
			: TypedResults.NotFound())
	.WithName(Endpoints.GET_MODEL)
	.WithSummary("Find a vehicle model")
	.WithDescription("Returns the model with the given code built by the manufacturer with the given code, or 404 if no such model exists.");

The full model code is rebuilt from the two URL segments ($"{make}-{model}"). The query also checks MakeCode == make, so /makes/ford/models/note doesn’t match a Nissan model by mistake.

The nested collection endpoints check that the parent exists first. That keeps the Module 04 distinction between “not found” and “empty”:

api.MapGet("/makes/{make}/models/{model}/vehicles",
	async Task<Results<Ok<ResourceList<VehicleResource>>, NotFound>> (AutobarnDbContext db, LinkGenerator links, HttpContext http,
		string make, string model,
		[Range(0, int.MaxValue)] int index = 0,
		[Range(1, MAX_COUNT)] int count = DEFAULT_COUNT) => {
		var modelCode = $"{make}-{model}";
		if (!await db.Models.AnyAsync(m => m.MakeCode == make && m.Code == modelCode, http.RequestAborted)) return TypedResults.NotFound();
		var query = db.Vehicles.AsNoTracking().Where(v => v.ModelCode == modelCode);
		var total = await query.CountAsync(http.RequestAborted);
		var vehicles = await query
			.Include(v => v.Model)
			.OrderBy(v => v.Registration)
			.Skip(index).Take(count)
			.ToListAsync(http.RequestAborted);
		var items = vehicles.Select(v => v.ToResource(links, http)).ToList();
		var pageLinks = Paginate(links, http, Endpoints.GET_VEHICLES_BY_MODEL, new { make, model }, index, count, total);
		return TypedResults.Ok(new ResourceList<VehicleResource>(pageLinks, index, count, total, items));
	})
	// ...

Paginate receives new { make, model } as its route values, so the next and prev links keep pointing at the same model’s vehicles.

The imports changed as well: using Autobarn.Data.Entities; was replaced by using Autobarn.Website.Api.Resources; and using System.ComponentModel.DataAnnotations; (for [Range]).

Here’s how a client walks from the API root down to individual vehicles:

GET /api                                     → _links.makes
GET /api/makes                               → items[n]._links.models   (and _links.next for more makes)
GET /api/makes/nissan/models                 → items[n]._links.vehicles (and _links.next for more models)
GET /api/makes/nissan/models/note/vehicles   → items[n]._links.self
GET /api/vehicles/AA07AMM                    → _links.model, _links.make

What’s next

With a navigable, paginated API in place, Module 06 builds a console client that uses it by following links alone.