HATEOAS and Hypermedia APIs — Deep Dive
The Richardson Maturity Model
Leonard Richardson defined four levels of REST maturity:
- Level 0: Single endpoint, RPC-style (SOAP)
- Level 1: Individual resources with distinct URLs
- Level 2: Proper HTTP methods (GET/POST/PUT/DELETE) and status codes
- Level 3: Hypermedia controls (HATEOAS)
Most “REST” APIs sit at Level 2. Level 3 is what Fielding actually described in his dissertation, but it’s rarely implemented because the tooling overhead has historically been high. Python’s modern frameworks make it significantly more practical.
Implementing HAL in FastAPI
HAL (Hypertext Application Language) is the lightest hypermedia format. Responses include _links for navigation and _embedded for nested resources:
from fastapi import FastAPI, Request
from pydantic import BaseModel
app = FastAPI()
class HalLink(BaseModel):
href: str
method: str = "GET"
title: str | None = None
class AccountResponse(BaseModel):
id: int
balance: float
status: str
_links: dict[str, HalLink]
class Config:
# Allow underscore-prefixed fields
fields = {"_links": {"alias": "_links"}}
def build_account_links(account, request: Request) -> dict[str, HalLink]:
base = str(request.base_url).rstrip("/")
links = {
"self": HalLink(href=f"{base}/accounts/{account.id}"),
"deposit": HalLink(
href=f"{base}/accounts/{account.id}/deposit",
method="POST",
title="Make a deposit"
),
}
# Conditional links based on business state
if account.balance > 0 and account.status == "active":
links["withdraw"] = HalLink(
href=f"{base}/accounts/{account.id}/withdraw",
method="POST",
title="Withdraw funds"
)
if account.status == "active":
links["transfer"] = HalLink(
href=f"{base}/accounts/{account.id}/transfer",
method="POST",
title="Transfer to another account"
)
links["close"] = HalLink(
href=f"{base}/accounts/{account.id}/close",
method="DELETE",
title="Close this account"
)
return links
The key pattern: links are computed at response time based on the resource’s current state. A frozen account doesn’t expose withdraw or transfer links. The client can render UI accordingly without encoding any business rules.
Embedded resources
HAL supports _embedded for including related resources inline, eliminating round-trip requests:
@app.get("/accounts/{account_id}")
async def get_account(account_id: int, request: Request, embed: str = None):
account = await fetch_account(account_id)
response = {
"id": account.id,
"balance": account.balance,
"status": account.status,
"_links": build_account_links(account, request),
}
if embed and "recent_transactions" in embed.split(","):
transactions = await fetch_recent_transactions(account_id, limit=5)
response["_embedded"] = {
"recent_transactions": [
{
"id": t.id,
"amount": t.amount,
"date": t.date.isoformat(),
"_links": {
"self": {"href": f"/transactions/{t.id}"}
}
}
for t in transactions
]
}
return response
The ?embed=recent_transactions query parameter lets clients opt into embedded data — keeping default responses lean while avoiding N+1 request patterns.
JSON:API implementation
JSON:API is more opinionated than HAL. It standardizes resource identification, relationships, sparse fieldsets, and error formats:
from fastapi import FastAPI, Query
def to_jsonapi_resource(user, include_relationships=True):
resource = {
"type": "users",
"id": str(user.id),
"attributes": {
"name": user.name,
"email": user.email,
"created_at": user.created_at.isoformat(),
},
"links": {
"self": f"/users/{user.id}"
}
}
if include_relationships:
resource["relationships"] = {
"orders": {
"links": {
"self": f"/users/{user.id}/relationships/orders",
"related": f"/users/{user.id}/orders"
}
}
}
return resource
@app.get("/users")
async def list_users(
page_number: int = Query(1, alias="page[number]"),
page_size: int = Query(20, alias="page[size]"),
fields_users: str = Query(None, alias="fields[users]"),
):
users, total = await fetch_users_paginated(page_number, page_size)
return {
"data": [to_jsonapi_resource(u) for u in users],
"meta": {"total": total, "page": page_number},
"links": {
"self": f"/users?page[number]={page_number}&page[size]={page_size}",
"first": f"/users?page[number]=1&page[size]={page_size}",
"last": f"/users?page[number]={-(-total // page_size)}&page[size]={page_size}",
"next": f"/users?page[number]={page_number + 1}&page[size]={page_size}"
if page_number * page_size < total else None,
"prev": f"/users?page[number]={page_number - 1}&page[size]={page_size}"
if page_number > 1 else None,
}
}
JSON:API’s sparse fieldsets (fields[users]=name,email) and compound documents (include=orders,orders.items) reduce over-fetching without building a custom GraphQL layer.
Link registry pattern
For large APIs, managing links across dozens of endpoints becomes unwieldy. A link registry centralizes link definitions:
from typing import Callable
class LinkRegistry:
def __init__(self):
self._generators: dict[str, Callable] = {}
def register(self, resource_type: str, rel: str):
def decorator(fn):
key = f"{resource_type}:{rel}"
self._generators[key] = fn
return fn
return decorator
def links_for(self, resource_type: str, resource, request) -> dict:
links = {}
prefix = f"{resource_type}:"
for key, generator in self._generators.items():
if key.startswith(prefix):
rel = key[len(prefix):]
link = generator(resource, request)
if link is not None: # None means "not available"
links[rel] = link
return links
registry = LinkRegistry()
@registry.register("account", "self")
def account_self(account, request):
return {"href": f"/accounts/{account.id}"}
@registry.register("account", "withdraw")
def account_withdraw(account, request):
if account.balance > 0 and account.status == "active":
return {"href": f"/accounts/{account.id}/withdraw", "method": "POST"}
return None # Link not available
This pattern keeps link logic testable and colocated with the business rules that control availability.
Client-side consumption
A well-built hypermedia client follows links rather than constructing URLs:
import httpx
class HypermediaClient:
def __init__(self, base_url: str):
self.client = httpx.Client(base_url=base_url)
self.root = self.client.get("/").json()
def follow(self, resource: dict, rel: str, **kwargs):
link = resource.get("_links", {}).get(rel)
if link is None:
raise ValueError(f"Link '{rel}' not available on this resource")
method = link.get("method", "GET").upper()
href = link["href"]
if method == "GET":
return self.client.get(href, params=kwargs).json()
elif method == "POST":
return self.client.post(href, json=kwargs).json()
elif method == "DELETE":
return self.client.delete(href).json()
# Usage
api = HypermediaClient("https://api.example.com")
accounts = api.follow(api.root, "accounts")
account = api.follow(accounts["_embedded"]["accounts"][0], "self")
if "withdraw" in account.get("_links", {}):
result = api.follow(account, "withdraw", amount=100.00)
The client never constructs a URL directly. If the server moves /accounts/{id}/withdraw to /v2/accounts/{id}/withdrawals, the client keeps working because it follows the link.
Caching considerations
HATEOAS responses are harder to cache because links may vary per user or account state. Strategies:
- Vary header:
Vary: Authorizationensures different users get different cached responses - Short TTLs: Cache for seconds rather than minutes since link availability changes with state
- Separate data from links: Cache the resource data and compute links at response time (a middleware layer can inject links before sending)
When not to use HATEOAS
HATEOAS adds response size (links can double payload size for small resources), implementation complexity, and cognitive overhead. Skip it when:
- Your API has a single consumer that deploys alongside the backend
- Resources are simple CRUD without conditional workflows
- Response size is critical (IoT devices, high-frequency trading)
- Your team doesn’t have time to build proper hypermedia clients
The PayPal API uses HATEOAS extensively because they have thousands of third-party integrations that can’t be updated simultaneously. A startup with one React frontend almost certainly doesn’t need it.
One thing to remember: HATEOAS trades response size and implementation complexity for radical evolvability — the server can change its URL structure without breaking a single client, which matters enormously at scale.
See Also
- Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
- Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
- Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
- Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
- Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.