フロントエンド連携
バックエンド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-get | GETリクエスト |
hx-post | POSTリクエスト |
hx-put | PUTリクエスト |
hx-delete | DELETEリクエスト |
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(),
}
}),
}
}
}
まとめ
| 方式 | 特徴 | 用途 |
|---|---|---|
| 静的ファイル配信 | シンプル、同一オリジン | 小〜中規模アプリ |
| 分離デプロイ | 柔軟、スケーラブル | 大規模アプリ |
| htmx | JavaScript最小限 | シンプルなUI |
| 設定 | 用途 |
|---|---|
| ServeDir | 静的ファイル配信 |
| CorsLayer | CORS設定 |
| 環境変数 | 設定の外部化 |
確認テスト
Q1. CORSが必要になるのはどのような場合?
正解: B) CORS(Cross-Origin Resource Sharing)は、異なるオリジン(ドメイン、ポート、プロトコル)間でのリクエストを制御する仕組みです。同一オリジンでは不要です。
Q2. Fetch APIで JSONを送信する際に必要なヘッダーは?
正解: B) JSONをPOST/PUTする際は
Content-Type: application/json ヘッダーが必要です。サーバーにボディの形式を伝えます。
Q3. htmxのhx-swap="outerHTML"の動作は?
正解: C)
outerHTMLはターゲット要素自体を含めて置き換えます。innerHTMLは要素の中身だけを置き換えます。
Q4. fetch APIでJSON送信時にContent-Typeヘッダーがない場合の問題は?
正解: A) Content-Typeヘッダーがないと、サーバーはリクエストボディの形式がわからず、JSONとして正しくパースできません。
Q5. allow_credentials(true)を使う場合のCORS設定で正しいものは?
正解: D)
allow_credentials(true)を使う場合、セキュリティ上の理由からallow_origin(Any)は使えません。特定のオリジンを明示的に指定する必要があります。
次のドキュメント: 07_deployment.md