Skip to content

Capstone 3: A REST notes API

~4 hours ยท prerequisite: A small notes REST service with FastAPI and the rest of Part 8

Goal

Build a JSON API for notes. A client can create, list, read, change, and delete notes over HTTP. The API validates input, returns the status codes REST uses, and stores its data in a NoteStore class, so the routes stay small and the storage can change later.

Requirements and acceptance checks

Your program is done when python3 -I notes_api.py prints all checks passed, and each row below has a matching check in run_checks. Use pip install fastapi uvicorn httpx in a virtual environment. The checks in the reference solution are one way to write them.

# Requirement Acceptance check
1 GET /health returns {"status": "ok"} with status 200. The check compares the JSON.
2 POST /notes creates a note with id, title, body (default ""), and done (default false), and returns 201. POST {"title": "Milk"} returns {"id": 1, "title": "Milk", "body": "", "done": false}.
3 title is required and has 1 to 100 characters. body has at most 1000. done must be a boolean. A title of "", a 101-character title, or "done": "maybe" each return 422.
4 GET /notes lists all notes. GET /notes?done=true and ?done=false filter them. With one open and one done note, each filter returns one note.
5 GET /notes/{id} returns the note, or 404 with {"detail": "note not found"}. GET /notes/99 returns 404 and that body.
6 PATCH /notes/{id} changes only the fields sent, and returns the note. Unknown ids return 404. PATCH {"done": true} changes done and keeps title.
7 DELETE /notes/{id} returns 204. Deleting it again returns 404. Two deletes return 204 then 404.
8 Route functions call NoteStore methods and do not read the dictionary directly. Code review: no _notes outside NoteStore.
9 Running the file executes the checks, which cover rows 1 to 7. python3 -I notes_api.py prints all checks passed and exits with status 0.

Suggested file layout

notes-api/
  notes_api.py    # models, NoteStore, routes, and run_checks

Start with one file. Once the checks pass, you may move the store into its own module, but then run the checks through pytest or a separate script, because python3 -I will not import sibling files.

Milestones

  1. Health route. Write GET /health and a check that calls it with TestClient. Hint: TestClient(app).get("/health") sends a request without a server.
  2. Create model. Describe the body with a BaseModel class. Hint: Field(min_length=1, max_length=100) gives the length rules, and FastAPI returns 422 for you.
  3. Store class. Put the dictionary and the next id inside NoteStore. Hint: dict.get returns None for an unknown id, which the routes can turn into a 404.
  4. Lists and filters. Read an optional done query parameter. Hint: a parameter typed bool | None = None is optional in the URL.
  5. Partial update. Use model_dump(exclude_unset=True) so that only the fields the client sent are changed. Hint: the reference solution also ignores null values.
  6. Delete. Return 204 with no body. Hint: dict.pop(note_id, None) returns the removed value, or None.
  7. Checks. Write one assert for each row in the table, then run the file.

Check it

Start the server from the folder that holds notes_api.py:

$ uvicorn notes_api:app --port 8000

In a second terminal, run these commands:

$ curl -s -X POST -H "Content-Type: application/json" -d '{"title":"Milk"}' http://127.0.0.1:8000/notes
{"id":1,"title":"Milk","body":"","done":false}
$ curl -s -X PATCH -H "Content-Type: application/json" -d '{"done":true}' http://127.0.0.1:8000/notes/1
{"id":1,"title":"Milk","body":"","done":true}
$ curl -s "http://127.0.0.1:8000/notes?done=true"
[{"id":1,"title":"Milk","body":"","done":true}]
$ curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://127.0.0.1:8000/notes/1
204

Reference solution

Try the milestones first. Open this only after you have a version of your own, or when you are stuck on a milestone.

Reference solution

from fastapi import FastAPI, HTTPException, status from fastapi.testclient import TestClient from pydantic import BaseModel, Field

class NoteCreate(BaseModel): title: str = Field(min_length=1, max_length=100) body: str = Field(default="", max_length=1000) done: bool = False

class NotePatch(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=100) body: str | None = Field(default=None, max_length=1000) done: bool | None = None

class NoteStore: """Keeps notes in memory. Routes call these methods and never touch the dict."""

def __init__(self):
    self._notes = {}
    self._next_id = 1

def create(self, data):
    note = {"id": self._next_id, **data}
    self._notes[self._next_id] = note
    self._next_id += 1
    return note

def all_notes(self, done=None):
    notes = list(self._notes.values())
    if done is None:
        return notes
    return [note for note in notes if note["done"] == done]

def get(self, note_id):
    return self._notes.get(note_id)

def update(self, note_id, changes):
    note = self._notes.get(note_id)
    if note is None:
        return None
    note.update(changes)
    return note

def delete(self, note_id):
    return self._notes.pop(note_id, None) is not None

app = FastAPI(title="Notes API") store = NoteStore()

def not_found(): return HTTPException(status_code=404, detail="note not found")

@app.get("/health") def health(): return {"status": "ok"}

@app.post("/notes", status_code=status.HTTP_201_CREATED) def create_note(note: NoteCreate): return store.create(note.model_dump())

@app.get("/notes") def list_notes(done: bool | None = None): return store.all_notes(done)

@app.get("/notes/{note_id}") def get_note(note_id: int): note = store.get(note_id) if note is None: raise not_found() return note

@app.patch("/notes/{note_id}") def patch_note(note_id: int, patch: NotePatch): changes = {k: v for k, v in patch.model_dump(exclude_unset=True).items() if v is not None} note = store.update(note_id, changes) if note is None: raise not_found() return note

@app.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_note(note_id: int): if not store.delete(note_id): raise not_found()

def run_checks(): client = TestClient(app) assert client.get("/health").json() == {"status": "ok"}

r = client.post("/notes", json={"title": "Milk"})
assert r.status_code == 201
assert r.json() == {"id": 1, "title": "Milk", "body": "", "done": False}
client.post("/notes", json={"title": "Tea", "body": "green", "done": True})

assert len(client.get("/notes").json()) == 2
assert [n["title"] for n in client.get("/notes?done=true").json()] == ["Tea"]
assert [n["title"] for n in client.get("/notes?done=false").json()] == ["Milk"]

assert client.get("/notes/1").json()["title"] == "Milk"
assert client.get("/notes/99").status_code == 404
assert client.get("/notes/99").json() == {"detail": "note not found"}

assert client.post("/notes", json={"title": ""}).status_code == 422
assert client.post("/notes", json={"title": "x" * 101}).status_code == 422
assert client.post("/notes", json={"title": "ok", "done": "maybe"}).status_code == 422

patched = client.patch("/notes/1", json={"done": True})
assert patched.json() == {"id": 1, "title": "Milk", "body": "", "done": True}
assert client.patch("/notes/99", json={"done": True}).status_code == 404

assert client.delete("/notes/2").status_code == 204
assert client.delete("/notes/2").status_code == 404
assert [n["id"] for n in client.get("/notes").json()] == [1]
print("all checks passed")

if name == "main": run_checks()

Sources