🚂

【rust/axum版Ruby on Rails】locoでのフルスタックWEB開発(テンプレートエンジン変更、ミドルウェア追加等)

2024/03/08に公開

https://loco.rs/


この記事について

  • 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
      • 開発の初動は遅めなものの、コンパイルが通った時点で大方バグを潰せている(出戻りが減る)ので長い目で見ると開発速度は遅くないとも言われる
  • 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を以下のようにしておきます。

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
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 devcargo-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`.

次にコントローラです。

bash
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になっています。

src/controllers/article.rs
// 略

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
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は日本人の方が開発したエンジンで非常にパフォーマンスが良いことが特徴のようです。

https://qiita.com/Kogia_sima/items/87157af0fbf50ceda0ae

その他の選択肢としては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
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
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箇所ほど手を加えます。

src/controllers/article.rs
/// 略

// 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を編集します。

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.stplDaisy UIを導入してみましょう。

templates/article/index.stpl
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については主に海外でとても流行っていて沢山記事があるので調べてみてください。

https://reffect.co.jp/html/html-first-time

ちなみにHTMZというものもありこちらも便利そうです。

https://zenn.dev/kawarimidoll/articles/33316cf9caa465

脱線しましたがtemplates/article/index.stplにフォームを設置して非同期にarticleの作成を出来るようにしていきたいと思います。

先にコントローラ側をarticleのリストを返却するようにしておきます。

src/controllers/article.rs
/// 略

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
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を使います。

https://loco.rs/docs/the-app/initializers/
https://www.shuttle.rs/blog/2023/12/28/using-loco-rust-rails#middleware-in-loco

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
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を有効化します。

src/initializers/mod.rs
#![allow(clippy::module_name_repetitions)]
pub mod view_engine;
pub mod security_helmet; // 追加
src/app.rs
// 略
    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
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
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を有効化します。

src/initializers/mod.rs
#![allow(clippy::module_name_repetitions)]
pub mod view_engine;
pub mod security_helmet;
pub mod openapi_utoipa; // 追加
src/app.rs
// 略
    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にアクセスしてみてください。

http://localhost:3000/swagger-ui/

http://localhost:3000/redoc

http://localhost:3000/rapidoc

これでutoipaの導入も完了です!
ちなみにこちらのスライドでutoipaが紹介されているのでチェックしてみてください。#[schema(inline)]は便利そうですね。

https://speakerdeck.com/tako8ki/rustdeshi-merukodohuasutonaopenapiding-yi-nosheng-cheng

本記事でのlocoの解説は以上になります!

感想など

  • RailsやLaravelに慣れ親しんでいた一方、しっかりとした型チェックとパフォーマンスがあるフレームワークを探していたのでlocoにはロマンを感じます。
  • vite等とは比べてはいけませんがホットリロードがあるので思ったよりは開発体験が良いです。
  • スクリプト言語の人間なのでrustは慣れないと辛いところがありますが、rustfmtなど強力な静的解析も魅力的ですね。
  • まだ登場して半年も経っていないので絶賛unstable中ですがlocoとrustでのWeb開発が盛り上がることを期待します!

Discussion