Richardson Maturity Model and HATEOAS - Building Truly RESTful APIs with FastAPI

Most developers claim their APIs are “RESTful,” but are they really? Leonard Richardson introduced a maturity model that classifies APIs into four levels, from Level 0 (basically RPC over HTTP) to Level 3 (true REST with hypermedia). In this article, we’ll explore each level and implement them in FastAPI.

What is the Richardson Maturity Model?

The Richardson Maturity Model, popularized by Martin Fowler, provides a way to grade your API according to how well it adheres to REST principles. Think of it as a ladder toward REST enlightenment.

Level 3: Hypermedia Controls (HATEOAS)
Level 2: HTTP Verbs
Level 1: Resources
Level 0: The Swamp of POX

Let’s explore each level with a practical example: a doctor appointment booking system.


Level 0: The Swamp of POX (Plain Old XML/JSON)

At Level 0, HTTP is merely a transport mechanism. Everything goes to a single endpoint, and the operation is specified in the request body.

Characteristics

  • Single endpoint for all operations
  • Operation type embedded in request body
  • HTTP used only as a tunnel
  • Essentially RPC over HTTP

FastAPI Example

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Literal
from datetime import datetime

app = FastAPI()

class AppointmentRequest(BaseModel):
action: Literal["get_slots", "book_appointment", "cancel_appointment"]
doctor_id: str | None = None
date: str | None = None
slot_id: str | None = None
patient_name: str | None = None

class AppointmentResponse(BaseModel):
success: bool
message: str
data: dict | list | None = None

# Single endpoint handling everything
@app.post("/appointmentService")
def appointment_service(request: AppointmentRequest) -> AppointmentResponse:
if request.action == "get_slots":
# Get available slots for a doctor on a date
return AppointmentResponse(
success=True,
message="Slots retrieved",
data=[
{"slot_id": "slot_1", "time": "09:00", "available": True},
{"slot_id": "slot_2", "time": "10:00", "available": False},
{"slot_id": "slot_3", "time": "11:00", "available": True},
]
)

elif request.action == "book_appointment":
# Book an appointment
return AppointmentResponse(
success=True,
message="Appointment booked",
data={"confirmation_id": "APT_12345"}
)

elif request.action == "cancel_appointment":
# Cancel an appointment
return AppointmentResponse(
success=True,
message="Appointment cancelled",
data=None
)

return AppointmentResponse(
success=False,
message="Unknown action",
data=None
)

Usage

# Get slots
curl -X POST "http://localhost:8000/appointmentService" \
-H "Content-Type: application/json" \
-d '{"action": "get_slots", "doctor_id": "dr_jones", "date": "2026-01-15"}'

# Book appointment
curl -X POST "http://localhost:8000/appointmentService" \
-H "Content-Type: application/json" \
-d '{"action": "book_appointment", "slot_id": "slot_1", "patient_name": "John Doe"}'

Problems with Level 0

  • No use of HTTP semantics
  • Everything looks the same to intermediaries (caching impossible)
  • Error handling is custom and inconsistent
  • No discoverability

Level 1: Resources

Level 1 introduces the concept of resources. Instead of one endpoint, we have multiple endpoints representing different entities.

Characteristics

  • Multiple endpoints (resources)
  • Each resource has its own URI
  • Still primarily uses POST for everything
  • Resources are nouns, not verbs

FastAPI Example

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class BookingRequest(BaseModel):
patient_name: str

class SlotResponse(BaseModel):
slot_id: str
time: str
available: bool

class BookingResponse(BaseModel):
confirmation_id: str
slot_id: str
patient_name: str

# Resource: Doctors
@app.post("/doctors/{doctor_id}/slots")
def get_doctor_slots(doctor_id: str, date: str) -> list[SlotResponse]:
"""Get available slots for a specific doctor."""
return [
SlotResponse(slot_id="slot_1", time="09:00", available=True),
SlotResponse(slot_id="slot_2", time="10:00", available=False),
SlotResponse(slot_id="slot_3", time="11:00", available=True),
]

