AdvancedPython · Lesson 5 of 9

Building REST APIs with FastAPI

Typed routes, automatic validation with Pydantic, dependencies and interactive docs.

FastAPI builds web APIs from ordinary typed Python functions. It validates requests using your type hints and Pydantic models, returns clear 422 errors for bad input, and generates interactive documentation at /docs automatically.

Pydantic models define the shape and rules of your data (Field(min_length=2), EmailStr, ranges). Use separate models for input (StudentIn) and output (StudentOut) so internal fields never leak.

Dependencies (Depends) inject shared things — a database session, the current user — into routes. Raise HTTPException for errors like 404. FastAPI's TestClient lets you test the API without starting a server.

TerminalShell
pip install "fastapi[standard]"
fastapi dev main.py            # then open http://127.0.0.1:8000/docs
main.pyPython
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from pydantic import BaseModel, Field

app = FastAPI(title="Results API")


class StudentIn(BaseModel):
    name: str = Field(min_length=2, max_length=80)
    form: int = Field(ge=1, le=6)
    scores: list[int] = Field(default_factory=list)


class StudentOut(StudentIn):
    id: int
    average: float


class Store:
    def __init__(self) -> None:
        self.students: dict[int, StudentIn] = {}
        self.next_id = 1


store = Store()


def get_store() -> Store:
    return store


StoreDep = Annotated[Store, Depends(get_store)]


def to_out(student_id: int, s: StudentIn) -> StudentOut:
    avg = sum(s.scores) / len(s.scores) if s.scores else 0.0
    return StudentOut(id=student_id, average=round(avg, 1), **s.model_dump())


@app.post("/students", status_code=status.HTTP_201_CREATED)
def create_student(body: StudentIn, db: StoreDep) -> StudentOut:
    student_id = db.next_id
    db.students[student_id] = body
    db.next_id += 1
    return to_out(student_id, body)


@app.get("/students/{student_id}")
def get_student(student_id: int, db: StoreDep) -> StudentOut:
    student = db.students.get(student_id)
    if student is None:
        raise HTTPException(status_code=404, detail="Student not found")
    return to_out(student_id, student)
test_main.pyPython
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_create_and_get() -> None:
    res = client.post("/students", json={"name": "Amina", "form": 4, "scores": [80, 90]})
    assert res.status_code == 201
    assert res.json()["average"] == 85.0
    assert client.get(f"/students/{res.json()['id']}").status_code == 200


def test_validation_and_404() -> None:
    assert client.post("/students", json={"name": "A", "form": 9}).status_code == 422
    assert client.get("/students/999").status_code == 404

Key points

  • Type hints + Pydantic = automatic validation and docs.
  • Separate input and output models; never return internal fields by accident.
  • Use Depends for shared resources and TestClient for fast API tests.

Exercise

Add GET /students?form=4 (filter with a query parameter), PATCH /students/{id} that accepts a partial update, and DELETE /students/{id}. Write tests for each, including the error cases.

Show solution

Try the exercise yourself first — then compare your approach with this one.

The list endpoint takes an optional form query parameter. PATCH accepts a StudentPatch model where every field is optional; model_dump(exclude_unset=True) keeps only the fields the client actually sent, so the rest stay unchanged. DELETE returns 204 with no body.

main.pyPython
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Response, status
from pydantic import BaseModel, Field

app = FastAPI(title="Results API")


class StudentIn(BaseModel):
    name: str = Field(min_length=2, max_length=80)
    form: int = Field(ge=1, le=6)
    scores: list[int] = Field(default_factory=list)


class StudentPatch(BaseModel):
    name: str | None = Field(default=None, min_length=2, max_length=80)
    form: int | None = Field(default=None, ge=1, le=6)
    scores: list[int] | None = None


class StudentOut(StudentIn):
    id: int


class Store:
    def __init__(self) -> None:
        self.students: dict[int, StudentIn] = {}
        self.next_id = 1


store = Store()
StoreDep = Annotated[Store, Depends(lambda: store)]


def get_or_404(db: Store, student_id: int) -> StudentIn:
    student = db.students.get(student_id)
    if student is None:
        raise HTTPException(status_code=404, detail="Student not found")
    return student


@app.post("/students", status_code=status.HTTP_201_CREATED)
def create_student(body: StudentIn, db: StoreDep) -> StudentOut:
    student_id = db.next_id
    db.students[student_id] = body
    db.next_id += 1
    return StudentOut(id=student_id, **body.model_dump())


@app.get("/students")
def list_students(db: StoreDep, form: int | None = None) -> list[StudentOut]:
    return [
        StudentOut(id=i, **s.model_dump())
        for i, s in db.students.items()
        if form is None or s.form == form
    ]


@app.patch("/students/{student_id}")
def update_student(student_id: int, body: StudentPatch, db: StoreDep) -> StudentOut:
    current = get_or_404(db, student_id)
    updated = current.model_copy(update=body.model_dump(exclude_unset=True))
    db.students[student_id] = updated
    return StudentOut(id=student_id, **updated.model_dump())


@app.delete("/students/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_student(student_id: int, db: StoreDep) -> Response:
    get_or_404(db, student_id)
    del db.students[student_id]
    return Response(status_code=status.HTTP_204_NO_CONTENT)
test_main.pyPython
from fastapi.testclient import TestClient

from main import app, store

client = TestClient(app)


def setup_function() -> None:
    store.students.clear()
    store.next_id = 1
    client.post("/students", json={"name": "Amina", "form": 4})
    client.post("/students", json={"name": "Juma", "form": 3})


def test_filter_by_form() -> None:
    names = [s["name"] for s in client.get("/students", params={"form": 4}).json()]
    assert names == ["Amina"]


def test_partial_update_keeps_other_fields() -> None:
    res = client.patch("/students/2", json={"form": 4})
    assert res.status_code == 200
    assert res.json() == {"id": 2, "name": "Juma", "form": 4, "scores": []}


def test_update_validation_and_missing() -> None:
    assert client.patch("/students/1", json={"form": 9}).status_code == 422
    assert client.patch("/students/99", json={"form": 4}).status_code == 404


def test_delete() -> None:
    assert client.delete("/students/1").status_code == 204
    assert client.delete("/students/1").status_code == 404
    assert len(client.get("/students").json()) == 1

Check your understanding

  1. What status code does FastAPI return when a request body fails Pydantic validation?

  2. Why have separate input (StudentIn) and output (StudentOut) models?

  3. What does Depends(get_store) do in a route parameter?

  4. How can you test a FastAPI app without starting a server?

Ask AI