Skip to content

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:

python -m pip install jinja2 python-multipart
python -m pip install jinja2 python-multipart

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:

examples/user_management/templates/base.html
<!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:

examples/user_management/templates/users.html
{% 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:

examples/user_management/app.py
"""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 303 to the list, not a rendered page. A browser that reloads a rendered POST re-submits it; a redirect makes a reload a GET. The driver asserts the status and the Location header rather than following the redirect, because create_test_client follows 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. DuplicateEmailError and UserValidationError are the two ways a valid form can still be refused; a 401, a 403, 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

examples/learn_traditional/traditional10_pages.py
"""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:", "&lt;" 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:", "&lt;script&gt;alert(1)&lt;/script&gt;" in rendered)
            print("and none of it is raw:", MARKUP not in rendered)


if __name__ == "__main__":
    main()

How to run it

$env:PYTHONPATH = "src;examples"
python -m learn_traditional.traditional10_pages
PYTHONPATH=src:examples python -m learn_traditional.traditional10_pages

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 200 for a refusal. A client that reads the status cannot tell a rejected write from an accepted one; 422 is 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() returns str | UploadFile; a submitted file field is an object, and the service's isinstance check 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
examples/learn_traditional/templates/edit.html
{% 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 %}
examples/learn_traditional/traditional10_exercise.py
"""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()
GET the edit form -> 200 prefilled: True
POST the edit -> 303 /pages/users
the list shows the new name: True
POST an empty name -> 422
and says why: True
GET a form for a missing user -> 404
The answer's template directory is *added* to the project's rather than replacing it — `Jinja2Templates(directory=[...])` takes a list, and Jinja's loader searches in order — so the exercise can extend the project's `base.html` without copying it. Two more details carry forward from lesson 6: - **`update_user` is the same method the `PATCH` route calls**, so the normalisation and the collision rule are shared: a form is a client, not a second implementation. - **A missing user is still a `404`.** The answer catches only `UserValidationError`, so `UserNotFoundError` reaches the adapter and answers `404`, which is the right answer to a form for a row that is not there — and the last line of the transcript is that distinction, checked.

Next

Lesson 11 makes the interface feel like an application: partial updates with HTMX, styling with Tailwind, and the same handlers serving both.