{% 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 %} {# base.html 的側欄由這份清單建出來,所以在這裡改名就等於改了導覽。 id 要跟 `lesson()` 產生的錨點一致。 #} {% set sections = [ {"id": "why", "num": "01", "title": "plugin 是什麼"}, {"id": "mount", "num": "02", "title": "mount():app 那一側"}, {"id": "extend", "num": "03", "title": "extend():Jinja 與每一次渲染"}, {"id": "rules", "num": "04", "title": "順序、碰撞與界線"}, {"id": "auth", "num": "05", "title": "auth plugin"}, {"id": "write", "num": "06", "title": "flash plugin"}, {"id": "apidocs", "num": "07", "title": "API 主控台"}, {"id": "charts", "num": "08", "title": "圖表"}, {"id": "admin", "num": "09", "title": "admin"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} 有些東西不是 macro。plugin 加的是 middleware、exception handler,或每個模板都該拿到的值: 一個物件,一個地方註冊。 {% endset %} {{ page_header("Plugins", intro) }} {# ============================================================== 01 #} {% set t01 %}兩個貢獻時機,對應 kit 的兩個時機。{% endset %} {% call lesson("why", "01", "plugin 是什麼", t01) %} {% call prose() %}
macro 只管一頁畫出什麼。handler 執行之前發生的事,以及每個模板都該知道、 但不該由每條路由傳過去的值,都不歸它管。session 兩者都要, 語系與 feature flag 只要後者,flash 訊息只要前者。
plugin 是一個物件,兩個方法都可選。mount() 在 app 建構時執行,
extend() 在 Jinja environment 建立時執行。只給每個模板一個值的 plugin
只寫 extend();只在請求經過時處理的 plugin 只寫 mount()。
註冊到此結束。mount_fjkit() 本來就要讀這份 config 來掛靜態檔與建立
environment,所以 plugin 不需要第二個呼叫,也沒有 import 順序要記。
兩個時機刻意保持可見。把它藏起來的代價,是某個 plugin 的 mount()
沒有執行而沒有人發現。
AppSetup 把呼叫轉給 FastAPI,並多做一件事:署名。
出問題時錯誤訊息會指名那個 plugin,不必逐一拔掉來找。
warn() 用於只有這個 plugin 認得出來的組合。例如正式設定配上記憶體版
session store:在開發機上正常,換到四個 worker 後面會隨機把人登出,
而且 traceback 裡沒有一行指向原因。
FjkitConfig.globals 每個 process 只算一次,適合版本字串,
不適合「現在登入的是誰」。add_context_processor() 補上另一半:
每次渲染執行一次,拿得到當下的 request。
兩個限制都是刻意的。processor 必須同步:它在
Templates.page() 裡執行,而那是 def handler 從 threadpool 進來的。
需要 I/O 的 plugin 在 middleware 裡做完,把結果留在 request.state 上,
上面那一行讀的正是這個。
provides= 是強制宣告,不是自動推得。沒有它,兩個 plugin 會默默搶同一個
user,只能靠渲染頁面時碰巧注意到。有了它,衝突變成一個點名雙方的啟動期錯誤。
add_template_dir() 是帶 UI 的 plugin 把 UI 帶進來的方式,第 07 節是範例。
它有一個附帶條件:Tailwind 的掃描範圍是 fjkit.css 裡一份明列的目錄清單,
所以 kit 內建的 plugin 要把自己的模板目錄加進那份清單。
漏掉不會產生任何錯誤:模板照樣渲染,但裡面的 utility class 都不在樣式表裡,頁面看起來是壞的。
<script>,零建置與封閉詞彙表會同時失守。要出 UI 的
plugin 出的是模板,而且跟其他東西一樣要過 fjkit check。
{% endset %}
{{ defs("plugin 做不到的事", [
("注入 markup", no_markup),
("依賴另一個 plugin", "順序就是你寫的順序,沒有解析步驟,因為目前沒有東西需要"),
("在啟動時開連線", "沒有 lifespan hook。plugin 收的是 app 已經建好的 client,RedisStore 有一個 client 參數就是這個原因"),
]) }}
{% endcall %}
{# ============================================================== 05 #}
{% set t05 %}瀏覽器拿 cookie,token 留在伺服器。{% endset %}
{% call lesson("auth", "05", "auth plugin", t05, tag="fjkit.auth") %}
{% call prose() %}
內建的第一個 plugin,也是這個接縫存在的理由。瀏覽器只拿到一個不透明、簽名過的
HttpOnly cookie。access token、refresh token 與 claims 全部留在伺服器端的
session store。
這正是 htmx app 需要的形狀:不必用 htmx:configRequest 掛 header,
不必寫 refresh 重試的接合程式碼,DOM 裡也沒有 token。cookie 由瀏覽器自動帶上。
路由看不到這些。在 router 上掛 Depends(auth.required),底下的 handler
簽名不用改。session 在 request.state.auth 上,在每個模板裡叫
session。
只有中間那一列不能是 401:一般導覽沒有機制可以回應它,只會停在空白頁上。
htmx 那一列處理完 HX-Redirect 就 return,還沒讀到狀態碼,
所以狀態碼可以照實回,而瀏覽器照樣整頁移動。導覽的判定是 Sec-Fetch-Mode: navigate,
沒有這個標頭時改看 Accept 有沒有提到 text/html;其餘都是
render_mode="auto" 下會拿到 JSON 的呼叫端。那個 401 不帶
WWW-Authenticate:RFC 9110 要求這個欄位,但所有已註冊的機制在這裡都不適用。
Basic 會讓瀏覽器跳出自己的帳密對話框,而那正是這個 plugin 要取代的登入表單。
轉址會讓訊息消失。HX-Trigger 是叫頁面觸發一個事件,但頁面在那個標頭抵達之前
已經被換掉;一般導覽更是全程沒有 JavaScript 參與。唯一跨得過去的只有 cookie。
所以 flash 是一個短命的簽名 cookie:在轉址的那個回應上種下去,在落地的那個請求上讀回來,
跟其他訊息一樣排進 fjkit.messages,並在真的顯示過之後才清掉。
它因此只出現一次,重新整理不會再跳。ui/shell.html 裡的 toaster 區域本來就在,
也本來就在畫那個佇列,所以註冊 plugin 就是全部的設定,模板不用改。
app 把 flash 交給 auth,auth 不自己 import 它。兩個 plugin 互不依賴:
auth 沒有 flash 也能跑,而一條剛存好資料的路由要的是
flash.add(response, "Saved"),跟 session 無關。把它們接起來的是 app,
理由與第 04 節沒有相依解析相同。
它在 Jinja 那一側沒有任何東西:沒有 extend(),也沒有自己的 context key。
middleware 把 cookie 排進 fjkit.messages,所以模板不會知道這則訊息
是搭 cookie 來的,還是它正在渲染的那個 handler 給的。這就是「上手」那一頁講的分層換來的結果:
shell 裡一個迴圈,而不是每種送達方式各一個。
那顆 cookie 在 messages.shown(request) 為真時才被清掉,也就是有模板迭代過
那個佇列,或佇列被序列化進 HX-Trigger。一次頁面載入發出的請求不只頁面本身,
htmx 的輪詢與 prefetch 都算。若把「顯示過」定義成「這個請求帶了那顆 cookie」,
其中任何一個都會吃掉訊息,讓它該出現的那一頁空著。
在任何地方呼叫 flash.add(response, "Saved", category="success"),
訊息就會以 toast 出現在瀏覽器接下來落地的那一頁上,而那一頁的路由對它一無所知。
這也是判斷一樣東西該不該做成 plugin 的標準:這個值,路由自己傳得過去嗎?
這裡傳不過去,因為發出訊息的路由不是渲染它的路由。
Swagger UI 的 Authorize 只能表達 OpenAPI 文件描述得出來的東西:API key、HTTP scheme、
OAuth2 flow。第 05 節那種 session——簽章過的 HttpOnly cookie,token 留在伺服器
——四種都不是。它的 Try it out 也送不出去:那是在瀏覽器裡跑 fetch(),
而那顆 cookie 正好不給 JavaScript 讀。
所以兩件事都搬到伺服器。登入是一個由你的 app 提供的 Python 物件,對 fjkit.auth
的 app 來說就是登入頁本來就在呼叫的那個 TokenSource。送出請求則在你自己的 app
裡行程內重放,帶著呼叫者自己的 cookie 走完整條 middleware。
token 換發、撤銷與 Origin 檢查因此用的是你已經寫好的那一份,而不是文件頁裡的第二實作。
主控台是獨立的 distribution——只渲染頁面的 app 沒有理由背一套 Swagger UI 的替代品—— 所以要多裝一個套件:
{% 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() %}
接線到此結束。不用寫路由、不用渲染模板、不用服務靜態檔:plugin 自己帶路由與模板,
並從同一個 plugins tuple 裡找出 AuthPlugin 接上登入面板。
app 用別的方式認證時傳 flow=;只想留這一頁時,設
FastAPI(docs_url=None, redoc_url=None)。
它同時是第 03 節 add_template_dir() 在 kit 裡的實例。主控台的模板與它的程式碼
放在一起,在 fjkit_apidocs/templates/apidocs/,由 extend() 把那個
目錄交給 loader。裡面那層 apidocs/ 讓模板名維持 apidocs/page.html,
所以你的 app 只要寫一份自己的 templates/apidocs/<名稱>.html
就能換掉其中任何一支,因為 app 的目錄排在最前面被搜。
Basecoat 有 chart 元件,kit 沒有把它包進來。圖表是唯一一個沒辦法只靠 markup
與 token 完成的元件:總得有東西把數字變成路徑,而那個東西是 1.1 MB 的 JavaScript。
把它做成 plugin,就是為了讓不畫圖的頁面不必付這個數字。
註冊之後,app 會得到 chart macro、主題橋接、Plotly bundle,以及載入它們的兩行
<script>。不用下載也不用另外掛載:bundle 就在這個 wheel 裡,由 plugin
自己的 static mount 提供。
router 每一張圖建一個 Chart,頁面把它們畫出來。中間沒有東西:沒有圖表端點、
沒有瀏覽器去抓的 JSON、沒有 client 狀態。