Skip to content

description: "Building APIs (Flask/FastAPI) — uvicorn is the ASGI server that actually runs a FastAPI application."---

08 · Building APIs (Flask/FastAPI)

🎥 Video walkthrough

So far you've been a client of other people's APIs. This module flips that: building your own web API with FastAPI, a modern framework built on type hints, automatic request validation via Pydantic, and automatically generated interactive documentation.

Installing FastAPI

pip install fastapi uvicorn

uvicorn is the ASGI server that actually runs a FastAPI application.

A minimal API

# main.py
from fastapi import FastAPI

app = FastAPI(title="Task API")


@app.get("/")
def root():
    return {"message": "Task API is running"}


@app.get("/health")
def health_check():
    return {"status": "ok"}
uvicorn main:app --reload
# visit http://127.0.0.1:8000/         -> {"message": "Task API is running"}
# visit http://127.0.0.1:8000/docs     -> interactive Swagger UI, generated automatically

Path and query parameters

FastAPI reads Python type hints to validate and convert incoming data automatically.

from fastapi import FastAPI

app = FastAPI()

tasks = {1: "Write docs", 2: "Review PR"}


@app.get("/tasks/{task_id}")
def get_task(task_id: int):          # FastAPI converts the URL segment to int, or 422s
    return {"task_id": task_id, "title": tasks.get(task_id)}


@app.get("/tasks")
def list_tasks(limit: int = 10, done: bool | None = None):
    # limit and done are optional query params: /tasks?limit=5&done=true
    return {"limit": limit, "done_filter": done}

Visiting /tasks/abc (a non-integer) automatically returns a 422 Unprocessable Entity response with a clear validation error — no manual checking required.

Request bodies with Pydantic models

Pydantic models define the shape of data you expect, and FastAPI validates incoming JSON against them automatically.

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    done: bool = False


class TaskOut(BaseModel):
    id: int
    title: str
    done: bool


tasks_db: dict[int, TaskOut] = {}
next_id = 1


@app.post("/tasks", response_model=TaskOut, status_code=201)
def create_task(task: TaskCreate):
    global next_id
    new_task = TaskOut(id=next_id, title=task.title, done=task.done)
    tasks_db[next_id] = new_task
    next_id += 1
    return new_task

If the client sends {"title": ""} (violating min_length=1), FastAPI rejects it with a 422 response describing exactly which field failed and why — before your function body ever runs.

Full CRUD example

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Task API")


class TaskCreate(BaseModel):
    title: str
    done: bool = False


class TaskUpdate(BaseModel):
    title: str | None = None
    done: bool | None = None


class TaskOut(BaseModel):
    id: int
    title: str
    done: bool


tasks_db: dict[int, TaskOut] = {}
next_id = 1


@app.post("/tasks", response_model=TaskOut, status_code=201)
def create_task(task: TaskCreate):
    global next_id
    new_task = TaskOut(id=next_id, **task.model_dump())
    tasks_db[next_id] = new_task
    next_id += 1
    return new_task


@app.get("/tasks", response_model=list[TaskOut])
def list_tasks():
    return list(tasks_db.values())


@app.get("/tasks/{task_id}", response_model=TaskOut)
def get_task(task_id: int):
    task = tasks_db.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail="task not found")
    return task


@app.patch("/tasks/{task_id}", response_model=TaskOut)
def update_task(task_id: int, patch: TaskUpdate):
    task = tasks_db.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail="task not found")
    updated_data = task.model_dump()
    updated_data.update({k: v for k, v in patch.model_dump().items() if v is not None})
    updated_task = TaskOut(**updated_data)
    tasks_db[task_id] = updated_task
    return updated_task


@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    if task_id not in tasks_db:
        raise HTTPException(status_code=404, detail="task not found")
    del tasks_db[task_id]

HTTPException is FastAPI's way of returning a specific HTTP status code and error body from inside a route — raising it works like any other exception, but FastAPI catches it and turns it into the right response.

