{% extends "base.html" %} {% from "_parts.html" import code, defs, files, lesson, prose, stage %} {% from "ui/data.html" import badge, card, code_block, link %} {% from "ui/layout.html" import page_header, stack %} {% from "ui/table.html" import cell, table %} {# The rail in base.html is built from this list, so a section renamed here is renamed in the navigation. Ids match the anchors `lesson()` emits. The plugins an app registers come first; the seam for writing one comes after, because fewer readers need it. #} {% set sections = [ {"id": "auth", "num": "01", "title": "The auth plugin", "group": "Ready-made plugins"}, {"id": "write", "num": "02", "title": "The flash plugin", "group": "Ready-made plugins"}, {"id": "charts", "num": "03", "title": "Charts", "group": "Ready-made plugins"}, {"id": "apidocs", "num": "04", "title": "The API console", "group": "Ready-made plugins"}, {"id": "admin", "num": "05", "title": "The admin", "group": "Ready-made plugins"}, {"id": "why", "num": "06", "title": "What a plugin is", "group": "Write your own"}, {"id": "mount", "num": "07", "title": "mount(): the app side", "group": "Write your own"}, {"id": "extend", "num": "08", "title": "extend(): Jinja, and every render", "group": "Write your own"}, {"id": "rules", "num": "09", "title": "Order, collisions, and limits", "group": "Write your own"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} Plugins integrate the features most apps need into a fjkit app: sign-in, flash messages, charts, an API console, an admin. 01–05 are the ones that ship with fjkit or beside it, and how to register each. 06–09 show how to write your own. {% endset %} {{ page_header("Plugins", intro) }} {# ============================================================== 01 #} {% set t01 %}The browser holds a cookie. The server holds the token.{% endset %} {% call lesson("auth", "01", "The auth plugin", t01, tag="fjkit.auth") %} {% call prose() %}
Sign-in for an htmx app, shipped inside fjkit as fjkit.auth. The browser gets one opaque,
signed, HttpOnly cookie. The access token, the refresh token and your claims stay
in a session store on the server.
This is what an htmx app wants: no htmx:configRequest hook attaching a header, no
refresh-and-retry glue, no token in the DOM. The browser sends the cookie on every request
because that is what browsers do.
Routes see none of it. Put Depends(auth.required) on a router and its handlers
keep the signatures they had. A handler that wants the session takes it as a parameter,
session: Session = Depends(auth.required), or Depends(auth.optional)
on a page that is also public; code that holds the plugin and a request calls
auth.session(request); every template has it as session.
Those are the places to read it, and the reason is an outage. While the identity provider
is unreachable, a request whose token has already expired is answered 503 by whatever asks
for the session, and a handler that asks for nothing, such as a logout, runs. The
middleware's own request.state.auth is None on that request, so a
handler reading it directly would take a signed-in user for an anonymous one.
Only the middle row cannot be a 401: a plain navigation has no way to act on one, so it would
land on a blank page. htmx is the interesting case. It reads HX-Redirect and
returns before it looks at the status code, so the status stays honest while
the browser still moves the whole page.
A navigation is recognised by Sec-Fetch-Mode: navigate, or failing that by
Accept mentioning text/html. Anything else is the caller a route in
render_mode="auto" answers in JSON. The 401 carries no
WWW-Authenticate, although RFC 9110 requires the field: every registered scheme
is wrong here, and Basic would make the browser open its own credential dialog —
the login form this plugin exists to replace.
A redirect loses a message. HX-Trigger names an event for the page to fire, and
the page is replaced before the header arrives; a plain navigation involves no JavaScript at
all. The cookie jar is the only thing that crosses.
So a flash is written as a short-lived signed cookie on the response that redirects, read back
on the request that lands, queued into fjkit.messages like any other message, and
cleared once it has been shown — which is why it appears once and not again on reload. The
toaster region in ui/shell.html is already there and already draws that queue, so
registering the plugin is the entire setup and no template in your app changes.
auth declares uses = ("flash",) and takes whichever plugin is
registered under that name, which is why flash is listed first. Pass
flash= to hand it one configured differently from the one your routes use; an
explicit argument outranks the lookup. The dependency runs one way and is optional: auth
works without a flash, and a route that just saved something wants
flash.add(response, "Saved") with no session in sight.
It has no Jinja side: no extend(), and not one context key of its own. The cookie
is queued into fjkit.messages from middleware, so a template never learns that a
message arrived by cookie rather than from the handler it is rendering. That is what the
layering the Learn page describes
buys — one loop in the shell, not one per delivery mechanism.
The interesting half is when the cookie is cleared: once
messages.shown(request) is true, meaning a template iterated the queue or the
queue was serialised into an HX-Trigger. A page load fires more requests than
the page — an htmx poll, a prefetch — and if "shown" meant "this request carried the cookie",
any of them would eat the message and leave the page it belonged to empty.
Raise one from anywhere — flash.add(response, "Saved", category="success") — and
it appears as a toast on whatever page the browser lands on next, with no route on that page
knowing anything about it. That is the test of whether something should be a plugin at all:
could a route have passed it instead? Here it could not, because the route that raises the
message is not the route that renders it.
Charts are drawn with {{ link("Plotly", "https://plotly.com/python/") }}.
{% endcall %} {{ code("uv add fjkit-charts plotly", label="shell") }} {{ code("from fjkit_charts import ChartsPlugin mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(ChartsPlugin(),)))", label="app/main.py") }} {% call prose() %}
A route returns a list of Chart objects. The page draws each one with the
chart() macro and loads Plotly with chart_scripts(). The browser
fetches no separate chart data.
Swagger UI's Authorize dialog can express only what an OpenAPI document can describe: an API
key, an HTTP scheme, an OAuth2 flow. An app whose sessions are section 01's — a signed
HttpOnly cookie over a server-side token — is none of those. Its Try it out could
not send one anyway: it runs fetch() in the browser, and that cookie is the one
JavaScript may not read.
So both halves move to the server. Signing in is a Python object your app supplies, which for
a fjkit.auth app is the TokenSource your login page already calls.
Sending a request is an in-process replay through your own app, carrying the caller's own
cookie through the whole middleware stack, so token refresh, revocation and the Origin check
are the ones you already wrote rather than a second implementation living in a docs page.
The console is its own distribution — an app that renders pages has no reason to carry a replacement for Swagger UI — so it is one install away:
{% endcall %} {{ code("uv add fjkit-apidocs", label="shell") }} {{ code("from fjkit_apidocs import ApiDocsPlugin docs = ApiDocsPlugin(title=\"Board API\", home_url=\"/\") mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(auth, docs)))", label="app/main.py") }} {% call prose() %}
That is the whole of the wiring. No route to write, no template to render, no static file to
serve: the plugin brings its own router and templates, declares uses = ("auth",),
and wires the sign-in panel to whatever the app registered under that name. Pass flow=
instead when the app authenticates some other way, and
FastAPI(docs_url=None, redoc_url=None) when you want only this one.
It is also the kit's worked example of section 08's add_template_dir(). The
console's templates live beside its code, in fjkit_apidocs/templates/apidocs/,
and extend() hands that directory to the loader. The inner apidocs/
keeps the names at apidocs/page.html, which lets your app replace any one of them
by writing templates/apidocs/<name>.html of its own — the app's directory is
searched first.
Section 02 of the charter puts an admin generator outside the kit, and the reason is the one thing it cannot do without: read your schema. fjkit imports no ORM and is not going to. That rules the feature out of this wheel; it does not rule it out.
So fjkit-admin is shaped like the API console above. It owns a URL prefix, ships
its templates under its own admin/ namespace so an app can shadow any one of
them, renders inside whatever base_template names, and gates every route through
dependencies. What it adds on top is the data layer the kit must not carry: a
sessionmaker, and the SQLAlchemy models its views register.
That is a list with search, filters, sortable headers, pagination and bulk actions, plus the add, change and delete forms — all of it the kit's own macros, and none of it a route you wrote.
One region carries the whole screen. #admin-main is the target of every swap: a
sort, a page, a search, a saved form and a bulk action all replace it. The list partial and
the form partial render into that same id, which is why a saved form comes back as the list
with no second target declared anywhere — and why the admin ships no JavaScript of its own.
A macro covers what a page draws. It does not cover what happens before the handler runs, or what every template should know without a route passing it along. Sessions need both. Locales and feature flags need the second. A flash message needs only the first.
A plugin is one object with two optional methods. mount() runs when the app is
constructed; extend() runs when the Jinja environment is built. A plugin that
only puts a value in every template writes only the second, and one that only touches the
request on its way past writes only the first.
That is the whole registration. mount_fjkit() already reads this config for the
static mount and the environment, so a plugin needs no second call and no import order to
get right.
The split between the two methods stays visible on purpose. Hiding it would be friendlier
until someone's mount() silently never ran.
AppSetup is a thin pass-through to FastAPI. What it adds is attribution: a
misbehaving plugin is named rather than found by elimination.
warn() is for the combinations only the plugin can recognise — an in-memory
session store under a production config, say. That works on your laptop and logs users out at
random behind four workers, and nothing in the traceback points at the cause.
FjkitConfig.globals is resolved once per process. That is right for a version
string and useless for the signed-in user. add_context_processor() is the
other half: a callable run once per render, with the live request.
Two constraints, both deliberate. A processor is synchronous: it runs inside
Templates.page(), which a def handler reaches from the threadpool. A
plugin needing I/O does it in middleware and leaves the result on
request.state, which is what session() reads for the line above.
provides= is required rather than inferred. Without it, two plugins fighting over
user are discoverable only by rendering a page and noticing. With it, the clash
is a startup error naming both.
add_template_dir() is how a plugin that ships UI ships it; section 04 is the
worked example. A plugin that lives inside the fjkit repository has one more step: the Tailwind
build scans an explicit list of directories in fjkit.css, so the plugin adds its
template directory to that list. Miss it and nothing fails — the templates render, every utility class in them is
absent from the stylesheet, and the page looks broken.
<script> on every page, and both "no build step on your side" and the closed
vocabulary would leave through it. A plugin that ships UI ships templates, and they pass
fjkit check like everything else.
{% endset %}
{{ defs("What a plugin cannot do", [
("inject markup", no_markup),
("reach a sibling it did not declare", "a plugin that reads another says so — uses = (\"flash\",) — and gets it from setup.plugin(\"flash\"), or None when the app registered none. An undeclared lookup is a startup error, and so is a sibling listed after the plugin that uses it. Order is still the order you wrote: the host checks it and never reorders"),
("open a connection at startup", "no lifespan hook — a plugin takes a client the app already built, which is why RedisStore has a client parameter"),
]) }}
{% endcall %}
{% endcall %}
{% endblock %}