Week 2: FastAPI Core & Dependency Injection

Week 1 ended with one hardcoded route proving the FastAPI setup worked end to end. This week turns that into something you'd actually recognize as an API: typed path and query parameters that FastAPI validates before your function body ever runs, your first real Pydantic request body, and Depends() as the mechanism FastAPI uses to wire shared logic — like pagination — into any route that needs it. You'll also start reading the interactive documentation FastAPI generates for free at /docs and /redoc, because from here on it becomes your primary tool for exploring and testing your own API as it grows.

Module 2 of 22 Week 2 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Accept and validate path and query parameters with Annotated, Query() and Path(), and write your first Pydantic request body
  • Share logic across routes with Depends(), and explain how FastAPI caches a dependency's result within a single request
  • Shape the auto-generated OpenAPI docs at /docs and /redoc with summary, description and response_model

1. Path Operations, Parameters & Pydantic Models

@app.get, @app.post, @app.put, @app.patch and @app.delete are all path operation decorators — each registers a function against an HTTP method and a URL pattern. FastAPI matches incoming requests against these patterns in the order they're declared, which matters the moment you mix static and dynamic segments: a route like /items/latest has to be declared before /items/{item_id}, or the dynamic route matches first and "latest" gets passed in where FastAPI expects an integer.

A path parameter like {item_id} is captured from the URL itself and handed to your function as an argument with the same name. The type hint on that argument isn't cosmetic here — FastAPI uses it to coerce and validate the raw string from the URL before your code runs. A query parameter is any function argument that isn't part of the path: give it a default value and it becomes optional (?q=foo); leave it without one and the request is rejected with 422 if it's missing.

src/app/main.py
from typing import Annotated
from fastapi import FastAPI, Path, Query

app = FastAPI()


@app.get("/items/{item_id}")
def get_item(
    item_id: Annotated[int, Path(ge=1, description="The positive integer ID of the item")],
    q: Annotated[str | None, Query(max_length=50)] = None,
):
    result = {"item_id": item_id}
    if q:
        result["q"] = q
    return result

Annotated[int, Path(ge=1, ...)] attaches a constraint directly to the type hint: item_id must parse as an integer and be greater than or equal to 1, or the request never reaches the function body — FastAPI responds with 422 and a JSON body listing exactly which field failed and why. Query(max_length=50) works the same way for the optional q parameter. This is the pattern you'll use for every constrained parameter from here on: Annotated[type, Query(...) | Path(...)], with the default value (or its absence) controlling whether the parameter is required.

Path and query parameters are fine for scalars, but a request body — the JSON a client POSTs — needs something richer than a dataclass. A Pydantic BaseModel looks similar to the @dataclass from Week 1, but it actually validates: constructing one from bad data raises a ValidationError, and when that model is a route's parameter type, FastAPI turns that error into a 422 response automatically, before your function ever executes.

src/app/main.py
from pydantic import BaseModel, Field


class BookCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    author: str
    year: int = Field(ge=1450, le=2100)


@app.post("/books")
def create_book(body: BookCreate):
    return {"id": 1, **body.model_dump()}

Field(min_length=1, max_length=200) is the Pydantic equivalent of Query()/Path() constraints, but for model fields — posting a title over 200 characters, or a year of 1200, fails validation before create_book runs. body.model_dump() turns the validated model back into a plain dict, which is what you'll reach for constantly once you're storing or re-shaping parsed request data.

422, not 400

FastAPI's default response for a failed validation is 422 Unprocessable Entity, not 400 Bad Request — the body was syntactically valid JSON, it just didn't match the shape the model requires. The response body includes a detail list with a loc (which field), msg (what went wrong) and type for every failing field at once, not just the first one.

2. Reusable Dependencies with Depends()

