Skip to content

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, and DELETE on /notes to 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:

  1. NoteIn checks the body. min_length=1 rejects an empty title, and default="" makes body optional.
  2. NoteStore keeps notes in a dictionary, so they are lost when the server stops.
  3. @app.post(..., status_code=201) runs create_note for POST /notes after FastAPI checks the body.
  4. get_note raises HTTPException for a 404. A returned value becomes JSON with status 200.
  5. run_checks sends requests with TestClient, and each assert checks one result.
  6. The if __name__ == "__main__": guard runs the checks only when you run the file directly. uvicorn imports the file without running them.

Try it

Run the checks. The output is a single line:

$ python3 -I notes_api.py
all checks passed

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

  1. Starting uvicorn from the wrong folder. Run it where notes_api.py is. Otherwise you get:

    ERROR:    Error loading ASGI app. Could not import module "notes_api".
    
  2. Declaring a fixed route after a variable one. Routes are matched in the order they are written. If /notes/{note_id} comes first, then GET /notes/count is 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/count before /notes/{note_id}.

  3. Forgetting status_code on create. A POST route returns 200 by default. Add status_code=status.HTTP_201_CREATED to match the REST convention.

  4. Expecting data to survive a restart. NoteStore lives in memory, so stopping the server empties it. Lesson 2 showed SQLite storage.

Practice

  1. Add a check to run_checks that two POST requests make GET /notes return a list of length 2.
  2. Add a GET /health route that returns {"status": "ok"}. Assert it with TestClient.
  3. Add an optional tags field to NoteIn: a list of strings with at most 5 items. Assert that six tags return 422.
  4. Add GET /notes/count that returns {"count": n}. Place it before /notes/{note_id}.
  5. Stretch: add GET /notes?q=text that returns only notes whose title contains text, ignoring case. Without q, return every note.
Solution
  1. Inside run_checks, before the final print:
client.post("/notes", json={"title": "Bread"})
client.post("/notes", json={"title": "Tea"})
assert len(client.get("/notes").json()) == 2
  1. 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"}
  1. Add the field to NoteIn, and then assert the rejection. The responses now include "tags": [], so update the two expected dictionaries in run_checks as 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.

  1. Place this route directly after list_notes:
@app.get("/notes/count")
def count_notes():
    return {"count": len(store.notes)}
  1. Replace list_notes with 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 BaseModel classes validate input, and invalid input gets a 422 response before your code runs.
  • Use TestClient to test routes in a script, and uvicorn to serve them. The in-memory store is a placeholder for a real database.

Sources