Lesson 10 — server-rendered interfaces¶
What you will build¶
The application's first interface. Everything so far returned JSON, and the service underneath did not care; this lesson adds pages, forms, and the two shapes that make a form safe — post, then redirect, and re-render the form when a submission is refused.
This lesson is application code, not a framework feature. Xyberos routes and serializes; it ships no
template engine, no HTML helper, and no static-file convention. The engine here is Jinja2 through
Starlette's Jinja2Templates, which is the shortest path from the routes you already have to a page a
person can use.
What you need¶
Two dependencies the framework does not bring, and does not want to:
Jinja2 renders the page. python-multipart is what Starlette parses a submitted form with: without it
request.form() raises instead of reading the body, because the parser is a separate library rather
than part of Starlette.
The templates¶
examples/user_management/templates/ holds two files. A layout, so a page is a page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}User management{% endblock %}</title>
<!-- Both of these are application dependencies, not framework features. Tailwind's play build is
for prototyping: a deployment compiles the stylesheet it uses. -->
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2" defer></script>
</head>
<body class="bg-slate-50 text-slate-800">
<header class="border-b border-slate-200 bg-white">
<h1 class="mx-auto max-w-3xl px-4 py-6 text-2xl font-semibold">User management</h1>
</header>
<main class="mx-auto max-w-3xl px-4 py-6">
{% block content %}{% endblock %}
</main>
</body>
</html>
And the page itself. It renders the same bounded collection the JSON route returns, then a form:
{% extends "base.html" %}
{% block content %}
{% if error %}
<p role="alert" class="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-rose-800">
{{ error }}
</p>
{% endif %}
<table class="w-full border-collapse bg-white text-left text-sm shadow-sm">
<thead class="bg-slate-100 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th class="px-2 py-2">Id</th>
<th class="px-2 py-2">Email</th>
<th class="px-2 py-2">Name</th>
<th class="px-2 py-2">Enabled</th>
<th class="px-2 py-2"></th>
</tr>
</thead>
<tbody id="users">
{% for user in users %}
{% include "_row.html" %}
{% endfor %}
</tbody>
</table>
<h2 class="mt-8 text-lg font-semibold">Add a user</h2>
<form method="post" action="/pages/users" class="mt-3 space-y-3">
<p>
<label for="email" class="block text-sm text-slate-600">Email</label>
<input
id="email"
name="email"
type="email"
value="{{ form.email if form else '' }}"
class="w-full rounded border border-slate-300 px-3 py-2"
>
</p>
<p>
<label for="display_name" class="block text-sm text-slate-600">Name</label>
<input
id="display_name"
name="display_name"
value="{{ form.display_name if form else '' }}"
class="w-full rounded border border-slate-300 px-3 py-2"
>
</p>
<button type="submit" class="rounded bg-slate-800 px-4 py-2 text-sm text-white hover:bg-slate-700">
Create
</button>
</form>
{% endblock %}
Autoescaping is on (Starlette's Jinja2Templates uses autoescape=True), which is why
{{ user.display_name }} is safe to write and why this lesson's transcript ends by proving it. The two
ways to lose that are worth naming now: |safe, and Markup, and building HTML with an f-string before
a template ever sees it.
The routes¶
examples/user_management/app.py gains two page routes and one small helper. Nothing about the service
changed: the page reads the same list_users() the API does, with the same bound, and the form calls the
same create_user() with the same rules:
"""Compose the application and expose it as an ASGI app."""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Annotated
from starlette.applications import Starlette
from starlette.datastructures import QueryParams
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from starlette.templating import Jinja2Templates
from user_management.errors import DuplicateEmailError, UserValidationError
from user_management.identity import PrincipalVerifier, require_role
from user_management.service import DEFAULT_LIMIT, UserService
from xyberos import App, Inject, Settings
from xyberos.database import DatabaseProvider, SqliteSettings, sqlite_provider
from xyberos.http import HttpAdapter, read_json_object
#: The role the administrative routes require. One constant, so the route table reads as a list.
ADMIN = "admin"
#: Where the pages live. Templates are application code: the framework routes and serializes, and a
#: template engine is something an application adds.
TEMPLATES = Path(__file__).parent / "templates"
#: The header HTMX sends. It is how one route answers a browser and a script differently.
HX_REQUEST = "HX-Request"
def create_app(
*,
settings: SqliteSettings | None = None,
database: DatabaseProvider | None = None,
secret: str | None = None,
) -> App:
"""Compose the application.
Pass ``settings`` to let the application own and start its database, or ``database`` to supply
an already-started provider, which the application then owns and closes on shutdown. ``secret``
is the identity provider's shared secret; without one the verifier refuses every request.
"""
app = App(settings=Settings.load(environ={}, overrides={"app": {"name": "user_management"}}))
if database is None:
app.provide(DatabaseProvider, factory=sqlite_provider(settings), eager=True)
else:
app.provide(DatabaseProvider, instance=database)
app.provide(UserService)
app.provide(PrincipalVerifier, instance=PrincipalVerifier(secret))
def administrator(request: Request, verifier: PrincipalVerifier) -> None:
"""Refuse a caller who is not an administrator, before anything else happens.
One call at the top of a route, and its cost is that it can be forgotten: there is no marker
on the handler that a reviewer can grep for except this line. That is the trade the ADR
describes — the application owns authorization, so the route table is where it is audited.
"""
require_role(verifier.verify(request), ADMIN)
@app.get("/health")
async def health(database: Annotated[DatabaseProvider, Inject()]) -> dict[str, str]:
await database.health()
return {"status": "ready"}
@app.post("/users")
async def create_user(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> dict[str, object]:
administrator(request, verifier)
payload = await read_json_object(request)
return await users.create_user(payload.get("email"), payload.get("display_name"))
@app.get("/users")
async def list_users(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> dict[str, object]:
administrator(request, verifier)
query = request.query_params
return await users.list_users(
limit=_whole_number(query, "limit", default=DEFAULT_LIMIT),
offset=_whole_number(query, "offset", default=0),
)
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> dict[str, object]:
administrator(request, verifier)
return await users.get_user(user_id)
@app.patch("/users/{user_id}")
async def update_user(
user_id: int,
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> dict[str, object]:
administrator(request, verifier)
payload = await read_json_object(request)
return await users.update_user(user_id, payload)
@app.delete("/users/{user_id}")
async def delete_user(
user_id: int,
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> None:
administrator(request, verifier)
await users.delete_user(user_id)
@app.get("/me")
async def read_me(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> dict[str, object]:
principal = verifier.verify(request)
return await users.find_own_user(principal)
@app.post("/me")
async def claim_account(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> dict[str, object]:
principal = verifier.verify(request)
payload = await read_json_object(request)
return await users.create_user(
payload.get("email"),
payload.get("display_name"),
external_subject=principal.subject,
)
@app.patch("/me")
async def update_me(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> dict[str, object]:
principal = verifier.verify(request)
payload = await read_json_object(request)
return await users.update_own_profile(principal, payload)
templates = Jinja2Templates(directory=str(TEMPLATES))
async def users_page(
request: Request, users: UserService, *, status_code: int = 200, error: str | None = None
) -> Response:
page = await users.list_users()
return templates.TemplateResponse(
request,
"users.html",
{"users": page["items"], "error": error, "form": await _submitted_form(request)},
status_code=status_code,
)
@app.get("/pages/users")
async def show_users(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> Response:
administrator(request, verifier)
return await users_page(request, users)
@app.post("/pages/users")
async def create_user_from_form(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> Response:
administrator(request, verifier)
form = await request.form()
try:
await users.create_user(form.get("email"), form.get("display_name"))
except (DuplicateEmailError, UserValidationError) as error:
return await users_page(request, users, status_code=422, error=str(error))
return RedirectResponse("/pages/users", status_code=303)
@app.post("/pages/users/{user_id}/toggle")
async def toggle_user(
user_id: int,
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> Response:
administrator(request, verifier)
current = await users.get_user(user_id)
updated = await users.update_user(user_id, {"enabled": not current["enabled"]})
if request.headers.get(HX_REQUEST):
return templates.TemplateResponse(request, "_row.html", {"user": updated})
return RedirectResponse("/pages/users", status_code=303)
return app
async def _submitted_form(request: Request) -> Mapping[str, object] | None:
"""Return what the client typed, so a refused form can be shown back to them."""
if request.method != "POST":
return None
form = await request.form()
return {key: str(value) for key, value in form.items()}
def _whole_number(query: QueryParams, name: str, *, default: int) -> int:
"""Read one query string value as a whole number, or refuse the request.
A query string is text: `?limit=abc` arrives as `"abc"`. The conversion belongs here, where the
request is untrusted, and the *bounds* stay in the service, which knows what a page may be.
"""
raw = query.get(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
raise UserValidationError(f"{name} must be a whole number") from None
def build_asgi_app(app: App) -> Starlette:
"""Build an ASGI application for a composed application."""
return HttpAdapter(app).asgi_app
def app_factory() -> Starlette:
"""Create the application's ASGI app for an ASGI server.
The identity secret is read from the environment by the verifier, and a deployment that has not
set it refuses every request rather than trusting one.
Usage: ``uvicorn --app-dir examples --factory user_management.app:app_factory``
"""
return build_asgi_app(create_app())
Three decisions are in those twenty lines:
- A successful submission is a
303to the list, not a rendered page. A browser that reloads a renderedPOSTre-submits it; a redirect makes a reload aGET. The driver asserts the status and theLocationheader rather than following the redirect, becausecreate_test_clientfollows redirects by default and would hide the whole point. - A refused submission re-renders the form with
422, carrying the message and what the client typed. Losing their input is the fastest way to make a form feel broken, and the status tells a programmatic client that the write did not happen. - The refusal is caught by class, not by
except Exception.DuplicateEmailErrorandUserValidationErrorare the two ways a valid form can still be refused; a401, a403, or a crash must not be turned into a polite page.
One more thing to note, because it is the same rule as lessons 8 and 9: a browser cannot send the administrator's assertion. In a deployment the proxy authenticates the person, sets the assertion headers, and the page routes check them exactly as the API routes do. Nothing about the HTTP shape changes; the assertion just arrives from a different client.
What the lesson runs¶
"""Lesson 10 — server-rendered pages over the same handlers.
Run it with:
python -m learn_traditional.traditional10_pages
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from httpx import Response
from user_management.app import create_app
from user_management.identity import PrincipalVerifier
from user_management.migrate import migrate
from xyberos.database import SqliteSettings
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client
SECRET = "a-secret-the-deployment-keeps"
ADMIN_HEADERS = PrincipalVerifier(SECRET).headers("admin@idp", "admin")
MARKUP = "<script>alert(1)</script>"
def main() -> None:
with tempfile.TemporaryDirectory() as directory:
settings = SqliteSettings(path=Path(directory) / "users.db")
migrate(settings)
app = create_app(settings=settings, secret=SECRET)
with create_test_client(HttpAdapter(app)) as client:
client.headers.update(ADMIN_HEADERS)
page: Response = client.get("/pages/users")
print("GET /pages/users ->", page.status_code, page.headers["content-type"])
print("an empty table and a form:", "<table" in page.text, "<form" in page.text)
created = client.post(
"/pages/users",
data={"email": "grace@example.com", "display_name": "Grace Hopper"},
follow_redirects=False,
)
print("POST /pages/users ->", created.status_code, created.headers["location"])
print("the new row appears:", "Grace Hopper" in client.get("/pages/users").text)
refused = client.post(
"/pages/users",
data={"email": "not-an-email", "display_name": "Nobody"},
follow_redirects=False,
)
print("POST with a bad address ->", refused.status_code)
print("the form keeps what was typed:", 'value="not-an-email"' in refused.text)
print("and the refusal is text, not markup:", "<" not in refused.text)
posted = client.post(
"/pages/users",
data={"email": "markup@example.com", "display_name": MARKUP},
follow_redirects=False,
)
rendered = client.get("/pages/users").text
print("POST with markup ->", posted.status_code)
print("the markup is escaped:", "<script>alert(1)</script>" in rendered)
print("and none of it is raw:", MARKUP not in rendered)
if __name__ == "__main__":
main()
How to run it¶
What it prints¶
GET /pages/users -> 200 text/html; charset=utf-8
an empty table and a form: True True
POST /pages/users -> 303 /pages/users
the new row appears: True
POST with a bad address -> 422
the form keeps what was typed: True
and the refusal is text, not markup: True
POST with markup -> 303
the markup is escaped: True
and none of it is raw: True
| Line | What it establishes |
|---|---|
GET /pages/users -> 200 text/html; charset=utf-8 |
a handler may return any response; the framework stops serializing JSON when you do |
POST /pages/users -> 303 /pages/users |
the form wrote, then redirected: a reload is now a GET |
POST with a bad address -> 422 |
the refusal is a status, not a message on a 200 |
the form keeps what was typed |
the re-render carries the submitted value back |
the refusal is text, not markup |
an error message is escaped like any other value |
the markup is escaped / and none of it is raw |
a display name containing <script> renders as text, and never as a tag |
Common mistakes¶
|safe,Markup, or HTML built in Python. Autoescaping protects exactly one thing: the values it is allowed to escape. The moment a page marks a user-supplied value as safe, the interface is an injection point — the last two lines of the transcript are the test that says so.- Rendering instead of redirecting after a write. Double-submitted forms are an ordinary consequence
of a reload, and the fix is one line:
RedirectResponse(..., status_code=303). - Answering
200for a refusal. A client that reads the status cannot tell a rejected write from an accepted one;422is the same status the JSON API uses for the same failure. - Doing business rules in the template. A template that filters, sorts, or limits is a second service
layer with no tests. Render
list_users()'s page, not the table. - Trusting a form value to be a string.
request.form()returnsstr | UploadFile; a submitted file field is an object, and the service'sisinstancecheck is what refuses it. - Forgetting the page routes in the route table review. These routes are new endpoints with their own authorization, exactly like the API's.
Exercise¶
Add the edit form: GET /pages/users/{user_id}/edit renders the current name, and POST to the same
path saves it — redirecting on success, re-rendering with 422 on a refused name, and letting a missing
user stay lesson 6's 404.
Show the answer
{% extends "base.html" %}
{% block title %}Edit {{ user.display_name }}{% endblock %}
{% block content %}
{% if error %}
<p role="alert">{{ error }}</p>
{% endif %}
<h2>Edit {{ user.email }}</h2>
<form method="post" action="/pages/users/{{ user.id }}/edit">
<p>
<label for="display_name">Name</label>
<input id="display_name" name="display_name" value="{{ form.display_name if form else user.display_name }}">
</p>
<button type="submit">Save</button>
</form>
<p><a href="/pages/users">Back to the list</a></p>
{% endblock %}
"""Lesson 10, exercise — an edit form over the same service and the same rules.
The answer to the lesson's exercise. In your project the two routes go into ``app.py`` and the
template into ``templates/``; the answer adds its own template directory here so it stays runnable
without editing the project.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Annotated
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from starlette.templating import Jinja2Templates
from user_management.app import ADMIN, TEMPLATES, create_app
from user_management.errors import UserValidationError
from user_management.identity import PrincipalVerifier, require_role
from user_management.migrate import migrate
from user_management.service import UserService
from xyberos import App, Inject
from xyberos.database import SqliteSettings
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client
SECRET = "a-secret-the-deployment-keeps"
ADMIN_HEADERS = PrincipalVerifier(SECRET).headers("admin@idp", ADMIN)
EXERCISE_TEMPLATES = Path(__file__).parent / "templates"
def create_edit_app(*, settings: SqliteSettings) -> App:
"""The lesson's application, with the exercise's two routes added to it."""
app = create_app(settings=settings, secret=SECRET)
templates = Jinja2Templates(directory=[str(TEMPLATES), str(EXERCISE_TEMPLATES)])
async def render(
request: Request,
users: UserService,
user_id: int,
*,
status_code: int = 200,
error: str | None = None,
) -> Response:
"""Render the form for one user, with what was typed if a submission was refused."""
user = await users.get_user(user_id)
form = None
if request.method == "POST":
submitted = await request.form()
form = {key: str(value) for key, value in submitted.items()}
return templates.TemplateResponse(
request,
"edit.html",
{"user": user, "error": error, "form": form},
status_code=status_code,
)
@app.get("/pages/users/{user_id}/edit")
async def edit_form(
user_id: int,
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> Response:
require_role(verifier.verify(request), ADMIN)
return await render(request, users, user_id)
@app.post("/pages/users/{user_id}/edit")
async def save_edit(
user_id: int,
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> Response:
require_role(verifier.verify(request), ADMIN)
submitted = await request.form()
try:
await users.update_user(user_id, {"display_name": submitted.get("display_name")})
except UserValidationError as error:
return await render(request, users, user_id, status_code=422, error=str(error))
return RedirectResponse("/pages/users", status_code=303)
return app
def main() -> None:
"""Read a form, save it, refuse a bad one, and let a missing user stay a 404."""
with tempfile.TemporaryDirectory() as directory:
settings = SqliteSettings(path=Path(directory) / "users.db")
migrate(settings)
app = create_edit_app(settings=settings)
with create_test_client(HttpAdapter(app)) as client:
client.headers.update(ADMIN_HEADERS)
client.post(
"/pages/users",
data={"email": "grace@example.com", "display_name": "Grace Hopper"},
)
form = client.get("/pages/users/1/edit")
print(
"GET the edit form ->", form.status_code, "prefilled:", "Grace Hopper" in form.text
)
saved = client.post(
"/pages/users/1/edit",
data={"display_name": " Rear Admiral Grace Hopper "},
follow_redirects=False,
)
print("POST the edit ->", saved.status_code, saved.headers["location"])
print(
"the list shows the new name:",
"Rear Admiral Grace Hopper" in client.get("/pages/users").text,
)
refused = client.post(
"/pages/users/1/edit", data={"display_name": " "}, follow_redirects=False
)
print("POST an empty name ->", refused.status_code)
print("and says why:", "display_name must be a non-empty string" in refused.text)
missing = client.get("/pages/users/99/edit")
print("GET a form for a missing user ->", missing.status_code)
if __name__ == "__main__":
main()
Next¶
Lesson 11 makes the interface feel like an application: partial updates with HTMX, styling with Tailwind, and the same handlers serving both.