You could write a plain helper function — say, pagination(limit, offset) — and call it directly inside every route that needs pagination. It would work, but FastAPI would have no idea it exists: it couldn't validate its parameters against your route's query string, document them in /docs, cache the result within a request, or let you swap in a fake version during tests. Depends() promotes that helper into something FastAPI actively manages — a dependency — by declaring it as a parameter's default instead of calling it yourself.

src/app/main.py
from typing import Annotated
from fastapi import Depends, FastAPI, Query

app = FastAPI()


def pagination(
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
    offset: Annotated[int, Query(ge=0)] = 0,
):
    return {"limit": limit, "offset": offset}


@app.get("/tasks")
def list_tasks(page: Annotated[dict, Depends(pagination)]):
    return {"items": [], **page}

Reading this line by line: pagination is an ordinary function whose own parameters — limit and offset — are themselves constrained query parameters, exactly like the ones in the previous section. Wrapping it in Depends(pagination) and using that as the type of list_tasks's page parameter tells FastAPI: "before calling list_tasks, call pagination with values pulled from this same request's query string, and pass its return value in as page." GET /tasks?limit=10&offset=20 resolves pagination to {"limit": 10, "offset": 20} automatically — your route function never touches the raw query string at all.

The genuinely useful part shows up once a dependency is needed more than once per request. If two different parameters in the same route both declare Depends(pagination) — directly, or indirectly through another dependency — FastAPI calls pagination() exactly once for that request and reuses the cached result everywhere it's needed, rather than re-running it and potentially getting different values. You can opt out per declaration with Depends(pagination, use_cache=False) if you genuinely need a fresh call, but the default caching is what makes composing dependencies out of smaller dependencies safe.

src/app/main.py — composing dependencies
def get_sort_field(sort: str = "id") -> str:
    allowed = {"id", "title", "created_at"}
    return sort if sort in allowed else "id"


def list_query(
    page: Annotated[dict, Depends(pagination)],
    sort: Annotated[str, Depends(get_sort_field)],
):
    return {**page, "sort": sort}


@app.get("/tasks")
def list_tasks(query: Annotated[dict, Depends(list_query)]):
    return {"items": [], **query}

list_query is itself a dependency that depends on two smaller ones — FastAPI resolves the whole graph before calling list_tasks, and if pagination were also needed directly by list_tasks in addition to through list_query, it still would only run once. This is the shape real FastAPI codebases converge on: small, single-purpose dependencies composed into larger ones, instead of one large dependency doing everything.

Why this matters for testing

Because a dependency is just a callable FastAPI looks up rather than one you call yourself, tests can swap it out with app.dependency_overrides[pagination] = lambda: {"limit": 5, "offset": 0} — no monkeypatching, no real query string needed. You won't write that test until Week 8, but it's the payoff for using Depends() instead of a plain function call now.

3. Auto-Generated Docs: Swagger UI & ReDoc

Every FastAPI app exposes two documentation UIs for free, with zero extra code: /docs renders Swagger UI, an interactive page where you can expand any route, fill in parameters and actually fire a request against your running server from the browser. /redoc renders ReDoc, a cleaner read-only reference that's easier to skim or share with someone who just wants to look up a shape. Both are generated from the exact same source: the JSON schema FastAPI builds from your route's type hints, Query()/ Path() constraints, and Pydantic models, served raw at /openapi.json.

That means the constraints from Section 1 — Field(min_length=1, max_length=200), Path(ge=1) — aren't just runtime validation, they're documentation too: a title field's max length shows up in Swagger UI without you writing a separate sentence about it. Three decorator arguments let you sharpen the generated docs further without touching the schema itself: summary (a short label), description (longer prose, supports Markdown), and response_model (the shape of a successful response).

src/app/main.py
from fastapi import HTTPException
from pydantic import BaseModel


class BookOut(BaseModel):
    id: int
    title: str
    author: str
    year: int


