Module 12: Web Apps and Service Boundaries
A Read-Only Local Interface Over the Shortlist
Exposing results other software can consume, keeping the surface read-only on purpose, designing a response shape that survives a schema change, and knowing what "local only" actually means.
Lesson 39 of 46 in the recommended order · About 30 min (estimate)
On this page
Outcome
By the end of this lesson you can expose the shortlist through an interface other software can call, with a response shape that survives a schema change, and you can say precisely what running it locally protects.
Why it matters
The assistant currently produces a CSV. That is enough for a person and awkward for anything else: a dashboard, a spreadsheet add-in, or a colleague's script all want to ask a question and get an answer, not parse yesterday's file.
Read-only is a deliberate constraint rather than a stage on the way to something bigger. These handlers run parameterised reads and cannot change stored rows through this interface. That removes write-path audit and idempotency from this lesson's immediate implementation. It does not protect confidential results from a caller who can reach the service, or make expensive reads harmless; access controls and rate limits depend on that exposure. Anything that changes data stays behind the command-line pipeline from Module 10, where a person runs it deliberately.
Concept
Design the response shape before the routes. A response shape is a contract, and unlike code it is expensive to change once anything depends on it.
Four rules that hold up:
- Return an object, not a bare array.
{"results": [...], "count": 2}has somewhere to add a field later; a top-level[...]does not. - Name every field. A list of dictionaries survives a column being added to the table; a list of tuples means the consumer indexes by position and breaks silently.
- Report the count of returned rows separately. If
countreacheslimit, more rows may exist; this is not a total-match count or a definitivehas_moresignal. - Keep error bodies the same shape everywhere, so a caller writes the handling once.
The routes for a read-only interface over the shortlist:
GET /shortlist?state=OR&limit=25 the matching notices
GET /shortlist/summary?state=OR counts and totals for the limited result page
GET /health is the service running
/health is worth including from the start. It answers "is this thing up" without touching the data, and it is what any scheduler, container, or monitor will ask for.
Local only means the process is bound to the loopback address, 127.0.0.1, rather than 0.0.0.0. That difference is one argument at start-up and it is the whole of the deployment boundary here: bound to loopback, the service accepts connections only from the same machine, so nothing on the network can reach it.
Be precise about what that protects. It stops other machines connecting. It does not stop other programs or other users on the same machine, it is not authentication, and it stops applying the moment the port is forwarded, published from a container, or exposed through a tunnel. Treat "local only" as a decision you can lose by accident, and check what the service binds to whenever the way it starts changes.
Two more constraints that keep the surface honest. The interface never writes: no route creates, updates, or deletes anything, so the capstone's --dry-run and its idempotent load stay in the command-line tool where a person invokes them. And the handlers reuse the functions you already have, so the interface adds routing and serialisation and no new logic.
Read the code
import sqlite3
connection = sqlite3.connect(":memory:")
connection.execute(
"CREATE TABLE notices (notice_id TEXT PRIMARY KEY, state TEXT NOT NULL, amount INTEGER NOT NULL)"
)
connection.executemany(
"INSERT INTO notices VALUES (?, ?, ?)",
[("A-1", "OR", 310000), ("A-2", "WA", 720000), ("A-3", "OR", 128000)],
)
MAX_LIMIT = 100
def fetch_shortlist(state, limit):
"""Ordinary function. No request, no HTTP, fully testable."""
rows = connection.execute(
"SELECT notice_id, amount FROM notices WHERE state = ? ORDER BY amount DESC LIMIT ?",
(state, limit),
).fetchall()
return [{"notice_id": notice_id, "amount": amount} for notice_id, amount in rows]
def shortlist_handler(query):
state = query.get("state")
if state is None or state == "":
return 400, {"error": "missing_parameter", "parameter": "state"}
if not isinstance(state, str) or not state.strip():
return 400, {"error": "invalid_parameter", "parameter": "state"}
raw_limit = query.get("limit", "25")
if not isinstance(raw_limit, str) or not raw_limit.isascii() or not raw_limit.isdecimal():
return 400, {"error": "invalid_parameter", "parameter": "limit"}
try:
requested_limit = int(raw_limit)
except ValueError:
return 400, {"error": "invalid_parameter", "parameter": "limit"}
if requested_limit < 1:
return 400, {"error": "invalid_parameter", "parameter": "limit"}
limit = min(requested_limit, MAX_LIMIT)
results = fetch_shortlist(state.strip().upper(), limit)
return 200, {"results": results, "count": len(results), "limit": limit}
def summary_handler(query):
status, body = shortlist_handler(query)
if status != 200:
return status, body
total = sum(record["amount"] for record in body["results"])
return 200, {"count": body["count"], "total_value": total, "limit": body["limit"]}
def health_handler(query):
return 200, {"status": "ok"}
print(shortlist_handler({"state": "or"}))
print(summary_handler({"state": "OR"}))
print(shortlist_handler({}))
print(health_handler({}))
fetch_shortlist is the only function that touches the database, and it takes ordinary arguments. Every test of the interesting behaviour goes through it, with no request object anywhere.
shortlist_handler expects query values as text, checks the required state, accepts only positive ASCII decimal limit text, caps it at 100, calls the read function, and shapes the result. Invalid values return a named 400 error before SQL. Its job is input and response handling rather than a second database query.
summary_handler reuses shortlist_handler rather than writing a second query. For the same query it sums the same limited result page and echoes that page's limit; it is not a total across every matching notice. A separate query could drift from the list it claims to summarise.
health_handler ignores its argument and touches nothing. In this listing it only returns a Python value; an actual HTTP adapter could use it to report that the service is answering, not that its data is good.
One read-only shortlist call, from parameters to named response
1. Direct handler call
shortlist_handler(query)
Read state and optional limit
The worked listing calls Python functions directly. The GET routes are an interface design; this example starts no HTTP server.
2. Validate before reading
Missing state- Return 400 with named parameter: state.
Non-text or whitespace state- Return 400 before reading.
Invalid, non-text or nonpositive limit- Return 400 with named parameter: limit.
Valid values- Trim and uppercase state; default limit 25; cap positive ASCII decimal text at 100.
An error branch returns before fetch_shortlist or any SQL.
3. Bound database read
Normalized state and bounded limit
fetch_shortlist(state, limit)
SQL- SELECT notice_id, amount ... WHERE state = ? ORDER BY amount DESC LIMIT ?
Bound values- (state, limit)
The in-memory SQLite example reads rows and maps them to named fields. There is no INSERT, UPDATE or DELETE path in these handlers.
4. Named result envelope
Status- 200 for a successful handler call
Body- results: named records; count: returned-row count; limit: applied limit
Summary- Uses that same limited result page, not all matching rows.
These are Python (status, body) values, not HTTP response bytes. Equal count and limit means more matches are possible, not certain.
Predict the output
Predict all four printed lines.
Check your prediction
(200, {'results': [{'notice_id': 'A-1', 'amount': 310000}, {'notice_id': 'A-3', 'amount': 128000}], 'count': 2, 'limit': 25})
(200, {'count': 2, 'total_value': 438000, 'limit': 25})
(400, {'error': 'missing_parameter', 'parameter': 'state'})
(200, {'status': 'ok'})
Two Oregon notices, largest first, with the Washington one excluded by the WHERE. The summary's total is those two amounts added, and it agrees with the list by construction rather than by coincidence.
The limit is echoed in the successful responses. If count equals limit, there may be more matches; this response alone does not prove that another page exists. Invalid or nonpositive limits return the same named error shape before a database read.
Modify the code
In fetch_shortlist, return rows directly instead of building dictionaries. Predict what the first two printed lines become, and what breaks for a consumer.
What changes, and why
The first line's results become [('A-1', 310000), ('A-3', 128000)], and summary_handler raises TypeError: tuple indices must be integers or slices, not str on record["amount"].
Two costs, one immediate and one delayed. The immediate one is that every consumer must know that position 0 is the identifier and position 1 the amount, which is nowhere in the response.
The delayed cost is fragility. Reordering selected columns could make positional consumers read the wrong field without an error; appending a column could instead break fixed-length unpacking. Named response fields let the interface keep a stable contract as the storage query evolves. That is Module 9's argument against SELECT *, arriving at the response boundary where it affects software you do not control.
Debug the bug
An assistant was asked to add a refresh capability to the read-only interface. It produced this.
def refresh_handler(query):
connection.execute("DELETE FROM notices")
for row in fetch_from_source(query.get("state", "")):
connection.execute("INSERT INTO notices VALUES (?, ?, ?)", row)
connection.commit()
return 200, {"status": "refreshed"}
ROUTES = {
("GET", "/shortlist"): shortlist_handler,
("GET", "/refresh"): refresh_handler,
}
What's actually wrong
Every problem this module has raised, in one function.
It writes, from a read-only interface. The original read-only surface could not change data through these handlers, though confidential results still needed protection from reachable callers. This route removes the write-safety property, so the risk changes sharply.
It is registered as GET. A GET is meant to be safe and repeatable. Browsers prefetch them, proxies cache them, monitors poll them, and crawlers follow them. Any of those now triggers a database rewrite. This is Module 10's "what happens if it runs twice", made worse by the fact that things will run it without being asked.
It is delete-then-insert with no empty guard. Module 10's exact failure: a source returning nothing empties the table and reports success.
It has no managed rollback path. SQLite starts an implicit transaction for the delete and inserts. If an error interrupts the loop before commit(), partial changes are pending and visible on this connection, not committed. A rollback discards the pending changes; it restores earlier rows only if they were committed before this operation, and otherwise also discards earlier uncommitted work. This function neither handles the error nor rolls back, so later code could commit the partial state.
It has no authentication and no rate limit, and it is now the most expensive endpoint on the service.
The correct answer is not to fix this route; it is not to have it. Refreshing data is what the Module 10 command-line pipeline does, run deliberately by a person or a scheduler, with a dry run, an empty-batch guard, and an idempotent upsert. The interface stays read-only.
If a service genuinely must trigger work over HTTP, it needs a different method (POST), authentication, an idempotency key, a rate limit, and an audit record, which is a much larger design than this milestone. Recognising that the small change was actually a large one is the reviewing skill here.
Try it yourself
Complete the read-only endpoint. It must validate first, read with bound parameters, and return the named response shape.
Loading this exercise…
Practical challenge (optional)
Optional: write down the interface contract for your capstone, one short section per route: method, path, parameters with types and defaults, the success shape, and the error codes it can return. Then hand the document, not the code, to someone and ask them to describe what the service does. Anything they get wrong is a documentation defect, and it is far cheaper to find now than after something depends on it.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why return an object rather than a bare array at the top level of a response?
- Why should the summary endpoint reuse the shortlist handler rather than run its own query?
- What does binding to
127.0.0.1protect against, and what does it not? - Why does keeping the interface read-only remove so much work?
Answers
- An object has somewhere to add a field later without breaking existing consumers. A top-level array has no room for a count, a limit, or a warning.
- For the same parameters, both describe the same limited shortlist page. A separately written query could drift, making the summary disagree with the list.
- It stops connections from other machines. It does not stop other programs or users on the same machine, it is not authentication, and it stops applying if the port is forwarded, published from a container, or tunnelled.
- These routes contain no data-changing operation, so they avoid write-path audit and idempotency concerns. That does not remove confidentiality or availability risks: callers who can reach the service may read results or make costly requests, so access control and rate limits can still matter.
Sign in to track your progress on this exercise.
Summary and next step
Design the response shape first, name every field, report the count beside the rows, reuse the functions you already have, keep the surface read-only on purpose, and know exactly what loopback binding does and does not give you. Module 13 turns to the assistant itself: framing a task, reading a diff, and deciding what to accept.