Capstone: A Complete, Working Small Application

Building a Web Framework

Chapter 10 · Capstone: A Complete, Working Small Application

Nine chapters, nine real classes: Router, Request/Response, TemplateEngine, App's own onion-model middleware, session/cookie handling, the descriptor-based ORM, the debug/live-reload dev server, and finally a real static-file route wired into an argparse CLI. Every one of them has been verified in isolation. This chapter wires all nine together for the first time into Notes — a small, genuinely working note-sharing app with real login, protected routes, template-rendered pages, a JSON API, and static files — and runs it end to end through the exact real command a user of this framework would actually type: python manage.py runserver.

The App: A Small Note-Sharing Site

A logged-out visitor can browse and search notes. A logged-in user can add new ones. Everything is real and persisted — SQLite on disk, not an in-memory placeholder:

notes_app/ ├── manage.py # the CLI entry point (Ch.9) ├── notes.db # real SQLite -- notes, users, sessions (Ch.7) ├── templates/ │ ├── base.html # layout: nav, {% block title %}, {% block content %} (Ch.4) │ ├── notes_list.html # extends base.html; search box + note cards │ ├── note_card.html # one note, rendered once per item and reused as a SafeString │ ├── note_detail.html # extends base.html; a single note │ ├── login.html # extends base.html; a login form │ └── new_note.html # extends base.html; the add-note form (protected) └── static/ └── style.css # served via the real path converter + safe_static_path (Ch.9)
RouteMethodProtected?What it does
/GETNoList notes, or search via ?q=
/notes/<int:id>GETNoA single note's own page
/loginGET / POSTNoShow / process the login form
/logoutGETNoClear the session, redirect home
/notes/newGET / POSTYesShow / process the add-note form
/api/notesGETNoEvery note as real JSON
/static/<path:filename>GETNoReal static file serving (Ch.9)

A Real Constraint the Template Engine Imposes on the Design

Chapter 4's own TemplateEngine was built and verified with a flat context dict and a single-identifier {{ name }} — it never gained dotted attribute access. Passing a whole Note object into {% for note in notes %}{{ note.title }}{% endfor %} was never something this framework's own real code was built to do. Rather than quietly bolting on a new capability at the last minute, the app's own home handler works within the real, already-verified engine: each note is rendered once, through note_card.html, producing a real, already-escaped HTML fragment — then wrapped as a SafeString before being handed to the outer template's own {% for card in note_cards %}{{ card }}{% endfor %} loop.

def home(request): q = request.query.get('q') notes = Note.search(conn, q) if q else Note.all(conn, order_by='id DESC') # each card is rendered THROUGH the engine once (real escaping happens here), # then wrapped SafeString -- the exact opt-out Chapter 4 verified -- so the # outer template's own {{ card }} doesn't escape already-escaped HTML a second time note_cards = [SafeString(templates.render( 'note_card.html', id=n.id, title=n.title, body=n.body, )) for n in notes] ctx = {'note_cards': note_cards, 'q': q or '', 'empty': len(notes) == 0} ctx.update(nav_ctx(current_user(request))) return Response.html(templates.render('notes_list.html', **ctx))
A genuine, satisfying use of Chapter 4's own SafeString — not a shortcut around escaping
This isn't skipping the security check Chapter 4 built — every note's own title and body still passes through escape() exactly once, inside note_card.html's own {{ title }}/{{ body }}. SafeString only marks the already-escaped result as safe for the outer template to insert verbatim, which is exactly the real, narrow case Chapter 4 designed the opt-out for.

Assembling Every Middleware, in the Right Order

Registration order controls nesting depth (Chapter 5) — the debug middleware needs to be outermost, to catch an exception from anywhere else in the chain; the session middleware needs to be inner enough that request.state['session'] is already populated by the time any handler runs:

app.use(make_debug_middleware(app)) # Ch.8 -- outermost: catches everything below it app.use(timing_middleware) # Ch.5 -- measures the full request, including session I/O app.use(make_session_middleware(store)) # Ch.6 -- innermost: request.state['session'] ready before any handler runs

login_required itself is genuinely new code — but it needed zero new framework machinery to write, because Request.state already exists:

def login_required(handler): def wrapped(request): if not request.state['session'].get('user_id'): return Response.redirect('/login') return handler(request) return wrapped app.add_route('GET', '/notes/new', login_required(new_note_get), name='new_note_get') app.add_route('POST', '/notes/new', login_required(new_note_post), name='new_note_post')
Application code, not framework code — and that's the point
login_required lives in manage.py, not inside App, Router, or any of the classes this course built. It's an ordinary Python function wrapping another ordinary Python function, using a piece of state (request.state) the framework already exposed for exactly this kind of thing back in Chapter 5. A framework doesn't need to anticipate every application-level pattern a real app will want — it needs to expose enough real primitives that a pattern like this can be built on top, in five lines, with nothing added to the framework itself.

