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.

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: Authorization ensures 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.

pythonwebapisresthateoasfastapi

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.