REST APIs and HTTP · Intermediate
Design and build a REST API that others enjoy using
Why it matters
Backend, platform and forward deployed engineers build APIs that other teams and customers depend on. A predictable API saves hours for everyone who integrates with it; an inconsistent one creates support tickets forever. Interviews include "design an API for X" questions and often a live coding task.
The concept
Design conventions that make an API predictable:
Resources as nouns, plural:
/v1/tasks,/v1/tasks/{id}. The method says the action.Correct status codes:
201with aLocationheader on create,404for missing resources,422for validation errors,204for successful deletes.Validation at the edge, with clear error messages naming the field.
Pagination for every list (
limitandoffset, or cursors for large or changing data), with the total or a next link.Filtering with query parameters (
?status=done).Versioning in the path (
/v1/) so you can change the contract without breaking clients.Idempotency:
PUTandDELETEare naturally idempotent; forPOSTthat creates payments or orders, accept anIdempotency-Keyheader and return the original result on retries.Documentation generated from the code (OpenAPI).
Worked example: a study tasks API
"""A small REST API for study tasks: validation, pagination, errors and idempotent updates."""
from datetime import date
from typing import Literal
from fastapi import FastAPI, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
app = FastAPI(title="Study tasks API", version="1.0.0")
class TaskIn(BaseModel):
title: str = Field(min_length=1, max_length=200)
course: str = Field(pattern=r"^[A-Z]{3,5}-?\d{2,3}$", examples=["MCS-011"])
due: date
status: Literal["todo", "doing", "done"] = "todo"
class Task(TaskIn):
id: int
TASKS: dict[int, Task] = {}
NEXT_ID = 1
@app.post("/v1/tasks", status_code=status.HTTP_201_CREATED)
def create_task(body: TaskIn, response: Response) -> Task:
global NEXT_ID
task = Task(id=NEXT_ID, **body.model_dump())
TASKS[task.id] = task
NEXT_ID += 1
response.headers["Location"] = f"/v1/tasks/{task.id}"
return task
@app.get("/v1/tasks")
def list_tasks(status_filter: Literal["todo", "doing", "done"] | None = Query(None, alias="status"),
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> dict:
items = [t for t in TASKS.values() if status_filter is None or t.status == status_filter]
items.sort(key=lambda t: (t.due, t.id))
return {"items": items[offset:offset + limit], "total": len(items), "limit": limit, "offset": offset}
@app.get("/v1/tasks/{task_id}")
def get_task(task_id: int) -> Task:
if task_id not in TASKS:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return TASKS[task_id]
@app.put("/v1/tasks/{task_id}")
def replace_task(task_id: int, body: TaskIn) -> Task:
if task_id not in TASKS:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
TASKS[task_id] = Task(id=task_id, **body.model_dump())
return TASKS[task_id]
@app.delete("/v1/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int) -> None:
TASKS.pop(task_id, None) # deleting twice is fine: DELETE is idempotentfrom fastapi.testclient import TestClient
from main import app
client = TestClient(app)
task = {"title": "Revise pointers", "course": "MCS-011", "due": "2026-12-01"}
def test_create_returns_201_and_location():
response = client.post("/v1/tasks", json=task)
assert response.status_code == 201
assert response.headers["location"] == f"/v1/tasks/{response.json()['id']}"
def test_validation_error_is_422():
assert client.post("/v1/tasks", json={**task, "course": "maths"}).status_code == 422
def test_missing_task_is_404():
assert client.get("/v1/tasks/9999").status_code == 404
def test_put_is_idempotent():
task_id = client.post("/v1/tasks", json=task).json()["id"]
body = {**task, "status": "done"}
first = client.put(f"/v1/tasks/{task_id}", json=body).json()
second = client.put(f"/v1/tasks/{task_id}", json=body).json()
assert first == second
def test_pagination_and_filter():
for i in range(5):
client.post("/v1/tasks", json={**task, "title": f"Unit {i}", "status": "doing"})
page = client.get("/v1/tasks", params={"status": "doing", "limit": 2}).json()
assert len(page["items"]) == 2 and page["total"] >= 5
def test_delete_twice_is_204():
task_id = client.post("/v1/tasks", json=task).json()["id"]
assert client.delete(f"/v1/tasks/{task_id}").status_code == 204
assert client.delete(f"/v1/tasks/{task_id}").status_code == 204pip install fastapi uvicorn httpx pytest
pytest -q # 6 passed
uvicorn main:app --reload # interactive docs at http://localhost:8000/docsThe validation error for a bad course code comes back as 422 with the field name and the pattern it failed, generated from the Pydantic model. The same model produces the OpenAPI document at /openapi.json, so documentation cannot drift from the code.
The in-memory dictionary keeps the example short; in a real service, store tasks in a database and move NEXT_ID to the database's own id generation.
Common mistakes
Verbs in URLs (
/createTask,/getTasks). Use methods on resources.200for everything, with errors hidden in the body. Clients cannot handle what they cannot detect.Unbounded lists. One large client request can take down the service. Always paginate and cap
limit.Breaking changes without a new version. Renaming a field breaks every client silently.
Interview angle
"Design an API for a food delivery app's orders." Name resources and methods, status codes, pagination for order history, idempotency keys for placing orders, versioning, authentication, and rate limits. Mention what you would log and monitor.
Practice task
Add an
Idempotency-Keyheader toPOST /v1/tasks: the same key returns the original task instead of creating a duplicate.Add
PATCH /v1/tasks/{id}that updates only the fields provided.Replace offset pagination with a cursor based on
(due, id)and explain when that is better.