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:
| Route | Method | Protected? | What it does |
|---|---|---|---|
| / | GET | No | List notes, or search via ?q= |
| /notes/<int:id> | GET | No | A single note's own page |
| /login | GET / POST | No | Show / process the login form |
| /logout | GET | No | Clear the session, redirect home |
| /notes/new | GET / POST | Yes | Show / process the add-note form |
| /api/notes | GET | No | Every note as real JSON |
| /static/<path:filename> | GET | No | Real 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.
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:
login_required itself is genuinely new code — but it needed zero new framework machinery to write, because Request.state already exists:
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:
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:
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:
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
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:
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 Notes | Chapter | What it reused, unmodified |
|---|---|---|
| Every route's own dispatch | 2 | Router, typed converters, named routes (url_for('note_detail', id=...)) |
| Every handler's own request/response | 3 | Request, Response.html()/.json()/.redirect() |
| Every rendered page | 4 | TemplateEngine, extends/block, auto-escaping, SafeString |
App itself, the middleware chain | 5 | App.use(), onion-model composition, request.state |
| Login, logout, "who's viewing this page" | 6 | SessionStore interface, cookie handling, HttpOnly |
| Every note, every user, the session table itself | 7 | Field/Model, parameterized queries, SqliteSessionStore |
The outermost middleware, --debug | 8 | make_debug_middleware(), run_with_reloader(), FileWatcher |
/static/..., the CLI itself | 9 | safe_static_path(), serve_static(), argparse-based main() |
login_required, Note.search(), the SafeString note-card trick | 10 (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
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 solutionConstruct 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 solutionSend 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 solutionChapter 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