Skip to main content

SmartNotes API Capstone

If you're new to programming

This is a timed challenge: aim for 25 minutes, but take as long as you need. The goal is combining concepts, not speed. You have every piece from Lessons 1-5. No new concepts. The challenge is assembling them into a complete, tested API.

If you've coded before

Full TDG capstone: spec five routes, write TestClient tests, generate implementation, verify. Deliverables: smartnotes/api.py and tests/test_api.py. In-memory storage is fine; the goal is the API contract and test coverage.

In Lesson 5, you tested individual routes. Now you build the complete SmartNotes API from scratch using the full TDG cycle. Every route specified, every route tested, every test passing.

Emma starts the timer. "Twenty-five minutes. You know HTTP methods, FastAPI routes, Pydantic models, and TestClient. Assemble them."


The Problem

Build a SmartNotes web API with these routes:

RouteMethodPurposeSuccess Code
GET /notesGETList all notes200
GET /notes/{note_id}GETFetch one note200 (or 404)
POST /notesPOSTCreate a note201
GET /notes/search?q=...&tag=...GETSearch notes200
GET /notes/export?format=jsonGETExport all notes200

Models:

class NoteCreate(BaseModel):
title: str
body: str
tags: list[str] = []

class NoteResponse(BaseModel):
id: int
title: str
body: str
word_count: int
tags: list[str]

Deliverables:

FilePurpose
smartnotes/api.pyFastAPI app with all routes and models
tests/test_api.pyTestClient tests for every route

Start the timer.


Step 1: Specify (5 minutes)

Open smartnotes/api.py. Write the complete module skeleton: imports, models, app instance, and route stubs.

Hint: module skeleton
"""SmartNotes Web API: expose notes over HTTP."""

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app: FastAPI = FastAPI(
title="SmartNotes API",
description="A note-taking API built with FastAPI",
version="0.1.0",
)

# --- Models ---

class NoteCreate(BaseModel):
title: str
body: str
tags: list[str] = []


class NoteResponse(BaseModel):
id: int
title: str
body: str
word_count: int
tags: list[str]


# --- In-memory storage ---

_notes: list[dict[str, object]] = []
_next_id: int = 1


# --- Routes ---

@app.get("/notes", response_model=list[NoteResponse])
async def list_notes() -> list[dict[str, object]]:
"""List all notes."""
...


@app.get("/notes/search", response_model=list[NoteResponse])
async def search_notes(
q: str, tag: str | None = None
) -> list[dict[str, object]]:
"""Search notes by keyword and optional tag."""
...


@app.get("/notes/export")
async def export_notes(format: str = "json") -> list[dict[str, object]]:
"""Export all notes in the given format."""
...


@app.get("/notes/{note_id}", response_model=NoteResponse)
async def get_note(note_id: int) -> dict[str, object]:
"""Fetch a single note by ID."""
...


@app.post("/notes", response_model=NoteResponse, status_code=201)
async def create_note(note: NoteCreate) -> dict[str, object]:
"""Create a new note."""
...

Notice the route ordering: /notes/search and /notes/export are defined before /notes/{note_id}. FastAPI matches routes in order. If {note_id} came first, a request to /notes/search would try to convert "search" to an integer and return a 422.

Run the type checker to catch issues early:

uv run pyright smartnotes/api.py

Step 2: Test (7 minutes)

Open tests/test_api.py. Write the complete test suite.

Think about what each route needs:

RouteSuccess testError test
GET /notesReturns list (empty or populated)N/A (always returns 200)
GET /notes/{note_id}Returns matching note404 for missing ID
POST /notesReturns 201 + created note422 for missing fields
GET /notes/searchReturns matching notes422 for missing q param
GET /notes/exportReturns all notes as JSONN/A
Hint: complete test suite
"""Tests for SmartNotes API."""

from fastapi.testclient import TestClient

from smartnotes.api import _notes, app

client: TestClient = TestClient(app)


def setup_function() -> None:
"""Clear notes before each test."""
_notes.clear()


# --- GET /notes ---

def test_list_notes_empty() -> None:
response = client.get("/notes")
assert response.status_code == 200
assert response.json() == []


def test_list_notes_after_creation() -> None:
client.post("/notes", json={"title": "A", "body": "Body A"})
client.post("/notes", json={"title": "B", "body": "Body B"})
response = client.get("/notes")
assert response.status_code == 200
assert len(response.json()) == 2


# --- POST /notes ---

