{% 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()。

{% endcall %} {{ code("config = FjkitConfig( template_dir=APP_DIR / \"templates\", plugins=(auth, flash), ) mount_fjkit(app, config)", label="app/main.py") }} {% call prose() %}

註冊到此結束。mount_fjkit() 本來就要讀這份 config 來掛靜態檔與建立 environment,所以 plugin 不需要第二個呼叫,也沒有 import 順序要記。

兩個時機刻意保持可見。把它藏起來的代價,是某個 plugin 的 mount() 沒有執行而沒有人發現。

{% endcall %} {% endcall %} {# ============================================================== 02 #} {% set t02 %}middleware、handler、路由——而且都記在加它的那個 plugin 頭上。{% endset %} {% call lesson("mount", "02", "mount():app 那一側", t02) %} {% call prose() %}

AppSetup 把呼叫轉給 FastAPI,並多做一件事:署名。 出問題時錯誤訊息會指名那個 plugin,不必逐一拔掉來找。

{% endcall %} {{ defs("AppSetup", [ ("add_middleware(cls, **options)", "Starlette 依註冊的反序執行 middleware,所以 plugins= 裡排在後面的 plugin 包在前面那些的外層"), ("add_exception_handler(exc, handler)", "middleware 裡拋出的例外到不了 handler:Starlette 的 handler 那一層在使用者 middleware 的內側"), ("include_router(router)", "給自帶端點的 plugin"), ("mount_static(url, directory)", "給自帶資產的 plugin,掛載名稱由 plugin 名字衍生"), ("warn(message)", "啟動期的設定檢查;訊息會冠上 plugin 名稱,以 PluginWarning 發出"), ("app, config", "活的 FastAPI app 與 FjkitConfig,給上面兩個表達不了的檢查用"), ]) }} {% call prose() %}

warn() 用於只有這個 plugin 認得出來的組合。例如正式設定配上記憶體版 session store:在開發機上正常,換到四個 worker 後面會隨機把人登出, 而且 traceback 裡沒有一行指向原因。

{% endcall %} {% endcall %} {# ============================================================== 03 #} {% set t03 %}per-request 的 context,就是 globals 缺的那一塊。{% endset %} {% call lesson("extend", "03", "extend():Jinja 與每一次渲染", t03) %} {% call prose() %}

FjkitConfig.globals 每個 process 只算一次,適合版本字串, 不適合「現在登入的是誰」。add_context_processor() 補上另一半: 每次渲染執行一次,拿得到當下的 request。

{% endcall %} {{ defs("EnvSetup", [ ("add_template_dir(path)", "搜尋順序排在 app 的模板之後、kit 的之前——plugin 可以蓋掉 kit 的 macro,永遠蓋不掉 app 自己寫的檔案"), ("add_global(name, value)", "整個 process 一個值"), ("add_filter(name, fn)", "一個 Jinja filter"), ("add_context_processor(fn, provides=[…])", "併進每個模板的 context,每次渲染一次"), ]) }} {{ code("def extend(self, setup: EnvSetup) -> None: setup.add_context_processor( lambda request: {\"session\": getattr(request.state, \"auth\", None)}, provides=[\"session\"], )", label="auth plugin 的 extend(),全部") }} {% call prose() %}

兩個限制都是刻意的。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 都不在樣式表裡,頁面看起來是壞的。

{% endcall %} {% endcall %} {# ============================================================== 04 #} {% set t04 %}所有可能相撞的東西都在啟動時報錯,不會留到正式環境。{% endset %} {% call lesson("rules", "04", "順序、碰撞與界線", t04) %} {% call stage("模板搜尋順序", "先找到的贏。") %} {% call table([{"label": "順序"}, {"label": "屬於"}, {"label": "可以蓋掉"}]) %} {{ cell("1", tone="muted", numeric=true) }} {{ cell("app 的 template_dir", tone="strong") }} {{ cell("全部——這就是 fjkit eject") }} {{ cell("2", tone="muted", numeric=true) }} {{ cell("每個 plugin,依 plugins= 的順序", tone="strong") }} {{ cell("kit 的 macro,永遠不含 app 的檔案") }} {{ cell("3", tone="muted", numeric=true) }} {{ cell("kit", tone="strong") }} {{ cell("沒有") }} {% endcall %} {% endcall %} {{ defs("啟動時就拒絕", [ ("兩個 plugin 同名", "它們會在 request.state. 上相撞,之後的錯誤訊息無法分辨是哪一個"), ("兩個 plugin 搶同一個 global、filter 或 context key", "錯誤訊息會點名雙方,並要求改名或拿掉一個"), ("processor 回傳沒宣告過的 key", "strict_undefined 會抓到,所以在開發階段就浮現"), ]) }} {% set no_markup %} 沒有任何 hook 能把 markup 塞進 shell。有的話,任何 plugin 都能在每一頁放一段 <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 由瀏覽器自動帶上。

{% endcall %} {{ code("auth = AuthPlugin( secret=os.environ[\"FJKIT_SECRET\"], store=RedisStore(redis.from_url(REDIS_URL)), source=MyOIDCSource(), trusted_origins=[\"https://app.example.com\"], ) mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(auth,)))", label="app/main.py") }} {{ defs("三個可替換的接縫", [ ("SessionStore", "開發用 MemoryStore、正式用 RedisStore,或你自己的實作,共五個 async 方法"), ("TokenSource", "憑證怎麼變成 session:比對密碼、OIDC code 交換、跟上游換 token。預設的 LocalSource 沒有上游"), ("Csrf", "cookie 是 ambient credential,所以 CSRF 回來了。SameSite=Lax 加上精確的 Origin 比對,兩者漏掉的東西不同"), ]) }} {% call prose() %}

路由看不到這些。在 router 上掛 Depends(auth.required),底下的 handler 簽名不用改。session 在 request.state.auth 上,在每個模板裡叫 session。

{% endcall %} {% call stage("未登入的請求怎麼被拒絕", "@render 決定線上格式的那個問題,換成:該怎麼拒絕。") %} {% call table([{"label": "誰在問"}, {"label": "回什麼"}, {"label": "為什麼不是 401"}]) %} {{ cell("htmx", tone="strong") }} {{ cell(badge("401 + HX-Redirect", variant="info")) }} {{ cell("htmx 在讀狀態碼之前就處理 HX-Redirect,所以兩者可以同時成立") }} {{ cell("瀏覽器導覽", tone="strong") }} {{ cell(badge("303", variant="info")) }} {{ cell("沒有 body 的 401 就是一片空白;人要的是登入表單") }} {{ cell("腳本、curl、另一個服務", tone="strong") }} {{ cell(badge("401", variant="outline")) }} {{ cell("401 就是為這種呼叫端而存在") }} {% endcall %} {% endcall %} {% call prose() %}

只有中間那一列不能是 401:一般導覽沒有機制可以回應它,只會停在空白頁上。 htmx 那一列處理完 HX-Redirect 就 return,還沒讀到狀態碼, 所以狀態碼可以照實回,而瀏覽器照樣整頁移動。導覽的判定是 Sec-Fetch-Mode: navigate, 沒有這個標頭時改看 Accept 有沒有提到 text/html;其餘都是 render_mode="auto" 下會拿到 JSON 的呼叫端。那個 401 不帶 WWW-Authenticate:RFC 9110 要求這個欄位,但所有已註冊的機制在這裡都不適用。 Basic 會讓瀏覽器跳出自己的帳密對話框,而那正是這個 plugin 要取代的登入表單。

{% endcall %} {{ defs("動手之前值得知道的", [ ("每個請求一次 store 查詢", "這是「刪一筆就能撤銷 session」與「refresh token 不出伺服器」的代價"), ("refresh 有鎖", "否則同時發出的四個 swap 會送出四次 refresh,而有輪替的上游在第二次到達時撤銷整個 family"), ("refresh 期間不換 session id", "換了的話並行的 swap 會各帶回一張不同的 Set-Cookie,最後一張生效,其餘變孤兒"), ("登入路由是 async def", "它們要 await store,真正的 TokenSource 還要 await 身分提供者。只負責渲染的路由維持 def"), ]) }} {% endcall %} {# ============================================================== 06 #} {% set t06 %}一則活得比產生它的那個回應更久的訊息。{% endset %} {% call lesson("write", "06", "flash plugin", t06, tag="fjkit.flash") %} {% call prose() %}

轉址會讓訊息消失。HX-Trigger 是叫頁面觸發一個事件,但頁面在那個標頭抵達之前 已經被換掉;一般導覽更是全程沒有 JavaScript 參與。唯一跨得過去的只有 cookie。

所以 flash 是一個短命的簽名 cookie:在轉址的那個回應上種下去,在落地的那個請求上讀回來, 跟其他訊息一樣排進 fjkit.messages,並在真的顯示過之後才清掉。 它因此只出現一次,重新整理不會再跳。ui/shell.html 裡的 toaster 區域本來就在, 也本來就在畫那個佇列,所以註冊 plugin 就是全部的設定,模板不用改。

{% endcall %} {{ code("flash = FlashPlugin(secret=os.environ[\"FJKIT_SECRET\"]) auth = AuthPlugin(secret=…, trusted_origins=…, flash=flash) config = FjkitConfig(template_dir=…, plugins=(flash, auth))", label="app/main.py") }} {% call prose() %}

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」, 其中任何一個都會吃掉訊息,讓它該出現的那一頁空著。

{% endcall %} {{ code("def mount(self, setup: AppSetup) -> None: setup.add_middleware(_FlashMiddleware, plugin=self) # 那支 middleware 進來時與出去時做的事: carried = self.plugin.load(request) if carried: messages.extend(request, carried) # 跟其他訊息一樣排進去 response = await call_next(request) if carried and messages.shown(request): self.plugin.clear(response) # 真的到過某一頁之後才清", label="fjkit/flash.py") }} {% call prose() %}

在任何地方呼叫 flash.add(response, "Saved", category="success"), 訊息就會以 toast 出現在瀏覽器接下來落地的那一頁上,而那一頁的路由對它一無所知。 這也是判斷一樣東西該不該做成 plugin 的標準:這個值,路由自己傳得過去嗎? 這裡傳不過去,因為發出訊息的路由不是渲染它的路由。

{% endcall %} {{ defs("鴨子型別,不是繼承", [ ("name", "一個 str 屬性,必要,而且只有這一個"), ("mount / extend", "兩個都可選;沒有定義的那個不會被呼叫"), ("沒有基底類別", "Plugin 是 Protocol,所以一個 plugin 不需要 import fjkit 才能成為 plugin"), ]) }} {% endcall %} {# ============================================================== 07 #} {% set t07 %}在這一頁登入,底下每一次呼叫都以你的身分送出。{% endset %} {% call lesson("apidocs", "07", "API 主控台", t07, tag="fjkit_apidocs") %} {% call prose() %}

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 檢查因此用的是你已經寫好的那一份,而不是文件頁裡的第二實作。

{% endcall %} {% call prose() %}

主控台是獨立的 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)。

{% endcall %} {% call stage("Swagger 做不到的那一半", "兩邊讀的是同一份 /openapi.json,差別從那之後才開始。") %} {% call table([{"label": "問題"}, {"label": "Swagger UI"}, {"label": "fjkit_apidocs"}]) %} {{ cell("怎麼登入", tone="strong") }} {{ cell("文件描述得出來的那四種") }} {{ cell("你的 app 自己的程式——一個 Python 物件") }} {{ cell("請求在哪裡跑", tone="strong") }} {{ cell("在瀏覽器,用 fetch()") }} {{ cell("在 app 裡,用 ASGI 重放") }} {{ cell("帶了什麼出去", tone="strong") }} {{ cell("JavaScript 摸得到的東西") }} {{ cell("你的 session cookie,HttpOnly 也照帶") }} {{ cell("這一頁怎麼擋", tone="strong") }} {{ cell(badge("不歸它管", variant="outline")) }} {{ cell("dependencies=[Depends(auth.required)]") }} {% endcall %} {% endcall %} {{ defs("每個參數決定什麼", [ ("url=\"/api-docs\"", "這一頁的位置。不是 /docs:plugin 掛載時 FastAPI 早就把它拿走了,先註冊的路由勝出,而一頁永遠不會渲染的文件比換個網址更糟,所以路徑被佔用時 plugin 會在啟動時警告"), ("flow=", "怎麼從這一頁登入。預設是同一份 config 裡的 AuthPlugin;沒有的話,面板只會顯示 anonymous"), ("dependencies=", "加在 plugin 新增的每一條路由上,也就是擋住這一頁的方式。公開 API 可以留空,內部服務不行"), ("try_it=False", "只渲染參考文件、拿掉主控台,用於不接受在伺服器端重放請求的環境"), ("home_url=", "側欄頁尾的返回連結。操作清單已經佔滿側欄,沒有它就沒有離開這一頁的入口"), ]) }} {% call prose() %}

它同時是第 03 節 add_template_dir() 在 kit 裡的實例。主控台的模板與它的程式碼 放在一起,在 fjkit_apidocs/templates/apidocs/,由 extend() 把那個 目錄交給 loader。裡面那層 apidocs/ 讓模板名維持 apidocs/page.html, 所以你的 app 只要寫一份自己的 templates/apidocs/<名稱>.html 就能換掉其中任何一支,因為 app 的目錄排在最前面被搜。

{% endcall %} {{ defs("動手之前值得知道", [ ("它的路由不在你的文件裡", "include_in_schema=False,跟 FastAPI 把 /docs 排除在外是同一個理由:這些是讀 API 的方式,不是 API 的一部分"), ("一次呼叫有上限", "body 64 KB、逾時 30 秒,兩個都是參數。主控台是讀 API 的工具,不是壓測工具"), ("form body 會拆成欄位", "一條 Annotated[str, Form()] 的路由拿到的是一欄一個輸入框,而不是一個文字框,不必自己手寫 urlencoding。JSON body 則是一份可編輯的範例,內容由 schema 預先填好"), ("這一頁上沒有任何東西讀得到憑證", "登入是你的 app 定義的表單,不是一個貼 token 的框,性質與那顆 cookie 相同"), ]) }} {% endcall %} {# ============================================================== 08 #} {% set t08 %}圖形的形狀由 Python 決定,圖形的外裝由瀏覽器決定。{% endset %} {% call lesson("charts", "08", "圖表", t08, tag="fjkit_charts") %} {% call prose() %}

Basecoat 有 chart 元件,kit 沒有把它包進來。圖表是唯一一個沒辦法只靠 markup 與 token 完成的元件:總得有東西把數字變成路徑,而那個東西是 1.1 MB 的 JavaScript。 把它做成 plugin,就是為了讓不畫圖的頁面不必付這個數字。

註冊之後,app 會得到 chart macro、主題橋接、Plotly bundle,以及載入它們的兩行 <script>。不用下載也不用另外掛載:bundle 就在這個 wheel 裡,由 plugin 自己的 static mount 提供。

{% endcall %} {{ code("uv add fjkit-charts", label="shell") }} {{ code("from fjkit_charts import ChartsPlugin mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(ChartsPlugin(),)))", label="app/main.py") }} {% call prose() %}

router 每一張圖建一個 Chart,頁面把它們畫出來。中間沒有東西:沒有圖表端點、 沒有瀏覽器去抓的 JSON、沒有 client 狀態。

{% endcall %} {{ files([ {"id": "charts-router", "label": "router.py", "lang": "python", "source": "from fjkit_charts import Chart, figure_of Chart( id=\"by-owner\", title=\"工作量\", summary=\"12 件未結案任務中,Ana 佔 5 件。\", figure=figure_of(go.Figure(...)), )", "note": "figure_of() 會在轉換途中丟掉 Plotly 的預設 template——7,621 bytes、111 個顏色字面值——再驗證其餘部分,所以瀏覽器畫不出來的 trace 在這裡就失敗,而不是渲染出一個空框。它以 to_plotly_json() 做 duck-typing,因此 plotly 不會進 kit 的依賴,傳一個純 dict 也可以。"}, {"id": "charts-page", "label": "charts/page.html", "lang": "jinja", "source": "{% from \"charts/macros.html\" import chart, chart_scripts %} {% for item in charts %}{{ chart(item) }}{% endfor %} {% block scripts %}{{ chart_scripts() }}{% endblock %}", "note": "chart_scripts() 由頁面呼叫,不由 shell 呼叫。plugin 不得把 markup 注入 ui/shell.html,而這正是那條規則要擋的情況:一個讓它在每頁加