# Resource: Slots
@app.post("/slots/{slot_id}")
def book_slot(slot_id: str, booking: BookingRequest) -> BookingResponse:
"""Book a specific slot."""
return BookingResponse(
confirmation_id="APT_12345",
slot_id=slot_id,
patient_name=booking.patient_name
)

# Resource: Appointments
@app.post("/appointments/{appointment_id}/cancel")
def cancel_appointment(appointment_id: str) -> dict:
"""Cancel an appointment."""
return {"message": "Appointment cancelled", "appointment_id": appointment_id}

Improvement Over Level 0

  • Resources are identifiable by URI
  • /doctors/dr_jones is clearly different from /slots/slot_1
  • Better organization and clarity

Remaining Issues

  • Still using POST for read operations
  • HTTP verbs not utilized properly
  • Caching still difficult

Level 2: HTTP Verbs

Level 2 properly leverages HTTP methods (GET, POST, PUT, PATCH, DELETE) and status codes.

Characteristics

  • GET for safe, read-only operations
  • POST for creating resources
  • PUT/PATCH for updating resources
  • DELETE for removing resources
  • Proper HTTP status codes (201, 404, 409, etc.)
  • Enables caching for GET requests

FastAPI Example

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

# In-memory storage for demo
slots_db: dict[str, dict] = {
"slot_1": {"time": "09:00", "doctor_id": "dr_jones", "available": True, "patient": None},
"slot_2": {"time": "10:00", "doctor_id": "dr_jones", "available": False, "patient": "Jane Doe"},
"slot_3": {"time": "11:00", "doctor_id": "dr_jones", "available": True, "patient": None},
}

appointments_db: dict[str, dict] = {}

class SlotResponse(BaseModel):
slot_id: str
time: str
doctor_id: str
available: bool

class BookingRequest(BaseModel):
patient_name: str

class AppointmentResponse(BaseModel):
appointment_id: str
slot_id: str
patient_name: str
time: str
doctor_id: str

# GET - Safe, idempotent, cacheable
@app.get("/doctors/{doctor_id}/slots", response_model=list[SlotResponse])
def get_doctor_slots(doctor_id: str, date: str | None = None):
"""Get available slots for a doctor. Safe operation, can be cached."""
return [
SlotResponse(slot_id=sid, **{k: v for k, v in slot.items() if k != "patient"})
for sid, slot in slots_db.items()
if slot["doctor_id"] == doctor_id
]

@app.get("/slots/{slot_id}", response_model=SlotResponse)
def get_slot(slot_id: str):
"""Get a specific slot. Returns 404 if not found."""
if slot_id not in slots_db:
raise HTTPException(status_code=404, detail="Slot not found")
slot = slots_db[slot_id]
return SlotResponse(slot_id=slot_id, **{k: v for k, v in slot.items() if k != "patient"})

# POST - Create new resource, returns 201
@app.post("/slots/{slot_id}/book", response_model=AppointmentResponse, status_code=status.HTTP_201_CREATED)
def book_slot(slot_id: str, booking: BookingRequest):
"""Book a slot. Returns 201 on success, 409 if already booked."""
if slot_id not in slots_db:
raise HTTPException(status_code=404, detail="Slot not found")

slot = slots_db[slot_id]
if not slot["available"]:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Slot is not available"
)

# Book the slot
appointment_id = f"APT_{datetime.now().strftime('%Y%m%d%H%M%S')}"
slots_db[slot_id]["available"] = False
slots_db[slot_id]["patient"] = booking.patient_name

appointments_db[appointment_id] = {
"slot_id": slot_id,
"patient_name": booking.patient_name,
"time": slot["time"],
"doctor_id": slot["doctor_id"]
}

return AppointmentResponse(
appointment_id=appointment_id,
**appointments_db[appointment_id]
)

# GET appointment
@app.get("/appointments/{appointment_id}", response_model=AppointmentResponse)
def get_appointment(appointment_id: str):
"""Get appointment details."""
if appointment_id not in appointments_db:
raise HTTPException(status_code=404, detail="Appointment not found")
return AppointmentResponse(appointment_id=appointment_id, **appointments_db[appointment_id])

