Run the reading-list API and choose an exerciseSUPPORTING MATERIAL
REFERENCE SHELF
Your guided curriculum
SUPPORTING MATERIALGUIDED READING

Run the reading-list API and choose an exercise

This is a small HTTP service you can run, call and change. Alice and Bob belong to one study group. They save documentation links to a shared list, edit their own notes and keep separate reading status. Saving a URL succeeds even when its title lookup times out.

The server uses Python's standard library and a SQLite file. It provides a concrete starting application for the backend, frontend and reading-list exercises. It is not the completed five-stage project: there is no browser UI, production login, request tracing, durable worker, cloud adapter or deployment configuration.

Download the repository once

Install Python 3.12+ and Git. The request examples also use curl. No package installation or AWS account is needed.

git clone https://github.com/Soulful-Iris/junior-to-staff.git
cd junior-to-staff
python3 --version

Already have a checkout? Open a terminal in that checkout instead of cloning again. Commands throughout the projects assume this directory. Browse the repository or this application's source on GitHub.

Start the API in terminal 1

python3 examples/reading-list-starter/app.py --db /tmp/reading-list.sqlite3 --port 8080

Leave it running. It prints its address and database path. Stop with Ctrl+C. Restarting with the same path preserves saved bookmarks. Use a different database filename for a fresh exercise. If port 8080 is busy, pass --port 8081 and change the request URLs below.

Save and read a bookmark in terminal 2

curl -i http://127.0.0.1:8080/bookmarks \
  -H 'X-Demo-User: alice' -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/docs","title_mode":"timeout"}'

curl -s http://127.0.0.1:8080/bookmarks -H 'X-Demo-User: alice'

The first request returns 201, an integer id, title: null, title_status: "timeout" and version: 1. The second returns that saved URL in items. The controlled timeout lasts about half a second. Use "title_mode":"ok" for a title of "Example documentation". The fixture makes no outbound HTTP request. The submitted URL is stored as data.

Use the returned ID in the following commands. 1 assumes a new database:

curl -s -X PATCH http://127.0.0.1:8080/bookmarks/1 \
  -H 'X-Demo-User: alice' -H 'Content-Type: application/json' \
  -d '{"version":1,"note":"Read the storage section"}'

curl -s -X PATCH http://127.0.0.1:8080/bookmarks/1/read \
  -H 'X-Demo-User: bob' -H 'Content-Type: application/json' \
  -d '{"is_read":true}'

The edit returns version: 2. Repeating it with version 1 returns 409. Bob can read the group's links and change his own reading status. Editing Alice's note as Bob returns 404. Missing or unknown demo identity returns 401. No delete, tags, pagination or individual GET /bookmarks/{id} endpoint is supplied.

Find the code you will change

Read app.py (download file, source below). These names exist in that file:

Read the supplied code · app.py
app.py · app.py
"""Local teaching API. Python 3.12+, standard library, no cloud resources.

Run: python3 examples/reading-list-starter/app.py --db /tmp/reading-list.sqlite3
Demo identity is NOT authentication. This server binds only to loopback.
Title lookup is a controlled fixture; it never fetches submitted URLs.
"""
import argparse
from contextlib import contextmanager
import json
import sqlite3
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit

MEMBERS = {"alice", "bob"}


@contextmanager
def connect(path):
    db = sqlite3.connect(path, timeout=5)
    db.row_factory = sqlite3.Row
    db.execute("PRAGMA foreign_keys = ON")
    try:
        with db:
            yield db
    finally:
        db.close()


def initialize(path):
    with connect(path) as db:
        db.executescript("""
        CREATE TABLE IF NOT EXISTS bookmarks (
            id INTEGER PRIMARY KEY, owner TEXT NOT NULL, url TEXT NOT NULL,
            title TEXT, title_status TEXT NOT NULL DEFAULT 'pending',
            note TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1
        );
        CREATE TABLE IF NOT EXISTS reading_state (
            bookmark_id INTEGER REFERENCES bookmarks(id) ON DELETE CASCADE,
            member TEXT NOT NULL, is_read INTEGER NOT NULL,
            PRIMARY KEY (bookmark_id, member)
        );
        """)


