Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

フロントエンド連携

バックエンドAPIとフロントエンドの連携方法を学びます。

連携パターン

1. 静的ファイル配信 + API

┌────────────────────────────────────────────┐
│                  Axumサーバー                │
│  ┌──────────────┐    ┌──────────────┐     │
│  │ 静的ファイル   │    │   API        │     │
│  │ /static/*    │    │   /api/*     │     │
│  │ HTML/JS/CSS  │    │   JSON       │     │
│  └──────────────┘    └──────────────┘     │
└────────────────────────────────────────────┘

2. 分離デプロイ

┌──────────────┐         ┌──────────────┐
│ フロントエンド │  CORS   │  APIサーバー  │
│ (Vercelなど)  │ ←─────→ │   (Rust)     │
└──────────────┘         └──────────────┘

静的ファイルの配信

Cargo.toml

[dependencies]
tower-http = { version = "0.5", features = ["fs", "cors"] }

ディレクトリ構造

project/
├── src/
│   └── main.rs
├── static/
│   ├── index.html
│   ├── style.css
│   └── app.js
└── Cargo.toml

静的ファイルの配信設定

use axum::{routing::get, Router};
use tower_http::services::ServeDir;

#[tokio::main]
async fn main() {
    let app = Router::new()
        // API ルート
        .route("/api/users", get(list_users))

        // 静的ファイル配信
        .nest_service("/", ServeDir::new("static"));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

index.html の例

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My App</title>
    <link rel="stylesheet" href="/style.css">
</head>
<body>
    <div id="app">
        <h1>ブックマーク管理</h1>
        <ul id="bookmarks"></ul>
        <form id="add-form">
            <input type="text" name="url" placeholder="URL" required>
            <input type="text" name="title" placeholder="タイトル" required>
            <button type="submit">追加</button>
        </form>
    </div>
    <script src="/app.js"></script>
</body>
</html>

JavaScript (Fetch API)

// app.js

// ブックマーク一覧を取得
async function fetchBookmarks() {
    const response = await fetch('/api/bookmarks');
    const bookmarks = await response.json();

    const ul = document.getElementById('bookmarks');
    ul.innerHTML = '';

    bookmarks.forEach(bookmark => {
        const li = document.createElement('li');
        li.innerHTML = `
            <a href="${bookmark.url}" target="_blank">${bookmark.title}</a>
            <button onclick="deleteBookmark(${bookmark.id})">削除</button>
        `;
        ul.appendChild(li);
    });
}

// ブックマークを追加
async function addBookmark(url, title) {
    const response = await fetch('/api/bookmarks', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({ url, title }),
    });

    if (response.ok) {
        fetchBookmarks();  // 一覧を更新
    }
}

// ブックマークを削除
async function deleteBookmark(id) {
    const response = await fetch(`/api/bookmarks/${id}`, {
        method: 'DELETE',
    });

    if (response.ok) {
        fetchBookmarks();  // 一覧を更新
    }
}

// フォーム送信
document.getElementById('add-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    await addBookmark(formData.get('url'), formData.get('title'));
    e.target.reset();
});

// 初期読み込み
fetchBookmarks();

CORS(Cross-Origin Resource Sharing)

フロントエンドとバックエンドが異なるオリジンの場合、CORSの設定が必要です。

なぜCORSが必要か

フロントエンド: https://frontend.example.com
バックエンド:   https://api.example.com

→ オリジンが異なるため、ブラウザがリクエストをブロック
→ CORS設定で許可する

CORS設定

use axum::{routing::get, Router};
use tower_http::cors::{CorsLayer, Any};
use http::Method;

#[tokio::main]
async fn main() {
    // CORS設定
    let cors = CorsLayer::new()
        .allow_origin(Any)  // すべてのオリジンを許可(開発用)
        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
        .allow_headers(Any);

    let app = Router::new()
        .route("/api/bookmarks", get(list_bookmarks).post(create_bookmark))
        .layer(cors);

    // ...
}

本番環境のCORS設定

#![allow(unused)]
fn main() {
use tower_http::cors::{CorsLayer, AllowOrigin};
use http::{Method, HeaderValue};

let cors = CorsLayer::new()
    .allow_origin(AllowOrigin::exact(
        "https://your-frontend.com".parse().unwrap()
    ))
    .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
    .allow_headers([
        http::header::CONTENT_TYPE,
        http::header::AUTHORIZATION,
    ])
    .allow_credentials(true);  // Cookieを使う場合
}

htmx によるシンプルな連携

htmxはJavaScriptをほぼ書かずにAjaxを実現できるライブラリです。

htmxの基本

<!DOCTYPE html>
<html lang="ja">
<head>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
    <!-- クリックでGETリクエスト -->
    <button hx-get="/api/hello" hx-target="#result">
        挨拶を取得
    </button>

    <!-- 結果を表示 -->
    <div id="result"></div>
</body>
</html>

htmxの主な属性

属性説明
hx-getGETリクエスト
hx-postPOSTリクエスト
hx-putPUTリクエスト
hx-deleteDELETEリクエスト
hx-target結果を挿入する要素
hx-swap挿入方法(innerHTML, outerHTML等)
hx-triggerトリガーイベント

htmxでのCRUD例

<!-- ブックマーク一覧 -->
<div id="bookmarks" hx-get="/api/bookmarks" hx-trigger="load">
    <!-- サーバーからHTMLが挿入される -->
</div>

<!-- 追加フォーム -->
<form hx-post="/api/bookmarks"
      hx-target="#bookmarks"
      hx-swap="beforeend">
    <input type="text" name="url" placeholder="URL" required>
    <input type="text" name="title" placeholder="タイトル" required>
    <button type="submit">追加</button>
</form>

サーバー側(HTMLを返す)

#![allow(unused)]
fn main() {
use axum::response::Html;

// htmx用:HTMLフラグメントを返す
async fn list_bookmarks_html(State(pool): State<SqlitePool>) -> Html<String> {
    let bookmarks = sqlx::query_as::<_, Bookmark>("SELECT * FROM bookmarks")
        .fetch_all(&pool)
        .await
        .unwrap_or_default();

    let html = bookmarks
        .iter()
        .map(|b| format!(
            r#"<li>
                <a href="{}">{}</a>
                <button hx-delete="/api/bookmarks/{}"
                        hx-target="closest li"
                        hx-swap="outerHTML">削除</button>
            </li>"#,
            b.url, b.title, b.id
        ))
        .collect::<Vec<_>>()
        .join("\n");

    Html(html)
}

// 追加後は新しいリストアイテムを返す
async fn create_bookmark_html(
    State(pool): State<SqlitePool>,
    Form(payload): Form<CreateBookmark>,
) -> Html<String> {
    // ... DBに保存 ...

    Html(format!(
        r#"<li>
            <a href="{}">{}</a>
            <button hx-delete="/api/bookmarks/{}"
                    hx-target="closest li"
                    hx-swap="outerHTML">削除</button>
        </li>"#,
        payload.url, payload.title, id
    ))
}
}

環境変数による設定

.env ファイル

DATABASE_URL=sqlite:./database.db
FRONTEND_URL=http://localhost:5173

環境変数の読み込み

use std::env;

fn main() {
    // .envファイルを読み込み(開発用)
    dotenv::dotenv().ok();

    let database_url = env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let frontend_url = env::var("FRONTEND_URL")
        .unwrap_or_else(|_| "http://localhost:3000".to_string());

    // ...
}

APIレスポンスの形式

成功時

{
  "data": {
    "id": 1,
    "url": "https://example.com",
    "title": "Example"
  }
}

エラー時

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Bookmark not found"
  }
}

統一レスポンス型

#![allow(unused)]
fn main() {
use serde::Serialize;

#[derive(Serialize)]
#[serde(untagged)]
enum ApiResponse<T> {
    Success { data: T },
    Error { error: ApiError },
}

#[derive(Serialize)]
struct ApiError {
    code: String,
    message: String,
}

// 使用例
async fn get_bookmark(Path(id): Path<i64>) -> Json<ApiResponse<Bookmark>> {
    match find_bookmark(id).await {
        Ok(bookmark) => Json(ApiResponse::Success { data: bookmark }),
        Err(_) => Json(ApiResponse::Error {
            error: ApiError {
                code: "NOT_FOUND".to_string(),
                message: "Bookmark not found".to_string(),
            }
        }),
    }
}
}

まとめ

方式特徴用途
静的ファイル配信シンプル、同一オリジン小〜中規模アプリ
分離デプロイ柔軟、スケーラブル大規模アプリ
htmxJavaScript最小限シンプルなUI
設定用途
ServeDir静的ファイル配信
CorsLayerCORS設定
環境変数設定の外部化

確認テスト

Q1. CORSが必要になるのはどのような場合?

Q2. Fetch APIで JSONを送信する際に必要なヘッダーは?

Q3. htmxのhx-swap="outerHTML"の動作は?

Q4. fetch APIでJSON送信時にContent-Typeヘッダーがない場合の問題は?

Q5. allow_credentials(true)を使う場合のCORS設定で正しいものは?


次のドキュメント: 07_deployment.md