Back to top

a notes app in FastAPI step by step

Warning:
the final code is available at this repo (as well as all the steps in the successive commits)

so, our goal here is to show why we're doing things, and in what order
do not try to apply these successive changes yourself in the code !
This would be very time-consuming, you would find yourself spending all your time doing cut-and-paste, and that's not the goal here :)

step 00: the starter code

the starter code in step 00 is the basic FastAPI hello world app

main.py
VERSION = "00"
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return dict(message="Hello FastAPI World!",
version=VERSION)

that you start from the terminal with:

fastapi run --reload

By default this uses port 8000, so no need to specify it even on MacOS (where port 5000, the Flask default, is used by a system service)

01a: using a database

00 -> 01a - changes in main.py

create a database with sqlmodel using a lifespan

we import the sqlmodel module; and in order to create the database, we define the lifespan function.

FastAPI expects this function to be a generator function; this allows to define the application prolog and epilog (the prolog is executed before the first yield statement, and the epilog is executed after that point)

Note how the app variable is created by passing the lifespan function to the FastAPI constructor.

With this version, the database is created and populated when the application starts (although at this point the database is empty)

01b: a DB session

01a -> 01b - changes in main.py

defining SessionDep for interacting with the DB

this is boilerplate code to expose a session object, which we'll need to actually talk to the database

it's interesting to note: this pattern is called "dependency injection"; the SessionDep object allows to reference a session object even though we do not explicitly create it; by defining get_session, we just explain how to create it, the framework will ensure its creation will happen before the first use

02a: POST /api/notes to create notes

01b -> 02a - changes in main.py

a table to store notes, and an API endpoint to create them

this is typical code to create a table in a database; note the table=True passed to the Note class
this is what defines the actual set of columns in the database table

as far as the API is concerned, we define a POST endpoint to create a note
in particular, note how minimal the code is; we don't need to make any check on the incoming data, it will automatically be checked for consistency; and the framework does that based on the note: Note argument type
generally speaking, FastAPI will automatically check the types of the arguments, based on such type hints, and will return a 422 error if the types do not match
similarly, having typed the return value of the function as Note, FastAPI will automatically convert the returned object to JSON, and set the appropriate HTTP status code (201)

02b: GET /api/notes endpoint

02a -> 02b - changes in main.py

is that simple or what ?

we will see later on how to control the fields that are returned in the JSON response

02c: GET /api/notes/id endpoint

02b -> 02c - changes in main.py

how to get all the details on a specific note

as you can see, the code is as simple as it gets
here again, FastAPI takes advantage of the type hints to automatically return a JSON-encoded dictionary, and we control the actual set of fields exposed in the result by stating that the function returns a Note object

03a: expose static files

02c -> 03a - changes in main.py

expose /static/ folder with the CSS style

ok so at this point we want to expose a new endpoint, /front/notes, which will return the HTML page to display, and later on interact with, the notes.

in order to do that, we need to expose some static content; for starters we have a stylesheet, that we store in static/style.css; we're not going to detail the CSS here, as we want to focus on the backend side of things

but it's important that the browser can access this file; and the way to do that as shown here, by "mounting" the local static folder to the /static URL.

03b (1/2): GET /front/notes returns templated HTML

03a -> 03b - changes in main.py

serve a HTML page from a Jinja template

this new /front/notes/ endpoint will return a HTML page, which is rendered from a Jinja template

templating

worth being noted here, is the way the data is passed from Python to the template, by setting the context parameter of the render_template function

fetching data

another important point is this:
for fetching the list of notes, we could have gotten it from the database directly, using this code

def notes_page(request: Request, session: SessionDep):
notes = session.exec(select(Note)).all()
return templates.TemplateResponse(
request=request,
name="notes.html.j2",
context={"notes": notes})