def test_create_note() -> None:
response = client.post(
"/notes",
json={"title": "Test", "body": "Hello world"},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test"
assert data["word_count"] == 2
assert data["tags"] == []
assert "id" in data


def test_create_note_with_tags() -> None:
response = client.post(
"/notes",
json={
"title": "Tagged",
"body": "Note with tags",
"tags": ["python", "api"],
},
)
assert response.status_code == 201
assert response.json()["tags"] == ["python", "api"]


def test_create_note_missing_body() -> None:
response = client.post("/notes", json={"title": "Incomplete"})
assert response.status_code == 422


# --- GET /notes/{note_id} ---

def test_get_note_by_id() -> None:
create = client.post(
"/notes", json={"title": "Find Me", "body": "By ID"}
)
note_id = create.json()["id"]
response = client.get(f"/notes/{note_id}")
assert response.status_code == 200
assert response.json()["title"] == "Find Me"


def test_get_note_not_found() -> None:
response = client.get("/notes/99999")
assert response.status_code == 404


# --- GET /notes/search ---

def test_search_notes_by_keyword() -> None:
client.post("/notes", json={"title": "Python Tips", "body": "Learn FastAPI"})
client.post("/notes", json={"title": "Cooking", "body": "Make pasta"})
response = client.get("/notes/search?q=python")
assert response.status_code == 200
results = response.json()
assert len(results) >= 1
assert all("python" in r["title"].lower() or "python" in r["body"].lower() for r in results)


def test_search_notes_missing_query() -> None:
response = client.get("/notes/search")
assert response.status_code == 422


def test_search_notes_with_tag() -> None:
client.post(
"/notes",
json={"title": "A", "body": "Tagged note", "tags": ["python"]},
)
client.post(
"/notes",
json={"title": "B", "body": "Another python note", "tags": ["cooking"]},
)
response = client.get("/notes/search?q=note&tag=python")
results = response.json()
assert len(results) == 1
assert results[0]["title"] == "A"


# --- GET /notes/export ---

def test_export_notes() -> None:
client.post("/notes", json={"title": "Export Me", "body": "Data here"})
response = client.get("/notes/export?format=json")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1

The setup_function() clears the in-memory notes list before each test. This prevents test pollution: each test starts with a clean state.

Run the tests. They should all FAIL (the route stubs return ...):

uv run pytest tests/test_api.py -v

Output:

tests/test_api.py::test_list_notes_empty FAILED
tests/test_api.py::test_create_note FAILED
...
12 failed

All red. This is correct. The stubs have no implementation yet.


Step 3: Generate (5 minutes)

Prompt Claude Code to implement the routes:

Implement all route functions in smartnotes/api.py so that
every test in tests/test_api.py passes. Use in-memory storage
with the _notes list and _next_id counter. Do not modify
the test file. Keep all type annotations.

The AI has everything it needs: the route stubs define the API contract, the tests define the expected behavior, the Pydantic models define the data shapes.


Step 4: Verify (3 minutes)

Run the full verification stack:

uv run ruff check smartnotes/api.py tests/test_api.py
uv run pyright smartnotes/api.py
uv run pytest tests/test_api.py -v
OutcomeWhat to do
All GREENMove to Step 6
Some REDMove to Step 5

Then start the server and verify manually:

uv run uvicorn smartnotes.api:app --reload

Open http://localhost:8000/docs. Create a note, search for it, export. Everything should match your test expectations.


Step 5: Debug (5 minutes)

Common issues:

ProblemCauseFix
Tests affect each other_notes not cleared between testsAdd setup_function() that calls _notes.clear()
422 on valid POSTMissing json= keyword in client.post()Use client.post("/notes", json={...}) not data={...}
Search returns wrong resultsCase-sensitive comparisonUse .lower() on both query and note fields
Route ordering conflict/notes/{note_id} catches /notes/searchDefine /notes/search BEFORE /notes/{note_id}
_next_id not resettingGlobal state persistsReset _next_id in setup_function() or use a module-level function

If tests fail, apply the debugging loop. Paste the failure output into Claude Code:

These tests are failing:

[paste failure output]

Fix the route implementations in smartnotes/api.py
without modifying the tests.

Step 6: Read (2 minutes)

All tests pass. The server runs. Review the generated code:

  1. Does every route have a response_model?
  2. Does the POST route return status_code=201?
  3. Does get_note raise HTTPException(status_code=404) for missing notes?
  4. Does search_notes handle the optional tag parameter correctly?
  5. Are all function return types annotated?

Open /docs one final time. Every route should be documented with its parameters, request body schema, and response schema.


PRIMM-AI+ Practice: Extend the API

Predict [AI-FREE]

Press Shift+Tab to enter Plan Mode.

You want to add a DELETE /notes/{note_id} route. Predict:

  1. What tests do you need? (List at least three)
  2. What status code does a successful delete return?
  3. What happens to the note IDs after deletion? (Does ID 3 get reused?)

Run

Press Shift+Tab to exit Plan Mode.

Write the tests for DELETE, then implement the route using the TDG cycle. Verify with pytest.

Investigate

Run /investigate @smartnotes/api.py in Claude Code and ask: "My API uses in-memory storage. What happens when I restart the server? How would I persist notes across restarts without changing the route logic?"

The answer previews dependency injection, a pattern FastAPI uses to swap storage backends.

Modify

Add a PUT /notes/{note_id} route that updates an existing note. Define a NoteUpdate model where both title and body are optional:

class NoteUpdate(BaseModel):
title: str | None = None
body: str | None = None
tags: list[str] | None = None

Write two tests (success and 404), implement, verify.

Make [Mastery Gate]

Without looking at any hints, add a GET /notes/stats route that returns:

{
"total_notes": 5,
"total_words": 142,
"average_words_per_note": 28.4,
"tags": ["python", "fastapi", "notes"]
}

Full /tdg cycle: stub, tests, generate, verify. This tests your ability to design a new route end-to-end.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Rate My API Design

Here is my complete SmartNotes API:

[paste smartnotes/api.py]

And my test suite:

[paste tests/test_api.py]

Rate this API on a scale of 1-10 for:
1. Route design (RESTful conventions, URL structure)
2. Error handling (status codes, error messages)
3. Test coverage (what is tested, what is missing)
4. Code organization (imports, models, routes)

For each rating below 8, give one specific improvement.

What you're learning: API code review covers dimensions beyond correctness: RESTful conventions, error message quality, test completeness, and code organization. The AI evaluates your complete project as a reviewer would, surfacing improvements you would not catch by running tests alone.

Prompt 2: Plan Real Storage

My SmartNotes API uses an in-memory list (_notes) that
loses all data when the server restarts. I want to
connect it to my existing SmartNotes storage module
from Chapter 62 (which reads and writes JSON files).

How would I replace the in-memory list with real file
storage? Show me the changes needed in api.py. Should
I use FastAPI's dependency injection (Depends) or
just import my storage functions directly?

What you're learning: The gap between a prototype API and a production API often comes down to storage. Dependency injection (via FastAPI's Depends) lets you swap storage backends without changing route logic, which also makes testing easier. This is the architectural pattern behind most production APIs.

