HTML API¶
The HTML API exposes @api(...) events as server-rendered HTML pages using Jinja2 templates.
How HTML rendering works¶
flowchart TD
C["Client (Browser)<br/>GET /dashboard"] --> L[API Listener]
L --> Match{URL matches an event?}
Match -->|no| Static["try static file from EDRI_ASSETS_PATH"]
Static --> Found{found?}
Found -->|yes| Serve["serve file"]
Found -->|no| E404["404"]
Match -->|"yes<br/>create Event<br/>route to handler"| Resp[Receive Response]
Resp --> Redir{RedirectResponseDirective?}
Redir -->|yes| R302["HTTP 302 redirect"]
Redir -->|no| Tmpl{template defined?}
Tmpl -->|yes| Render["load Jinja2 template<br/>render data<br/>status + scope"] --> R200["HTTP 200 text/html"]
Tmpl -->|no| Acc{"client accepts JSON?<br/>Accept */*<br/>or application/json"}
Acc -->|yes| JSON["serve JSON instead<br/>before HTML rendering"]
Acc -->|"no (Accept: text/html)"| E406["406 Not Acceptable"]
The JSON fallback for template-less events is decided by the Listener before the HTML renderer runs — see Content Negotiation.
Defining an HTML event¶
HTML exposure requires template and method; url is optional and defaults to /{snake_case_class_name}:
from http import HTTPMethod
from edri.api.dataclass.api_event import api, ApiType
from edri.dataclass.event import Event
from edri.dataclass.response import Response, response
@response
class DashboardResponse(Response):
user_name: str
items: list[dict]
@api(
url="/dashboard",
template="dashboard.j2",
method=HTTPMethod.GET,
exclude={ApiType.WS},
)
class Dashboard(Event):
response: DashboardResponse
Notes:
methodis set on the@api(...)decorator, not as an event field.exclude={ApiType.WS}is typical — HTML events rarely need WebSocket exposure.exclude={ApiType.REST}is also common if you don't want JSON responses for this endpoint.
Template rendering context¶
When EDRI renders a Jinja2 template, the following variables are available:
Variable |
Type |
Content |
|---|---|---|
|
dict |
Response fields (wrapped: |
|
str |
Response status ( |
|
dict |
ASGI scope (path, method, headers, etc.) |
Note: HTML templates always render response fields under
databecauseHTMLHandlercallsResponse.as_dict(..., wrapped=True). TheEDRI_API_RESPONSE_WRAPPEDsetting affects REST/WS JSON responses only, not the HTML template context. Field names are not camelCase-transformed in HTML templates (unlike REST/WS).
Example template¶
<!doctype html>
<html>
<body>
<h1>Dashboard</h1>
<p>Welcome, {{ data.user_name }}!</p>
<ul>
{% for item in data.items %}
<li>{{ item.name }} ({{ item.quantity }})</li>
{% endfor %}
</ul>
</body>
</html>
URL generation in templates¶
The {% url %} Jinja2 extension generates URLs from event class names, keeping URL patterns centralized.
Basic usage¶
<a href="{% url 'Dashboard' %}">Dashboard</a>
With path parameters¶
<a href="{% url 'UserDetail' user_id=42 %}">User #42</a>
With optional trailing parameters¶
@api(url="/user/group/{user_id}/{group_id}", method=HTTPMethod.GET)
class UserGroup(Event):
user_id: int
group_id: int | None = None # optional → trailing
<!-- Omit optional parameter: -->
<a href="{% url 'UserGroup' user_id=456 %}">Group overview</a>
<!-- Renders: /user/group/456 -->
<!-- Include optional parameter: -->
<a href="{% url 'UserGroup' user_id=456 group_id=12 %}">Group detail</a>
<!-- Renders: /user/group/456/12 -->
URL generation rules¶
The first argument is the event class name (Python class name).
Keyword arguments fill URL path placeholders.
Required placeholders must be provided — missing ones raise an error.
Optional placeholders (declared as
T | None) may be omitted — the trailing segment is dropped.Optional parameters must be trailing (after all required parameters).
Static files¶
A request URL that no event route claims is served from EDRI_ASSETS_PATH (default assets/), mapping the URL path one-to-one onto the directory: assets/static/css/style.css is served at /static/css/style.css. Reference assets from templates with root-absolute paths:
<link rel="stylesheet" href="/static/css/style.css">
Static files are public (middleware does not run for them), are served without caching headers, and are not served to JSON clients. See Static files for resolution rules, response headers, access control, and production setup.
Jinja2 configuration¶
Template directories¶
EDRI looks for templates in EDRI_TEMPLATE_PATH (default: templates/).
templates/
├── dashboard.j2
├── auth/
│ ├── login.j2
│ └── register.j2
└── contact/
├── form.j2
└── thanks.j2
Autoescaping¶
EDRI configures Jinja2 with select_autoescape(enabled_extensions=("html", "xml", "j2"), default_for_string=True, default=True). Autoescaping is therefore on by default for .html, .xml, and .j2 templates as well as for templates rendered from strings, so rendered values are HTML-escaped and protected against injection unless you explicitly mark them safe (| safe).
Custom Jinja2 extensions¶
EDRI provides a way to register custom Jinja2 extensions:
edri.start_api(
middlewares=[...],
jinja_extensions={MyCustomExtension},
)
Built-in extensions:
Extension |
Tag/Filter |
Purpose |
|---|---|---|
|
|
Generate URLs from event class names |
To put many endpoints under a shared URL/resource prefix, use
PrefixBaseinstead — it is an event base class, not a Jinja extension, and applies to REST/WS/HTML alike. See Shared URL/resource prefixes.
Post/Redirect/Get pattern¶
A common HTML pattern: the browser sends a form (POST), the server validates it and, on success, returns a redirect (302) so the browser issues a GET to the success page. This prevents form re-submission on refresh.
Manager implementation¶
from edri.abstract import ManagerBase, request
from edri.dataclass.directive.html import RedirectResponseDirective
from edri.dataclass.response import ResponseStatus
class ContactManager(ManagerBase):
@request
def submit(self, event: ContactSubmit) -> None:
# ... business logic ...
# On success: redirect to thank-you page
event.response.set_status(ResponseStatus.OK)
event.response.add_directive(
RedirectResponseDirective(ContactThanks)
)
The template is not rendered when a redirect directive is present — instead, the HTTP 302 response is sent directly.
Post/Redirect/Get with validation failure¶
When validation fails and you want to re-render the form with an error message:
@request
def submit(self, event: ContactSubmit) -> None:
if business_validation_fails(event):
event.response.error = "Invalid input"
event.response.set_status(ResponseStatus.FAILED)
return # ← template is rendered, error shown to user
# On success:
event.response.add_directive(RedirectResponseDirective(ContactThanks))
In the template:
{% if data.error %}
<p style="color: red">{{ data.error }}</p>
{% endif %}
Gotchas & Notes¶
HTML exposure requires
templateandmethod;urldefaults to/{snake_case_class_name}if not provided. Withoutmethod, both REST and HTML are excluded automatically.Optional path parameters must be trailing (after all required parameters).
RedirectResponseDirectivesuppresses template rendering — the redirect is sent instead.{% url %}uses the event class name (string), not the URL path.Static file serving does not support directory indexes — you must request the exact filename.
Event routes take precedence over static files; a dynamic route such as
/{slug}shadows every file at that depth. See How a request reaches a static file.Custom Jinja2 extensions are registered per
EDRIinstance (not global).The
methodparameter belongs on@api(...), not as an event field.
See also¶
API — API layer overview
API Event —
@api(...)decorator referenceResponse — response directives (redirects, cookies)
Static files — serving static assets
HTML form: GET the form, POST it, redirect on success — complete example
Environment Variables —
EDRI_TEMPLATE_PATH,EDRI_ASSETS_PATHvariables