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.
The finished vending machine page: four slots, each with a name, a price, a stock count and a Buy button. One slot is sold out and greyed.
The page you build. Three slots have stock and one is sold out.

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

The vending machine. The page is the real one from the project, and a simulated server answers its requests. If nothing appears, open the machine on its own page.
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.py and the real data layer and route table from server.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.

py/adapter.pythe replacement for the HTTP layer
"""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 server

This 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 real ROUTES table, 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.

BrowserHTML, CSS and a scriptHTML: The language that describes what is on a page: headings, paragraphs, buttons. CSS: The language that describes how a page looks: colours, sizes and layout.Serverserver: A program that waits for requests and answers them.A Python programDatabaseSQLite fileSQLite: A database that lives in one ordinary file and needs no separate server.1The browser notices the clickPOST /api/slots/A1/buy2The script builds a requestrequest: A message from the browser that says what it wants.3The request travels to port 8000request: A message from the browser that says what it wants. port: A number that says which program on a computer should receive a message.4The server chooses a handlerserver: A program that waits for requests and answers them. handler: The function that answers one kind of request.5The handler checks the inputhandler: The function that answers one kind of request.UPDATE ... SET stock = stock - 16The handler sends SQL to the databasehandler: The function that answers one kind of request. SQL: Structured Query Language. The language for asking a relational database to find or change rows.7The database changes the rowrow: One record in a table, such as one slot of the machine.1 row changedrow: One record in a table, such as one slot of the machine.8The handler packages the resulthandler: The function that answers one kind of request.200 OK with a JSON bodyJSON: JavaScript Object Notation. A text format for structured data, used by most web APIs.9The server sends a responseserver: A program that waits for requests and answers them. response: The message that the server sends back.10The reply reaches the script11The script changes the DOMDOM: Document Object Model. The browser's live tree of objects, one for every element on the page.12The browser repaints the screen
One click, twelve stations. Stations 1, 2, 10, 11 and 12 happen in the browser, Station 3 is the network between the two programs, Stations 4 to 9 happen in the server, and the database answers at Stations 6 and 7. The buttons step through the stations one at a time and show what is travelling at each one. Download this diagram as SVG
Reference:The twelve stations as a list
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. The reply reaches the script. The script receives the JSON and turns it back into an object. Waiting for a reply takes time, so fetch gives the script a promise, which stands for a value that has not arrived yet.
  11. 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.
  12. 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_iditem_nameprice_centsstock
A1Sparkling Water1758
A2Salted Peanuts1255
B1Chocolate Bar2003
B2Oat Biscuits1500
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.

  1. Create a folder called vending-py and open a terminal in it.
  2. Create a file called setup_db.py and type or paste the code below.
  3. Run python setup_db.py (on macOS or Linux you may need python3).
setup_db.pyin vending-py/
"""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.

  • sqlite3 is Python's built-in driver for SQLite, a library that knows how to talk to one kind of database.
  • closing makes sure a connection is closed when we finish with it.
  • Path builds 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 EXISTS deletes any old table, so running the script again resets the machine. That is handy in a classroom and dangerous anywhere else.
  • slot_id is 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 CHECK lines 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,
        )
Stations 6 to 7 The handler sends SQL to the database, then the database changes the row
  • 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.
  • executescript commits 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 port 8000.
  • 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.
http://127.0.0.1:8000/api/slots/A1/buyschemeThe protocol. Here it is plain HTTP, the agreement that browsers and servers use.HTTP: The agreement that browsers and servers use for requests and responses. Going deeper has the details. server: A program that waits for requests and answers them.hostWhich computer. This address always means this computer.portport: A number that says which program on a computer should receive a message.Which program on that computer. Our server listens here.server: A program that waits for requests and answers them.pathWhich thing, and what to do with it. The route table reads this part.route table: A list that pairs each method and path pattern with the function that handles it.How the server reads the pathserver: A program that waits for requests and answers them.The path/api/slots/A1/buyA row of the route tablerow: One record in a table, such as one slot of the machine. route table: A list that pairs each method and path pattern with the function that handles it./api/slots/([^/]+)/buyThe handler callhandler: The function that answers one kind of request.handle_buy_slot("A1")is tried againstcaptured text "A1"
A URL is an address with four parts, and the server reads the path part with its route table. The text captured from the path becomes the argument of the handler. Download this diagram as SVG

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.

