今天接續昨天的內容,編寫FastHTML app,其版面預覽如下:
FastHTML是一個於2024年七月底八月初開始宣傳的全端Python框架,其核心概念是希望所有endpoint能夠直接返回HTML格式,而不是使用如XML或JSON的格式。此外,FastHTML整合了許多HTMX功能,使得其可以在幾乎不需撰寫JavaScript的情況下,與使用者有著良好的互動。如果搭配Alpine.js或是_hyperscript使用的話,更是可以呈現許多以往認為需要React或Vue等框架才能呈現的前端效果。
由於FastHTML
仍在積極開發,文件說明大多也還在編寫中,所以很多功能需要由源碼自己推敲。而其源碼有兩個獨特的風格:
from xxx import *
來導入模組。雖然一開始可能有點不習慣,可是在經過一段時間適應後,FastHTML帶來的開發效率的確令人驚豔。
建議大家可以先觀看官方的影片demo,對其有些基礎的了解後,再進入今天的內容(不然可能會覺得這個框架的用法太玄了?)。
utils.py
提供兩個輔助函數:
get_todo_id()
的功用為建立todo id來作為HTML的attribute。query2ft()
的功用為將EdgeDB的query結果轉換為多個FastHTML component後回傳。如果是傳入一個內含單個或多個EdgeDB query結果的Iterable
,則會將轉換後的FastHTML component包在列表中回傳。# app/utils.py
from dataclasses import asdict
def get_todo_id(id: str):
return f"todo-{id}"
def query2ft(FTdataclass, query_results):
def _query2ft(FTdataclass, query_result):
return FTdataclass(**asdict(query_result))
try:
return [
_query2ft(FTdataclass, query_result)
for query_result in query_results
]
except TypeError: # not iterable
return _query2ft(FTdataclass, query_results)
lifespan.py
封裝所需資源作為fast_app
的lifespan
參數。
在_lifespan()
中,我們使用SVCS提供的svcs.Registry.register_factory()將create_db_client()
(回傳EdgeDB async client)註冊在factory內。
此外,這裡選擇使用_lifespan()
與make_lifespan()
來產生lifespan,而不使用裝飾器@svcs.starlette.lifespan()
的原因,是覺得這麼寫比較容易測試。
# app/lifespan.py
import edgedb
import svcs
from edgedb.asyncio_client import AsyncIOClient
from starlette.applications import Starlette
async def _lifespan(app: Starlette, registry: svcs.Registry):
db_client = edgedb.create_async_client()
async def create_db_client():
yield db_client
registry.register_factory(AsyncIOClient, create_db_client)
yield
await registry.aclose()
def make_lifespan(_lifespan):
return svcs.starlette.lifespan(_lifespan)
lifespan = make_lifespan(_lifespan)
Models.py
定義Todo
及TodoCreate
兩個dataclass
。
Todo
中有一個__ft__()
,這是FastHTML用來定義如何渲染FastHTML component的秘訣。其內使用了Strong
、Li
及A等HTML tag或FastHTML component來對每一個todo進行排版。
其中在A
中,使用了三個HTMX參數:
hx_delete
對應HTMX中的hx-delete,其功用是設定當A
進行HTTP DELETE
(註1)時所訪問的URL,即f"/{self.id}"
。hx_target
對應HTMX中的hx-target,其功用是指定A
的回傳值所替換的對象,即f"#{get_todo_id(self.id)}"
(Li
)。hx_swap
對應HTMX中的hx-swap,其功用是指定所需替換的部份,此處使用outerHTML
來指定將會完全替換hx-target
對象的所有HTML。可以理解為當A
被點擊後,會將新的HTML結果完全取代id
為get_todo_id(self.id)
的tag(Li
)。由於稍後我們會定義當使用HTTP DELETE
訪問每一個f"/{self.id}"
時,不回傳任何HTML tag或FastHTML component,也就是當A
被點擊後,該todo將被刪除並替換為回傳值。但因為其沒有回傳值,所以看起來的效果就像是該todo消失於畫面中。
# app/models.py
from dataclasses import dataclass
from fasthtml.common import A, Li, Strong
from .utils import get_todo_id
@dataclass
class Todo:
id: str
title: str
def __ft__(self):
show = Strong(self.title, target_id="current-todo")
delete = A(
"delete",
hx_delete=f"/{self.id}",
hx_target=f"#{get_todo_id(self.id)}",
hx_swap="outerHTML",
)
return Li(show, " | ", delete, id=get_todo_id(self.id))
@dataclass
class TodoCreate:
title: str
app前置作業有三項工作:
app
的fast_app
instance及一個rt
object。此處需使用我們於先前定義的lifespan及將svcs.starlette.SVCSMiddleware
加入到middleware(這樣才可以使用SVCS)。mk_input()
,其會回傳Input
(對應HTML的input
tag)作為使用者輸入todo的地方。# app/main.py
from dataclasses import asdict
import edgedb
import svcs
from edgedb.asyncio_client import AsyncIOClient
from fasthtml.common import (H1, Button, Card, Div, Form, Group, Input, Main,
Title, Ul, add_toast, fast_app, setup_toasts)
from starlette.middleware import Middleware
from starlette.requests import Request
from .lifespan import lifespan
from .models import Todo, TodoCreate
from .queries import create_todo_async_edgeql as create_todo_qry
from .queries import delete_todo_async_edgeql as delete_todo_qry
from .queries import get_todos_async_edgeql as get_todos_qry
from .utils import query2ft
app, rt = fast_app(
lifespan=lifespan,
middleware=[Middleware(svcs.starlette.SVCSMiddleware)],
)
setup_toasts(app)
def mk_input(**kw):
return Input(id="new-title", name="title", placeholder="New Todo", **kw)
@rt("/")
來定義post()
,作為以HTTP POST
訪問「"/"」時所使用的函數。post()
的三個參數都可以被FastHTML獲取,因為:
session
有設定setup_toasts(app)
。request
可經由Starlette自動捕抓。todo_create
有TodoCreate
作為型別提示來自動補抓。svcs.starlette.aget()
獲取EdgeDB async client。create_todo_qry.create_todo()
來執行query。
query2ft()
將其轉換為FastHTML component回傳。此外,還必須同時回傳一個mk_input()
,並將hx_swap_oob設為true
。其目的為每次新增todo時,都會替換原先的Input
,而不是添加一個新的Input
。如果沒有將hx_swap_oob
設為true
的話,看起來的效果會像是每次建立todo後,原先輸入的內容卻仍然留在Input
內。title property
的constraint,此時我們使用add_toast()
於畫面中跳出警告來提醒使用者。# app/main.py
@rt("/")
async def post(session, request: Request, todo_create: TodoCreate):
try:
db_client = await svcs.starlette.aget(request, AsyncIOClient)
todo = await create_todo_qry.create_todo(
db_client, **asdict(todo_create)
)
return query2ft(Todo, todo), mk_input(hx_swap_oob="true")
except edgedb.errors.ConstraintViolationError:
title = todo_create.title
if len(title) < 1:
err_msg = f'The title must contain at least 1 character.'
elif len(title) > 50:
err_msg = f'The title must not exceed 50 characters.'
else:
err_msg = f'The title "{title}" is duplicated.'
add_toast(session, err_msg, "error")
@rt("/{tid}")
來定義delete()
,作為以HTTP DELETE
訪問「"/{tid}"」時所使用的函數。delete()
的兩個參數都可以由FastHTML獲取,因為:
request
可經由Starlette自動捕抓。tid
位於@rt("/{tid}")
中。svcs.starlette.aget()
獲取EdgeDB async client。delete_todo_qry.delete_todo()
來執行query。由於我們並不需要對query結果做其它操作,所以不用定義變數接收,delete()
函數也不必設定回傳值(Python會隱性回傳None
)。
# app/main.py
@rt("/{tid}")
async def delete(request: Request, tid: str):
db_client = await svcs.starlette.aget(request, AsyncIOClient)
await delete_todo_qry.delete_todo(db_client, **{"id": tid})
@app.get("/")
來定義homepage()
,作為以HTTP GET
訪問「"/"」時所使用的函數。homepage()
的request
參數可經由Starlette自動捕抓。svcs.starlette.aget()
獲取EdgeDB async client。get_todos_qry.get_todos()
來執行query。Form
(對應HTML中的form
tag):
mk_input()
及一個Button
(對應HTML中的botton
tag)。hx_post
對應HTMX中的hx-post,其功用是設定當form
進行HTTP POST
時所訪問的URL,即「"/"」。target_id
對應HTMX中的hx-target,其功用是指定form
的回傳值所替換的對象,即id
為「"todo-list"」的tag(Card
中的Ul
)。hx_swap
對應HTMX中的hx-swap,其功用是指定所需替換的部份,此處使用beforeend
來指定將會將所取得的新HTML接在原有HTML之後,看起來的效果會像是將新todo加入至todo list的最底端。# app/main.py
@app.get("/")
async def homepage(request: Request):
add = Form(
Group(mk_input(), Button("Add")),
hx_post="/",
target_id="todo-list",
hx_swap="beforeend",
)
db_client = await svcs.starlette.aget(request, AsyncIOClient)
todos = await get_todos_qry.get_todos(db_client)
card = (
Card(
Ul(*query2ft(Todo, todos), id="todo-list"),
header=add,
footer=Div(id="current-todo"),
),
)
return Title("Todo list built with SVCS, FastHTML, and EdgeDB."), Main(
H1("Todo list"), card, cls="container"
)
於命令列中執行下列指令:
uvicorn app.main:app --reload
接著打開瀏覽器前往預設網址,如http://127.0.0.1:8000/:
透過實際操作可以確認無論是新增todo、刪除todo及取得當前所有todo都可以正常執行。此外,當所輸入的todo已經存在EdgeDB中時,FastHTML的通知功能也能夠正常運作。
註1:在傳統的hypermedia系統中,唯二兩個互動元件為a
tag(永遠發出HTTP GET
)
及form
tag(可以發出HTTP GET
或HTTP POST
)。如果想發出HTTP PUT
、HTTP PATCH
或HTTP DELETE
,則需要搭配使用JavaScript與HTTP POST
。有興趣了解的朋友,可以參考HTMX團隊所寫的Hypermedia Systems一書。
本日所有程式碼可參考fasthtml-svcs-edgedb-mvp repo。