【rust/axum版Ruby on Rails】locoでのフルスタックWEB開発(テンプレートエンジン変更、ミドルウェア追加等)
この記事について
- 2023年11月末に発表されたRuby on Railsライクのrust製Webフレームワークであるlocoについてキャッチアップした内容の共有です。
- 筆者はRailsやLaravel等の畑の人間でrustには疎いです。
- locoはunstableなFWですしセオリー通りではない実装があるかもしれませんが細かいところは気にしないでください。
- 開発環境はMac、Lubuntu、VScode or Cursorを想定しています。DB用にdockerも必要です。
コンテンツ
よくあるlocoについての説明や導入などを省いたコンテンツは以下です。導入がそこそこ長いのでlocoについて既に知っている方は軽く流してください。
- locoのscaffoldを使ってみる
- テンプレートエンジンにSailfishを導入してみる
- Daisy UIを使ってみる
- HTMXを使ってみる
- axumのミドルウェアを追加してみる
- セキュリティ(Helmet)
- OpenAPI(utoipa)
locoとは
Ruby on Railsライクなrust(axum)製Webフレームワーク
- Ruby on Railsライクという点が最大の特徴です。開発者の方はかなり古くからRailsに慣れ親しんでいたようで随所からRailsへのリスペクトが感じられます。
- プロジェクト作成時点からORMや認証などの機能がビルトインされている他、scaffold含めRailsとほぼ同じコマンドも多数あります。
- 完全に新規のFWというわけではなく、Axumという既存で人気のFWがベースになっている点もポイントです。若干クセがありますがAxumの資産が使えます。
locoで開発出来ると何が嬉しそうか、辛そうか
- rustのメリットを享受できる
- パフォーマンスが高い、強力な静的解析で未然にバグを防止、メモリ安全etc
- 開発の初動は遅めなものの、コンパイルが通った時点で大方バグを潰せている(出戻りが減る)ので長い目で見ると開発速度は遅くないとも言われる
- パフォーマンスが高い、強力な静的解析で未然にバグを防止、メモリ安全etc
- Ruby on Railsのメリットを享受できる
- 最初から機能が揃っている他CLIが充実しており生産性が高い、導線が敷かれているため迷いにくく統率が取りやすい、類似のFWを経験していればより参入しやすい
- rustとRoRが互いに真逆の性質を持っているので良い所取りのFWになれるかもしれない
- rustの難易度が高め
- 所有権など独自の言語仕様も相まってスクリプト言語と比較するとrust自体の敷居は高め、静的解析をパスしないと動かないので慣れるまでが辛い
セットアップとホットリロード
rustインストール
まだrustをインストールしていない方は次のコマンドでインストールできます。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
locoの準備
locoの開発に必要なcrate(ライブラリ)をインストールします。
cargo install loco-cli sea-orm-cli cargo-watch
locoプロジェクトセットアップ
locoのプロジェクトを作成します。
loco new
対話式で質問されるので以下のように回答しておきます。
% loco new
App name?
-> loco_practice
What would you like to build?
-> Saas app (with DB and user auth)
※Saas appを選択するとfrontendにReact + Viteの環境が出力されますが、本記事ではフロントエンドはRustのテンプレートエンジンを使用します。
Postgresqlの用意
公式ではDBの起動にdocker runコマンドが紹介されていますが面倒なのでdocker-compose.ymlを要します。
cd loco_practice
touch docker-compose.yml
docker-compose.ymlを以下のようにしておきます。
version: '3.8'
services:
postgres:
image: postgres:15.3-alpine
container_name: loco_practice_postgres
environment:
POSTGRES_USER: loco
POSTGRES_DB: loco_practice_development
POSTGRES_PASSWORD: "loco"
ports:
- "5432:5432"
Makefileにコマンドを作ります。
touch Makefile
PHONY: dev run_db stop_db
dev:
cargo-watch -x check -s 'cargo loco start'
run_db:
docker-compose up -d
stop_db:
docker-compose stop
適当に起動しておきましょう。
make run_db
$ make run_db
docker-compose up -d
[+] Running 2/2
✔ Network loco_practice_default Created 0.0s
✔ Container loco_practice_postgres Started
locoアプリの起動
loco newでセットアップしたアプリを起動してみましょう。初回は時間がかかります。
make dev
make dev
でcargo-watch -x check -s 'cargo loco start'
を実行していますがcargo-watch
を使用するとホットリロードが効くようになります。
以下のように表示されればOKです。以降は基本的に別タブでコマンドを実行してください。
▄ ▀
▀ ▄
▄ ▀ ▄ ▄ ▄▀
▄ ▀▄▄
▄ ▀ ▀ ▀▄▀█▄
▀█▄
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
██████ █████ ███ █████ ███ █████ ███ ▀█
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
██████ █████ ███ █████ █████ ███ ████▄
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
██████ █████ ███ ████ ███ █████ ███ ████▀
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
https://loco.rs
environment: development
database: automigrate
logger: debug
compilation: debug
modes: server
listening on [::]:3000
モデル・テーブルを追加してみる
locoはCLIが充実しておりrails generate
コマンドとほぼ同じものが多数用意されています。
todoモデルとそのコントローラを生成してみましょう。
※todoモデルはコマンドの確認用なので使いません。
cargo loco generate model todo content:text
結果
% cargo loco generate model todo content:text
Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running `target/debug/loco_practice-cli generate model todo 'content:text'`
added: "migration/src/m20240308_022718_todos.rs"
injected: "migration/src/lib.rs"
injected: "migration/src/lib.rs"
added: "tests/models/todos.rs"
injected: "tests/models/mod.rs"
Compiling migration v0.1.0 (/hoge/loco_practice/migration)
Compiling loco_practice v0.1.0 (/hoge/loco_practice)
Finished dev [unoptimized + debuginfo] target(s) in 5.95s
Running `target/debug/loco_practice-cli db migrate`
2024-03-08T02:27:25.135614Z INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development
2024-03-08T02:27:25.137897Z WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development
2024-03-08T02:27:25.241997Z WARN app: loco_rs::boot: migrate: environment=development
2024-03-08T02:27:25.247389Z INFO app: sea_orm_migration::migrator: Applying all pending migrations environment=development
2024-03-08T02:27:25.256388Z INFO app: sea_orm_migration::migrator: Applying migration 'm20240308_022718_todos' environment=development
2024-03-08T02:27:25.277656Z INFO app: sea_orm_migration::migrator: Migration 'm20240308_022718_todos' has been applied environment=development
Blocking waiting for file lock on build directory
Finished dev [unoptimized + debuginfo] target(s) in 2.18s
Running `target/debug/loco_practice-cli db entities`
2024-03-08T02:27:28.094562Z INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development
2024-03-08T02:27:28.098288Z WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development
2024-03-08T02:27:28.190726Z WARN app: loco_rs::boot: entities: environment=development
Connecting to Postgres ...
Discovering schema ...
... discovered.
Generating notes.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `title`: Option<String>
> Column `content`: Option<String>
Generating todos.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `content`: Option<String>
Generating users.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `pid`: Uuid, not_null
> Column `email`: String, not_null, unique
> Column `password`: String, not_null
> Column `api_key`: String, not_null, unique
> Column `name`: String, not_null
> Column `reset_token`: Option<String>
> Column `reset_sent_at`: Option<DateTime>
> Column `email_verification_token`: Option<String>
> Column `email_verification_sent_at`: Option<DateTime>
> Column `email_verified_at`: Option<DateTime>
Writing src/models/_entities/notes.rs
Writing src/models/_entities/todos.rs
Writing src/models/_entities/users.rs
Writing src/models/_entities/mod.rs
Writing src/models/_entities/prelude.rs
... Done.
2024-03-08T02:27:28.676098Z WARN app: loco_rs::boot: environment=development
* Migration for `todo` added! You can now apply it with `$ cargo loco db migrate`.
* A test for model `Todos` was added. Run with `cargo test`.
次にコントローラです。
cargo loco generate controller todo
% cargo loco generate controller todo
Finished dev [unoptimized + debuginfo] target(s) in 0.55s
Running `target/debug/loco_practice-cli generate controller todo`
added: "src/controllers/todo.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "tests/requests/todo.rs"
injected: "tests/requests/mod.rs"
詳細は省きますがRailsらしくファイルが色々生成されました。データベースにも変更が反映されてテーブルが追加されています。
scaffoldコマンドでモデル・テーブルを追加してみる
もっと一括で生成してくれるscaffoldコマンドもあります。こちらはAPIも多めに用意してくれるのでやってみましょう。
cargo loco generate scaffold post title:string! author:string content:text --kind htmx
結果
% cargo loco generate scaffold post title:string! author:string content:text --kind htmx
Finished dev [unoptimized + debuginfo] target(s) in 0.65s
Running `target/debug/loco_practice-cli generate scaffold post 'title:string'\!'' 'author:string' 'content:text' --kind htmx`
added: "migration/src/m20240308_023003_posts.rs"
injected: "migration/src/lib.rs"
injected: "migration/src/lib.rs"
added: "tests/models/posts.rs"
injected: "tests/models/mod.rs"
Compiling migration v0.1.0 (/hoge/loco_practice/migration)
Compiling loco_practice v0.1.0 (/hoge/rust/loco_practice)
Finished dev [unoptimized + debuginfo] target(s) in 6.84s
Running `target/debug/loco_practice-cli db migrate`
2024-03-08T02:30:10.902443Z INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development
2024-03-08T02:30:10.904764Z WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development
2024-03-08T02:30:11.029345Z WARN app: loco_rs::boot: migrate: environment=development
2024-03-08T02:30:11.036447Z INFO app: sea_orm_migration::migrator: Applying all pending migrations environment=development
2024-03-08T02:30:11.044914Z INFO app: sea_orm_migration::migrator: Applying migration 'm20240308_023003_posts' environment=development
2024-03-08T02:30:11.071231Z INFO app: sea_orm_migration::migrator: Migration 'm20240308_023003_posts' has been applied environment=development
Blocking waiting for file lock on build directory
Finished dev [unoptimized + debuginfo] target(s) in 2.08s
Running `target/debug/loco_practice-cli db entities`
2024-03-08T02:30:13.798875Z INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development
2024-03-08T02:30:13.801061Z WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development
2024-03-08T02:30:13.901424Z WARN app: loco_rs::boot: entities: environment=development
Connecting to Postgres ...
Discovering schema ...
... discovered.
Generating notes.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `title`: Option<String>
> Column `content`: Option<String>
Generating posts.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `title`: String, not_null
> Column `author`: Option<String>
> Column `content`: Option<String>
Generating todos.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `content`: Option<String>
Generating users.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `pid`: Uuid, not_null
> Column `email`: String, not_null, unique
> Column `password`: String, not_null
> Column `api_key`: String, not_null, unique
> Column `name`: String, not_null
> Column `reset_token`: Option<String>
> Column `reset_sent_at`: Option<DateTime>
> Column `email_verification_token`: Option<String>
> Column `email_verification_sent_at`: Option<DateTime>
> Column `email_verified_at`: Option<DateTime>
Writing src/models/_entities/notes.rs
Writing src/models/_entities/posts.rs
Writing src/models/_entities/todos.rs
Writing src/models/_entities/users.rs
Writing src/models/_entities/mod.rs
Writing src/models/_entities/prelude.rs
... Done.
2024-03-08T02:30:14.360179Z WARN app: loco_rs::boot: environment=development
added: "src/controllers/post.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "assets/views/post/edit.html"
added: "assets/views/post/create.html"
added: "assets/views/post/show.html"
added: "assets/views/post/list.html"
added: "src/views/post.rs"
injected: "src/views/mod.rs"
* Migration for `post` added! You can now apply it with `$ cargo loco db migrate`.
* A test for model `Posts` was added. Run with `cargo test`.
後ほど触れますが--kind htmx
をつけることでHTMXに対応したビューも用意されます。また、scaffoldで生成すると基本的なCRUDがあらかじめ実装された状態でコントローラが生成されます。postとtodoを見比べて確認してみてください。
ルーティングの確認
railsでもお馴染みですが以下のコマンドでアプリに設定されているルーティングが確認できます。
cargo loco routes
cargo loco routes
% cargo loco routes
2024-03-08T02:33:19.039859Z INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development
2024-03-08T02:33:19.042150Z WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development
[GET] /_health
[GET] /_ping
[POST] /api/auth/forgot
[POST] /api/auth/login
[POST] /api/auth/register
[POST] /api/auth/reset
[POST] /api/auth/verify
[GET] /api/notes
[POST] /api/notes
[GET] /api/notes/:id
[DELETE] /api/notes/:id
[POST] /api/notes/:id
[GET] /api/user/current
[GET] /posts
[POST] /posts
[GET] /posts/:id
[POST] /posts/:id
[GET] /posts/:id/edit
[GET] /posts/new
[GET] /todo
[POST] /todo/echo
postのCRUD
ブラウザでhttp://localhost:3000/postsにアクセスしてみましょう。scaffoldコマンドで生成したpostモデルの機能が利用できます。
お馴染みのTodoアプリの実装がコマンドの実行で完結するあたりがRailsぽいです。
また、イマイチ分かりにくいのですがnewでpostを作成した際にHTMXによって非同期でDOMが書き換わっています。HTMXについては後程触れます。
scaffoldコマンドでarticleモデル・コントローラを生成してみる
先ほどは--kind htmx付きで生成しましたが今度は無しでarticleモデル・コントローラを生成してみます。
cargo loco generate scaffold article title:string! author:string content:text
cargo loco generate scaffold
% cargo loco generate scaffold article title:string! author:string content:text
warning: unused variable: `ctx`
--> src/controllers/post.rs:46:11
|
46 | State(ctx): State<AppContext>,
| ^^^ help: if this is intentional, prefix it with an underscore: `_ctx`
|
= note: `#[warn(unused_variables)]` on by default
warning: `loco_practice` (lib) generated 1 warning (run `cargo fix --lib -p loco_practice` to apply 1 suggestion)
Finished dev [unoptimized + debuginfo] target(s) in 0.67s
Running `target/debug/loco_practice-cli generate scaffold article 'title:string'\!'' 'author:string' 'content:text'`
added: "migration/src/m20240308_024811_articles.rs"
injected: "migration/src/lib.rs"
injected: "migration/src/lib.rs"
added: "tests/models/articles.rs"
injected: "tests/models/mod.rs"
Compiling migration v0.1.0 (/hoge/loco_practice/migration)
Compiling loco_practice v0.1.0 (/hoge/loco_practice)
warning: unused variable: `ctx`
--> src/controllers/post.rs:46:11
|
46 | State(ctx): State<AppContext>,
| ^^^ help: if this is intentional, prefix it with an underscore: `_ctx`
|
= note: `#[warn(unused_variables)]` on by default
warning: `loco_practice` (lib) generated 1 warning (run `cargo fix --lib -p loco_practice` to apply 1 suggestion)
Finished dev [unoptimized + debuginfo] target(s) in 7.73s
Running `target/debug/loco_practice-cli db migrate`
2024-03-08T02:48:19.847222Z INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development
2024-03-08T02:48:19.849385Z WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development
2024-03-08T02:48:19.958728Z WARN app: loco_rs::boot: migrate: environment=development
2024-03-08T02:48:19.970968Z INFO app: sea_orm_migration::migrator: Applying all pending migrations environment=development
2024-03-08T02:48:19.983802Z INFO app: sea_orm_migration::migrator: Applying migration 'm20240308_024811_articles' environment=development
2024-03-08T02:48:20.027872Z INFO app: sea_orm_migration::migrator: Migration 'm20240308_024811_articles' has been applied environment=development
Blocking waiting for file lock on build directory
warning: unused variable: `ctx`
--> src/controllers/post.rs:46:11
|
46 | State(ctx): State<AppContext>,
| ^^^ help: if this is intentional, prefix it with an underscore: `_ctx`
|
= note: `#[warn(unused_variables)]` on by default
warning: `loco_practice` (lib) generated 1 warning (run `cargo fix --lib -p loco_practice` to apply 1 suggestion)
Finished dev [unoptimized + debuginfo] target(s) in 2.66s
Running `target/debug/loco_practice-cli db entities`
2024-03-08T02:48:23.395417Z INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development
2024-03-08T02:48:23.398999Z WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development
2024-03-08T02:48:23.478726Z WARN app: loco_rs::boot: entities: environment=development
Connecting to Postgres ...
Discovering schema ...
... discovered.
Generating articles.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `title`: String, not_null
> Column `author`: Option<String>
> Column `content`: Option<String>
Generating notes.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `title`: Option<String>
> Column `content`: Option<String>
Generating posts.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `title`: String, not_null
> Column `author`: Option<String>
> Column `content`: Option<String>
Generating todos.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `content`: Option<String>
Generating users.rs
> Column `created_at`: DateTime, not_null
> Column `updated_at`: DateTime, not_null
> Column `id`: i32, auto_increment, not_null
> Column `pid`: Uuid, not_null
> Column `email`: String, not_null, unique
> Column `password`: String, not_null
> Column `api_key`: String, not_null, unique
> Column `name`: String, not_null
> Column `reset_token`: Option<String>
> Column `reset_sent_at`: Option<DateTime>
> Column `email_verification_token`: Option<String>
> Column `email_verification_sent_at`: Option<DateTime>
> Column `email_verified_at`: Option<DateTime>
Writing src/models/_entities/articles.rs
Writing src/models/_entities/notes.rs
Writing src/models/_entities/posts.rs
Writing src/models/_entities/todos.rs
Writing src/models/_entities/users.rs
Writing src/models/_entities/mod.rs
Writing src/models/_entities/prelude.rs
... Done.
2024-03-08T02:48:24.043354Z WARN app: loco_rs::boot: environment=development
added: "src/controllers/article.rs"
injected: "src/controllers/mod.rs"
injected: "src/app.rs"
added: "tests/requests/article.rs"
injected: "tests/requests/mod.rs"
* Migration for `article` added! You can now apply it with `$ cargo loco db migrate`.
* A test for model `Articles` was added. Run with `cargo test`.
* Controller `Article` was added successfully.
* Tests for controller `Article` was added successfully. Run `cargo run test`.
今回はビューファイルは生成されません。コントローラも以下のように各APIの返り値がjsonになっています。
// 略
pub async fn list(State(ctx): State<AppContext>) -> Result<Json<Vec<Model>>> {
format::json(Entity::find().all(&ctx.db).await?)
}
// 略
articleのAPIを叩いてみる
curlでarticleのAPIを叩いてみましょう。何回か実行してみてください。
curl -X POST http://localhost:3000/articles \
-H "Content-Type: application/json" \
-d '{"title": "hogeタイトル", "author": "fugaオーサー", "content": "fooコンテンツ"}'
json形式で結果が返ってきます。
{"created_at":"2024-03-08T02:56:13.412145","updated_at":"2024-03-08T02:56:13.412145","id":1,"title":"hogeタイトル","author":"fugaオーサー","content":"fooコンテンツ"}
作成したarticlesは以下で取得できます。
curl http://localhost:3000/articles
% curl http://localhost:3000/articles
[{"created_at":"2024-03-08T02:56:13.412145","updated_at":"2024-03-08T02:56:13.412145","id":1,"title":"hogeタイトル","author":"fugaオーサー","content":"fooコンテンツ"},
{"created_at":"2024-03-08T02:56:53.035982","updated_at":"2024-03-08T02:56:53.035982","id":2,"title":"hogeタイトル","author":"fugaオーサー","content":"fooコンテンツ"},
{"created_at":"2024-03-08T02:56:53.927201","updated_at":"2024-03-08T02:56:53.927201","id":3,"title":"hogeタイトル","author":"fugaオーサー","content":"fooコンテンツ"}]%
2種類APIを叩きましたがこれらはscaffoldコマンドでコントローラに生成されたAPIです。ルーティングも下の方に記載してあるので確認しておきましょう。
src/controllers/article.rs
// 略
pub async fn list(State(ctx): State<AppContext>) -> Result<Json<Vec<Model>>> {
format::json(Entity::find().all(&ctx.db).await?)
}
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Json<Model>> {
let mut item = ActiveModel {
..Default::default()
};
params.update(&mut item);
let item = item.insert(&ctx.db).await?;
format::json(item)
}
// 略
pub fn routes() -> Routes {
Routes::new()
.prefix("articles")
.add("/", get(list))
.add("/", post(add))
.add("/:id", get(get_one))
.add("/:id", delete(remove))
.add("/:id", post(update))
}
articleのビューを実装する
articleのビューを実装していきます。
また、locoではデフォルトのテンプレートエンジン(RoRでいうとerb)にteraが採用されていますが、articleではsailfishを使ってみます。
sailfishは日本人の方が開発したエンジンで非常にパフォーマンスが良いことが特徴のようです。
その他の選択肢としてはAskamaが良い印象です(執筆開始前はAskamaで実装していました)。
sailfishを導入
sailfishのcrateを導入します。
cargo add sailfish
% cargo add sailfish
Updating crates.io index
Adding sailfish v0.8.3 to dependencies.
Features:
+ config
+ derive
+ perf-inline
+ sailfish-macros
- json
- serde
- serde_json
Updating crates.io index
各種ファイルの実装
まずはテンプレートファイルを作成、実装します。
mkdir -p templates/article ;
touch templates/article/index.stpl
stplファイルは構文的にもRoRでいうerb.htmlと同じ感覚で使えそうですが今のうちに拡張機能を入れておきましょう。
templates/article/index.stpl
の内容は以下のようにしておきます。
authorとcontentは型がOption<String>なので.unwrap_or_default()をつけています。
templates/article/index.stpl
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="prose p-10">
<h1 class="text-3xl font-bold underline mb-5">Articles</h1>
<div class="mb-10">
<% for article in articles { %>
<div class="mb-5">
<div>
<label><b><%= "title" %>:</b> <%= article.title %></label>
</div>
<div>
<label><b><%= "author" %>:</b> <%= article.author.unwrap_or_default() %></label>
</div>
<div>
<label><b><%= "content" %>:</b> <%= article.content.unwrap_or_default() %></label>
</div>
</div>
<% } %>
</div>
</body>
</html>
次にビューファイルを作成、実装します。
touch src/views/article.rs
rust:src/views/article.rs
use loco_rs::prelude::*;
use sailfish::TemplateOnce;
use crate::models::_entities::articles::Model;
#[derive(TemplateOnce)]
#[template(path = "article/index.stpl")]
struct IndexTemplate {
articles: Vec<Model>,
}
pub fn index(articles: Vec<Model>) -> Result<impl IntoResponse> {
let template = IndexTemplate { articles: articles };
match template.render_once() {
Ok(html) => format::html(&html),
Err(e) => Err(loco_rs::Error::from(eyre::Report::new(e))),
}
}
対応するコントローラにも3箇所ほど手を加えます。
/// 略
// Before
// use crate::models::_entities::articles::{ActiveModel, Entity, Model};
// After
use crate::{
models::_entities::articles::{ActiveModel, Entity, Model},
views,
};
/// 略
pub async fn list(State(ctx): State<AppContext>) -> Result<Json<Vec<Model>>> {
format::json(Entity::find().all(&ctx.db).await?)
}
// 追加
pub async fn index(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
let articles: Vec<Model> = Entity::find().all(&ctx.db).await?;
views::article::index(articles)
}
/// 略
pub fn routes() -> Routes {
Routes::new()
.prefix("articles")
.add("/", get(list))
.add("/", post(add))
.add("/index", get(index)) // 追加
.add("/:id", get(get_one))
.add("/:id", delete(remove))
.add("/:id", post(update))
}
今後も度々登場しますがjavascriptなどで言うとインポート/エクスポートができていないような状態なのでsrc/views/mod.rs
を編集します。
pub mod auth;
pub mod user;
pub mod post;
pub mod article; // 追加
ここまで出来たらコンパイルエラーが起きていないことを確認してhttp://localhost:3000/articles/indexにアクセスしてみましょう。
表示できていたらsailfishの導入は成功です!
※公式のteraの実装ではinitializersでもう少し手の込んだことをしているので本来ならこのような形にするのがベターかもしれません。
Daisy UIを使ってみる
loco特有ではないのですがReactに依存しない方向でフロントエンドを弄れるようにしたいのでtemplates/article/index.stpl
にDaisy UIを導入してみましょう。
templates/article/index.stpl
<!DOCTYPE html>
<html lang="en">
<head>
<link
href="https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.min.css"
rel="stylesheet"
type="text/css"
/>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="prose p-10">
<div class="collapse bg-base-200">
<input type="radio" name="my-accordion-1" checked="checked" />
<div class="collapse-title text-xl font-medium">
Click to open this one and close others
</div>
<div class="collapse-content">
<p>hello</p>
</div>
</div>
<div class="collapse bg-base-200">
<input type="radio" name="my-accordion-1" />
<div class="collapse-title text-xl font-medium">
Click to open this one and close others
</div>
<div class="collapse-content">
<p>hello</p>
</div>
</div>
<div class="collapse bg-base-200">
<input type="radio" name="my-accordion-1" />
<div class="collapse-title text-xl font-medium">
Click to open this one and close others
</div>
<div class="collapse-content">
<p>hello</p>
</div>
</div>
<hr class="m-5">
<h1 class="text-3xl font-bold underline mb-5">Articles</h1>
<div class="mb-10">
<% for article in articles { %>
<div class="mb-5">
<div>
<label><b><%= "title" %>:</b> <%= article.title %></label>
</div>
<div>
<label><b><%= "author" %>:</b> <%= article.author.unwrap_or_default() %></label>
</div>
<div>
<label><b><%= "content" %>:</b> <%= article.content.unwrap_or_default() %></label>
</div>
</div>
<% } %>
</div>
</body>
</html>
公式のサンプルを貼り付けただけですがアコーディオンメニューが実装できました。
Daisy UIの他にはPinesというAlpine.js×Tailwindのコンポーネントライブラリ等も便利そうです。上手く活用してフロントエンドの実装コストを削減していきたいですね。
HTMXを使ってみる
scaffold --kind htmxでHTMX導入済みのテンプレートが用意されるのですが、HTMXのサンプルとしては地味だったのでもう少し別の実装をしてみます。
HTMXについては主に海外でとても流行っていて沢山記事があるので調べてみてください。
ちなみにHTMZというものもありこちらも便利そうです。
脱線しましたがtemplates/article/index.stpl
にフォームを設置して非同期にarticleの作成を出来るようにしていきたいと思います。
先にコントローラ側をarticleのリストを返却するようにしておきます。
/// 略
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Json<Vec<Model>>> { // Vecに
let mut item = ActiveModel {
..Default::default()
};
params.update(&mut item);
item.insert(&ctx.db).await?;
format::json(Entity::find().all(&ctx.db).await?) // fn listと同じくlistを返却
}
/// 略
次にテンプレート側を以下のように編集します。
templates/articles/index.stpl
<!DOCTYPE html>
<html lang="en">
<head>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/client-side-templates.js"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
<script src="https://unpkg.com/mustache@latest"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="prose p-10">
<div class="collapse bg-base-200">
<input type="radio" name="my-accordion-1" checked="checked" />
<div class="collapse-title text-xl font-medium">
Click to open this one and close others
</div>
<div class="collapse-content">
<p>hello</p>
</div>
</div>
<div class="collapse bg-base-200">
<input type="radio" name="my-accordion-1" />
<div class="collapse-title text-xl font-medium">
Click to open this one and close others
</div>
<div class="collapse-content">
<p>hello</p>
</div>
</div>
<div class="collapse bg-base-200">
<input type="radio" name="my-accordion-1" />
<div class="collapse-title text-xl font-medium">
Click to open this one and close others
</div>
<div class="collapse-content">
<p>hello</p>
</div>
</div>
<hr class="m-5">
<h1 class="text-3xl font-bold underline mb-5">Article Form</h1>
<div hx-ext="client-side-templates">
<form hx-post="/articles" hx-target="#post_json_target_for_mustache" hx-ext="json-enc" mustache-array-template="articles_mustache">
<div class="mb-5">
<div>
<label>title</label>
<br />
<input id="title" class="border" name="title" type="text" value="" required/>
</div>
<div>
<label>author</label>
<br />
<input id="author" class="border" name="author" type="text" value=""/>
</div>
<div>
<label>content</label>
<br />
<textarea id="content" class="border" name="content" type="text" value="" rows="10" cols="50"></textarea>
</div>
</div>
<div>
<button class="text-xs py-3 px-6 rounded-lg bg-gray-900 text-white" type="submit">Submit</button>
</div>
</form>
<hr class="m-4">
<h1 class="text-3xl font-bold underline mb-5">Articles</h1>
<div id="post_json_target_for_mustache" class="mb-10">
<% for article in articles { %>
<div class="mb-5">
<div>
<label><b><%="title"%>:</b> <%=article.title%></label>
</div>
<div>
<label><b><%="author"%>:</b> <%=article.author.unwrap_or_default()%></label>
</div>
<div>
<label><b><%="content"%>:</b> <%=article.content.unwrap_or_default()%></label>
</div>
</div>
<% } %>
</div>
</div>
<%# サーバーから受け取ったArticles(json)を流し込んでDOMを生成しhx-targetにマウントする %>
<template id="articles_mustache">
{{ #data }}
<div class="mb-5">
<div>
<label><b>title:</b> {{ title }}</label>
</div>
<div>
<label><b>author:</b> {{ author }}</label>
</div>
<div>
<label><b>content:</b> {{ content }}</label>
</div>
</div>
{{ /data }}
</template>
</div>
</body>
</html>
HTMXのフォームとしては少しイレギュラーな実装になっています。
HTMXはSSRでDOMを書き換えるパターンが多いのですが、今回はclient-side-templatesというHTMXの拡張機能でmustache構文とjsonを使ってCSRしています。mustache構文内ではrustによる型チェックも効かないので基本的にはSSRの方がいいと思います。
ここまで出来たらフォームを送信してみてください。articleの一覧が非同期で増えていくようになっていれば成功です。
ここまでjavascriptはノータッチですがDaisy UIとHTMXを活用すれば基本的なUIは実装できそうですね。
axumのミドルウェアを追加してみる
locoはaxumがベースなのでaxumのcrateが利用できます。
公式ドキュメントやShuttlesによるとミドルウェアの追加はFromRequestParts
もしくはafter_routes
を利用するようです。今回はafter_routes
を使います。
Helmet
HelmetはもともとNode.jsのフレームワークで使用されているCSPの追加などのセキュリティ対策を行うミドルウェアです。それのaxum版がありlocoのベースはaxumなので利用することが出来ます。
余談ですが当初はテンプレートエンジンにAskamaを使っていてHTMXと合わせるとXSSの脆弱性が生まれてしまった為Helmetを入れたのですが、Sailfishだと大丈夫なようです。とりあえず入れるだけ入れます。
まずはcrateを追加しましょう。
cargo add axum_helmet helmet_core
次にinitializerを作成、実装します。
touch src/initializers/security_helmet.rs
src/initializers/security_helmet.rs
use axum::{async_trait, Router as AxumRouter};
use axum_helmet::{Helmet, HelmetLayer};
use helmet_core::{
ContentSecurityPolicy, CrossOriginOpenerPolicy, CrossOriginResourcePolicy, ReferrerPolicy,
StrictTransportSecurity, XContentTypeOptions, XDNSPrefetchControl, XDownloadOptions,
XFrameOptions, XPermittedCrossDomainPolicies, XXSSProtection,
};
use loco_rs::{
app::{AppContext, Initializer},
Result,
};
pub struct SecurityHelmetInitializer;
#[async_trait]
impl Initializer for SecurityHelmetInitializer {
fn name(&self) -> String {
"security-helmet".to_string()
}
async fn after_routes(&self, mut router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
let script_src_url = vec![
"'self'",
"https://cdn.tailwindcss.com/",
"https://unpkg.com/",
"https://cdn.jsdelivr.net/",
];
let content_security_policy = ContentSecurityPolicy::new().script_src(script_src_url);
let helmet = Helmet::new()
.add(content_security_policy)
.add(CrossOriginOpenerPolicy::same_origin())
.add(CrossOriginResourcePolicy::same_origin())
.add(ReferrerPolicy::no_referrer())
.add(
StrictTransportSecurity::new()
.max_age(15552000)
.include_sub_domains(),
)
.add(XContentTypeOptions::nosniff())
.add(XDNSPrefetchControl::off())
.add(XDownloadOptions::noopen())
.add(XFrameOptions::same_origin())
.add(XPermittedCrossDomainPolicies::none())
.add(XXSSProtection::off());
router = router.layer(HelmetLayer::new(helmet));
Ok(router)
}
}
AxumRouterに細工をして返却するのが基本的な流れのようです。
CSP以外はデフォルトという設定ですが、簡潔に書く方法が見当たらなかったので冗長になっています。
デフォルト値についてはドキュメントを参照してください。
次に実装したinitializerを有効化します。
#![allow(clippy::module_name_repetitions)]
pub mod view_engine;
pub mod security_helmet; // 追加
// 略
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::security_helmet::SecurityHelmetInitializer), // 追加
])
}
// 略
これでHelmetの追加は完了です。レスポンスにHelmetで設定したヘッダーが付与されます。
OpenAPI(utoipa)
ユートピアではないので注意です!
utoipaはactix-webやaxumなどで利用できるOpenAPIに関するミドルウェア群です。コードベースでのドキュメント生成やSwagger-UI、Redoc等にも対応しています。
追加する流れはHelmetと同じです。まずはcrateを追加しましょう。
cargo add utoipa --features axum_extras ;
cargo add utoipa-gen --features axum_extras ;
cargo add utoipa-swagger-ui --features axum ;
cargo add utoipa_redoc --features axum ;
cargo add utoipa_rapidoc --features axum
utoipaの場合はOpenAPIのスキーマを記述する必要があるので先に記述しておきます。
※とりあえず動かすために適当に設定しています。
src/controllers/article.rs
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{
models::_entities::articles::{ActiveModel, Entity, Model},
views,
};
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Params {
pub title: String,
pub author: Option<String>,
pub content: Option<String>,
}
#[derive(ToSchema)]
pub struct RequestBodyById {
pub id: i32,
}
#[derive(ToSchema)]
pub struct ResponseBodyArticles {
pub articles: Vec<Params>,
}
impl Params {
fn update(&self, item: &mut ActiveModel) {
item.title = Set(self.title.clone());
item.author = Set(self.author.clone());
item.content = Set(self.content.clone());
}
}
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
let item = Entity::find_by_id(id).one(&ctx.db).await?;
item.ok_or_else(|| Error::NotFound)
}
#[utoipa::path(
get,
path = "/articles",
responses(
(status = 200, description = "Articleの一覧をjsonで返却します。", body = ResponseBodyArticles),
)
)]
pub async fn list(State(ctx): State<AppContext>) -> Result<Json<Vec<Model>>> {
format::json(Entity::find().all(&ctx.db).await?)
}
#[utoipa::path(
get,
path = "/articles/index",
request_body = Params,
responses(
(status = 200, description = "Articleの一覧を表示するHTMLを返却します。", body = String),
)
)]
pub async fn index(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
let articles: Vec<Model> = Entity::find().all(&ctx.db).await?;
views::article::index(articles)
}
#[utoipa::path(
post,
path = "/articles",
request_body = Params,
responses(
(status = 200, description = "Articleの作成後、Articleの一覧をjsonで返却します。", body = ResponseBodyArticles),
)
)]
pub async fn add(
State(ctx): State<AppContext>,
Json(params): Json<Params>,
) -> Result<Json<Vec<Model>>> {
let mut item = ActiveModel {
..Default::default()
};
params.update(&mut item);
item.insert(&ctx.db).await?;
format::json(Entity::find().all(&ctx.db).await?)
}
#[utoipa::path(
post,
path = "/articles/:id",
request_body = RequestBodyById,
responses(
(status = 200, description = "Articleを更新し、それをjsonで返却します。", body = Params),
)
)]
pub async fn update(
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(params): Json<Params>,
) -> Result<Json<Model>> {
let item = load_item(&ctx, id).await?;
let mut item = item.into_active_model();
params.update(&mut item);
let item = item.update(&ctx.db).await?;
format::json(item)
}
#[utoipa::path(
delete,
path = "/articles/:id",
request_body = RequestBodyById,
responses(
(status = 200, description = "Articleを削除します。"),
)
)]
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<()> {
load_item(&ctx, id).await?.delete(&ctx.db).await?;
format::empty()
}
#[utoipa::path(
get,
path = "/articles/:id",
request_body = RequestBodyById,
responses(
(status = 200, description = "Articleを1つ取得し、それをjsonで返却します。", body = Params),
)
)]
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Json<Model>> {
format::json(load_item(&ctx, id).await?)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("articles")
.add("/", get(list))
.add("/", post(add))
.add("/index", get(index))
.add("/:id", get(get_one))
.add("/:id", delete(remove))
.add("/:id", post(update))
}
initializerを作成、実装します。
touch src/initializers/openapi_utoipa.rs
src/initializers/openapi_utoipa.rs
use axum::{async_trait, Router as AxumRouter};
use loco_rs::{
app::{AppContext, Initializer}, Result,
};
use utoipa::OpenApi;
use utoipa_rapidoc::RapiDoc;
use utoipa_redoc::{Redoc, Servable};
use utoipa_swagger_ui::SwaggerUi;
use crate::controllers;
use crate::controllers::article::{Params, RequestBodyById, ResponseBodyArticles};
pub struct OpenApiUtoipaInitializer;
#[async_trait]
impl Initializer for OpenApiUtoipaInitializer {
fn name(&self) -> String {
"openapi-utoipa".to_string()
}
async fn after_routes(&self, mut router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
router = router
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
.merge(Redoc::with_url("/redoc", ApiDoc::openapi()))
.merge(RapiDoc::new("/api-docs/openapi.json").path("/rapidoc"));
Ok(router)
}
}
#[derive(OpenApi)]
#[openapi(
paths(
controllers::article::index,
controllers::article::list,
controllers::article::get_one,
controllers::article::add,
controllers::article::update,
controllers::article::remove,
),
components(schemas(Params, RequestBodyById, ResponseBodyArticles,))
)]
pub struct ApiDoc;
実装したinitializerを有効化します。
#![allow(clippy::module_name_repetitions)]
pub mod view_engine;
pub mod security_helmet;
pub mod openapi_utoipa; // 追加
// 略
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::security_helmet::SecurityHelmetInitializer),
Box::new(initializers::openapi_utoipa::OpenApiUtoipaInitializer), // 追加
])
}
// 略
ここまで実装できたら以下のURLにアクセスしてみてください。
これでutoipaの導入も完了です!
ちなみにこちらのスライドでutoipaが紹介されているのでチェックしてみてください。#[schema(inline)]は便利そうですね。
本記事でのlocoの解説は以上になります!
感想など
- RailsやLaravelに慣れ親しんでいた一方、しっかりとした型チェックとパフォーマンスがあるフレームワークを探していたのでlocoにはロマンを感じます。
- vite等とは比べてはいけませんがホットリロードがあるので思ったよりは開発体験が良いです。
- スクリプト言語の人間なのでrustは慣れないと辛いところがありますが、rustfmtなど強力な静的解析も魅力的ですね。
- まだ登場して半年も経っていないので絶賛unstable中ですがlocoとrustでのWeb開発が盛り上がることを期待します!
Discussion