The visitor's browserNot under our controlThe visitor can edit the page,run any script in the console,console: The part of the developer tools where messages appear and where you can type JavaScript.or send requests by hand.request: A message from the browser that says what it wants.Our serverserver: A program that waits for requests and answers them.Under our controlThe server checks every request,server: A program that waits for requests and answers them. request: A message from the browser that says what it wants.owns every SQL statement,SQL: Structured Query Language. The language for asking a relational database to find or change rows.and decides what is allowed.The databaseReachable only from the serverserver: A program that waits for requests and answers them.The database holds the data and the rules(constraints) that protect it,constraint: A rule that the database enforces on its own, such as NOT NULL or CHECK (stock >= 0).and never faces the visitor.requestsrequest: A message from the browser that says what it wants.our SQLSQL: Structured Query Language. The language for asking a relational database to find or change rows.No direct route from the browser to the database
The browser is outside your control, so the server treats every request as untrusted. The database is reachable only through the server. Download this diagram as SVG

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. GET reads, and POST changes 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.
MethodURLWhat it doesOn successOn failure
GET/api/slotsLists every slot200 and a list of slotsNone expected
GET/api/slots/A1Shows one slot200 and one slot400 for a malformed id, 404 for a missing slot
POST/api/slots/A1/buyBuys one item200 and the updated slot400, 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.
The browserHTTP layerHTTP layer: One of the three parts of the server: the data layer talks to the database, the route layer matches URLs, and the HTTP layer reads and writes messages. Step 3 builds them.VendingRequestHandler, answer_request, send_jsonKnows methods, paths, status codes and headers.status code: The three digit number at the start of a response that says how the request went. header: A name and a value that describe a request or a response, such as Content-Type.Never touches SQL.SQL: Structured Query Language. The language for asking a relational database to find or change rows.Route layerroute layer: One of the three parts of the server: the data layer talks to the database, the route layer matches URLs, and the HTTP layer reads and writes messages. Step 3 builds them.ROUTES, handle_buy_slot, validate_slot_idKnows which URL means which action,URL: Uniform Resource Locator. The address of something on the web, made of scheme, host, port and path.and what a valid slot id looks like.Data layerdata layer: One of the three parts of the server: the data layer talks to the database, the route layer matches URLs, and the HTTP layer reads and writes messages. Step 3 builds them.get_all_slots, get_slot, buy_slotKnows SQL, transactions and the rules of the machine.SQL: Structured Query Language. The language for asking a relational database to find or change rows. transaction: A group of database steps that either all happen or none happen.Never touches HTTP or status codes.HTTP: The agreement that browsers and servers use for requests and responses. Going deeper has the details. status code: The three digit number at the start of a response that says how the request went.The SQLite databaseSQLite: A database that lives in one ordinary file and needs no separate server.requestrequest: A message from the browser that says what it wants.responseresponse: The message that the server sends back.calls a handlerhandler: The function that answers one kind of request.data, or an errorcalls a data functiondata, or an errorSQL and valuesSQL: Structured Query Language. The language for asking a relational database to find or change rows.rowsrow: One record in a table, such as one slot of the machine.
The server is three layers. Each one calls only the layer below it, so the SQL stays in the data layer, the URLs stay in the route layer, and the status codes stay in the HTTP layer. Download this diagram as SVG
  1. In vending-py, create a file called server.py and type or paste the code below.
  2. Run python server.py. The server stays running, and that is correct.
  3. Visit http://127.0.0.1:8000/api/slots in a browser.