Verified Live — the Full App, Through the Real CLI Command

Spawned as a genuine subprocess — python manage.py runserver --port 8971 — and driven with a real http.cookiejar.CookieJar-backed opener, the same real client-simulation technique Chapter 6 used to prove sessions need cookies at all:

GET / -> 200 (shows "Log in", no "New note" link) POST /login (username=sam, password=secret123) -> 302 Found / (real Set-Cookie received) GET /notes/new (same jar, now authenticated) -> 200 (shows the real add-note form) POST /notes/new (title="Live CLI Note", ...) -> 302 Found /notes/3 GET / -> 200 ("Live CLI Note" present, "Log out" present)
Verified directly — the real server-side log for this exact sequence
POST /login and POST /notes/new both logged a genuine 302 server-side; the client-observed status of 200 for each is urllib's own default opener transparently following the redirect and reporting the final page's status instead — the identical real distinction Chapter 3 already verified two ways (a following opener vs. a non-following one) for Response.redirect() in isolation, now showing up again inside a real multi-step flow.

A Real XSS Attempt, Escaped on the Page, Unescaped (Correctly) in the API

A real note created with title = "<script>alert(1)</script>", while authenticated, through the live server:

GET / -> body contains "&lt;script&gt;alert(1)&lt;/script&gt;" (escaped) -> body does NOT contain a literal "<script>alert(1)</script>" GET /api/notes -> b'[{"id": 3, "title": "<script>alert(1)</script>", "body": "..."}]' -> the raw, unescaped tag, correctly, inside a JSON string
Verified directly — two genuinely different, both genuinely correct outcomes
The HTML page correctly shows the escaped form — note_card.html's own {{ title }} ran it through escape(), exactly as Chapter 4 verified. The JSON API correctly returns the raw string — Response.json() calls json.dumps() directly, with no HTML escaping applied at all, because HTML escaping is meaningless (and would be a real bug) inside a JSON string a client is going to parse as data, not render as markup. Both are correct; each is correct for a genuinely different reason, tied to what actually consumes the output.

A Real SQL Injection Attempt Against the Search Feature

Note.search() is new code this chapter wrote, but it reuses Chapter 7's own exact parameterized-query discipline rather than reintroducing the f-string mistake that chapter's own find_author_naive() made:

@classmethod def search(cls, conn, query): sql = "SELECT id, title, body, author_id FROM notes WHERE title LIKE ? ORDER BY id DESC" rows = conn.execute(sql, (f'%{query}%',)).fetchall() return [cls(**dict(zip(cls._fields.keys(), row))) for row in rows]
Verified directly — the real, executed SQL, checked against SQLite's own trace log
A real GET /?q=x' OR '1'='1 against the live app returns zero notes — not both real notes leaked, which is what the naive version from Chapter 7 would have produced. Checked directly against SQLite's own set_trace_callback(), the actual query sent to the database is ...WHERE title LIKE '%x'' OR ''1''=''1%'... — the driver's own real escaping of the embedded quotes, sent as one bound parameter, with the entire malicious string treated as a single literal search term nothing in the real data matches.

Static Files & Path Traversal — Still Blocked, in the Full App

GET /static/style.css -> 200 Content-type: text/css GET /static/../manage.py -> 404 Not Found
Verified directly — Chapter 9's own fix, unchanged, still holding inside a real full app
safe_static_path() and serve_static() were wired in with zero modification from Chapter 9. The legitimate stylesheet request succeeds with the correct real Content-Type; a real attempt to escape STATIC_DIR and read this app's own manage.py source file returns a plain 404, exactly as verified in isolation.

The Real Payoff: A Genuine Restart, Session and Data Both Intact

This is the concrete, end-to-end version of what Chapter 7 promised the moment it built SqliteSessionStore. Server 1 is killed outright — not gracefully shut down, a real taskkill /F — and a genuinely fresh server 2 is started, on a different port, sharing nothing with server 1 except the same real file on disk, notes.db:

