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¶
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¶
- Health route. Write
GET /healthand a check that calls it withTestClient. Hint:TestClient(app).get("/health")sends a request without a server. - Create model. Describe the body with a
BaseModelclass. Hint:Field(min_length=1, max_length=100)gives the length rules, and FastAPI returns 422 for you. - Store class. Put the dictionary and the next id inside
NoteStore. Hint:dict.getreturnsNonefor an unknown id, which the routes can turn into a 404. - Lists and filters. Read an optional
donequery parameter. Hint: a parameter typedbool | None = Noneis optional in the URL. - Partial update. Use
model_dump(exclude_unset=True)so that only the fields the client sent are changed. Hint: the reference solution also ignoresnullvalues. - Delete. Return 204 with no body. Hint:
dict.pop(note_id, None)returns the removed value, orNone. - Checks. Write one
assertfor each row in the table, then run the file.
Check it¶
Start the server from the folder that holds notes_api.py:
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()