Lesson 11 — partial updates and styling¶
What you will build¶
An interface that behaves like an application. One route answers two clients — a browser that wants a page and a script that wants one row — and the row is defined once, in a template the page also includes. Styling arrives at the same time, because a table nobody can read is not finished work.
Both additions are application choices, like lesson 10's template engine. The framework does not know what HTMX is, and this lesson's route proves it: the difference between the two answers is one request header the application reads.
What you need¶
Nothing new. The two scripts are in base.html, and neither is required for the application to work:
without them the buttons are ordinary form buttons and the pages still function.
<!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>
Tailwind's play build is a prototyping tool — it compiles styles in the browser — and a deployment builds the stylesheet it actually uses. HTMX is a single script with no build step. Both are things you add and later replace, which is why they are two tags rather than a framework feature.
One row, two ways to arrive at it¶
examples/user_management/templates/_row.html is the row. It is a fragment: no <html>, no <table>,
just a <tr> with an id a client can target:
<tr id="user-{{ user.id }}" class="border-b border-slate-100">
<td class="px-2 py-2 text-slate-500">{{ user.id }}</td>
<td class="px-2 py-2">{{ user.email }}</td>
<td class="px-2 py-2">{{ user.display_name }}</td>
<td class="px-2 py-2">
<span class="{{ 'text-emerald-700' if user.enabled else 'text-rose-700' }}">
{{ "yes" if user.enabled else "no" }}
</span>
</td>
<td class="px-2 py-2 text-right">
<button
class="rounded bg-slate-200 px-3 py-1 text-sm hover:bg-slate-300"
hx-post="/pages/users/{{ user.id }}/toggle"
hx-target="closest tr"
hx-swap="outerHTML"
>
{{ "Disable" if user.enabled else "Enable" }}
</button>
</td>
</tr>
Three attributes are the whole mechanism. hx-post says where the button posts; hx-target="closest tr"
says which element will be replaced; hx-swap="outerHTML" says replace it entirely, including itself.
The page includes the same file, per user, inside its table body — so a row rendered by the list and a
row rendered by the fragment cannot drift apart:
{% 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 %}
The route that answers both¶
"""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())
toggle_user does the write once and then chooses a representation:
HX-Requestpresent → render_row.htmlfor the updated row. That is what HTMX swaps in, and it is why the fragment must contain exactly one element: a swap target with two roots produces whichever one the browser keeps.- no such header →
303back to the list, exactly as lesson 10's form does.
Note what the header is not: authorization. Any client can send HX-Request: true, and a script that
sends it deliberately is a script that prefers HTML. Access is decided by the assertion in lesson 9,
before any of this; a request that fails that check never reaches the branch.
What the lesson runs¶
"""Lesson 11 — one route, two clients: a full page or a swapped row.
Run it with:
python -m learn_traditional.traditional11_partials
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from user_management.app import HX_REQUEST, 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")
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)
for email in ("ada@example.com", "grace@example.com"):
client.post(
"/pages/users",
data={"email": email, "display_name": email.split("@")[0].title()},
)
without = client.post("/pages/users/1/toggle", follow_redirects=False)
print("toggle without HTMX ->", without.status_code, without.headers["location"])
with_htmx = client.post(
"/pages/users/2/toggle", headers={HX_REQUEST: "true"}, follow_redirects=False
)
print("toggle with HTMX ->", with_htmx.status_code, with_htmx.headers["content-type"])
print("the answer is one row:", with_htmx.text.count("<tr") == 1)
print("no page around it:", "<html" not in with_htmx.text)
print("the row carries the swap target:", 'id="user-2"' in with_htmx.text)
print("and the button now offers the other state:", "Enable" in with_htmx.text)
page = client.get("/pages/users").text
print("the list renders the same row:", 'id="user-2"' in page, "Enable" in page)
print("with the styling in place:", 'class="w-full' in page)
if __name__ == "__main__":
main()
How to run it¶
What it prints¶
toggle without HTMX -> 303 /pages/users
toggle with HTMX -> 200 text/html; charset=utf-8
the answer is one row: True
no page around it: True
the row carries the swap target: True
and the button now offers the other state: True
the list renders the same row: True True
with the styling in place: True
The two toggle lines are the lesson: the same route, the same write, two answers a client can use.
The rest are the properties a partial has to have to be useful — one row, no page around it, the id
the swap needs, and the button already offering the next state, because the fragment was rendered from
the database rather than from the request. The last line is styling: the list page is the same markup,
with classes.
Common mistakes¶
- Returning a whole page into a swap target. It works in a small test and destroys the layout in a
browser. A fragment endpoint returns one element; that is why
_row.htmlhas no<table>. - Assuming the client toggles the state. The button posts and the server decides: a fragment rendered from the request ("say the opposite of what the client sent") shows the wrong state the moment two tabs are open.
- Treating
HX-Requestas a permission. It is a preference, and it is trivially forged. The authorization check comes first, always. - Two definitions of a row. A page that renders rows one way and a fragment that renders them another
way is a bug report waiting for a styling change;
{% include %}is the fix. - Shipping the play CDN. It is explicit about being for prototyping. A deployment builds its stylesheet, so the page does not pay for a compiler in the browser.
- Forgetting
idon the fragment root.hx-target="closest tr"needs to find an element, and a list of rows with no identifiers is a list you cannot address.
Exercise¶
Add GET /pages/users/rows, which returns only the table's body so a client can refresh the list on a
timer. The rows must be the page's rows — the same include — and the fragment must contain no table.
Show the answer
{% for user in users %}
{% include "_row.html" %}
{% endfor %}
"""Lesson 11, exercise — a rows fragment, for a list that refreshes itself.
The answer to the lesson's exercise. The fragment is the same `_row.html` the full page includes,
so there is one definition of a row and two ways to arrive at it: a browser asking for the page, and
HTMX asking for the table body it will swap in.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Annotated
from starlette.requests import Request
from starlette.responses import Response
from starlette.templating import Jinja2Templates
from user_management.app import ADMIN, TEMPLATES, create_app
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_refreshing_app(*, settings: SqliteSettings) -> App:
"""The lesson's application, with the exercise's fragment route added to it."""
app = create_app(settings=settings, secret=SECRET)
templates = Jinja2Templates(directory=[str(TEMPLATES), str(EXERCISE_TEMPLATES)])
@app.get("/pages/users/rows")
async def user_rows(
request: Request,
verifier: Annotated[PrincipalVerifier, Inject()],
users: Annotated[UserService, Inject()],
) -> Response:
"""Return only the table's body: what a polling client swaps into the table."""
require_role(verifier.verify(request), ADMIN)
page = await users.list_users()
return templates.TemplateResponse(request, "_rows.html", {"users": page["items"]})
return app
def main() -> None:
"""Two clients, one definition of a row, and a fragment with no page in it."""
with tempfile.TemporaryDirectory() as directory:
settings = SqliteSettings(path=Path(directory) / "users.db")
migrate(settings)
app = create_refreshing_app(settings=settings)
with create_test_client(HttpAdapter(app)) as client:
client.headers.update(ADMIN_HEADERS)
client.post(
"/pages/users",
data={"email": "ada@example.com", "display_name": "Ada Lovelace"},
)
fragment = client.get("/pages/users/rows")
print(
"GET /pages/users/rows ->", fragment.status_code, fragment.headers["content-type"]
)
print("the fragment is a row:", fragment.text.count("<tr") == 1)
print(
"and nothing around it:",
"<table" not in fragment.text,
"<html" not in fragment.text,
)
print("the row is the page's row:", 'id="user-1"' in fragment.text)
client.post(
"/pages/users",
data={"email": "grace@example.com", "display_name": "Grace Hopper"},
)
refreshed = client.get("/pages/users/rows").text
print("after a write, the fragment has both rows:", refreshed.count("<tr") == 2)
print("and the page agrees:", 'id="user-2"' in client.get("/pages/users").text)
if __name__ == "__main__":
main()
Next¶
Lesson 12 takes it to a server: the deploy step, what to check before traffic arrives, and the operational facts that make the application in this path runnable.