Dependency injection

FastAPI's Depends lets you share setup logic (like "get the current user" or "get a database session") across multiple routes cleanly.

from fastapi import Depends, FastAPI

app = FastAPI()


def get_query_token(token: str | None = None):
    if token != "secret-token":
        from fastapi import HTTPException
        raise HTTPException(status_code=401, detail="invalid or missing token")
    return token


@app.get("/protected")
def protected_route(token: str = Depends(get_query_token)):
    return {"message": "you're in", "token": token}

Testing a FastAPI app

FastAPI's own TestClient makes it easy to exercise routes without running a real server.

# test_main.py
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def test_create_and_get_task():
    response = client.post("/tasks", json={"title": "Test task"})
    assert response.status_code == 201
    task_id = response.json()["id"]

    response = client.get(f"/tasks/{task_id}")
    assert response.status_code == 200
    assert response.json()["title"] == "Test task"


def test_get_missing_task_returns_404():
    response = client.get("/tasks/9999")
    assert response.status_code == 404

Cheat sheet

Concept Syntax
Path parameter def route(item_id: int): with @app.get("/items/{item_id}")
Query parameter plain function argument with a default value
Request body a BaseModel subclass as a function argument
Response shape response_model= on the route decorator
Error response raise HTTPException(status_code=..., detail=...)
Shared logic Depends(dependency_function)

How It Actually Works

def get_task(task_id: int): validating and converting the URL segment isn't FastAPI reading your mind about intent — at import time, FastAPI inspects each route function's signature using Python's inspect/typing machinery (the same runtime introspection functools.wraps relies on) and, for every parameter, builds a corresponding Pydantic field from its type annotation. When a request for /tasks/abc arrives, FastAPI extracts the raw string "abc" from the URL path, hands it to Pydantic's validator for int, which fails to coerce it and raises a structured validation error — FastAPI catches that specific exception type and converts it into the 422 JSON response, all before your function body executes at all; a matching request for /tasks/7 goes through the same coercion but succeeds, so task_id arrives inside your function already as a real Python int, not a string you'd have to convert yourself.

A BaseModel subclass like TaskCreate is not just documentation — Pydantic generates, once per model class (via __init_subclass__/metaclass hooks and a compiled validator, precompiled to Rust in Pydantic v2 for speed), a fast validation routine covering every field. When a request body arrives, FastAPI reads the raw bytes, decodes them as JSON (using the same recursive parsing you saw in Module 5), and passes the resulting dict through that compiled validator — a violated constraint like min_length=1 fails during that single pass and never reaches create_task's body, which is why routes can assume validated input rather than manually checking if not task.title: raise ... everywhere.

Depends(get_query_token) implements dependency injection through the same signature-introspection mechanism as request bodies: FastAPI sees the parameter's default is a Depends(...) marker, calls get_query_token itself (passing along any of its declared parameters, resolved recursively — dependencies can depend on other dependencies), and substitutes the return value as the argument to your route function. If get_query_token raises HTTPException, FastAPI's own exception handling middleware — installed automatically around every route — catches it and converts it directly into an HTTP response with that status code and detail message, short-circuiting before your route body ever runs, exactly like the type-validation path above.

uvicorn and FastAPI communicate through the ASGI interface: uvicorn is responsible for the raw TCP/HTTP protocol parsing and turns each incoming connection into a Python coroutine call following that standard interface, which is why FastAPI route handlers can be either plain def functions (uvicorn runs them in a thread pool to avoid blocking the event loop) or async def (run directly on the event loop) — the framework layer is decoupled from the network layer specifically so either style of handler works.

Exercise

Extend the CRUD example above with a priority: str field on TaskCreate (one of "low", "medium", "high", validated with a Pydantic Field/Literal), a GET /tasks?priority=high filter using a query parameter, and a TestClient-based test file covering create, list-with-filter, update, and delete-then-404.