REST APIs and HTTP · Intermediate

Design and build a REST API that others enjoy using

5 min read

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: 201 with a Location header on create, 404 for missing resources, 422 for validation errors, 204 for successful deletes.

  • Validation at the edge, with clear error messages naming the field.

  • Pagination for every list (limit and offset, 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: PUT and DELETE are naturally idempotent; for POST that creates payments or orders, accept an Idempotency-Key header and return the original result on retries.

  • Documentation generated from the code (OpenAPI).

Worked example: a study tasks API

main.py
"""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 idempotent
test_main.py
from 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 == 204
run.sh
pip install fastapi uvicorn httpx pytest
pytest -q                          # 6 passed
uvicorn main:app --reload          # interactive docs at http://localhost:8000/docs

The 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.

  • 200 for 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

  1. Add an Idempotency-Key header to POST /v1/tasks: the same key returns the original task instead of creating a duplicate.

  2. Add PATCH /v1/tasks/{id} that updates only the fields provided.

  3. Replace offset pagination with a cursor based on (due, id) and explain when that is better.

Further reading