iT邦幫忙

2024 iThome 鐵人賽

DAY 28
0
Software Development

一起看無間道學EdgeDB系列 第 28

[Day28] - 使用SVCS、FastHTML搭配EdgeDB建立Python todo app(2)

  • 分享至 

  • xImage
  •  

今天接續昨天的內容,編寫FastHTML app,其版面預覽如下:
todo list preview

FastHTML簡介

FastHTML是一個於2024年七月底八月初開始宣傳的全端Python框架,其核心概念是希望所有endpoint能夠直接返回HTML格式,而不是使用如XML或JSON的格式。此外,FastHTML整合了許多HTMX功能,使得其可以在幾乎不需撰寫JavaScript的情況下,與使用者有著良好的互動。如果搭配Alpine.js或是_hyperscript使用的話,更是可以呈現許多以往認為需要React或Vue等框架才能呈現的前端效果。

由於FastHTML仍在積極開發,文件說明大多也還在編寫中,所以很多功能需要由源碼自己推敲。而其源碼有兩個獨特的風格

  • 喜歡使用from xxx import *來導入模組。
  • 盡可能不換行地將程式碼壓縮在最小行數。

雖然一開始可能有點不習慣,可是在經過一段時間適應後,FastHTML帶來的開發效率的確令人驚豔。

建議大家可以先觀看官方的影片demo,對其有些基礎的了解後,再進入今天的內容(不然可能會覺得這個框架的用法太玄了?)。

Utilities

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

lifespan.py封裝所需資源作為fast_applifespan參數。

_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

Models.py定義TodoTodoCreate兩個dataclass

Todo中有一個__ft__(),這是FastHTML用來定義如何渲染FastHTML component的秘訣。其內使用了StrongLiA等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結果完全取代idget_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前置作業有三項工作:

  • 建立一個名為appfast_app instance及一個rt object。此處需使用我們於先前定義的lifespan及將svcs.starlette.SVCSMiddleware加入到middleware(這樣才可以使用SVCS)。
  • 設定setup_toasts(app)讓我們可以使用通知使用者的功能。
  • 定義一個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)

新增Todo

  • 使用@rt("/")來定義post(),作為以HTTP POST訪問「"/"」時所使用的函數。
  • post()的三個參數都可以被FastHTML獲取,因為:
    • session有設定setup_toasts(app)
    • request可經由Starlette自動捕抓。
    • todo_createTodoCreate作為型別提示來自動補抓。
  • 使用svcs.starlette.aget()獲取EdgeDB async client。
  • 呼叫EdgeDB query builder所生成的create_todo_qry.create_todo()來執行query。
    • 如果成功得到query結果,則呼叫query2ft()將其轉換為FastHTML component回傳。此外,還必須同時回傳一個mk_input(),並將hx_swap_oob設為true。其目的為每次新增todo時,都會替換原先的Input,而不是添加一個新的Input。如果沒有將hx_swap_oob設為true的話,看起來的效果會像是每次建立todo後,原先輸入的內容卻仍然留在Input內。
    • 如果無法得到query結果的話,最有可能的情況是違反了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")

刪除Todo

  • 使用@rt("/{tid}")來定義delete(),作為以HTTP DELETE訪問「"/{tid}"」時所使用的函數。
  • delete()的兩個參數都可以由FastHTML獲取,因為:
    • request可經由Starlette自動捕抓。
    • tid位於@rt("/{tid}")中。
  • 使用svcs.starlette.aget()獲取EdgeDB async client。
  • 呼叫EdgeDB query builder所生成的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})

取得Todos

  • 使用@app.get("/")來定義homepage(),作為以HTTP GET訪問「"/"」時所使用的函數。
  • homepage()request參數可經由Starlette自動捕抓。
  • 使用svcs.starlette.aget()獲取EdgeDB async client。
  • 呼叫EdgeDB query builder所生成的get_todos_qry.get_todos()來執行query。
  • 接下來的程式碼一樣是使用HTML tag或FastHTML component來建構整體todo app的版面。比較值得一提的是其中的Form(對應HTML中的form tag):
    • 使用Group來包住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"
    )

執行app

於命令列中執行下列指令:

uvicorn app.main:app --reload

接著打開瀏覽器前往預設網址,如http://127.0.0.1:8000/
todo list demo

透過實際操作可以確認無論是新增todo、刪除todo及取得當前所有todo都可以正常執行。此外,當所輸入的todo已經存在EdgeDB中時,FastHTML的通知功能也能夠正常運作。

備註

註1:在傳統的hypermedia系統中,唯二兩個互動元件為a tag(永遠發出HTTP GET
form tag(可以發出HTTP GETHTTP POST)。如果想發出HTTP PUTHTTP PATCHHTTP DELETE,則需要搭配使用JavaScript與HTTP POST。有興趣了解的朋友,可以參考HTMX團隊所寫的Hypermedia Systems一書。

程式碼傳送門

本日所有程式碼可參考fasthtml-svcs-edgedb-mvp repo。


上一篇
[Day27] - 使用SVCS、FastHTML搭配EdgeDB建立Python todo app(1)
下一篇
[Day29] - 學習如何使用Axum搭配EdgeDB建立Rust weather app
系列文
一起看無間道學EdgeDB30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言