and that would have worked well
however we choose to use the /api/notes endpoint instead, which is a bit more complex, but allows for better flexibility in the long run, especially because the first step for scaling up will be to separate, on the one hand, the service that serves the frontend, and on the other hand the API service itself (and the DB service as well, for that matter)

03b (2/2): GET /front/notes returns templated HTML

03b : new file notes.html.j2

a Jinja template for notes

here's the Jinja template for the notes page;
as you can see it's mostly HTML, with a few Jinja tags to insert the data from the FastAPI backend.

The Python code renders this template with a context, which is a dictionary of variables that are passed to the template.

In this template we see a few of the Jinja features in action:

{% for note in notes %}

For example this template iterates over the notes variable, which is expected to be a list of dictionaries, each one representing a note.

Once rendered, this template will thus contain as many <li> elements as there are notes in the notes list.

{{ note.title }}

Using the {{ ... }} syntax, we can insert the value of a variable in the template.

{% if note.done %}

We can also see in this example a use of a conditional statement.

04: redirect / to /front/notes

03b -> 04 - changes in main.py

a simple redirect

generally, people will just type a URL with the domain name in their browser; i.e.
https://awesomenotes.io/
and not
https://awesomenotes.io/front/notes

so it's good practice that the / URL redirects to the /front/notes URL, which is what we do here

and in order to still get a way to see the current version of the API, we pass another variable to the template context; this is used in the .j2 template (not shown here) to display the current version of the API

05: PATCH /api/notes endpoint to change a note

04 -> 05 - changes in main.py

allow callers to modify a note

It's cool to be able to create a note, but we also want to be able to modify it

it is the purpose of this new PATCH endpoint

Please note in this code:

  • the use of the Body annotation; this means that the payload argument is expected to be in the body of the request, and not in the URL or headers
  • again we use the Note class to define the type of the payload; this means that FastAPI will automatically check that the incoming data is consistent with the Note class, and will return a 422 error if it is not
  • also interesting, this line
    db_note.sqlmodel_update(payload.model_dump(exclude_unset=True))
    which allow us to safely update the note with the new values;
    compare this with a tedious code were we would consider each field one by one, checking of that field is provided or not by the caller, and update the object accordingly
    clearly this one-liner is much more readable and maintainable !

06: clicking done is published to the backend

06 : new file update-backend.js

a JS script to update the backend

here we define a (globally visible) function note_done_changed; its purpose is to be bound to the done checkbox of each note in the UI

and as you can see its job is simply to post a PATCH api request to the backend, with the new value of the done checkbox

07 (1/2): the UI can create a note

06 -> 07 - changes in notes.html.j2

a new form in the template

This is a typical example of a HTML <form> element. As the name suggests, it groups all the input fields that are needed for a specific API call, here a note creation.

Thanks to the JS code added in the same step, clicking the submit button - or hitting the enter key - will trigger the API call that will create a new note.

note: you could be tempted to create a new note in the UI upon completion of the API call. However we're not going to do it that way, because later on we will implement a broadcast system where the server will notify all clients of a new note. This way, all clients will be able to see the new note without having to refresh the page.
And so in particular, the UI that has created the note will create the corresponding UI element just like any other UI.

07 (2/2): the UI can create a note

07 : new file forms-use-json.js

HTML forms with JSON

HTML forms can have an action attribute that points to a URL. When the form is submitted, the browser sends a request to that URL with the form data. The server can then process the data and return a response.

However, the default way for forms to do that relies on older technologies like application/x-www-form-urlencoded or multipart/form-data. These formats are not as flexible or powerful as JSON.

This script allows you to use JSON to send form data; it is generic and can be used with any form. It uses the fetch API to send the data as JSON to the server.

Note the use of the call to event.preventDefault() to prevent the default form submission behavior.

08: DELETE /api/notes/id

07 -> 08 - changes in main.py

DELETE /api/notes/id endpoint to delete a note

nothing really special here. Just a typical DELETE endpoint that will delete a note from the database.

