How a click reaches a database and comes back
Start hereFrom click to row and back again
Press Buy on a vending machine page and the number of snacks left changes a moment later. In this tutorial you build that whole machine, one piece at a time, and you watch a click travel from the page to a database and back.
You will build
- A database that stores the machine's slots.
- A server that answers requests about those slots.
- A page with a Buy button.
- A short script that connects the page to the server.
You will learn
- What a database, a server and an API each do.
- How a click becomes a request, a query and a changed screen.
- How to watch that traffic in your browser.
You need
- Python 3.10 or newer. Nothing else needs installing with pip.
- A terminal and a code editor.
- A browser with developer tools. Chrome, Edge and Firefox all have them.
In this edition the server, the setup script and the database code are written in Python. The page is plain HTML and CSS plus one short script of about fifty lines. That script is JavaScript, because a browser can run only JavaScript. It does one job: it asks the server for data and changes the screen to match.
How to use this page
- Work through Steps 1 to 6 in order. Each step has a goal, the code to make, a check, and a short explanation.
- Code appears in small pieces, with the explanation beside each piece.
- A badge such as Station 4 shows where a piece of code sits in the journey from Step 1, with the station before it and the station after it.
- A box headed A prediction to make first asks you to guess before you read on. A wrong guess is fine, because the gap between your guess and the answer is where you learn.
- A box marked Optional holds extra detail that you can skip.
- The Going deeper sections after Step 6 cover HTTP, experiments and design choices.
Try it firstThe finished machine, running in your browser
Optional:What is real here, and what is simulated
A web page cannot open a network port, so no real server program is running. Three real things are running.
- Pyodide is the Python interpreter compiled for browsers. It runs the real
setup_db.pyand the real data layer and route table fromserver.py, including real SQL on a real SQLite database. - The page's own script,
app.js, is the file you will write in Step 5, with no changes. - A service worker, a script that sits between a page and the network, catches each request whose address starts with
/api/and hands it to Python.
The simulated part is the wire. No bytes travel between two programs, so Stations 3 and 9 of the journey in Step 1 are imitated, and the HTTP layer of the server is replaced by the short file below. That replacement is possible because the layers were kept apart.
The Station badges beside the code point to the journey that Step 1 introduces.
"""A stand-in for the HTTP layer of server.py, for use inside a browser.
A browser cannot open a network port, so the real HTTP layer (the class that
reads sockets) cannot run here. The data layer and the route layer in
server.py do not need sockets, so we import them unchanged and write only
this small replacement for the top layer.
"""
import json
from urllib.parse import urlsplit
import serverThis file is all of the new code. A browser cannot open a network port, so the bottom layer of our server, the one that reads from sockets, has no place to run. The two layers above that one never needed sockets, so they run unchanged, and we write only a replacement for the top.
import server loads the real server.py. Because of the __main__ guard, importing server.py does not start a server.
def answer(http_method, path):
"""Answer one request and return (status_code, body_text)."""
# The path may carry a query string, so we keep only the path.
url_path = urlsplit(path).path
try:
# The same route table that the real server uses. The first match wins.
for route_method, path_pattern, handler in server.ROUTES:
path_match = path_pattern.fullmatch(url_path)
if route_method == http_method and path_match:
return 200, json.dumps(handler(*path_match.groups()))
return 404, json.dumps({"error": "No such route."})The function takes the method and path as the browser sent them and returns a status code and body text.
- The loop is the one from the real
answer_request. The loop walks the realROUTEStable, and the first matching row calls the real handler. - The data layer then runs real SQL on a real SQLite database, which here lives in the page's memory.
# The same translation from exceptions to status codes as the real server.
except server.BadRequest as error:
return 400, json.dumps({"error": str(error)})
except server.NoSuchSlot as error:
return 404, json.dumps({"error": str(error)})
except server.SoldOut as error:
return 409, json.dumps({"error": str(error)})These lines repeat the translation from exceptions to status codes, because that translation belonged to the HTTP layer we replaced. A request for B2, which has no stock, ends here as a 409 with the same message the real server sends.
The real server's headers include Server and Date. This reply has neither, because no real server wrote it.
- Want to change the pages, the SQL or the server and watch each request travel? The workbench shows the browser, the server and the database side by side, with a timeline.
- When you are ready, start with Step 1. In Step 6 you run a real server on your own computer.
Step 1Follow one click
Press Buy on slot A1. The screen changes after a few milliseconds, and those milliseconds hold twelve stops, which this tutorial calls stations. Ten words appear on the diagram, so here they are first, in plain terms. Each one gets a fuller explanation in the step that builds it.
- request: A message from the browser that says what it wants.
- response: The message that the server sends back.
- fetch: The browser function that sends an HTTP request from a script and returns a promise.
- route table: A list that pairs each method and path pattern with the function that handles it.
- handler: The function that answers one kind of request.
- SQL: Structured Query Language. The language for asking a relational database to find or change rows.
- connection: An open line of communication between a program and a database.
- transaction: A group of database steps that either all happen or none happen.
- JSON: JavaScript Object Notation. A text format for structured data, used by most web APIs.
- DOM: Document Object Model. The browser's live tree of objects, one for every element on the page.
Use the buttons under the diagram to step through the stations. In the diagram and in the text, a dotted underline marks a key word, and hovering over it shows what it means.
In flight at this station
Reference:The twelve stations as a list
- The browser notices the click. The page has a script that listens for clicks. The script wakes up when you press Buy. A click is one kind of event, a message the browser creates whenever something happens on the page.
- The script builds a request. The script sends a message to the server, called a request. Here the request says please buy one item from slot A1. The script sends it with
fetch, a function that the browser provides. - The request travels. The message goes to an address with two parts: the host, which is the computer, and the port, which is the program on that computer. Here both are on your own machine.
- The server chooses a handler. The server is a program that waits for requests. The server reads the method and the path of the request, and picks the function that deals with that request. That function is called the handler.
- The handler checks the input. The handler checks that the slot id looks right, such as A1. Anything else is turned away with a message. Checking input like this is called validation.
- The handler sends SQL to the database. SQL is the language for asking a database to find or change rows. The handler sends one SQL statement through a connection, which is an open line between the program and the database.
- The database changes the row. The database takes one away from the stock and says how many rows it changed. The database does this as a transaction, which means the change either happens completely or not at all.
- The handler packages the result. The result becomes JSON, a text format that other programs can read. Only bytes can cross a network, so structured data has to become text first. This step is called serialising.
- The server sends a response. The response holds a status code that says how it went (200 means it worked), some headers that describe the message, and a body that carries the JSON.
- The reply reaches the script. The script receives the JSON and turns it back into an object. Waiting for a reply takes time, so
fetchgives the script a promise, which stands for a value that has not arrived yet. - The script changes the DOM. The DOM is the browser's live tree of the elements on the page. The script edits that tree: new text, or a button that becomes disabled.
- The browser repaints the screen. The browser notices that the tree has changed and redraws. You write no code for this step, and that is why the screen updates.
Step 2Build the database
Why a database?
A page forgets everything when it reloads, and a server forgets everything when it stops. The stock count has to live somewhere that outlasts both, and every visitor has to see the same count. A text file could hold the numbers, but then you would solve several hard problems yourself. A database has already solved them. A relational database keeps its data in tables of rows and columns.
A database gives you five things:
- Persistence. The data survives when the programs stop.
- Structure. A schema declares the tables, the columns and the type of each column.
- Queries. SQL asks for exactly the rows you want, and the database finds them quickly.
- Safe changes. A transaction makes a group of changes all-or-nothing, and keeps simultaneous users from spoiling each other's work.
- Rules. A constraint such as
CHECK (stock >= 0)makes the database refuse bad data, even when a bug in your code tries to write it.
The machine needs one table. Its primary key, slot_id, is a column whose value differs in every row, so it identifies exactly one row.
| slot_id | item_name | price_cents | stock |
|---|---|---|---|
| A1 | Sparkling Water | 175 | 8 |
| A2 | Salted Peanuts | 125 | 5 |
| B1 | Chocolate Bar | 200 | 3 |
| B2 | Oat Biscuits | 150 | 0 |
Optional:Why the price is a whole number of cents
Computers store fractions in binary, and many decimal fractions cannot be written exactly in binary. In most languages 0.1 + 0.2 gives 0.30000000000000004. A shop that adds up prices like that will be wrong by a cent sooner or later. Whole numbers are exact, so the table stores 175 cents and the page converts to euros only when it shows the price.
Optional:Where SQLite fits
SQLite is a database that lives inside a library that your program loads. SQLite keeps everything in one ordinary file and needs no installation and no separate server. Larger systems such as PostgreSQL and MySQL run as their own server programs. All of them speak SQL, so what you learn here carries over. SQLite suits a first lesson the way a quiet car park suits a first driving lesson: nothing gets in the way.
- Create a folder called
vending-pyand open a terminal in it. - Create a file called
setup_db.pyand type or paste the code below. - Run
python setup_db.py(on macOS or Linux you may needpython3).
"""Create the vending machine database and fill it with a few snacks.
Run this file once before you start the server.
Running it again resets the machine to its starting state.
"""
import sqlite3
from contextlib import closing
from pathlib import Path
# The database is one ordinary file that sits beside this script.
# Path(__file__).parent is the folder that holds this script, so the file
# ends up in the same place no matter where you run the script from.
DATABASE_FILE = Path(__file__).parent / "vending.db"The top of the file says what it is for and imports three tools.
sqlite3is Python's built-in driver for SQLite, a library that knows how to talk to one kind of database.closingmakes sure a connection is closed when we finish with it.Pathbuilds file locations that work on any operating system.
The database is one file, vending.db. The path starts from __file__, the location of this script, so the file lands beside the script wherever you run it from.
# The schema describes the shape of our data: one table with four columns.
# The comments after the double dashes are SQL comments.
SCHEMA = """
DROP TABLE IF EXISTS inventory;
CREATE TABLE inventory (
slot_id TEXT PRIMARY KEY, -- identifies one slot, for example A1
item_name TEXT NOT NULL, -- every slot must have a name
price_cents INTEGER NOT NULL CHECK (price_cents >= 0), -- whole cents, never decimals
stock INTEGER NOT NULL CHECK (stock >= 0) -- the database refuses negative stock
);
"""This text is the schema, written in SQL.
DROP TABLE IF EXISTSdeletes any old table, so running the script again resets the machine. That is handy in a classroom and dangerous anywhere else.slot_idis the primary key. The database refuses a second row with the same value and builds an index so that lookups by slot are fast.- The two
CHECKlines are constraints. The database enforces them even when our code has a bug. - Money is stored as whole cents.
# Each tuple is one row: (slot_id, item_name, price_cents, stock).
# The Oat Biscuits start with no stock so that we can test a sold-out slot.
STARTING_STOCK = [
("A1", "Sparkling Water", 175, 8),
("A2", "Salted Peanuts", 125, 5),
("B1", "Chocolate Bar", 200, 3),
("B2", "Oat Biscuits", 150, 0),
]A list of tuples, which are fixed groups of values, one tuple per row. They follow the column order of the INSERT statement below, which adds one row to the table. The Oat Biscuits start with zero stock on purpose, so that you can see a sold-out slot without buying everything first.
# closing() makes sure that the connection is closed when the block ends.
with closing(sqlite3.connect(DATABASE_FILE)) as connection:
# "with connection" groups the work into one transaction. If every
# statement succeeds, the changes are committed together. If one fails,
# the changes since the last commit are rolled back.
with connection:
# executescript runs several SQL statements in one call. It commits
# anything that came before it, so creating the table is its own step.
connection.executescript(SCHEMA)
# The question marks are placeholders. The values travel to the
# database separately from the SQL text. The four inserts that follow
# belong to one transaction, so either all of them are stored or none.
connection.executemany(
"INSERT INTO inventory (slot_id, item_name, price_cents, stock) VALUES (?, ?, ?, ?)",
STARTING_STOCK,
)- We open a connection. For SQLite this opens the file, and creates it if it does not exist yet.
- The inner
with connection:is a transaction. Python commits when the block ends normally and rolls back if an exception occurs, so either all four rows are stored or none is. executescriptcommits whatever came before it, so creating the table is a separate step and the transaction protects only the four inserts.- The question marks are placeholders. The SQL text and the values reach the database separately, and the experiments after Step 6 show why that matters.
# Read the rows back so that we can see what was stored.
for row in connection.execute(
"SELECT slot_id, item_name, price_cents, stock FROM inventory ORDER BY slot_id"
):
print(row)Finally we read the rows back, so that you can see what was stored. connection.execute returns a cursor that we can loop over, and each row arrives as a tuple.
Expected output
('A1', 'Sparkling Water', 175, 8)
('A2', 'Salted Peanuts', 125, 5)
('B1', 'Chocolate Bar', 200, 3)
('B2', 'Oat Biscuits', 150, 0)Step 3Build the server and its API
Two programs, one conversation
- A client starts a conversation. Your browser is the client.
- A server waits for conversations to start. Your Python program is the server.
- The client needs an address. The host says which computer, and the port says which program on that computer. We use host
127.0.0.1, which always means this computer, and port8000. - Host, port and path together make a URL, such as
http://127.0.0.1:8000/api/slots/A1/buy. The server compares the path with its route table, a list with one row for each kind of request. The matching row names the handler, the function that does the work.
Why the page never talks to the database
The visitor owns the browser. They can read every line of the page, change it, run any script in the console, or skip the page and send requests by hand. A check that exists only in the browser can always be skipped. So the rules of the machine live on the server, and the server is the only program with a route to the database.
Look at how Buy is designed. The browser names a slot and nothing else. The browser does not say what the price is or which SQL to run. Those decisions belong to the server.
Design the API before you write it
An API (Application Programming Interface) is the part of a program that other programs may use. A web API makes that part available over HTTP, and usually replies in JSON. Designing one means four decisions:
- Which things does the API offer? Each thing gets a URL, called an endpoint. Ours are all the slots and each single slot.
- Which actions are allowed? The method names the action.
GETreads, andPOSTchanges something. - What shape does the data have? The contract lists the fields and their types.
- How are outcomes reported? A status code gives the category, and a JSON message gives the detail. 200 means success, 400 means the client asked badly, 404 means nothing is there, and 409 means the request clashes with the current state.
| Method | URL | What it does | On success | On failure |
|---|---|---|---|---|
GET | /api/slots | Lists every slot | 200 and a list of slots | None expected |
GET | /api/slots/A1 | Shows one slot | 200 and one slot | 400 for a malformed id, 404 for a missing slot |
POST | /api/slots/A1/buy | Buys one item | 200 and the updated slot | 400, 404, or 409 when sold out |
Two shapes of JSON cross the network, a slot and an error:
A slot, followed by an error
{
"slot_id": "A1",
"item_name": "Sparkling Water",
"price_cents": 175,
"stock": 7
}
{ "error": "Oat Biscuits is sold out." }The page and the server are written at different times. If the server renames stock to quantity, the page breaks without any warning. So write the contract down, even if only as a table like the one above.
Build it in three layers
- The data layer talks to the database and knows nothing about HTTP.
- The route layer connects URLs to data functions and checks input.
- The HTTP layer reads requests and writes responses, and the last lines start the server.
- Each layer uses only the one beneath it, so you can change one without disturbing the others. The file lists the layers in this order.
- The diagram below names the functions that you will write in each layer.
- In
vending-py, create a file calledserver.pyand type or paste the code below. - Run
python server.py. The server stays running, and that is correct. - Visit
http://127.0.0.1:8000/api/slotsin a browser.
"""A small web server that sells snacks from a SQLite database.
The file has four layers, from the bottom of the program to the top:
1. The data layer talks to the database. It knows nothing about HTTP.
2. The route layer connects URLs to data functions and checks the input.
3. The HTTP layer reads requests and writes responses.
4. The last lines start the server.
"""
import json
import re
import sqlite3
from contextlib import closing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
# --- Settings ----------------------------------------------------------------
# 127.0.0.1 means "this computer", so only programs on your own machine can connect.
HOST = "127.0.0.1"
# The port decides which program on this computer receives the request.
PORT = 8000
DATABASE_FILE = Path(__file__).parent / "vending.db"
PUBLIC_FOLDER = Path(__file__).parent / "public"
# The only files that the server will send. Each URL path maps to a file name
# and a Content-Type. A fixed list stops visitors from asking for other files,
# such as server.py or vending.db, by typing a path like /../server.py.
STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"),
"/style.css": ("style.css", "text/css; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
}
# A valid slot id is one capital letter followed by one digit, such as A1 or B2.
SLOT_ID_PATTERN = re.compile(r"[A-Z][0-9]")The imports use only the standard library, so nothing needs installing.
http.serversupplies the pieces of a web server.urlsplittakes a URL apart,rehandles text patterns, andjsonconverts between text and data.- Names in capitals are constants, a convention for values that never change while the program runs.
HOSTdecides who may connect.127.0.0.1allows only your own computer, which is the safe choice while you develop.PORTdecides which program receives the request.STATIC_FILESlists the only files the server will send. If the server sent whatever file the URL named, a visitor could ask for/../server.pyand download our code and our database. That attack is path traversal.SLOT_ID_PATTERNis a regular expression, a compact pattern for text. This one means one capital letter followed by one digit.
# --- Errors ------------------------------------------------------------------
# The data layer reports problems by raising these exceptions. Each name
# describes a problem in the language of the vending machine, and the HTTP
# layer decides which status code each problem deserves.
class BadRequest(Exception):
"""The client sent something we cannot make sense of."""
class NoSuchSlot(Exception):
"""The slot id is well formed, but no such slot exists."""
class SoldOut(Exception):
"""The slot exists, but it has no stock left."""The program expects three kinds of trouble, and each gets its own exception class. The classes have no body, because only their names matter.
The data layer never mentions HTTP. When something goes wrong it raises an exception named after what went wrong in the machine, such as SoldOut, and one place near the end of the file turns those names into status codes. So we could reuse the data functions in a program that has no web server at all.
# --- Data layer --------------------------------------------------------------
# These functions are the only place where the program talks to the database.
def open_connection():
"""Open a new connection to the database file."""
connection = sqlite3.connect(DATABASE_FILE)
# With sqlite3.Row we can read a column by name, as in row["stock"],
# and not only by position.
connection.row_factory = sqlite3.Row
return connectionopen_connection opens a new connection to the database file. Every request opens its own.
ThreadingHTTPServerhandles each request in a separate thread.- By default a SQLite connection may be used only by the thread that made it.
- Opening one costs very little, so a fresh connection per request is safe and simple.
A server for PostgreSQL would keep a pool of ready connections instead, because opening those is slow.
def find_slot(connection, slot_id):
"""Return one slot as a dictionary, or None if there is no such slot."""
# The ? is a placeholder. The value travels separately from the SQL text,
# so a visitor can never change what the SQL means (SQL injection).
# A tuple with one item needs the trailing comma.
row = connection.execute(
"SELECT slot_id, item_name, price_cents, stock FROM inventory WHERE slot_id = ?",
(slot_id,),
).fetchone()
return dict(row) if row else None
def get_all_slots():
"""Return every slot as a list of dictionaries."""
with closing(open_connection()) as connection:
# Without ORDER BY, SQL promises no particular order for the rows.
rows = connection.execute(
"SELECT slot_id, item_name, price_cents, stock FROM inventory ORDER BY slot_id"
).fetchall()
return [dict(row) for row in rows]
def get_slot(slot_id):
"""Return one slot, or raise NoSuchSlot."""
with closing(open_connection()) as connection:
slot = find_slot(connection, slot_id)
if slot is None:
raise NoSuchSlot(f"No slot called {slot_id}.")
return slotThree functions read data.
find_slotruns a parameterised query. The?marks where a value goes, and the tuple supplies it separately.fetchonereturns one row, orNonewhen nothing matched.dict(row)turns the row into a dictionary, which is the second shape from Step 5.get_all_slotsandget_sloteach open a connection, read and close it.with closing(...)guarantees the close, whatever happens inside.get_slotraisesNoSuchSlotwhenfind_slotfinds nothing.
def buy_slot(slot_id):
"""Take one item from a slot and return the updated slot."""
with closing(open_connection()) as connection:
# "with connection" is one transaction. The changes are committed if
# the block ends normally and rolled back if an exception escapes.
with connection:
# One statement checks the stock and changes it. If two buyers
# arrive at the same moment, the database handles them one after
# the other, so they cannot both take the last item.
update_result = connection.execute(
"UPDATE inventory SET stock = stock - 1 WHERE slot_id = ? AND stock > 0",
(slot_id,),
)
# Read the slot inside the same transaction, so that the numbers
# we report agree with the change we just made.
slot = find_slot(connection, slot_id)
# rowcount is the number of rows that the UPDATE changed.
if slot is None:
raise NoSuchSlot(f"No slot called {slot_id}.")
if update_result.rowcount == 0:
raise SoldOut(f"{slot['item_name']} is sold out.")
return slotThis is the most important function in the file.
- We could read the stock, test it in Python and write the new value. That leaves a gap in which two buyers could both see a stock of 1 and both take the last item.
- One
UPDATEstatement, which changes a row that is already in the table, leaves no gap, because the database runs it as a single indivisible step. The section on design choices compares the two plans in a diagram. update_result.rowcountsays how many rows changed. Zero means the slot does not exist or its stock was already zero, andslottells us which.- We read the slot inside the same transaction, so the stock we report matches the change we made.
- After the transaction closes we decide what happened: no row means
NoSuchSlot, zero changes meansSoldOut, anything else is a purchase.
# --- Route layer -------------------------------------------------------------
# A handler connects one URL to one data function. It checks the input, calls
# the data layer and returns data. It contains no SQL and no HTTP details.
def validate_slot_id(raw_slot_id):
"""Tidy up a slot id from the URL and check that it has the right shape."""
# upper() lets a visitor type a1 and mean A1.
slot_id = raw_slot_id.upper()
if not SLOT_ID_PATTERN.fullmatch(slot_id):
raise BadRequest("Slot ids look like A1 or B2.")
return slot_idEverything that comes from the URL is untrusted text. validate_slot_id cleans up that text (turning a1 into A1), checks that the whole text has the expected shape, and raises BadRequest if not.
The queries are already safe from SQL injection because they use parameters. This check is a second layer that gives a clear error early and keeps odd text out of our logs.
def handle_list_slots():
return get_all_slots()
def handle_show_slot(raw_slot_id):
return get_slot(validate_slot_id(raw_slot_id))
def handle_buy_slot(raw_slot_id):
return buy_slot(validate_slot_id(raw_slot_id))
# The route table is the API written as data. Each row holds an HTTP method, a
# pattern for the URL path, and the handler for that route. In a pattern,
# ([^/]+) captures the text between two slashes, and the captured text is
# passed to the handler as an argument.
ROUTES = [
("GET", re.compile(r"/api/slots"), handle_list_slots),
("GET", re.compile(r"/api/slots/([^/]+)"), handle_show_slot),
("POST", re.compile(r"/api/slots/([^/]+)/buy"), handle_buy_slot),
]The three handlers are thin. Each connects a URL to a data function and validates the input on the way.
ROUTES is the API written as a table.
- In a pattern,
([^/]+)means one or more characters that are not a slash. - The parentheses capture them, so the text between the slashes becomes an argument to the handler.
- Every web framework builds a table like this for you. The design choices section shows what that looks like.
# --- HTTP layer --------------------------------------------------------------
class VendingRequestHandler(BaseHTTPRequestHandler):
"""Python creates one of these objects for every request that arrives."""
# BaseHTTPRequestHandler calls do_GET for GET requests and do_POST for
# POST requests. We send both to one method of our own.
def do_GET(self):
self.answer_request("GET")
def do_POST(self):
self.answer_request("POST")BaseHTTPRequestHandler is a standard library class that reads the raw request for us. The class calls the method whose name matches the HTTP method, so a GET calls do_GET. We send both methods to one method of our own, answer_request, and pass the method's name along.
def answer_request(self, http_method):
# self.path includes any query string, so we keep only the path.
url_path = urlsplit(self.path).path
try:
# First, is the visitor asking for one of our page files?
if http_method == "GET" and url_path in STATIC_FILES:
self.send_static_file(url_path)
return
# Otherwise look for a matching route. The first match wins.
for route_method, path_pattern, handler in ROUTES:
path_match = path_pattern.fullmatch(url_path)
if route_method == http_method and path_match:
# groups() holds the captured text, such as "A1".
self.send_json(200, handler(*path_match.groups()))
return
# Nothing matched.
self.send_json(404, {"error": "No such route."})
# These lines turn the exceptions of the data layer into status codes.
except BadRequest as error:
self.send_json(400, {"error": str(error)})
except NoSuchSlot as error:
self.send_json(404, {"error": str(error)})
except SoldOut as error:
self.send_json(409, {"error": str(error)})
except Exception as error:
# Anything else is a bug in our program. We print the details for
# the developer and tell the visitor very little.
print("Unexpected error:", repr(error))
self.send_json(500, {"error": "Something went wrong on the server."})- First the method checks whether the visitor asked for one of our three files.
- If not, the method walks through
ROUTES.fullmatchrequires the pattern to match the whole path. handler(*path_match.groups())spreads the captured text into arguments, and whatever the handler returns goes out as JSON with status 200.- The
exceptlines are the one place where exceptions become status codes: 400, 404 and 409. - Any other exception is our own bug. We print the error for the developer and send a vague 500 to the client, because Python's error details help attackers more than visitors.
def send_static_file(self, url_path):
file_name, content_type = STATIC_FILES[url_path]
body = (PUBLIC_FOLDER / file_name).read_bytes()
self.send_body(200, content_type, body)
def send_json(self, status_code, data):
# json.dumps turns the data into text, and encode turns the text into
# bytes, because a network carries bytes and not characters.
body = json.dumps(data).encode("utf-8")
self.send_body(status_code, "application/json; charset=utf-8", body)
def send_body(self, status_code, content_type, body):
# HTTP requires this order: status line, headers, a blank line, body.
self.send_response(status_code)
self.send_header("Content-Type", content_type) # how to read the body
self.send_header("Content-Length", str(len(body))) # how many bytes follow
self.send_header("Cache-Control", "no-store") # always ask again while we develop
self.end_headers() # writes the blank line
self.wfile.write(body)send_json turns the data into text with json.dumps and encodes the text as bytes. send_body writes the response in the order HTTP demands: the status line, the headers, a blank line and the body.
The class defaults to HTTP/1.0, which closes the connection after each response. That is slower than modern practice and fine for learning.
# This block runs only when you start this file directly. It does not run when
# another file imports server.py to reuse the data functions.
if __name__ == "__main__":
web_server = ThreadingHTTPServer((HOST, PORT), VendingRequestHandler)
print(f"Vending machine running at http://{HOST}:{PORT} (press Ctrl+C to stop)")
try:
# Wait for requests and answer each one in its own thread.
web_server.serve_forever()
except KeyboardInterrupt:
# Ctrl+C in the terminal ends up here.
print("\nStopping the server.")
web_server.server_close()ThreadingHTTPServer((HOST, PORT), VendingRequestHandler)asks the operating system to listen on that address. The server creates aVendingRequestHandlerfor each request, in a new thread each time.serve_foreverloops and waits.if __name__ == "__main__"means the block runs only when you start this file directly. The optional FastAPI file imports our data functions without starting a second server.
Step 4Build the page
One idea first: the DOM
- When a browser loads an HTML file it builds the DOM (Document Object Model), a tree with one object per element.
- The screen shows a drawing of that tree.
- A script can change the tree, and that is how a server's answer reaches the screen.
- The page itself has no logic. The HTML says what exists and the CSS says how it looks.
- In
vending-py, create a folder calledpublic. - In that folder, create
index.htmlandstyle.csswith the code below. - With the server still running, open
http://127.0.0.1:8000.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- Makes the page fit a phone screen instead of shrinking a desktop layout. -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vending Machine</title>
<link rel="stylesheet" href="/style.css">
</head>- The first line tells the browser this is an HTML5 document.
charset="utf-8"says how to turn bytes into characters, and theviewportline makes the page fit a phone screen.- The title appears on the browser tab, and
linkloads the stylesheet. /style.cssbegins with a slash, so the browser asks the server that delivered this page. That request goes through the same stations as an API request.
<body>
<main class="machine">
<h1>Vending Machine</h1>
<!-- role="status" makes screen readers announce changes to this text. -->
<p id="status" class="screen" role="status">Loading...</p>
<!-- Empty at first. The script adds one card here for each slot. -->
<section id="slots" class="slots"></section>
</main>The browser reads this file and builds the DOM, a tree with one object per element. What you see is a drawing of that tree, and the script will change the tree later.
- The
idattributes are names the script uses to find elements. #slotsstarts empty. The script fills#slotswith one card per row in the database.
<!-- A template is HTML that the browser keeps but does not show. The script
copies it once for each slot and fills in the copy. -->
<template id="slot-template">
<article class="slot">
<h2 class="item-name"></h2>
<p class="price"></p>
<p class="stock"></p>
<button type="button" class="buy">Buy</button>
</article>
</template>A <template> holds HTML that the browser reads but neither shows nor runs. The script copies the template once per slot and fills in the copy.
- HTML is the clearest way to describe structure.
- Pieces of HTML joined together inside script strings are hard to read and easy to get wrong.
- Each element has a class so that the script and the stylesheet can refer to it.
<!-- The script comes last, so every element it looks for already exists. -->
<script type="module" src="/app.js"></script>
</body>
</html>The script comes last, so every element that the script looks for already exists when the script runs. type="module" opts in to the modern script mode, in which the names inside the file stay private to the file.
/* Named values that the rest of the file reuses. Change a colour here once. */
:root {
--page: #eceff1;
--body: #37474f;
--screen-bg: #102a12;
--screen-text: #39ff14;
--card: #455a64;
--accent: #ffb300;
--text: #ffffff;
}
/* Centre the machine horizontally. */
body {
font-family: system-ui, sans-serif;
background: var(--page);
display: flex;
justify-content: center;
padding: 24px 16px;
}The :root block defines custom properties, named values such as --accent that the rest of the stylesheet reads with var(--accent). Changing a colour means editing one line.
The body is a flex container, a layout mode that lines up its children in a row or a column. Here it centres the machine horizontally.
/* min() means "480 pixels wide, but never wider than the screen". */
.machine {
background: var(--body);
color: var(--text);
padding: 24px;
border-radius: 12px;
width: min(100%, 480px);
}
h1 {
margin: 0 0 16px;
font-size: 1.4rem;
}
/* The status line looks like the green display of an old machine. */
.screen {
background: var(--screen-bg);
color: var(--screen-text);
font-family: ui-monospace, Menlo, Consolas, monospace;
padding: 12px 16px;
border-radius: 6px;
margin: 0 0 16px;
}width: min(100%, 480px) means as wide as 480 pixels but never wider than the screen, so the machine fits on a phone.
/* A grid with two equal columns. */
.slots {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.slot {
background: var(--card);
padding: 12px;
border-radius: 8px;
}
.slot h2 {
font-size: 1rem;
margin: 0 0 8px;
}
.slot p {
margin: 0 0 4px;
}The slots form a grid of two equal columns, and each card is a slightly lighter box. Nothing here depends on the data, so the same styles work for one slot or twenty.
/* The script only adds or removes the class "sold-out". This stylesheet
decides what a sold-out card looks like. */
.slot.sold-out {
opacity: 0.55;
}
button {
margin-top: 8px;
width: 100%;
padding: 10px;
font-weight: bold;
background: var(--accent);
border: none;
border-radius: 6px;
cursor: pointer;
}
/* The script sets the disabled property of a button when stock is zero. */
button:disabled {
background: #90a4ae;
cursor: not-allowed;
}The script never sets colours. When a slot has no stock, the script adds the class sold-out and sets the button's disabled property. This stylesheet decides what those states look like.
The script says what state the card is in, and the stylesheet says how that state looks. A designer can change the appearance without touching the logic.
Step 5Build the script
The script has one job: ask the server for data and change the page to match the reply. A browser can run only JavaScript, so this part is JavaScript, and the script stays short on purpose. Everything with rules in it, such as the stock count and the validation, lives in the Python server.
The same fact in five shapes
The fact that slot A1 has seven items left takes five shapes on its way from the disk to the screen. The script you are about to write handles the last three.
1. A row in the database
slot_id | item_name | price_cents | stock A1 | Sparkling Water | 175 | 7
fetchone() returns a row, and dict(row) converts it2. A Python dictionary in the server
{'slot_id': 'A1', 'item_name': 'Sparkling Water', 'price_cents': 175, 'stock': 7}json.dumps(...) serialises the dictionary into text3. Text on the network (JSON)
{"slot_id": "A1", "item_name": "Sparkling Water", "price_cents": 175, "stock": 7}response.json() parses the text into an object4. An object in the browser's script
{ slot_id: "A1", item_name: "Sparkling Water", price_cents: 175, stock: 7 }createSlotCard copies the values into elements with textContent5. Elements in the DOM
<p class="stock">7 left</p>
- Only the third shape can cross the network, because a network carries text and bytes.
- Serialising turns a structure into text. Parsing turns text back into a structure.
- Every arrow is a place where something can go wrong: a field can be missing, a number can arrive as text, or a value can be empty.
- In
vending-py/public, createapp.jsand type or paste the code below. - Reload
http://127.0.0.1:8000.
// This script runs in the browser. It asks the server for data and changes
// the page to match what the server says.
// Find the elements that we need, once, when the script loads.
const slotListElement = document.getElementById("slots");
const statusElement = document.getElementById("status");
const slotTemplate = document.getElementById("slot-template");
// A formatter from the browser that writes prices in euros, so that 1.75
// becomes €1.75.
const euroFormatter = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" });- The first three constants look up elements by
idand keep them in variables, so we search the page once and not on every click. Intl.NumberFormatis a formatter the browser provides. With the localeen-IEand the currencyEURthe formatter writes 1.75 as€1.75.- Writing
"€" + (cents / 100).toFixed(2)works until you meet a place with different conventions, so we let the formatter decide.
// Send a request to our API and return the JSON from the reply. The function
// is async, so it can wait for the network without freezing the page.
async function requestJson(url, options) {
let response;
try {
// fetch sends the request and returns a promise of a response.
// "await" pauses this function until the response arrives.
response = await fetch(url, options);
} catch {
// fetch fails like this only when no reply arrives at all, for example
// when the server is not running.
throw new Error("Cannot reach the server. Is it running?");
}
// The body of the reply is JSON text. json() parses it into an object.
const data = await response.json();
// A reply can arrive with a bad status such as 404 or 409.
// response.ok is false for those, so we pass the server's message on.
if (!response.ok) {
throw new Error(data.error);
}
return data;
}This function wraps every API call, so the rest of the script never repeats the checks.
- Inside an
asyncfunction,awaitpauses that function, and only that one, until a promise, an object standing for a value that has not arrived yet, settles. The browser stays responsive meanwhile. fetchsends the request at Station 2, and its reply arrives at Station 10.- If no response arrives at all,
fetchrejects, and we turn that into a friendlier message. - If a response arrives with a bad status such as 404 or 409,
fetchstill counts it as success, so we checkresponse.okourselves and pass on the server's own message. response.json()returns a promise too (a value that has not arrived yet), so it needs its ownawait.
// Build one card for one slot. The card is not on the page until we add it.
function createSlotCard(slot) {
// Copy the card layout from the template in index.html.
const slotCard = slotTemplate.content.firstElementChild.cloneNode(true);
const isSoldOut = slot.stock === 0;
// data-slot remembers which slot this card belongs to, for the click handler.
slotCard.dataset.slot = slot.slot_id;
// The sold-out class lets the stylesheet decide how a sold-out card looks.
slotCard.classList.toggle("sold-out", isSoldOut);
// textContent treats a value as plain text, never as HTML, so data from the
// server can never run as code in the page.
slotCard.querySelector(".item-name").textContent = `${slot.slot_id}: ${slot.item_name}`;
// Prices are stored in cents. We divide by 100 only when we show them.
slotCard.querySelector(".price").textContent = euroFormatter.format(slot.price_cents / 100);
slotCard.querySelector(".stock").textContent = isSoldOut ? "Sold out" : `${slot.stock} left`;
slotCard.querySelector("button").disabled = isSoldOut;
return slotCard;
}This function turns one slot (the fourth shape from Step 5) into one card (the fifth). cloneNode(true) makes a deep copy of the template, so the template stays unchanged for the next card.
- Every line that shows data uses
textContent, which treats the value as plain text. - With
innerHTML, an item name containing a script tag would run. That attack is cross-site scripting. - The function returns a card that is not on the screen yet, because nothing has attached it to the page.
// Ask the server for every slot and show them all on the page.
async function loadAllSlots() {
try {
const slots = await requestJson("/api/slots");
// map turns a list of slot data into a list of cards.
// replaceChildren puts the cards on the page. This changes the DOM, and
// the browser then redraws the screen.
slotListElement.replaceChildren(...slots.map(createSlotCard));
statusElement.textContent = "Choose a snack.";
} catch (error) {
statusElement.textContent = error.message;
}
}slots.map(createSlotCard)converts an array of data into an array of cards.replaceChildren(...)swaps the contents of#slotsfor them in one step. That is the moment the DOM changes, and the browser repaints at Station 12.tryandcatchshow any error in the status line, so the visitor sees what went wrong.
// One listener on the whole list handles clicks on every Buy button. This
// technique is called event delegation, and it also covers buttons that the
// script adds later.
slotListElement.addEventListener("click", async (event) => {
// closest() climbs from the clicked element up to the nearest Buy button.
// It returns null when the click landed somewhere else.
const buyButton = event.target.closest("button.buy");
if (!buyButton) {
return;
}
const slotCard = buyButton.closest("article");
try {
// POST asks the server to change something. The reply is the updated slot.
const updatedSlot = await requestJson(`/api/slots/${slotCard.dataset.slot}/buy`, { method: "POST" });
// Replace only this card, using what the server says and not what we guess.
slotCard.replaceWith(createSlotCard(updatedSlot));
statusElement.textContent = `Enjoy your ${updatedSlot.item_name}!`;
} catch (error) {
statusElement.textContent = error.message;
}
});We attach one listener to the container and not one to each button, because the buttons do not exist yet when this line runs. A click on a button bubbles up through its parents, so the container hears it. This is event delegation.
- This function is Station 1.
- The function reads the slot id from
slotCard.dataset.slot, which is thedata-slotattribute that the card carries, sends aPOST, and swaps in a new card built from the server's reply. - We draw what the server said and not what we guess, so if someone bought the last item a moment earlier, the card tells the truth.
// Show the slots as soon as the page opens.
loadAllSlots();The last line runs once when the script loads and sends the first request.
Step 6Run it and watch the traffic
- Open a terminal in the project folder and run the commands below.
- Open
http://127.0.0.1:8000in a browser.
Start the project
cd vending-py
python setup_db.py # creates the database file (on macOS or Linux you may need to type python3)
python server.py # starts the server and leaves it runningWatch the traffic in the browser
- Press F12 to open the developer tools (Ctrl+Shift+I on Windows and Linux, Option+Command+I on a Mac).
- Choose the Network tab and reload the page. Each row is one message the browser sent: the page,
style.css,app.jsandslots, which is the API request. - Click the
slotsrow. Headers shows the request and the response, and Response shows the JSON. - Press Buy. A new row appears, this time a
POST. The Status column shows the status code, and the Time column shows how long the round trip took, from sending the request to receiving the reply.
Watch the traffic in the terminal
The server prints one line for each request it answers. Put the terminal beside the browser and press Buy a few times.
What the terminal shows
127.0.0.1 - - [17/Oct/2026 22:33:32] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [17/Oct/2026 22:33:32] "GET /style.css HTTP/1.1" 200 -
127.0.0.1 - - [17/Oct/2026 22:33:32] "GET /app.js HTTP/1.1" 200 -
127.0.0.1 - - [17/Oct/2026 22:33:32] "GET /api/slots HTTP/1.1" 200 -
127.0.0.1 - - [17/Oct/2026 22:33:32] "POST /api/slots/A1/buy HTTP/1.1" 200 -The browser may also ask for /favicon.ico, the tab's little icon, without being told to. The server has no route for that file and answers 404, which is harmless and shows that browsers send requests you never wrote.
Call the API without the page
The address bar is a client too. Visit http://127.0.0.1:8000/api/slots and you see the raw JSON. A command line tool called curl is a third client:
Three requests from a terminal
curl http://127.0.0.1:8000/api/slots
curl -i http://127.0.0.1:8000/api/slots/B2
curl -i -X POST http://127.0.0.1:8000/api/slots/A1/buy-ishows the response headers.-X POSTchooses the method.- In older versions of Windows PowerShell,
curlis an alias (a second name) for another command, so typecurl.exeinstead.
Going deeperHTTP messages and status codes
You can build the whole project without reading this section. This section is here for when you want to know exactly what the browser and the server say to each other.
- A protocol is an agreement about the order and shape of messages.
- HTTP (HyperText Transfer Protocol) is the agreement that browsers and web servers use.
- Every exchange is two plain-text messages, a request and a response. Below is a real pair from the running project.
POST /api/slots/a1/buy HTTP/1.1The request line holds three things: the method (POST, the kind of action), the path (which thing it is done to) and the protocol version.
Host: 127.0.0.1:8000
User-Agent: curl/8.5.0
Accept: */*Headers come next, one per line, each as a name, a colon and a value.
Hostnames the server the client believes it is talking to.User-Agentnames the client program.Acceptlists the formats the client can read.
A blank line ends the headers. A POST may carry a body after that line, such as a form or a JSON document. This one has none, because the path already says everything the server needs.
HTTP/1.0 200 OKThe status line holds the protocol version, the status code and a short reason. Our Python server answers in HTTP/1.0, an older version of the protocol. The code 200 means success.
Server: BaseHTTP/0.6 Python/3.13.16
Date: Sat, 17 Oct 2026 21:33:37 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 81
Cache-Control: no-storeThe response headers describe the body.
Content-Typesays how to read the body: JSON in UTF-8, the standard way of turning characters into bytes.Content-Lengthsays how many bytes of body follow.Cache-Control: no-storeasks the browser never to reuse an old copy, so an edit shows up on the next reload.
The Python class added the Server and Date headers without being asked.
{"slot_id": "A1", "item_name": "Sparkling Water", "price_cents": 175, "stock": 7}After the blank line comes the body. This is the JSON that the script parses at Station 10.
Status codes
The status code is a three digit number that tells the client how things went before the client reads the body. The first digit gives the family: 2 means success, 4 means the client made a mistake, and 5 means the server did.
| Code | Name | When we send it |
|---|---|---|
| 200 | OK | The request worked and the body holds the result. |
| 400 | Bad Request | The client sent something the server cannot make sense of, such as the slot id hello. |
| 404 | Not Found | The request was well formed, but nothing exists at that address, such as slot Z9. |
| 409 | Conflict | The request was fine but clashes with the current state. Buying from a sold-out slot is a conflict. |
| 500 | Internal Server Error | Something broke inside the server. The fault is ours, not the client's. |
Methods
GETreads something and is expected to change nothing. A method like that is safe.POSTasks the server to do something, and it may change data.- Browsers, link previews in chat apps and search engine crawlers send
GETrequests freely and may repeat them. If buying were aGET, a chat app that previews a link could buy a snack. - Buying is also not idempotent, because doing it twice does not leave the same result as doing it once. Reading is idempotent, so you can refresh a page as often as you like.
A server with no memory
HTTP is stateless: the server treats every request as new and remembers nothing between them. So the stock count lives in the database and never in a variable inside the server.
REST in one paragraph
Many web APIs follow a style called REST (Representational State Transfer). URLs name things with nouns, the method chooses the action, and each request is complete in itself. Our API borrows that style without following every rule of it.
Going deeperBreak it on purpose
- Open
vending.dbin the free program DB Browser for SQLite, or use thesqlite3command line tool if it is installed. Python 3.12 and newer also offerspython -m sqlite3 vending.db. - To reset the machine after an experiment, run the setup script again.
Experiment 1: three kinds of sold out
- In the page, press Buy on the Chocolate Bar until it is sold out. The button greys out after the third press.
- Send the same purchase from a terminal, where no disabled button can stop you:
curl -i -X POST http://127.0.0.1:8000/api/slots/B1/buy - Try to break the rule inside the database:
UPDATE inventory SET stock = -1 WHERE slot_id = 'A1';
Experiment 2: two kinds of wrong address
Visit http://127.0.0.1:8000/api/slots/hello and then http://127.0.0.1:8000/api/slots/Z9. Guess each status code first.
Experiment 3: joining strings versus passing parameters
Run python injection_demo.py. The script looks up a slot twice, once with a normal id and once with the text x' OR '1'='1, using two ways of building the query.
Output
The user typed: A1
Joined into the string:
SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = 'A1'
rows returned: 1
Passed as a parameter:
SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = ? with value 'A1'
rows returned: 1
The user typed: x' OR '1'='1
Joined into the string:
SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = 'x' OR '1'='1'
rows returned: 4
Passed as a parameter:
SQL sent to the database: SELECT item_name, stock FROM inventory WHERE slot_id = ? with value "x' OR '1'='1"
rows returned: 0Experiment 4: opening the page straight from disk
With the server still running, double-click public/index.html so that the browser opens it from disk. The page stays on Loading.... Read the errors in the developer console.
Experiment 5: the page on one port and the API on another
- Leave the API on port 8000. In a second terminal, serve only the
publicfolder on port 9000, for examplepython -m http.server 9000 --directory public --bind 127.0.0.1(ornpx http-server public -p 9000if you prefer Node). - In the page's script, change the two URLs that begin with
/api/so that they begin withhttp://127.0.0.1:8000/api/. - Open
http://127.0.0.1:9000.
The page says Cannot reach the server. Is it running?, yet the server is running. Check the console and the server's terminal before you read on.
Experiment 6: stopping the server
Press Ctrl+C to stop the server, return to the page and press Buy. Look at the status line and the console.
Experiment 7: changing data without touching any code
With the server running, change a price with UPDATE inventory SET price_cents = 99 WHERE slot_id = 'A1'; and reload the page.
Going deeperDesign choices and alternatives
Almost every line in this project was a choice, and most had a respectable alternative. For each choice the table gives the reason we chose as we did and the situation where you would choose differently. When you build your own project, ask yourself those same two questions about each decision.
| The choice | Why it fits here | When you might choose differently |
|---|---|---|
| SQLite for storage | Nothing to install, one file to copy, and the same SQL that bigger systems use. | When many programs or computers write at once, or you need accounts, backups and monitoring at scale, a server database such as PostgreSQL fits better. |
| The Python standard library for the server, not a framework | Every mechanism stays visible: reading the request, routing and writing the response. Nothing needs installing. | In real projects, because a framework such as FastAPI or Flask supplies routing, validation, safe static files and a production server. The next section shows the same API in FastAPI. |
| A new database connection for each request | The server handles each request in its own thread, and a SQLite connection belongs to the thread that made it. Opening a SQLite connection is very cheap. | With a client-server database, opening a connection is slow, so servers keep a pool of connections and lend them out. |
| The same server delivers the page and the API | Both share one origin, so no CORS settings are needed, and only one program has to start. | When the page lives on a static host such as GitHub Pages and the API lives elsewhere, the origins differ. The API then needs CORS headers and HTTPS. |
| JSON replies, with the page building its own elements | The page and the API stay independent, and the same API can serve a phone app. | When a page must work without scripts, the server can send finished HTML, and a form with a full page reload replaces the script. |
| Prices stored as whole cents | Whole numbers are exact, so sums never drift. | Some databases have an exact decimal type, which is also fine. A floating point column (numbers that carry tiny rounding errors) is never a good choice for money. |
| POST for buying | Buying changes data and is not idempotent, so a link preview must not be able to trigger that action. | Other changes use other methods: PUT to replace something, PATCH to adjust part of it, DELETE to remove it. |
| Parameterised queries everywhere | The database receives SQL and values separately, so a value can never turn into SQL. | There is no good reason to choose differently. |
One UPDATE with AND stock > 0 | A single statement checks and changes together. Reading first and writing afterwards leaves a gap in which two buyers could both take the last item. | When a purchase needs several steps, group them in one explicit transaction, and think about what the database does when two transactions collide. |
| Rules in three places | The page is a convenience, the server holds the rules and explains them, and the database is the last line of defence. | You may drop the check in the page, because a visitor can skip it anyway. The server and database checks stay. |
textContent rather than innerHTML | textContent treats a value as plain text. innerHTML parses the value as markup, so an item name containing a script tag could run code in every visitor's browser. That attack is cross-site scripting (XSS). | innerHTML is fine for markup you wrote yourself and never for data from elsewhere. |
A <template> element for the card | The structure of a card stays in HTML, where it is easy to read and restyle, and not inside strings in the script. | Front end frameworks such as React and Svelte generate this part for you and become worthwhile as pages grow. |
| Redrawing the card from the server's reply | The page shows what the server says, so it cannot drift from the truth. If somebody bought the last item a second ago, the reply says so. | An optimistic update shows the change first and corrects it if the server disagrees. That style feels faster on a slow network and costs more code. |
| A fixed list of files that the server will send | A server that sent whatever file the URL named could be asked for its own source code or the database file. That attack is path traversal. | A framework's static file helper handles this safely, so use it instead of writing your own. |
Why one UPDATE beats read-then-write
The diagram shows the race that a single UPDATE prevents. The same pattern of a check followed by a change appears in booking systems, bank transfers and ticket sales.
Where frameworks fit
- Real projects rarely write server plumbing by hand. A framework provides routing, validation, safe static files, error handling and a production server.
- We wrote the plumbing ourselves so that each mechanism is visible once.
- Below is the same API in FastAPI, a popular Python framework. This file is optional, and it is in the download as
server_fastapi.py.
"""The same API, written with the FastAPI framework.
Install: pip install fastapi uvicorn
Run: uvicorn server_fastapi:app --port 8000
Then open http://127.0.0.1:8000 and also http://127.0.0.1:8000/docs
"""
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, Path as UrlPath, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
# We reuse the data layer from server.py. Importing that file does not start
# the other server, because of the "if __name__ == '__main__'" guard there.
from server import NoSuchSlot, SoldOut, buy_slot, get_all_slots, get_slotThis file reuses our own data layer with from server import .... Importing server.py does not start the server, because of the __main__ guard. FastAPI is a third-party framework, so you install it with pip.
app = FastAPI(title="Vending machine")
# A slot id must match this pattern. The framework checks it for us, which
# replaces the validate_slot_id function in server.py.
SlotId = Annotated[str, UrlPath(pattern=r"^[A-Za-z][0-9]$")]FastAPI(...) creates the application. SlotId is a type that says a slot id must match a pattern, and the framework checks it for us. That type replaces our validate_slot_id.
# Each decorator adds one row to the framework's route table. These are the same
# three routes as the ROUTES list in server.py.
@app.get("/api/slots")
def list_slots():
return get_all_slots()
@app.get("/api/slots/{slot_id}")
def show_slot(slot_id: SlotId):
return get_slot(slot_id.upper())
@app.post("/api/slots/{slot_id}/buy")
def buy(slot_id: SlotId):
return buy_slot(slot_id.upper())The decorators @app.get(...) and @app.post(...) (lines that begin with @ and attach the function below them to something) add rows to a route table that the framework keeps. These are the same three rows as our ROUTES, and each function shrinks to one line.
The framework converts the returned dictionary to JSON, sets the headers, and turns the {slot_id} part of the path into an argument.
# These handlers turn exceptions into status codes. We keep the same
# {"error": ...} shape as before, so the page does not need to change.
@app.exception_handler(RequestValidationError)
def handle_bad_request(request: Request, error: RequestValidationError):
return JSONResponse({"error": "Slot ids look like A1 or B2."}, status_code=400)
@app.exception_handler(NoSuchSlot)
def handle_no_such_slot(request: Request, error: NoSuchSlot):
return JSONResponse({"error": str(error)}, status_code=404)
@app.exception_handler(SoldOut)
def handle_sold_out(request: Request, error: SoldOut):
return JSONResponse({"error": str(error)}, status_code=409)The exception handlers do the job of our except lines. We keep the same {"error": ...} shape so the page does not change.
Without them the framework answers with {"detail": ...} and status 422 for a malformed id. Frameworks come with defaults, and keeping your API's contract is still your job.
# Serve the page files. This line must come last, because the routes above
# are checked first.
app.mount("/", StaticFiles(directory=Path(__file__).parent / "public", html=True))The last line serves the public folder. That line must come last, because the routes above are checked first.
- Run the file with
pip install fastapi uvicornand thenuvicorn server_fastapi:app --port 8000. - FastAPI writes documentation for your API automatically. Visit
http://127.0.0.1:8000/docsto see the endpoints from Step 3 in a standard format called OpenAPI.
Going deeperExtend it yourself
Each extension below is small and practises one idea from this tutorial. They are problems to approach your own way, and the hints say where to start and no more.
- A restock route. Add
POST /api/slots/A1/restockwith a JSON body such as{"amount": 5}. Reject a missing body, invalid JSON and an amount that is not a whole number from 1 to 100, each with a 400. To read a body you can useint(self.headers["Content-Length"])to learn how many bytes to read, thenself.rfile.read(n)to read them, andjson.loadsto parse them. - A sales log in the same transaction. Create a
salestable with an id, a slot id that points to a row ininventory(a foreign key), a timestamp and the price paid. Make a purchase insert a sale and decrement the stock inside one transaction. Why should the sale store the price again? Then prove the transaction works: raise an error between the two statements and check that the stock did not change. - A summary route. Add
GET /api/sales/summarythat returns, for each item, how many were sold and how much money they earned. This needs aJOIN, which combines rows from two tables that share a value, and aGROUP BY, which gathers rows that share a value so that you can count or add them. The result has to arrive as clean JSON. - Automatic refresh. Make the page ask for the slots every five seconds with
setInterval, a browser function that repeats another function at a fixed interval, and open it in two windows. When you buy in one, how long until the other notices? Server-sent events and WebSockets let the server push changes instead. - Separate hosting. Serve the page from its own port and make it work with CORS, as in Experiment 5. Then think about hosting only the page on GitHub Pages. You would need an absolute API address, a CORS header for your Pages origin, and an API reachable over HTTPS, because a page served over HTTPS may not call an insecure address.
ReferenceGlossary
Terms in alphabetical order. The name in brackets shows where the term is explained.
- API
- Application Programming Interface. The part of a program that other programs may use, and the ways they may ask it to do things. (Step 3)
- Body
- The data that follows the headers in a request or a response, such as the JSON of a slot. (HTTP messages)
- Client
- A program that starts a conversation with a server. Our browser is the client. (Step 3)
- Connection
- An open line of communication between a program and a database. (Step 1)
- Constraint
- A rule that the database enforces on its own, such as NOT NULL or CHECK (stock >= 0). (Step 2)
- Contract
- The agreed shape of requests and replies in an API: which fields exist and what type each has. (Step 3)
- CORS
- Cross-Origin Resource Sharing. Headers with which a server allows a page from another origin to read its responses. (Break it on purpose)
- Database management system
- A program that stores data safely and answers queries about it, for example SQLite or PostgreSQL. (Step 2)
- DOM
- Document Object Model. The browser's live tree of objects, one for every element on the page. (Step 1)
- Endpoint
- One address of an API, such as /api/slots/A1. (Step 3)
- Event
- A message that the browser creates when something happens on the page, such as a click. (Step 1)
- Event delegation
- Listening for events on a parent element instead of on each child, which also covers children added later. (Step 5)
- fetch
- The browser function that sends an HTTP request from a script and returns a promise. (Step 5)
- Handler
- The function that answers one kind of request. (Step 1)
- Header
- A name and value that describe a request or a response, such as Content-Type. (HTTP messages)
- Host
- The name or number that says which computer a request goes to. (Step 3)
- HTTP
- HyperText Transfer Protocol. The agreed text format of requests and responses between browsers and servers. (HTTP messages)
- Idempotent
- Describes an action that leaves the same result whether you do it once or many times. (HTTP messages)
- JSON
- JavaScript Object Notation. A text format for structured data, used by most web APIs. (Step 1)
- Method
- The word in a request that says what kind of action is wanted, such as GET or POST. (HTTP messages)
- Origin
- The combination of scheme, host and port. Pages may read responses only from their own origin unless the server allows more. (Break it on purpose)
- Parameterised query
- A query in which values travel separately from the SQL text, marked by placeholders such as ?. (Break it on purpose)
- Parsing
- Turning text into a structure that a program can use, such as JSON text into an object. (Step 5)
- Path
- The part of a URL after the port, such as /api/slots. (Step 3)
- Path traversal
- An attack that uses parts such as /../ in a URL to reach files that were never meant to be served. (Design choices)
- Placeholder
- A mark such as ? in SQL that shows where a value goes. The value travels separately from the SQL text. (Step 2)
- Port
- A number that tells the operating system which waiting program should receive a message. (Step 3)
- Prepared statement
- SQL that the database has read and checked once, so that it can be run many times. (Step 3)
- Primary key
- A column whose value is different in every row, so it identifies exactly one row. (Step 2)
- Promise
- An object that stands for a value that has not arrived yet. (Step 1)
- Protocol
- An agreement about the order and shape of messages. (HTTP messages)
- Relational database
- A database that keeps its data in tables of rows and columns. (Step 2)
- Request
- A message from a client that says what it wants. (Step 1)
- Response
- The message a server sends back, made of a status code, headers and a body. (Step 1)
- REST
- Representational State Transfer. A style of web API in which URLs name things and methods choose the action. (HTTP messages)
- Route
- A pairing of a method and a path pattern with the function that handles it. (Step 1)
- Route table
- A list that pairs each method and path pattern with the function that handles it. (Step 3)
- Safe method
- A method that changes nothing on the server, such as GET. (HTTP messages)
- Same-origin policy
- The browser rule that stops a page from reading responses that come from another origin. (Break it on purpose)
- Schema
- The declared structure of a database: its tables, columns and types. (Step 2)
- Serialising
- Turning a structure in memory into text or bytes so that it can be stored or sent. (Step 5)
- Server
- A program that waits for requests and answers them. (Step 3)
- Service worker
- A script that sits between a page and the network and can answer requests itself. The in-page demos use one to stand in for a server. (Try it first)
- SQL
- Structured Query Language. The language for asking a relational database to find or change rows. (Step 1)
- SQL injection
- An attack in which text typed by a user becomes part of an SQL statement. (Break it on purpose)
- Stateless
- Describes a server that treats every request as new and remembers nothing between them. (HTTP messages)
- Status code
- The three digit number at the start of a response that says how the request went. (HTTP messages)
- Transaction
- A group of database steps that either all happen or none happen. (Step 1)
- URL
- Uniform Resource Locator. The address of something on the web, made of scheme, host, port and path. (Step 3)
- Validation
- Testing that input is well formed before using it. (Step 1)
- XSS
- Cross-site scripting. An attack that gets a page to run a script that arrived as data. (Design choices)