Module 13: Capstone Project
Why this matters
Every module in this track taught one facet of the request/response cycle in isolation — the journey (00), raw messages (01), headers (02), methods (03), CORS (04), status codes (05), caching (06), versions (07), negotiation and compression (08), TLS (09), routing (10), and versioning plus serialization (11). Real backend work never uses these one at a time. A single endpoint returns the right status code and honest headers and a cache strategy and the negotiated format and a versioned route — all at once, correctly, or it's broken.
This capstone forces the synthesis. You'll build a small service close to
the metal — using Python's http.server (or raw socket) rather than a
full framework — precisely so you can't hide behind a framework's magic.
FastAPI would set Content-Type, Content-Length, and status lines for
you; here you write them yourself, which is the whole point. When you can
hand-assemble a correct HTTP response with the right status line, honest
representation headers, a working ETag/Cache-Control strategy, a
content-negotiated body, and a versioned route that can emit both JSON
and protobuf, you have genuinely internalized this track rather than
memorized it.
There is no solution code here. That's deliberate: the struggle to
assemble the pieces yourself, hitting the exact bugs the modules warned
about (a wrong Content-Length that hangs the connection, a missing
Vary that poisons a cache, a 200 where a 304 belonged), is where the
learning consolidates.
The project
Build a raw-HTTP-aware "catalog" service in Python using http.server
(subclass BaseHTTPRequestHandler) or the socket module directly — not
FastAPI/Flask/Starlette. It serves a small catalog of products and must
demonstrate correct, hand-written behavior across the whole track.
Functional requirements
Versioned routing (modules 10, 11).
GET /v1/productsandGET /v1/products/{id}— a v1 shape.GET /v2/products/{id}— a v2 shape with at least one breaking change from v1 (rename/restructure a field, change a type), and a 64-bit id serialized safely (module 11's string-id lesson).- Route dispatch is yours to implement: parse the method and path, match
static vs. dynamic (
{id}) segments, and get the specificity/order right (module 10) — a request to a static route must not be swallowed by the dynamic one.
Correct status codes (module 05), hand-written.
200for a found product;404for a missing one;405(with anAllowheader) when the path matches but the method doesn't;400for a malformed request body on writes;422-style rejection for a well-formed-but-invalid body;406when the client demands a format you can't produce.- Write the status line yourself (
HTTP/1.1 200 OK) — no framework doing it for you.
Honest headers, including a real caching strategy (modules 02, 06).
- Every response has an accurate
Content-Typeand a correctContent-Length(compute it from the actual body bytes — a mismatch will hang or truncate, module 01). - Product responses carry a
Cache-Control(a sensiblemax-age) and anETagderived from the product's content. - Support conditional requests: if the client sends
If-None-Matchmatching the currentETag, return304 Not Modifiedwith no body (module 06). Per-user or non-cacheable responses (if you add any) must useprivate/no-store.
- Every response has an accurate
Content negotiation + compression (module 08).
- Honor
Accept: serve JSON by default; if the client accepts your binary type, serve protobuf (see requirement 5). If it demands something you can't produce,406. - Honor
Accept-Encoding: gzipby gzip-compressing text responses and settingContent-Encoding: gzip(only when the client accepts it). - Set
Varyon every negotiated response listing every request header you varied on (Accept,Accept-Encoding) — or a shared cache will serve the wrong representation (module 06/08).
- Honor
JSON and binary (protobuf) serialization (module 11).
- Define a
.protoschema for a product, compile it (protoc --python_out=...), and serve a protobuf-encoded body when the client sendsAccept: application/x-protobuf(or your chosen media type), with the correctContent-Type. - Serve JSON otherwise. The same product resource, two serializations, chosen by negotiation — and observe/compare the byte sizes of each.
- Validate incoming write payloads before trusting them (module 11's "validate before you deserialize").
- Define a
Security headers (module 02).
- Set at least three appropriate response security headers
(
X-Content-Type-Options: nosniff, etc.). If you serve over TLS (optional stretch, module 09), add HSTS.
- Set at least three appropriate response security headers
(
Stretch goals (optional)
- Serve it over HTTPS with a self-signed cert (module 09) and add
Strict-Transport-Security; observe the handshake withopenssl s_client. - Add a deprecation signal to
/v1(Deprecation/Sunsetheaders, module 11). - Add proper CORS handling (module 04): answer a preflight
OPTIONSwith the rightAccess-Control-*headers, and reject a disallowed origin. - Add
Transfer-Encoding: chunkedstreaming for a large listing instead of buffering (module 08), and contrast it withContent-Length.
Acceptance checklist
Verify each with curl (and openssl/protoc where relevant). You should
be able to demonstrate all of these:
-
curl -s http://localhost:PORT/v1/products/1returns the v1 shape with200, correctContent-Type: application/json, aContent-Lengththat matches the body's byte count, aCache-Control, and anETag. -
curl -s http://localhost:PORT/v2/products/1returns the v2 (breaking-change) shape, with the 64-bit id as a string. - Re-requesting with
If-None-Match: "<the etag>"returns304and an empty body. - Changing a product (or a different product) yields a different
ETag, and the conditional request then returns200with the new body. -
curl -s -w '%{http_code}' http://localhost:PORT/v1/products/999returns404. -
curl -X DELETE http://localhost:PORT/v1/products/1returns405with anAllowheader (assuming you didn't implement delete). - A request to a static route that overlaps the dynamic one resolves to the correct handler (specificity/order correct — module 10).
-
curl -H 'Accept: application/x-protobuf' .../v2/products/1returns a protobuf body with the matchingContent-Type; the default returns JSON. Both carryVary: Accept. -
curl -H 'Accept-Encoding: gzip' -sD - -o /dev/null .../v1/productsshowsContent-Encoding: gzipandVaryincludesAccept-Encoding; the gzippedsize_downloadis smaller than the plain one. -
curl -H 'Accept: text/csv' ...(a format you don't produce) returns406. - A malformed JSON write returns a parse-level
400; a well-formed-but-invalid write returns a422-style rejection. - At least three security headers appear on responses.
- (If attempted) HTTPS handshake succeeds and HSTS is present;
preflight
OPTIONSreturns correctAccess-Control-*headers.
Hints
- Start with the raw response writer. Write one function that, given a
status code, headers dict, and body bytes, emits a correct HTTP response:
status line, headers (including a
Content-Lengthcomputed fromlen(body)), the blank line, then the body. Get this right first (module 01) — everything else is building on it. Test it with a rawsocketorBaseHTTPRequestHandler.wfile.write. - Dispatch is a small matcher. Split the path on
/, match the version prefix, then matchproductsand an optional{id}. Check static routes before treating a segment as a dynamic id (module 10's order lesson). - ETag = hash of the serialized content.
'"' + hashlib.sha256(body_bytes).hexdigest()[:16] + '"'. Compare the client'sIf-None-Matchagainst it; equal ⇒304(module 06). - Negotiation is just reading headers and branching. Parse
AcceptandAccept-Encoding; pick a body format and encoding; set the matchingContent-Type/Content-Encodingand theVaryheader (module 08). Do the gzip with the stdlibgzipmodule. - protobuf: write a tiny
product.proto(message Product { int64 id = 1; string name = 2; ... }), runprotoc --python_out=. product.proto, import the generated module, populate aProduct, andSerializeToString()for the body.Content-Type: application/x-protobuf. - Content-Length is load-bearing. Compute it from the final body bytes — after gzip/protobuf encoding, not before. A mismatch is the module 01 hang-or-truncate bug; if a response hangs, suspect this first.
- Test the failure modes deliberately. Send the wrong
Accept, a staleIf-None-Match, aDELETEon a read-only route, a malformed body — the acceptance checklist is your test suite. - Compare sizes. Use
curl -w '%{size_download}'to see JSON vs. protobuf vs. gzipped-JSON byte counts — the module 08/11 tradeoff, made concrete on your own data.
When every box on the acceptance checklist is checked and you can explain which module each behavior came from, you've completed track 01. You can now read a raw HTTP exchange, reason about every header, choose correct status codes, design a cache strategy, negotiate formats, and version an evolving API — the vocabulary every remaining backend track assumes.
Further reading & sources
- Python docs: http.server -
BaseHTTPRequestHandler, the raw handler you subclass for this project. - Python docs: socket and gzip - the lower-level transport and the compression you set by hand.
- RFC 9110: HTTP Semantics and RFC 9111: HTTP Caching - the specs to check your hand-written status codes, headers, and
ETag/304behavior against. - Protocol Buffers: Python tutorial - compiling a
.protoand usingSerializeToString()for the binary body. - MDN: HTTP messages - the message anatomy your raw response writer must reproduce exactly, including
Content-Length.
Next
Track 02 builds directly on this: ../../02-api-layer-and-request-handling/README.md — you'll take everything here and design a properly validated, middleware-chained, OpenAPI-documented RESTful API on top of a real framework, now that you understand exactly what that framework is doing for you underneath.