Level 5 · In the Wild

Building a Web App 🚀

You have been calling other people's servers. Now you write one, and the whole thing stops being mysterious.

A real web server, no packages

from http.server import BaseHTTPRequestHandler, HTTPServer
import json


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/":
            self.respond(200, "text/html", "<h1>Ahoy from Python</h1>")
        elif self.path == "/api/crew":
            body = json.dumps([{"name": "Guybrush"}, {"name": "Elaine"}])
            self.respond(200, "application/json", body)
        else:
            self.respond(404, "text/plain", "Not found")

    def respond(self, status, content_type, body):
        encoded = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", content_type + "; charset=utf-8")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def log_message(self, *args):
        pass          # quiet


if __name__ == "__main__":
    HTTPServer(("localhost", 8000), Handler).serve_forever()

That is a genuinely working web server in thirty lines, with nothing installed. Run it, visit http://localhost:8000, and you have served a page. The Rusty School next door is served by a Rust program of exactly this shape, which is the capstone project over there.

It is also not something you would deploy: no routing to speak of, one request at a time, no templates, no security. Which is what frameworks are for.

Flask: the small one

from flask import Flask, request, jsonify, render_template_string      # pip install flask

app = Flask(__name__)

CREW = [
    {"id": 1, "name": "Guybrush", "role": "captain"},
    {"id": 2, "name": "Elaine", "role": "governor"},
]

PAGE = """
<!doctype html>
<title>Crew</title>
<h1>The crew of the {{ ship }}</h1>
<ul>{% for member in crew %}<li>{{ member.name }} ({{ member.role }})</li>{% endfor %}</ul>
"""


@app.route("/")
def index():
    return render_template_string(PAGE, ship="Sea Monkey", crew=CREW)


@app.route("/api/crew")
def list_crew():
    return jsonify(CREW)


@app.route("/api/crew/<int:member_id>")
def get_member(member_id):
    for member in CREW:
        if member["id"] == member_id:
            return jsonify(member)
    return jsonify({"error": "no such crew member"}), 404


@app.route("/api/crew", methods=["POST"])
def add_member():
    data = request.get_json()
    if not data or "name" not in data:
        return jsonify({"error": "name is required"}), 400
    member = {"id": max(m["id"] for m in CREW) + 1, "name": data["name"],
              "role": data.get("role", "deckhand")}
    CREW.append(member)
    return jsonify(member), 201


if __name__ == "__main__":
    app.run(debug=True)

The @app.route decorator should look familiar now: Lesson 35 explained exactly what it is doing, which is registering your function in a lookup table of paths. Nothing here is magic any more.

FastAPI: the modern one

from fastapi import FastAPI, HTTPException      # pip install "fastapi[standard]"
from pydantic import BaseModel

app = FastAPI(title="Crew API")


class Member(BaseModel):
    """The type hints are the validation, the docs and the parsing."""
    name: str
    role: str = "deckhand"
    insults: int = 0


CREW: dict[int, Member] = {1: Member(name="Guybrush", role="captain", insults=8)}


@app.get("/api/crew")
def list_crew() -> list[Member]:
    return list(CREW.values())


@app.get("/api/crew/{member_id}")
def get_member(member_id: int) -> Member:
    if member_id not in CREW:
        raise HTTPException(status_code=404, detail="no such crew member")
    return CREW[member_id]


@app.post("/api/crew", status_code=201)
def add_member(member: Member) -> Member:
    new_id = max(CREW) + 1
    CREW[new_id] = member
    return member
$ fastapi dev main.py

INFO  Uvicorn running on http://127.0.0.1:8000
INFO  Application startup complete.

# and for free, at http://127.0.0.1:8000/docs :
# a complete interactive API documentation page, generated from your type hints
INTERFACING[Formidable: Success]

Look at what those type hints just bought. FastAPI reads them and generates request parsing, validation with proper error messages, JSON serialisation, and a full interactive documentation site.