# server 1, port 8971: log in, create "Live CLI Note", confirm both are visible # server 1 is then killed outright (taskkill /F) -- simulating the whole process ending # server 2: a brand-new App, a brand-new SqliteSessionStore, PORT 8972, # hit with the SAME real cookie jar the client has held the whole time GET / on server 2 -> 200 still logged in (a real "Log out" link present): True the note created on server 1 is still there: True
Verified directly, end to end — nothing about this app remembers anything except the real database file
Both real facts survive a genuine process death and a fresh process, on a different port, with a completely new App instance: the logged-in session (because SqliteSessionStore reads it back from the real sessions table, keyed by the same session ID the client's cookie jar kept sending) and the note itself (because Note.save() committed it to the same real notes table). Nothing about the client changed at all — it never even noticed the server underneath it was replaced.

A final, lighter confirmation closes the loop on Chapters 8-9 directly: spawned with --debug, a real edit to manage.py's own source while the server keeps running triggers a genuine os.execv() reload — the PID listening on the port changed from a real 10560 to a real 36420, and a request made immediately afterward still returned 200. The exact same Windows-specific finding from Chapters 8 and 9, reproduced a third time, on this exact app.

Chapter Attribution

Piece of NotesChapterWhat it reused, unmodified
Every route's own dispatch2Router, typed converters, named routes (url_for('note_detail', id=...))
Every handler's own request/response3Request, Response.html()/.json()/.redirect()
Every rendered page4TemplateEngine, extends/block, auto-escaping, SafeString
App itself, the middleware chain5App.use(), onion-model composition, request.state
Login, logout, "who's viewing this page"6SessionStore interface, cookie handling, HttpOnly
Every note, every user, the session table itself7Field/Model, parameterized queries, SqliteSessionStore
The outermost middleware, --debug8make_debug_middleware(), run_with_reloader(), FileWatcher
/static/..., the CLI itself9safe_static_path(), serve_static(), argparse-based main()
login_required, Note.search(), the SafeString note-card trick10 (this chapter)Genuinely new — built entirely from the eight rows above, with zero new framework code

Course Complete

Ten chapters, one real, working framework: a WSGI-compliant routing engine with typed converters; ergonomic request/response objects; a real template engine with auto-escaping and inheritance; onion-model middleware; cookie-backed sessions with a genuine persistence option; a minimal, descriptor-based ORM; a real debug page and a live-reload dev server; real static file serving with a genuine path-traversal fix; and a small CLI tying all of it together. Every class, every fix, and every documented finding along the way — the WSGI double-read hang, the naming collision in render(), the missing next_() crash, the SQL injection, the N+1 query, SQLite's own thread restriction, and the Windows-specific os.execv() PID change — was verified directly against real, running code, not asserted. This chapter's own app is the proof: every one of those pieces, together, for the first time, running a real, working site.

Hands-On Exercises

Exercise 1

Add a real POST /notes/<int:id>/delete route to this chapter's own app, wrapped in login_required, that deletes the note's own row from the real notes table and redirects home — then verify, via a real authenticated request and a real unauthenticated one against the same route, that the authenticated request genuinely removes the row (confirmed by a direct row-count check and a real 404 on the deleted note's own detail page afterward) while the unauthenticated one leaves the row count completely unchanged.

📄 View solution
Exercise 2

Construct a real SQL injection attempt against this chapter's own live search feature (a real GET /?q=... request with a crafted value), verify the response shows zero notes matched rather than every real note leaking, and then confirm directly — using sqlite3's own set_trace_callback() — exactly what real SQL string was sent to the database for that request.

📄 View solution
Exercise 3

Send a real, unauthenticated POST to /notes/new (no session cookie at all) and verify two things directly: the response is a real redirect to /login, and — checked against the real notes table's own row count before and after — that new_note_post() genuinely never ran at all, rather than running and merely hiding its own result.

📄 View solution

Chapter 10 Quick Reference — The Whole Course

  • Ch.2 Router — typed converters, registration order, 404 vs. 405, named/reverse routing
  • Ch.3 Request/Response — lazy query/body parsing, the double-read-hangs-a-live-socket finding, automatic Content-Length
  • Ch.4 TemplateEngine — auto-escaping/SafeString, extends/block inheritance, compile-once caching
  • Ch.5 App & middleware — the onion model, short-circuiting, request.state, the real crash from a forgotten next_()
  • Ch.6 Sessions & cookies — why sessions need cookies at all, server-side expiry as an independent control, HttpOnly
  • Ch.7 The ORM — Field/Model, real SQL injection closed by parameterization, the N+1 fix, SqliteSessionStore
  • Ch.8 Debug & live reload — a real traceback page, FileWatcher, os.execv()-based restart
  • Ch.9 Static files & the CLI — the path converter's own dual nature, safe_static_path(), argparse's runserver command
  • Ch.10 This chapter — all of the above, wired into one real, working app, verified live through the actual CLI, including a genuine process restart that loses nothing