Skip to main content
Learning Center
Python Programming

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 count reaches limit, more rows may exist; this is not a total-match count or a definitive has_more signal.
  • 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.

Follow a successful direct call from input checks through one parameterised read to named Python response values. Invalid inputs exit before SQL. No server, loopback binding, authentication, HTTP serialisation or database write is implemented by this listing.

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

  1. Why return an object rather than a bare array at the top level of a response?
  2. Why should the summary endpoint reuse the shortlist handler rather than run its own query?
  3. What does binding to 127.0.0.1 protect against, and what does it not?
  4. Why does keeping the interface read-only remove so much work?
Answers
  1. 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.
  2. For the same parameters, both describe the same limited shortlist page. A separately written query could drift, making the summary disagree with the list.
  3. 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.
  4. 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.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.