# DELETE - Remove resource
@app.delete("/appointments/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_appointment(appointment_id: str):
"""Cancel an appointment. Returns 204 on success."""
if appointment_id not in appointments_db:
raise HTTPException(status_code=404, detail="Appointment not found")

# Free up the slot
apt = appointments_db[appointment_id]
slots_db[apt["slot_id"]]["available"] = True
slots_db[apt["slot_id"]]["patient"] = None

del appointments_db[appointment_id]
return None # 204 No Content

HTTP Methods and Their Semantics

Method Safe Idempotent Cacheable Use Case
GET Yes Yes Yes Retrieve resources
POST No No No Create resources
PUT No Yes No Replace resources
PATCH No No No Partial update
DELETE No Yes No Remove resources

Status Codes Matter

# Good: Semantic status codes
@app.post("/users/", status_code=201) # Created
@app.get("/users/{id}") # 200 OK or 404 Not Found
@app.delete("/users/{id}", status_code=204) # No Content
@app.post("/slots/{id}/book") # 409 Conflict if already booked

# Bad: Everything returns 200
@app.post("/users/")
def create_user(user: User):
return {"status": "error", "message": "User already exists"} # Still 200!

Level 3: Hypermedia Controls (HATEOAS)

HATEOAS (Hypermedia As The Engine Of Application State) is the pinnacle of REST. Responses include links that tell clients what actions are possible next.

Characteristics

  • Self-documenting API responses
  • Clients discover actions dynamically
  • Server can change URLs without breaking clients
  • API behaves like a state machine

FastAPI Example

from fastapi import FastAPI, HTTPException, Request, status
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

# Storage
slots_db: dict[str, dict] = {
"slot_1": {"time": "09:00", "doctor_id": "dr_jones", "available": True, "patient": None},
"slot_2": {"time": "10:00", "doctor_id": "dr_jones", "available": False, "patient": "Jane Doe"},
"slot_3": {"time": "11:00", "doctor_id": "dr_jones", "available": True, "patient": None},
}

appointments_db: dict[str, dict] = {}

# Link model for HATEOAS
class Link(BaseModel):
rel: str # Relationship type
href: str # URL
method: str # HTTP method

class SlotResponse(BaseModel):
slot_id: str
time: str
doctor_id: str
available: bool
links: list[Link] # HATEOAS links

class AppointmentResponse(BaseModel):
appointment_id: str
slot_id: str
patient_name: str
time: str
doctor_id: str
links: list[Link] # HATEOAS links

class DoctorSlotsResponse(BaseModel):
doctor_id: str
slots: list[SlotResponse]
links: list[Link]

def build_url(request: Request, path: str) -> str:
"""Build absolute URL from request."""
return str(request.base_url).rstrip("/") + path

@app.get("/doctors/{doctor_id}/slots", response_model=DoctorSlotsResponse)
def get_doctor_slots(doctor_id: str, request: Request):
"""Get slots with hypermedia links."""
slots = []
for slot_id, slot in slots_db.items():
if slot["doctor_id"] != doctor_id:
continue

# Build links based on slot state
links = [
Link(rel="self", href=build_url(request, f"/slots/{slot_id}"), method="GET")
]

if slot["available"]:
# Only show booking link if slot is available
links.append(
Link(rel="book", href=build_url(request, f"/slots/{slot_id}/book"), method="POST")
)

slots.append(SlotResponse(
slot_id=slot_id,
time=slot["time"],
doctor_id=slot["doctor_id"],
available=slot["available"],
links=links
))

return DoctorSlotsResponse(
doctor_id=doctor_id,
slots=slots,
links=[
Link(rel="self", href=build_url(request, f"/doctors/{doctor_id}/slots"), method="GET"),
Link(rel="doctor", href=build_url(request, f"/doctors/{doctor_id}"), method="GET"),
]
)

@app.get("/slots/{slot_id}", response_model=SlotResponse)
def get_slot(slot_id: str, request: Request):
"""Get a slot with context-aware links."""
if slot_id not in slots_db:
raise HTTPException(status_code=404, detail="Slot not found")

slot = slots_db[slot_id]
links = [
Link(rel="self", href=build_url(request, f"/slots/{slot_id}"), method="GET"),
Link(rel="doctor_slots", href=build_url(request, f"/doctors/{slot['doctor_id']}/slots"), method="GET"),
]

if slot["available"]:
links.append(Link(rel="book", href=build_url(request, f"/slots/{slot_id}/book"), method="POST"))

return SlotResponse(
slot_id=slot_id,
time=slot["time"],
doctor_id=slot["doctor_id"],
available=slot["available"],
links=links
)

@app.post("/slots/{slot_id}/book", response_model=AppointmentResponse, status_code=201)
def book_slot(slot_id: str, patient_name: str, request: Request):
"""Book a slot, return appointment with available actions."""
if slot_id not in slots_db:
raise HTTPException(status_code=404, detail="Slot not found")

slot = slots_db[slot_id]
if not slot["available"]:
raise HTTPException(status_code=409, detail="Slot not available")

appointment_id = f"APT_{datetime.now().strftime('%Y%m%d%H%M%S')}"
slots_db[slot_id]["available"] = False
slots_db[slot_id]["patient"] = patient_name

appointments_db[appointment_id] = {
"slot_id": slot_id,
"patient_name": patient_name,
"time": slot["time"],
"doctor_id": slot["doctor_id"]
}

# Return appointment with available actions
return AppointmentResponse(
appointment_id=appointment_id,
slot_id=slot_id,
patient_name=patient_name,
time=slot["time"],
doctor_id=slot["doctor_id"],
links=[
Link(rel="self", href=build_url(request, f"/appointments/{appointment_id}"), method="GET"),
Link(rel="cancel", href=build_url(request, f"/appointments/{appointment_id}"), method="DELETE"),
Link(rel="reschedule", href=build_url(request, f"/appointments/{appointment_id}/reschedule"), method="PUT"),
Link(rel="add_tests", href=build_url(request, f"/appointments/{appointment_id}/tests"), method="POST"),
]
)

@app.get("/appointments/{appointment_id}", response_model=AppointmentResponse)
def get_appointment(appointment_id: str, request: Request):
"""Get appointment with available actions."""
if appointment_id not in appointments_db:
raise HTTPException(status_code=404, detail="Appointment not found")

apt = appointments_db[appointment_id]
return AppointmentResponse(
appointment_id=appointment_id,
**apt,
links=[
Link(rel="self", href=build_url(request, f"/appointments/{appointment_id}"), method="GET"),
Link(rel="cancel", href=build_url(request, f"/appointments/{appointment_id}"), method="DELETE"),
Link(rel="reschedule", href=build_url(request, f"/appointments/{appointment_id}/reschedule"), method="PUT"),
Link(rel="slot", href=build_url(request, f"/slots/{apt['slot_id']}"), method="GET"),
]
)

@app.delete("/appointments/{appointment_id}", status_code=204)
def cancel_appointment(appointment_id: str):
"""Cancel an appointment."""
if appointment_id not in appointments_db:
raise HTTPException(status_code=404, detail="Appointment not found")

apt = appointments_db[appointment_id]
slots_db[apt["slot_id"]]["available"] = True
slots_db[apt["slot_id"]]["patient"] = None
del appointments_db[appointment_id]

Example Response

When you book a slot, you get not just the appointment data but also links to possible next actions:

{
"appointment_id": "APT_20260108093045",
"slot_id": "slot_1",
"patient_name": "John Doe",
"time": "09:00",
"doctor_id": "dr_jones",
"links": [
{"rel": "self", "href": "http://localhost:8000/appointments/APT_20260108093045", "method": "GET"},
{"rel": "cancel", "href": "http://localhost:8000/appointments/APT_20260108093045", "method": "DELETE"},
{"rel": "reschedule", "href": "http://localhost:8000/appointments/APT_20260108093045/reschedule", "method": "PUT"},
{"rel": "add_tests", "href": "http://localhost:8000/appointments/APT_20260108093045/tests", "method": "POST"}
]
}

The client now knows:

  • How to view the appointment (self)
  • How to cancel it (cancel)
  • How to reschedule it (reschedule)
  • How to add lab tests (add_tests)

Advanced HATEOAS with FastAPI-HATEOAS

For production use, consider using a library. Here’s a more structured approach:

from fastapi import FastAPI, Request
from pydantic import BaseModel, computed_field
from typing import ClassVar

app = FastAPI()

class HATEOASMixin(BaseModel):
"""Mixin to add HATEOAS support to any model."""
_links_config: ClassVar[dict] = {}

@computed_field
@property
def _links(self) -> dict[str, str]:
"""Generate links based on model state."""
return {}

class BookResource(HATEOASMixin):
id: int
title: str
available: bool

@computed_field
@property
def _links(self) -> dict[str, dict]:
links = {
"self": {"href": f"/books/{self.id}", "method": "GET"},
"collection": {"href": "/books", "method": "GET"},
}
if self.available:
links["borrow"] = {"href": f"/books/{self.id}/borrow", "method": "POST"}
else:
links["return"] = {"href": f"/books/{self.id}/return", "method": "POST"}
return links

@app.get("/books/{book_id}")
def get_book(book_id: int) -> BookResource:
return BookResource(id=book_id, title="Clean Code", available=True)

Comparison Summary

Aspect Level 0 Level 1 Level 2 Level 3
Endpoints Single Multiple Multiple Multiple
HTTP Verbs POST only POST only GET/POST/PUT/DELETE GET/POST/PUT/DELETE
Status Codes 200 only 200 only Semantic Semantic
Caching No No Yes (GET) Yes (GET)
Discoverability No No No Yes
Client Coupling High Medium Medium Low

When to Use Each Level

Level 2 is Often Enough

For most APIs, Level 2 (proper HTTP verbs and status codes) is sufficient. It provides:

  • Clear semantics
  • Caching support
  • Standard error handling
  • Good developer experience

Consider Level 3 When

  • Building public APIs with long lifecycles
  • API consumers need to adapt to changes automatically
  • You want truly decoupled client-server evolution
  • Building hypermedia-driven applications

Practical Considerations

# Level 2 is clean and practical
@app.get("/users/{user_id}")
@app.post("/users/")
@app.delete("/users/{user_id}")

# Level 3 adds overhead but provides discoverability
# Useful for complex workflows where next steps vary by state

Conclusion

The Richardson Maturity Model provides a useful framework for evaluating and improving REST APIs:

  1. Level 0: HTTP as transport (avoid this)
  2. Level 1: Resources with URIs (basic structure)
  3. Level 2: HTTP verbs and status codes (recommended baseline)
  4. Level 3: HATEOAS for self-documenting APIs (when needed)

Most FastAPI applications should aim for Level 2 at minimum. Level 3 (HATEOAS) adds value for complex, long-lived APIs where client-server decoupling is critical.

Remember: REST is not about which level you’re at, but about choosing the right level for your use case. A well-designed Level 2 API is better than a poorly implemented Level 3 one.

References


   Reprint policy


《Richardson Maturity Model and HATEOAS - Building Truly RESTful APIs with FastAPI》 by Isaac Zhou is licensed under a Creative Commons Attribution 4.0 International License
 Previous
Deploy FastAPI to AWS - EC2, ECS, and Lambda Deploy FastAPI to AWS - EC2, ECS, and Lambda
A comprehensive guide to deploying FastAPI applications on AWS using EC2, ECS with Fargate, and Lambda with API Gateway.
2026-01-08
Next 
Organizing Routers in Python Web Frameworks: APIRouter vs Flask Blueprint vs Django URLconf Organizing Routers in Python Web Frameworks: APIRouter vs Flask Blueprint vs Django URLconf
APIRouter vs Flask Blueprint vs Django URLconfAs your Python web application grows, keeping all routes in a single file
2026-01-06
  TOC