09 (1/2): the UI can delete a note

08 -> 09 - changes in update-backend.js

a new function in JS

we define the new function note_delete in update-backend.js to delete a note.

it simply sends a DELETE request to the backend with the note ID.

09 (2/2): the UI can delete a note

08 -> 09 - changes in notes.html.j2

add a button to delete a note

the HTML template now also create a button to delete the note
notice how the note id is passed to function note_delete

10a: fine-grained types NoteCreate NoteUpdate Note

09 -> 10a - changes in main.py

more elaborate typing

until now we had only one Note type; in practice it is often useful to define several several types for the same entity, because you do not expect all the fields to be present in all cases.

for example here what we choose to do is to define:

  • NoteCreate, the type used for creating a note, that only has a title and a description; so notes are always created with their done field set to False;
  • NoteUpdate, which contains the same 2 fields augmented with the done field; indeed we want it to be possible to change all 3 fields - at some point one will want to change done to True, and users can also change the title and description;
  • Note, the full type that contains all 3 fields plus the id field

10b: use the new types

10a -> 10b - changes in main.py

using the new types

we can now take advantage of these different types to annotate the various methods, and FastAPI will automatically adjust the allowed fields in each request body accordingly

in particular with the choice made above, it is no longer possible to pass a value for done at creation time - and this is by design

11a (1/2): the WebsocketBroadcaster class

10b -> 11a - changes in main.py

create a broadcaster instance

our application needs to maintain a list of connected clients, so the main program creates one instance of the WebSocketBroadcaster class - this is known as a singleton pattern.

next we'll see how to take advantage of this instance for reacting on incoming websocket connections

11a (2/2): the WebsocketBroadcaster class

11a : new file broadcaster.py

a broadcaster class

for starters, we define in broadcaster.py a class WebSocketBroadcaster, that will be in charge of

  • registering all the connected clients (i.e. browsers)
  • broadcasting messages to all the clients
  • remove then ones that are disconnected

we will see in a bit how this is used in the application

11b: /ws endpoint

11a -> 11b - changes in main.py

the /ws endpoint

so far our application has several endpoints that are all pure HTTP endpoints - they react to GET, POST and similar verbs;
when it comes to websockets, things are a bit different, there is no verb, and the connection is kept open for a long time so the endpoint has a rather different structure, primarily it describes the behaviour of the server during the whole connection lifetime, rather than just the one request and one response that we have seen so far

so here, we basically say this:
each time a browser connects to this /ws/ endpoint:

  • first we register the client in our broadcaster instance
  • then we keep the connection open indefinitely, and we wait for messages from the client

it turns out in our application, we use websockets only as a back channel to send messages from the server to the client, so we don't need to do anything with the messages that are sent by the client

11c: inject ws-listener.js in template

11c : new file ws-listener.js

the browser listens on the WS channel

in this new JS file, we program the browser: it will initialize a WebSocket connection to the server and handle incoming messages; for now in this early version, we simply assume incoming messages contain JSON data, so we docode it and display it in the console

this new script is then simply included in the HTML template (we omit this diff here)

so at this point, our application has the basic WS infrastructure:

  • each time a browser opens the notes page, it connects to the server on the /ws/ endpoint
  • the server knows the list of all connected clients
  • also the server can detect when a client disconnects

we still need - see next step - to take advantage of that back channel, and to have the server notify its clients of all the changes made to the notes

12: the api endpoints broadcast the changes on WS

11c -> 12 - changes in main.py

changes are broadcast to all clients

now that we have a back channel for the server to talk to clients, let 's use it to broadcast changes to all clients

this now is a pretty straightforward change; as you can see, the messages that are sent on the ws channel are all of the form

{
"action": "create", # or "update" or "delete"
"note": {
... the details of the note
}
}

13a (1/2): handle incoming WS messages in the frontend

12 -> 13a - changes in ws-listener.js

