{% 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()` 產生的錨點一致。app 會註冊的外掛排在前面; 自己寫外掛的介面排在後面,因為需要它的讀者較少。 #} {% set sections = [ {"id": "auth", "num": "01", "title": "auth plugin", "group": "現成的外掛"}, {"id": "write", "num": "02", "title": "flash plugin", "group": "現成的外掛"}, {"id": "charts", "num": "03", "title": "圖表", "group": "現成的外掛"}, {"id": "apidocs", "num": "04", "title": "API 主控台", "group": "現成的外掛"}, {"id": "admin", "num": "05", "title": "admin", "group": "現成的外掛"}, {"id": "why", "num": "06", "title": "plugin 是什麼", "group": "自己寫外掛"}, {"id": "mount", "num": "07", "title": "mount():app 那一側", "group": "自己寫外掛"}, {"id": "extend", "num": "08", "title": "extend():Jinja 與每一次渲染", "group": "自己寫外掛"}, {"id": "rules", "num": "09", "title": "順序、碰撞與界線", "group": "自己寫外掛"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} plugin 把多數 app 都需要的常見功能整合進 fjkit app:登入、flash 訊息、圖表、API console、後台。 01–05 是 fjkit 內建或另行發佈的 plugin,以及各自的註冊方式。 06–09 說明如何自己寫 plugin。 {% endset %} {{ page_header("Plugins", intro) }} {# ============================================================== 01 #} {% set t01 %}瀏覽器拿 cookie,token 留在伺服器。{% endset %} {% call lesson("auth", "01", "auth plugin", t01, tag="fjkit.auth") %} {% call prose() %}
htmx app 的登入機制,內建於 fjkit,模組為 fjkit.auth。瀏覽器只拿到一個不透明、簽名過的
HttpOnly cookie。access token、refresh token 與 claims 全部留在伺服器端的
session store。
這正是 htmx app 需要的形狀:不必用 htmx:configRequest 掛 header,
不必寫 refresh 重試的接合程式碼,DOM 裡也沒有 token。cookie 由瀏覽器自動帶上。
路由看不到這些。在 router 上掛 Depends(auth.required),底下的 handler
簽名不用改。需要 session 的 handler 把它當參數拿:session: Session = Depends(auth.required),
公開頁面則用 Depends(auth.optional);手上有 plugin 和 request 的程式碼呼叫
auth.session(request);每個模板裡叫 session。
只從這幾個地方讀,原因是斷線。身分提供者連不上時,token 已經過期的請求會由任何索取 session
的地方回 503,什麼都不索取的 handler,例如登出,照常執行。middleware 自己的
request.state.auth 在那次請求裡是 None,直接讀它的 handler
會把已登入的使用者當成匿名。
只有中間那一列不能是 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 就是全部的設定,模板不用改。
auth 宣告 uses = ("flash",),拿的是註冊在這個名字下的那個 plugin,
所以 flash 要排在前面。要給它一個跟路由用的不同設定的 flash,就傳
flash=;明確傳入的參數優先於查詢。這條依賴是單向且選用的:auth 沒有 flash
也能跑,而一條剛存好資料的路由要的是 flash.add(response, "Saved"),跟 session 無關。
它在 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 的標準:這個值,路由自己傳得過去嗎?
這裡傳不過去,因為發出訊息的路由不是渲染它的路由。
圖表使用 {{ 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() %}
路由回傳一組 Chart 物件。頁面用 chart() macro 逐一畫出,
並以 chart_scripts() 載入 Plotly。瀏覽器不另外抓取圖表資料。
Swagger UI 的 Authorize 只能表達 OpenAPI 文件描述得出來的東西:API key、HTTP scheme、
OAuth2 flow。第 01 節那種 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 自己帶路由與模板,
並宣告 uses = ("auth",),把登入面板接到 app 註冊在這個名字下的那個 plugin。
app 用別的方式認證時傳 flow=;只想留這一頁時,設
FastAPI(docs_url=None, redoc_url=None)。
它同時是第 08 節 add_template_dir() 在 kit 裡的實例。主控台的模板與它的程式碼
放在一起,在 fjkit_apidocs/templates/apidocs/,由 extend() 把那個
目錄交給 loader。裡面那層 apidocs/ 讓模板名維持 apidocs/page.html,
所以你的 app 只要寫一份自己的 templates/apidocs/<名稱>.html
就能換掉其中任何一支,因為 app 的目錄排在最前面被搜。
charter 第 02 節把 admin 產生器放在 kit 之外,理由是它少不了的那件事:讀你的 schema。 fjkit 不 import 任何 ORM,將來也不會。那排除的是「這個 wheel 做這件事」,不是「這件事不該有人做」。
所以 fjkit-admin 的形狀跟上一節的 API 主控台一樣:它擁有一段 URL 前綴、
把模板放在自己的 admin/ 命名空間下讓 app 可以逐一覆蓋、在
base_template 指定的模板裡渲染,並用 dependencies 擋住每一條路由。
它多出來的是 kit 不能帶的資料層:一個 sessionmaker,以及它的 view 註冊的
SQLAlchemy model。
這樣就有一個帶搜尋、篩選、可排序表頭、分頁與批次動作的列表,以及新增、修改、刪除三種表單。 全部由 kit 自己的 macro 渲染,沒有一條路由是你寫的。
整個畫面由一個區塊承載。#admin-main 是每一次 swap 的 target:排序、換頁、搜尋、
存檔後的表單與批次動作,全都替換它。列表 partial 與表單 partial 渲染進同一個 id,
所以表單存檔後會直接以列表的形式回來,任何地方都不需要宣告第二個 target——
也因此 admin 自己不出貨任何 JavaScript。
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 上,
上面那一行的 session() 讀的正是這個。
provides= 是強制宣告,不是自動推得。沒有它,兩個 plugin 會默默搶同一個
user,只能靠渲染頁面時碰巧注意到。有了它,衝突變成一個點名雙方的啟動期錯誤。
add_template_dir() 是帶 UI 的 plugin 把 UI 帶進來的方式,第 04 節是範例。
位於 fjkit repository 內的 plugin 多一個步驟:Tailwind 的掃描範圍是 fjkit.css
裡一份明列的目錄清單,所以該 plugin 要把自己的模板目錄加進那份清單。
漏掉不會產生任何錯誤:模板照樣渲染,但裡面的 utility class 都不在樣式表裡,頁面看起來是壞的。
<script>,零建置與封閉詞彙表會同時失守。要出 UI 的
plugin 出的是模板,而且跟其他東西一樣要過 fjkit check。
{% endset %}
{{ defs("plugin 做不到的事", [
("注入 markup", no_markup),
("讀取沒宣告的同伴", "要讀別的 plugin 就先宣告——uses = (\"flash\",)——再用 setup.plugin(\"flash\") 拿;app 沒註冊時拿到 None。沒宣告就查是啟動錯誤,被用的 plugin 排在使用者後面也是。順序仍然是你寫的順序:host 只檢查,不重排"),
("在啟動時開連線", "沒有 lifespan hook。plugin 收的是 app 已經建好的 client,RedisStore 有一個 client 參數就是這個原因"),
]) }}
{% endcall %}
{% endcall %}
{% endblock %}