Send it {\"name\": 42} and it replies with a precise 422 explaining that name must be a string, without you writing a line of validation. This is the strongest practical argument for Lesson 38 that exists.

Choosing

FrameworkBest forTrade
http.serverLearning, tiny internal toolsYou write everything
FlaskSmall apps, HTML pages, huge ecosystemSynchronous by default; validation is manual
FastAPIJSON APIs, async, automatic docsNewer; more concepts to learn
DjangoFull products: admin, auth, ORM, migrationsLarge and opinionated; overkill for an API
Litestar, StarletteAlternatives worth knowing about

A reasonable default in 2026: FastAPI if you are serving JSON, Django if you are building a product with users and an admin panel, Flask if you want something small that renders HTML.

Templates: HTML with holes in it

from jinja2 import Template      # ships with Flask; pip install jinja2 otherwise

template = Template("""
<h1>{{ ship }}</h1>
<ul>
{% for member in crew %}
  <li>{{ member.name }}{% if member.captain %} (captain){% endif %}</li>
{% endfor %}
</ul>
<p>{{ crew | length }} aboard.</p>
""")

print(template.render(
    ship="Sea Monkey",
    crew=[{"name": "Guybrush", "captain": True}, {"name": "Otis", "captain": False}],
))
🛡️ Jinja escapes HTML for you. Do not switch it off.

If a user's name is <script>steal()</script> and you insert it into a page unescaped, you have a cross-site scripting hole. Jinja escapes by default in Flask; the |safe filter turns that off and should be treated as a loaded weapon. Never build HTML with f-strings and user input.

Where the danger actually is

Getting it online

# development
fastapi dev main.py

# production: a real server process, several workers
pip install "uvicorn[standard]"
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4

# in front of it, nginx or Caddy for TLS and static files
WhereGood forCost
A small VPSFull control, learning how it all fitsA few pounds a month, and you patch it
Fly.io, Railway, RenderPush and it deploysFree tiers exist; they sleep when idle
A serverless platformBursty traffic, no servers to runCold starts, and a different mental model
Cloudflare Pages / WorkersStatic sites and edge functionsFree tier, and how this school is hosted
🏫 This school as the example

The site you are reading is static HTML on Cloudflare Pages, with a handful of small serverless functions for progress sync and the anonymous completion counter. Free tier, no server to patch, and the whole thing is in a public repository you can read. Static-first with a small dynamic edge is a genuinely good default for a personal project.

Exercise 1

A JSON API from the standard library

Extend the plain http.server example with a /api/time endpoint returning JSON, and a 404 that is also JSON.

Reveal solution
import json
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer

ROUTES = {}


def route(path):
    """A tiny decorator, exactly like Flask's, so you can see there is no magic."""
    def register(func):
        ROUTES[path] = func
        return func
    return register


@route("/api/time")
def current_time():
    return {"utc": datetime.now(timezone.utc).isoformat(), "ok": True}


@route("/api/crew")
def crew():
    return {"crew": ["Guybrush", "Elaine"]}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        handler = ROUTES.get(self.path)
        status = 200 if handler else 404
        body = handler() if handler else {"error": "not found", "path": self.path}
        encoded = json.dumps(body).encode("utf-8")

        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def log_message(self, *args):
        pass


# Proving the routing works without starting a server:
print(json.dumps(ROUTES["/api/crew"]()))
print(sorted(ROUTES))
{"crew": ["Guybrush", "Elaine"]}
['/api/crew', '/api/time']

Writing your own @route decorator is the moment Flask stops being magic. It is a dictionary from paths to functions, and that is genuinely all a router is.

Exercise 2

Find the security holes

Three serious problems. Name them.

from flask import Flask, request
import sqlite3

app = Flask(__name__)


@app.route("/search")
def search():
    term = request.args.get("q")
    conn = sqlite3.connect("shop.db")
    rows = conn.execute(f"SELECT * FROM products WHERE name LIKE '%{term}%'").fetchall()
    return f"<h1>Results for {term}</h1>" + "".join(f"<p>{r}</p>" for r in rows)


app.run(debug=True, host="0.0.0.0")
Reveal solution
  1. SQL injection. The query is built with an f-string, so ?q=' OR 1=1 -- returns the whole table and worse is possible. Use conn.execute("... LIKE ?", (f"%{{term}}%",)).
  2. Cross-site scripting. term goes straight into the HTML, so ?q=<script>...</script> executes in every visitor's browser. Render a template and let it escape.
  3. debug=True on 0.0.0.0. That exposes an interactive Python console to the entire network. This is a remote code execution hole, deliberately, for development only.

Bonus: the connection is never closed, and there is no error handling if the database file is missing.

Exercise 3

Design the endpoints

Design a REST API for a to-do list, before writing code. What paths, what methods, what status codes?

Reveal solution
GET    /api/tasks              200  list all, supports ?done=true&limit=20
POST   /api/tasks              201  create one, returns it with its new id
                               400  if the body is invalid

GET    /api/tasks/{id}         200  one task
                               404  if there is no such id
PUT    /api/tasks/{id}         200  replace it entirely
PATCH  /api/tasks/{id}         200  change some fields, eg {"done": true}
DELETE /api/tasks/{id}         204  deleted, no body to return
                               404  if it was not there

The conventions worth absorbing: plural nouns for collections, the id in the path rather than a query parameter, the method carries the verb so the URL never contains /deleteTask, 201 for creation with the new object in the body, and 204 for a successful delete with nothing to say. Following them means other developers can guess your API correctly.

+100 XP