bind the callbacks to events

we can easily connect the callbacks to the events that we want to listen to

13a (2/2): handle incoming WS messages in the frontend

13a : new file clientside-rendering.js

the skeleton for actions callbacks

in this first, incomplete step, we create a new JS file in which we create 3 callback functions that will handle events related to changes in the DB.

like always, this new JS file is also mentioned in the HTML template (not detailed here)

13b: define id=note-

13a -> 13b - changes in notes.html.j2

keep track of note ids in the HTML

with this simple change, we can now keep track of the ids of the notes in the HTML tree

this will come in handy in the next steps, when we will need to actually apply changes (received on the ws channel) to the corresponding notes in the DOM.

13c: CSR on note creation

13b -> 13c - changes in clientside-rendering.js

rendering new notes

so, to summarize; at this point:

  • when the browser connects to the server, the page rendered by the server is obtained from the template; this is called SSR (Server Side Rendering)
  • however when a new note gets created (either throught the API or by another browser), the server broadcasts the new note to all clients

and so at this point we still have to write the code for the browser to display the new note; and this is called CSR (Client Side Rendering)

there is obviously some redundancy here; indeed it is important that the CSR code creates a structure that is exactly similar to what is served by SSR in the first place

for now, we simply duplicate - or rather, mimick - the SSR code into our clientside-rendering.js file

in this step, we address note creation

13d: CSR on note update

13c -> 13d - changes in clientside-rendering.js

CSR for note updates

this now is about client-side rendering of note updates; this is a tad more tricky than pure creation, in particular we're using the fact that notes <li> elements have their id based on the note DB id, so we can easily find the corresponding DOM element to update

13e: CSR on note deletion

13d -> 13e - changes in clientside-rendering.js

CSR deletion

and finally we code the way the browser must react upon note deletion; it is an easy task

at this point we have a fully functional app, with which we can safely create, update and delete notes in a consistent way across all clients, and ensure we always get an accurate view of the state of all notes

........
-
VERSION = "00"
+
VERSION = "01a"
+
+
from contextlib import asynccontextmanager
from fastapi import FastAPI
+
from sqlmodel import SQLModel, create_engine
-
app = FastAPI()
+
SQLITE_URL = f"sqlite:///notes.db"
+
engine = create_engine(SQLITE_URL)
+
+
# this is how we control what is done at startup and shutdown
+
@asynccontextmanager
+
async def lifespan(app: FastAPI):
+
# startup logic comes here
+
# Create the database and tables if they don't exist
+
SQLModel.metadata.create_all(engine)
+
+
yield
+
# shutdown logic comes here
+
# none so far
+
+
+
# Create the FastAPI app with the lifespan context manager
+
app = FastAPI(lifespan=lifespan)
@app.get("/")

conclusion

and a few words to conclude

one caveat

as pointed out towards the end, our code still suffers from a few issues; the most important one being maybe the code duplication between the SSR code (the HTML template) and the CSR code (the JS code in the last 3 steps)

one way to circumvent this would be to

  • send an empty template (just the page outer layout, and just a place holder for the content)
  • and then use WS to notify the newly-connected browser about the content to load

this improvement is left to the reader as an exercise

splitting into services

here's how the whole system could be split into three different services

  • front.notes.io serves only the frondend (i.e. HTML, CSS, JS)
  • api.notes.io serves the API and outputs JSON data
  • db.notes.io serves the database, and talks SQL; only the API service is supposed to access it

WebSocket pros over HTTP

here's a small figure that outlines the pro of websockets over regular HTTP traffic

SocketIO

FYI it may be even simpler to use a library like SocketIO that abstracts away the details of the underlying protocol (here WebSocket) and provides a simple API for real-time communication.
For example, our code does not handle reconnections, which is a common issue in real-time applications; using SocketIO would take care of that for us.

see e.g. this library for a library that integrates SocketIO with FastAPI