@app.get(
    "/books/{book_id}",
    response_model=BookOut,
    summary="Fetch a single book",
    description="Returns a book by its numeric ID, or 404 if no book with that ID exists.",
    tags=["books"],
)
def get_book(book_id: int):
    book = books_by_id.get(book_id)
    if book is None:
        raise HTTPException(status_code=404, detail="Book not found")
    return book

response_model=BookOut does two things at once: it documents the response shape in /docs and /redoc, and it actively filters whatever get_book returns down to exactly BookOut's fields — if your stored book dict ever grows an internal field like internal_notes, this route still only ever serializes id, title, author and year. That filtering behavior becomes more important once real database rows show up later in the course. tags=["books"] just groups related routes together under a "books" heading in the generated docs.

Try it now

With your server running, open /docs, expand POST /books, and click "Try it out." Submit a year of 1200 and read the 422 response body carefully — that detail array is exactly what your future API clients will parse to show a real user what they got wrong.

4. Hands-on Exercise

Hands-on

Build an in-memory books API

Put path/query parameters, your first Pydantic models, a reusable pagination dependency and documentation-shaping decorator arguments all to work in one small API.

Requirements:

  1. Define BookCreate (title, author, year with Field constraints) and BookOut (same fields plus id) as separate Pydantic models.
  2. Store books in a plain dict[int, dict] seeded with 3–4 books at startup — no database yet, that's a later week.
  3. Add GET /books using the pagination dependency from Section 2 (limit/offset), plus an optional author query parameter (Annotated[str | None, Query(max_length=100)]) that filters by a case-insensitive substring match.
  4. Add GET /books/{book_id} with book_id: int constrained by Path(ge=1), raising HTTPException(status_code=404) when the ID isn't found.
  5. Add POST /books accepting a BookCreate body, returning a BookOut via response_model with status_code=201.
  6. Give every route a summary and description, and confirm all three render correctly — including the constraint hints — in both /docs and /redoc.
Hint

Assign each seeded book's id yourself (an incrementing counter is fine) — don't reach for a real ID-generation strategy yet. And don't worry about a consistent error-response shape across every failure case; that's the centralized exception-handling topic coming in Week 3.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is Depends() better than calling a shared helper function directly inside each route?

Wrapping a function in Depends() hands FastAPI the whole dependency, not just its return value — FastAPI resolves its parameters against the current request the same way it does for a route, documents it in the generated OpenAPI schema, caches its result so it only runs once per request even if needed by multiple parts of the dependency graph, and lets tests override it entirely via app.dependency_overrides without touching the route's code. A directly-called helper gets none of that; it's just a function call FastAPI can't see.

Q2

Your route declares item_id: int as a path parameter, and a client requests /items/abc. What happens?

Your function body never runs. FastAPI tries to coerce the raw URL segment "abc" into an int before calling your route, fails, and responds with 422 Unprocessable Entity and a JSON detail array describing exactly which parameter failed and why. This is the same mechanism that validates Pydantic request bodies — it's just applied to a single scalar path parameter instead of a whole object.

Q3

What determines whether a query parameter is required or optional, and how does that interact with Query()'s constraints?

It's purely about whether the parameter has a default value — q: Annotated[str | None, Query(max_length=50)] = None is optional because of the = None, not because of anything inside Query(). Query() only ever adds validation constraints (max_length, ge, a regex pattern, and so on) on top of whatever requiredness the default value already establishes. To make a constrained query parameter required, you give it no default — or explicitly write Query(...) with the ellipsis as a marker meaning "no default, this is required."

Q4

What's the practical difference between /docs and /redoc, and where does the data both of them render actually come from?

/docs (Swagger UI) is interactive — you can expand a route, fill in real parameter values and fire an actual request against your running app from the browser. /redoc (ReDoc) is a read-only reference page, generally easier to skim or link someone who just needs to look up a shape rather than test it live. Both are rendered client-side from the same raw JSON document, served by FastAPI at /openapi.json, which it builds automatically from your routes' type hints, Query()/Path() constraints, and Pydantic models — you never write that schema by hand.