Prompt 3: What Would a Real API Need?

My SmartNotes API has 5 routes, Pydantic models, and
12 tests. If I wanted to deploy this for real users,
what am I missing?

List the top 5 things a production API needs that my
current code does not have. For each one, explain why
it matters and how hard it would be to add with FastAPI.

What you're learning: Production APIs need authentication, rate limiting, logging, error monitoring, and database persistence. FastAPI has built-in support for most of these. Understanding the gap between "it works locally" and "it works in production" prepares you for the deployment chapters in Phase 8.


James opens http://localhost:8000/docs in his browser. Five routes, all documented, all tested. He clicks POST /notes, creates a note, clicks GET /notes, sees it listed. He opens a terminal next to the browser and types smartnotes add "Terminal Note" "From the CLI".

Both work. The CLI and the API are two interfaces to the same system.

"From library to tool to service," James says. "Chapter 63 made it a package. Chapter 65 made it a CLI. Chapter 67 made it an API. Three interfaces, same SmartNotes underneath."

Emma looks at his screen. "You know what both of those have in common? Someone has to run them manually. You run pytest by hand. You start the server by hand. You check the tests by hand."

"Is there a way to automate that?"

"Phase 8 builds a CI pipeline. Every time you push code to GitHub, a server runs your tests automatically. If anything breaks, you get notified before anyone else sees the problem. Right now you run tests manually. Phase 8 makes the computer run them for you, on every commit."

She pauses. "And there is another problem. AI-generated code optimizes for functionality, not safety. Your API accepts any string as a title. What if someone sends a title with embedded JavaScript? What if they send a 10-megabyte body? Phase 8 also teaches you to audit AI-generated code for security vulnerabilities that AI consistently misses."

James looks at his 12 passing tests. "The tests verify correctness."

"Correctness is not safety. That is Phase 8's lesson."