A small notes REST service with FastAPI¶
~30 min ยท prerequisite: Checking input and wrapping handlers in middleware
What you will be able to do¶
- Install FastAPI in a virtual environment and run a small service with
uvicorn. - Map
GET,POST,PUT, andDELETEon/notesto functions, and return the right status code for each. - Test the routes with
TestClient, without starting a server.
The idea¶
REST is a style for HTTP APIs. Each thing the API manages is a resource with its own URL, such as /notes/1. The method says what to do: GET reads, POST creates, PUT replaces, and DELETE removes.
FastAPI turns Python functions into routes. A decorator such as @app.get("/path") registers a function, and FastAPI reads its type hints. note_id: int converts the URL text to an integer, or answers 422. A BaseModel class describes and checks the JSON body.
Install the libraries in a virtual environment:
$ python3 -m venv .venv
$ source .venv/bin/activate # on Windows: .venv\Scripts\activate
$ pip install fastapi uvicorn httpx
uvicorn runs the app, and httpx is needed by TestClient. The versions are not pinned, so pip installs the newest release. The code was checked against FastAPI 0.143.
Worked example¶
Save this as notes_api.py:
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI(title="Notes API")
class NoteIn(BaseModel):
title: str = Field(min_length=1, max_length=100)
body: str = Field(default="", max_length=1000)
class NoteStore:
def __init__(self):
self.notes = {}
self._next_id = 1
def add(self, data):
note = {"id": self._next_id, **data}
self.notes[self._next_id] = note
self._next_id += 1
return note
store = NoteStore()
@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(note: NoteIn):
return store.add(note.model_dump())
@app.get("/notes")
def list_notes():
return list(store.notes.values())
@app.get("/notes/{note_id}")
def get_note(note_id: int):
if note_id not in store.notes:
raise HTTPException(status_code=404, detail="note not found")
return store.notes[note_id]
@app.put("/notes/{note_id}")
def replace_note(note_id: int, note: NoteIn):
if note_id not in store.notes:
raise HTTPException(status_code=404, detail="note not found")
store.notes[note_id] = {"id": note_id, **note.model_dump()}
return store.notes[note_id]
@app.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_note(note_id: int):
if store.notes.pop(note_id, None) is None:
raise HTTPException(status_code=404, detail="note not found")
def run_checks():
client = TestClient(app)
r = client.post("/notes", json={"title": "Milk", "body": "2 litres"})
assert r.status_code == 201
assert r.json() == {"id": 1, "title": "Milk", "body": "2 litres"}
assert client.get("/notes/1").json()["title"] == "Milk"
assert client.get("/notes/99").status_code == 404
assert client.post("/notes", json={"title": ""}).status_code == 422
put = client.put("/notes/1", json={"title": "Eggs"})
assert put.json() == {"id": 1, "title": "Eggs", "body": ""}
assert client.delete("/notes/1").status_code == 204
assert client.get("/notes").json() == []
print("all checks passed")
if __name__ == "__main__":
run_checks()
Line by line:
NoteInchecks the body.min_length=1rejects an empty title, anddefault=""makesbodyoptional.NoteStorekeeps notes in a dictionary, so they are lost when the server stops.@app.post(..., status_code=201)runscreate_noteforPOST /notesafter FastAPI checks the body.get_noteraisesHTTPExceptionfor a 404. A returned value becomes JSON with status 200.run_checkssends requests withTestClient, and eachassertchecks one result.- The
if __name__ == "__main__":guard runs the checks only when you run the file directly.uvicornimports the file without running them.
Try it¶
Run the checks. The output is a single line:
Newer releases may print a StarletteDeprecationWarning about httpx. It does not change the result, and pip install httpx2 removes it.
Now start the server in one terminal from the folder that holds notes_api.py:
$ uvicorn notes_api:app --port 8000
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
In a second terminal:
$ curl -i -X POST -H "Content-Type: application/json" -d '{"title":"Milk"}' http://127.0.0.1:8000/notes
HTTP/1.1 201 Created
...
{"id":1,"title":"Milk","body":""}
$ curl -s -X POST -H "Content-Type: application/json" -d '{"title":""}' http://127.0.0.1:8000/notes
{"detail":[{"type":"string_too_short","loc":["body","title"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}}]}
Stop the server with Ctrl+C.
Common mistakes¶
-
Starting
uvicornfrom the wrong folder. Run it wherenotes_api.pyis. Otherwise you get: -
Declaring a fixed route after a variable one. Routes are matched in the order they are written. If
/notes/{note_id}comes first, thenGET /notes/countis treated as an id and returns:{"detail":[{"type":"int_parsing","loc":["path","note_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"count"}]}Put
/notes/countbefore/notes/{note_id}. -
Forgetting
status_codeon create. APOSTroute returns 200 by default. Addstatus_code=status.HTTP_201_CREATEDto match the REST convention. -
Expecting data to survive a restart.
NoteStorelives in memory, so stopping the server empties it. Lesson 2 showed SQLite storage.
Practice¶
- Add a check to
run_checksthat twoPOSTrequests makeGET /notesreturn a list of length 2. - Add a
GET /healthroute that returns{"status": "ok"}. Assert it withTestClient. - Add an optional
tagsfield toNoteIn: a list of strings with at most 5 items. Assert that six tags return 422. - Add
GET /notes/countthat returns{"count": n}. Place it before/notes/{note_id}. - Stretch: add
GET /notes?q=textthat returns only notes whose title containstext, ignoring case. Withoutq, return every note.
Solution
- Inside
run_checks, before the finalprint:
client.post("/notes", json={"title": "Bread"})
client.post("/notes", json={"title": "Tea"})
assert len(client.get("/notes").json()) == 2
- Add this route, and check it in
run_checks:
@app.get("/health")
def health():
return {"status": "ok"}
assert TestClient(app).get("/health").json() == {"status": "ok"}
- Add the field to
NoteIn, and then assert the rejection. The responses now include"tags": [], so update the two expected dictionaries inrun_checksas well:
tags: list[str] = Field(default_factory=list, max_length=5)
assert client.post("/notes", json={"title": "x", "tags": ["a"] * 6}).status_code == 422
Pydantic 2, which FastAPI installs, reads max_length on a list as the maximum number of items.
- Place this route directly after
list_notes:
- Replace
list_noteswith this version:
@app.get("/notes")
def list_notes(q: str | None = None):
notes = list(store.notes.values())
if q is None:
return notes
return [n for n in notes if q.lower() in n["title"].lower()]
q is read from the query string, so GET /notes?q=mi matches Milk.
Recap¶
- Each route pairs an HTTP method and a path with a function. Return the status code that matches the result, such as 201, 204, or 404.
- Type hints and
BaseModelclasses validate input, and invalid input gets a 422 response before your code runs. - Use
TestClientto test routes in a script, anduvicornto serve them. The in-memory store is a placeholder for a real database.