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,
VehicleResourcehas noModelorModelCode, only a link to the model. - HAL-style
_links. Every resource inheritsLinksfromResource.[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.
LinkListis a dictionary keyed by relation name (self,next,modelsand 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, theitems, 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".)
Step 3: Generate links and map entities to resources
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.GetPathByNameuses the routing table, so links stay correct if a route template or the/apiprefix changes. It also respects the request’sPathBase, so links still work when the app is hosted in a virtual directory or behind a reverse proxy. - Relative links.
GetPathByNamereturns a path such as/api/makes/nissan, not an absolute URL. Clients resolve it against the URL they called. VehicleModel.ModelCodein use. Model URLs use the short code, sonissan-notebecomes/api/makes/nissan/models/note. The computedModelCodeproperty from Module 01 is what makes this possible.- Vehicles need their model loaded. A vehicle’s
modelandmakelinks readvehicle.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,firstandlastare always present.nextappears only when there are more items, andprevonly when you aren’t on the first page. A client knows it has reached the end when there’s nonextlink. It never needs to calculate page numbers.lastrounds down to the start of the final page:(total - 1) / count * count.Pageis a local function, so it captureslinks,http,endpointName,routeValuesandcount.new RouteValueDictionary(routeValues)copies route values such as{ make }and addsindexandcount. Values that don’t match a route segment become query-string parameters.- The class was also renamed from
AutobarnApiRouteBuilderExtensionstoAutobarnApiEndpointRouteBuilderExtensions, 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."]}}
Step 7: Add endpoints for every resource the links point to
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]).
Following the links
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.