server.pyin vending-py/
"""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.server supplies the pieces of a web server.
  • urlsplit takes a URL apart, re handles text patterns, and json converts between text and data.
  • Names in capitals are constants, a convention for values that never change while the program runs.
  • HOST decides who may connect. 127.0.0.1 allows only your own computer, which is the safe choice while you develop.
  • PORT decides which program receives the request.
  • STATIC_FILES lists the only files the server will send. If the server sent whatever file the URL named, a visitor could ask for /../server.py and download our code and our database. That attack is path traversal.
  • SLOT_ID_PATTERN is 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 connection

open_connection opens a new connection to the database file. Every request opens its own.

  • ThreadingHTTPServer handles 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 slot

Three functions read data.

  • find_slot runs a parameterised query. The ? marks where a value goes, and the tuple supplies it separately.
  • fetchone returns one row, or None when nothing matched. dict(row) turns the row into a dictionary, which is the second shape from Step 5.
  • get_all_slots and get_slot each open a connection, read and close it. with closing(...) guarantees the close, whatever happens inside.
  • get_slot raises NoSuchSlot when find_slot finds 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 slot
Stations 6 to 7 The handler sends SQL to the database, then the database changes the row

This 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 UPDATE statement, 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.rowcount says how many rows changed. Zero means the slot does not exist or its stock was already zero, and slot tells 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 means SoldOut, 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_id

Everything 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. fullmatch requires 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 except lines 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 a VendingRequestHandler for each request, in a new thread each time.
  • serve_forever loops 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.
  1. In vending-py, create a folder called public.
  2. In that folder, create index.html and style.css with the code below.
  3. With the server still running, open http://127.0.0.1:8000.
public/index.htmlin vending-py/public/
<!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 the viewport line makes the page fit a phone screen.
  • The title appears on the browser tab, and link loads the stylesheet.
  • /style.css begins 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 id attributes are names the script uses to find elements.
  • #slots starts empty. The script fills #slots with 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.

public/style.cssin vending-py/public/
/* 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;
}
Stations 11 to 12 The script changes the DOM, then the browser repaints the screen

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 databaserow: One record in a table, such as one slot of the machine.A1 | Sparkling Water | 175 | 7On the server's disk, inside the SQLite fileserver: A program that waits for requests and answers them. SQLite: A database that lives in one ordinary file and needs no separate server.fetchone() then dict(row)row: One record in a table, such as one slot of the machine.2. A Python dictionary in the serverdictionary: Python's collection of named values, such as {'stock': 7}. server: A program that waits for requests and answers them.{'slot_id': 'A1', 'item_name': 'Sparkling Water', 'price_cents': 175, 'stock': 7}In the memory of the server programserver: A program that waits for requests and answers them.json.dumps(slot)3. Text on the network (JSON)JSON: JavaScript Object Notation. A text format for structured data, used by most web APIs.{"slot_id": "A1", "item_name": "Sparkling Water", "price_cents": 175, "stock": 7}On the network, as bytesbytes: The numbers that a computer stores and sends. Text has to become bytes to cross a network.response.json()response: The message that the server sends back.4. An object in the page's script{ slot_id: "A1", item_name: "Sparkling Water", price_cents: 175, stock: 7 }In the memory of the page's scriptcreateSlotCard(slot)5. Elements in the DOMDOM: Document Object Model. The browser's live tree of objects, one for every element on the page.<p class="stock">7 left</p>In the browser's DOM, then on the screenDOM: Document Object Model. The browser's live tree of objects, one for every element on the page.
The same fact in five shapes, with the place where each one lives and the step that converts one shape into the next. Download this diagram as SVG

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 it

2. A Python dictionary in the server

{'slot_id': 'A1', 'item_name': 'Sparkling Water', 'price_cents': 175, 'stock': 7}
↓ json.dumps(...) serialises the dictionary into text

3. Text on the network (JSON)

{"slot_id": "A1", "item_name": "Sparkling Water", "price_cents": 175, "stock": 7}
↓ response.json() parses the text into an object

4. 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 textContent

5. 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.
  1. In vending-py/public, create app.js and type or paste the code below.
  2. Reload http://127.0.0.1:8000.
public/app.jsin vending-py/public/
// 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 id and keep them in variables, so we search the page once and not on every click.
  • Intl.NumberFormat is a formatter the browser provides. With the locale en-IE and the currency EUR the 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 async function, await pauses 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.
  • fetch sends the request at Station 2, and its reply arrives at Station 10.
  • If no response arrives at all, fetch rejects, and we turn that into a friendlier message.
  • If a response arrives with a bad status such as 404 or 409, fetch still counts it as success, so we check response.ok ourselves 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 own await.
// 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;
  }
}
Stations 10 to 12 The reply reaches the script, then the browser repaints the screen
  • slots.map(createSlotCard) converts an array of data into an array of cards.
  • replaceChildren(...) swaps the contents of #slots for them in one step. That is the moment the DOM changes, and the browser repaints at Station 12.
  • try and catch show 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;
  }
});
Stations 1 to 2 The browser notices the click, then the script builds a request
← Before: the click has not happened yetNext: Station 3, The request travels →
Stations 10 to 11 The reply reaches the script, then the script changes the DOM

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 the data-slot attribute that the card carries, sends a POST, 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

  1. Open a terminal in the project folder and run the commands below.
  2. Open http://127.0.0.1:8000 in 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 running

Watch the traffic in the browser

  1. Press F12 to open the developer tools (Ctrl+Shift+I on Windows and Linux, Option+Command+I on a Mac).
  2. Choose the Network tab and reload the page. Each row is one message the browser sent: the page, style.css, app.js and slots, which is the API request.
  3. Click the slots row. Headers shows the request and the response, and Response shows the JSON.
  4. 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
  • -i shows the response headers.
  • -X POST chooses the method.
  • In older versions of Windows PowerShell, curl is an alias (a second name) for another command, so type curl.exe instead.

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.
The request the client sends
POST /api/slots/a1/buy HTTP/1.1

The 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: */*
Stations 2 to 3 The script builds a request, then the request travels

Headers come next, one per line, each as a name, a colon and a value.

  • Host names the server the client believes it is talking to.
  • User-Agent names the client program.
  • Accept lists 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.

The response the server sends back
HTTP/1.0 200 OK

The 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-store

The response headers describe the body.

  • Content-Type says how to read the body: JSON in UTF-8, the standard way of turning characters into bytes.
  • Content-Length says how many bytes of body follow.
  • Cache-Control: no-store asks 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}
Stations 8 to 9 The handler packages the result, then the server sends a response

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.

CodeNameWhen we send it
200OKThe request worked and the body holds the result.
400Bad RequestThe client sent something the server cannot make sense of, such as the slot id hello.
404Not FoundThe request was well formed, but nothing exists at that address, such as slot Z9.
409ConflictThe request was fine but clashes with the current state. Buying from a sold-out slot is a conflict.
500Internal Server ErrorSomething broke inside the server. The fault is ours, not the client's.

Methods

  • GET reads something and is expected to change nothing. A method like that is safe.
  • POST asks the server to do something, and it may change data.
  • Browsers, link previews in chat apps and search engine crawlers send GET requests freely and may repeat them. If buying were a GET, 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.db in the free program DB Browser for SQLite, or use the sqlite3 command line tool if it is installed. Python 3.12 and newer also offers python -m sqlite3 vending.db.
  • To reset the machine after an experiment, run the setup script again.

Experiment 1: three kinds of sold out

  1. In the page, press Buy on the Chocolate Bar until it is sold out. The button greys out after the third press.
  2. 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
  3. 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: 0

Experiment 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

  1. Leave the API on port 8000. In a second terminal, serve only the public folder on port 9000, for example python -m http.server 9000 --directory public --bind 127.0.0.1 (or npx http-server public -p 9000 if you prefer Node).
  2. In the page's script, change the two URLs that begin with /api/ so that they begin with http://127.0.0.1:8000/api/.
  3. 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.

Script in the pagepage came from port 9000port: A number that says which program on a computer should receive a message.The browserenforces the same-origin policysame-origin policy: The browser rule that stops a page from reading responses that come from another origin.The API serverAPI: Application Programming Interface. The part of a program that other programs may use, and the ways they may ask it to do things. server: A program that waits for requests and answers them.listens on port 8000port: A number that says which program on a computer should receive a message.fetch("http://127.0.0.1:8000/api/slots")fetch: The browser function that sends an HTTP request from a script and returns a promise.GET /api/slots200 OK and JSON, with no permission headerJSON: JavaScript Object Notation. A text format for structured data, used by most web APIs. header: A name and a value that describe a request or a response, such as Content-Type.server log: GET /api/slots 200server: A program that waits for requests and answers them. log: A printed record of what the program did.The page is from port 9000, the answer is from port 8000,port: A number that says which program on a computer should receive a message.and no header gives permission, so the answer is withheld.header: A name and a value that describe a request or a response, such as Content-Type.network error: the script never sees the answer
The server received the request and answered it. The browser, not the server, refused to give the answer to the script. Download this diagram as SVG

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 choiceWhy it fits hereWhen you might choose differently
SQLite for storageNothing 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 frameworkEvery 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 requestThe 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 APIBoth 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 elementsThe 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 centsWhole 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 buyingBuying 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 everywhereThe 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 > 0A 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 placesThe 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 innerHTMLtextContent 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 cardThe 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 replyThe 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 sendA 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.

Read, then writetwo steps with a gap between themBuyer ABuyer BDatabaseSELECT stock1SELECT stock1write stock = 0write stock = 0Two buyers took the one item that was left.One statementcheck and change happen togetherBuyer ABuyer BDatabaseUPDATE ... WHERE stock > 01 row changedrow: One record in a table, such as one slot of the machine.UPDATE ... WHERE stock > 00 rows changedrow: One record in a table, such as one slot of the machine.The second buyer is told that the slot is sold out.
Two buyers reach a slot with one item left. When the program reads and then writes, the database can serve the second buyer between those steps. A single UPDATE with the condition inside it leaves no gap. Download this diagram as SVG

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.
server_fastapi.pyin vending-py/ (optional)
"""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_slot

This 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)
Stations 8 to 9 The handler packages the result, then the server sends a response

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 uvicorn and then uvicorn server_fastapi:app --port 8000.
  • FastAPI writes documentation for your API automatically. Visit http://127.0.0.1:8000/docs to 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.

  1. A restock route. Add POST /api/slots/A1/restock with 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 use int(self.headers["Content-Length"]) to learn how many bytes to read, then self.rfile.read(n) to read them, and json.loads to parse them.
  2. A sales log in the same transaction. Create a sales table with an id, a slot id that points to a row in inventory (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.
  3. A summary route. Add GET /api/sales/summary that returns, for each item, how many were sold and how much money they earned. This needs a JOIN, which combines rows from two tables that share a value, and a GROUP BY, which gathers rows that share a value so that you can count or add them. The result has to arrive as clean JSON.
  4. 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.
  5. 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)