def lookup_title(mode):
    """Replace this fixture with a bounded, SSRF-safe adapter in that exercise."""
    if mode == "timeout":
        time.sleep(0.5)
        return None, "timeout"
    return "Example documentation", "ready"


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        # Avoid logging request paths or URL query strings in the starter.
        pass

    def reply(self, status, value):
        body = json.dumps(value).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def body(self):
        length = int(self.headers.get("Content-Length", "0"))
        if not 0 < length <= 16384:
            raise ValueError("Send a JSON body of 1 to 16384 bytes")
        value = json.loads(self.rfile.read(length))
        if not isinstance(value, dict):
            raise ValueError("Expected a JSON object")
        return value

    def dispatch(self):
        # Intentional instrumentation boundary for the tracing exercise.
        self.connection.settimeout(5)
        member = self.headers.get("X-Demo-User")
        if member not in MEMBERS:
            return self.reply(401, {"error": "Use X-Demo-User: alice or bob locally"})
        route = urlsplit(self.path).path.strip("/").split("/")
        if route[0] != "bookmarks":
            return self.reply(404, {"error": "Unknown route"})
        try:
            if self.command == "GET" and route == ["bookmarks"]:
                with connect(self.server.db_path) as db:
                    rows = db.execute("""SELECT b.*, COALESCE(r.is_read, 0) AS is_read
                        FROM bookmarks b LEFT JOIN reading_state r
                        ON r.bookmark_id=b.id AND r.member=? ORDER BY b.id""", (member,))
                    items = [dict(row, is_read=bool(row["is_read"])) for row in rows]
                return self.reply(200, {"items": items})
            if self.command == "POST" and route == ["bookmarks"]:
                data = self.body()
                url = data.get("url")
                mode = data.get("title_mode", "ok")
                if not isinstance(url, str) or len(url) > 2048 or urlsplit(url).scheme not in {"http", "https"} or not urlsplit(url).hostname:
                    raise ValueError("url must be an HTTP(S) URL of at most 2048 characters")
                if mode not in {"ok", "timeout"}:
                    raise ValueError("title_mode must be ok or timeout")
                with connect(self.server.db_path) as db:
                    item_id = db.execute("INSERT INTO bookmarks(owner,url) VALUES (?,?)", (member, url)).lastrowid
                # Save commits before enrichment. A failed title never loses the URL.
                title, outcome = lookup_title(mode)
                with connect(self.server.db_path) as db:
                    db.execute("UPDATE bookmarks SET title=?,title_status=? WHERE id=?", (title, outcome, item_id))
                    item = dict(db.execute("SELECT * FROM bookmarks WHERE id=?", (item_id,)).fetchone())
                return self.reply(201, item)
            if len(route) >= 2 and route[1].isdigit():
                item_id = int(route[1])
                data = self.body() if self.command == "PATCH" else {}
                with connect(self.server.db_path) as db:
                    if self.command == "PATCH" and len(route) == 3 and route[2] == "read":
                        if not isinstance(data.get("is_read"), bool):
                            raise ValueError("is_read must be a boolean")
                        if not db.execute("SELECT 1 FROM bookmarks WHERE id=?", (item_id,)).fetchone():
                            return self.reply(404, {"error": "Unknown bookmark"})
                        db.execute("""INSERT INTO reading_state VALUES (?,?,?)
                            ON CONFLICT(bookmark_id,member) DO UPDATE SET is_read=excluded.is_read""", (item_id, member, data["is_read"]))
                        result = {"id": item_id, "is_read": data["is_read"]}
                    elif self.command == "PATCH" and len(route) == 2:
                        if not isinstance(data.get("note"), str) or len(data["note"]) > 4000 or type(data.get("version")) is not int:
                            raise ValueError("Send note (up to 4000 characters) and integer version")
                        row = db.execute("SELECT owner,version FROM bookmarks WHERE id=?", (item_id,)).fetchone()
                        if not row or row["owner"] != member:
                            return self.reply(404, {"error": "Bookmark not editable by this member"})
                        changed = db.execute("UPDATE bookmarks SET note=?,version=version+1 WHERE id=? AND owner=? AND version=?", (data["note"], item_id, member, data["version"])).rowcount
                        if not changed:
                            return self.reply(409, {"error": "Version conflict; refresh before editing"})
                        result = dict(db.execute("SELECT * FROM bookmarks WHERE id=?", (item_id,)).fetchone())
                    else:
                        return self.reply(404, {"error": "Unknown route"})
                return self.reply(200, result)
            return self.reply(404, {"error": "Unknown route"})
        except (ValueError, json.JSONDecodeError) as error:
            return self.reply(400, {"error": str(error)})
        except sqlite3.OperationalError:
            return self.reply(503, {"error": "Store unavailable; no success promised"})

    do_GET = dispatch
    do_POST = dispatch
    do_PATCH = dispatch


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--db", default="/tmp/reading-list.sqlite3")
    parser.add_argument("--port", type=int, default=8080)
    args = parser.parse_args()
    initialize(args.db)
    server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
    server.db_path = args.db
    print(f"Reading-list API: http://127.0.0.1:{server.server_port}; SQLite: {args.db}", flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
Function or class Supplied behavior Typical exercise change
Handler.dispatch() Routes HTTP requests and checks demo membership Add request context, safe diagnostics and response IDs.
Handler.reply() Writes JSON and status Add the request ID header and a consistent error envelope.
connect() and initialize() SQLite connection and tables Introduce a repository interface before changing storage.
lookup_title() Deterministic success or timeout fixture Add a bounded public-URL fetcher, or move work into a durable queue.
Save transaction in dispatch() Commits URL before title enrichment Persist job intent in the same transaction for the durable-jobs exercise.
Conditional note update Owner and version condition Build a UI that preserves a draft after a 409 response.

X-Demo-User is an explicit local shortcut, not authentication. Anyone who can call this server can select Alice or Bob. Keep it on loopback. A deployed version needs verified identity and a membership model. Do not expose this teaching server to the internet.

How this code connects to AWS

Nothing in this directory deploys or calls AWS. The diagrams in the project pages describe the target after you build adapters. You keep the business rules but replace the process, identity and storage boundaries deliberately.

Local component Example AWS destination Work you must do
BaseHTTPRequestHandler routes API Gateway and a Lambda handler, or an ALB and a container Translate the incoming event into application arguments. Package the handler or container. The Python HTTP handler is not a Lambda handler.
X-Demo-User Cognito or another trusted identity provider Validate issuer, audience and token lifetime. Map the subject to group membership. Never trust the demo header.
SQLite transactions RDS PostgreSQL or a redesigned DynamoDB model Write and exercise a storage adapter. SQLite SQL does not run unchanged on DynamoDB. Preserve owner/version conditions and atomic job intent.
In-process title lookup SQS plus a separately deployed worker Add an outbox, duplicate handling, leases and durable status. Queue creation alone does not provide these behaviors.
Local structured events you add CloudWatch Logs Configure log delivery, retention and scoped access. Keep request/job identity in the event schema.

First complete the local assignment and record its visible result. Then use that page's AWS mapping and configuration as a separate extension. The optional AWS foundation provisions only its documented resources. It does not deploy this API or connect it to a database.