rust-fullstack - 解説

実装の詳細

アーキテクチャ概要

┌─────────────────────────────────────────────────────┐
│                    Frontend (WASM)                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐  │
│  │   Yew    │  │  Router  │  │  State (Context) │  │
│  └────┬─────┘  └────┬─────┘  └────────┬─────────┘  │
│       └─────────────┴─────────────────┘            │
│                      ↕ HTTP/JSON                   │
└─────────────────────────────────────────────────────┘
                       ↕
┌─────────────────────────────────────────────────────┐
│                    Backend (Axum)                   │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐  │
│  │  Router  │→ │ Handler  │→ │     Service      │  │
│  └──────────┘  └──────────┘  └────────┬─────────┘  │
│                                        ↓           │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐  │
│  │   Auth   │  │  Error   │  │    Repository    │  │
│  └──────────┘  └──────────┘  └────────┬─────────┘  │
│                                        ↓           │
│  ┌──────────────────────────────────────────────┐  │
│  │              SQLx (Database)                 │  │
│  └──────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

JWT認証の実装

use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub user_id: i64,
    pub exp: usize,
    pub iat: usize,
}

pub fn create_token(user_id: i64, secret: &str) -> Result<String, AppError> {
    let now = chrono::Utc::now();
    let exp = now + chrono::Duration::hours(24);

    let claims = Claims {
        user_id,
        exp: exp.timestamp() as usize,
        iat: now.timestamp() as usize,
    };

    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .map_err(|e| AppError::Auth(e.to_string()))
}

pub fn verify_token(token: &str, secret: &str) -> Result<Claims, AppError> {
    decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )
    .map(|data| data.claims)
    .map_err(|e| AppError::Auth(e.to_string()))
}

// Axum Extractorとして実装
#[async_trait]
impl<S> FromRequestParts<S> for Claims
where
    S: Send + Sync,
{
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        let auth_header = parts
            .headers
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or(AppError::Auth("Missing authorization header".into()))?;

        let token = auth_header
            .strip_prefix("Bearer ")
            .ok_or(AppError::Auth("Invalid authorization header".into()))?;

        let app_state = parts
            .extensions
            .get::<Arc<AppState>>()
            .ok_or(AppError::Auth("Internal error".into()))?;

        verify_token(token, &app_state.config.jwt_secret)
    }
}

WASM API クライアント

// frontend/src/api.rs
use gloo_net::http::Request;
use gloo_storage::{LocalStorage, Storage};
use serde::{de::DeserializeOwned, Serialize};

const API_BASE: &str = "/api";

pub async fn fetch<T: DeserializeOwned>(
    method: &str,
    path: &str,
    body: Option<impl Serialize>,
) -> Result<T, String> {
    let url = format!("{}{}", API_BASE, path);

    let mut request = match method {
        "GET" => Request::get(&url),
        "POST" => Request::post(&url),
        "PUT" => Request::put(&url),
        "DELETE" => Request::delete(&url),
        _ => return Err("Invalid method".into()),
    };

    // 認証トークンを追加
    if let Ok(token) = LocalStorage::get::<String>("token") {
        request = request.header("Authorization", &format!("Bearer {}", token));
    }

    // ボディを追加
    let request = if let Some(body) = body {
        request
            .header("Content-Type", "application/json")
            .json(&body)
            .map_err(|e| e.to_string())?
    } else {
        request
    };

    let response = request.send().await.map_err(|e| e.to_string())?;

    if !response.ok() {
        let error: ApiError = response.json().await.unwrap_or(ApiError {
            error: "Unknown error".into(),
        });
        return Err(error.error);
    }

    response.json().await.map_err(|e| e.to_string())
}

// 型付きAPI関数
pub async fn login(email: &str, password: &str) -> Result<AuthResponse, String> {
    let body = LoginRequest {
        email: email.into(),
        password: password.into(),
    };

    let response: AuthResponse = fetch("POST", "/auth/login", Some(body)).await?;

    // トークンを保存
    LocalStorage::set("token", &response.token).ok();

    Ok(response)
}

pub async fn get_tasks() -> Result<Vec<Task>, String> {
    fetch("GET", "/tasks", None::<()>).await
}

pub async fn create_task(title: &str, priority: &str) -> Result<Task, String> {
    let body = CreateTaskRequest {
        title: title.into(),
        priority: Some(priority.into()),
        description: None,
        due_date: None,
    };
    fetch("POST", "/tasks", Some(body)).await
}

pub async fn delete_task(id: i64) -> Result<(), String> {
    fetch("DELETE", &format!("/tasks/{}", id), None::<()>).await
}

よくある間違い

1. CORS設定の不備

// 間違い: CORS設定なし
let app = Router::new()
    .route("/api/tasks", get(list_tasks));
// → ブラウザからアクセス不可

// 正しい: CORS設定
use tower_http::cors::{CorsLayer, Any};

let cors = CorsLayer::new()
    .allow_origin(Any)  // 本番では特定ドメインに制限
    .allow_methods(Any)
    .allow_headers(Any);

let app = Router::new()
    .route("/api/tasks", get(list_tasks))
    .layer(cors);

2. パスワードの安全でない保存

// 間違い: パスワードを平文で保存
user.password = req.password.clone();

// 正しい: Argon2でハッシュ化
use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};

pub fn hash_password(password: &str) -> Result<String, AppError> {
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();

    argon2
        .hash_password(password.as_bytes(), &salt)
        .map(|hash| hash.to_string())
        .map_err(|e| AppError::Auth(e.to_string()))
}

pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
    let parsed_hash = PasswordHash::new(hash)
        .map_err(|e| AppError::Auth(e.to_string()))?;

    Ok(Argon2::default()
        .verify_password(password.as_bytes(), &parsed_hash)
        .is_ok())
}

3. SQLインジェクション

// 間違い: 文字列補間
let query = format!("SELECT * FROM tasks WHERE title = '{}'", title);
sqlx::query(&query).fetch_all(&pool).await?;

// 正しい: パラメータ化クエリ
sqlx::query("SELECT * FROM tasks WHERE title = $1")
    .bind(&title)
    .fetch_all(&pool)
    .await?;

4. WASM での非同期処理

// 間違い: 直接awaitを使用
#[function_component(TaskList)]
fn task_list() -> Html {
    let tasks = api::get_tasks().await; // コンパイルエラー
}

// 正しい: spawn_localを使用
#[function_component(TaskList)]
fn task_list() -> Html {
    let tasks = use_state(Vec::new);

    {
        let tasks = tasks.clone();
        use_effect_with((), move |_| {
            wasm_bindgen_futures::spawn_local(async move {
                if let Ok(fetched) = api::get_tasks().await {
                    tasks.set(fetched);
                }
            });
            || ()
        });
    }

    // ...
}

パフォーマンス最適化

バックエンド

// 接続プール設定
let pool = PgPoolOptions::new()
    .max_connections(10)
    .min_connections(2)
    .acquire_timeout(Duration::from_secs(3))
    .connect(&database_url)
    .await?;

// クエリ最適化
// N+1問題を避ける
sqlx::query_as::<_, TaskWithUser>(
    r#"
    SELECT t.*, u.username
    FROM tasks t
    JOIN users u ON t.user_id = u.id
    WHERE t.user_id = $1
    "#
)
.bind(user_id)
.fetch_all(&pool)
.await?;

フロントエンド

// メモ化
#[function_component(TaskItem)]
fn task_item(props: &TaskItemProps) -> Html {
    // 重い計算をメモ化
    let formatted_date = use_memo(
        props.task.due_date.clone(),
        |date| format_date(date)
    );

    html! { /* ... */ }
}

// 仮想スクロール(大量データ対応)
// yew-virtual-scroll を使用

発展トピック

WebSocket リアルタイム同期

// バックエンド
use axum::extract::ws::{WebSocket, WebSocketUpgrade};

async fn ws_handler(
    ws: WebSocketUpgrade,
    Extension(state): Extension<Arc<AppState>>,
) -> impl IntoResponse {
    ws.on_upgrade(|socket| handle_socket(socket, state))
}

async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>) {
    while let Some(msg) = socket.recv().await {
        if let Ok(msg) = msg {
            // メッセージ処理
            // 他のクライアントにブロードキャスト
        }
    }
}

// フロントエンド
use gloo_net::websocket::{futures::WebSocket, Message};

async fn connect_ws() {
    let ws = WebSocket::open("ws://localhost:8080/ws").unwrap();
    let (mut write, mut read) = ws.split();

    // 受信ループ
    while let Some(msg) = read.next().await {
        match msg {
            Ok(Message::Text(text)) => {
                // 更新を処理
            }
            _ => {}
        }
    }
}

Service Worker オフライン対応

// service_worker.rs (WASM)
use wasm_bindgen::prelude::*;
use web_sys::{ServiceWorkerGlobalScope, FetchEvent, Response};

#[wasm_bindgen]
pub fn handle_fetch(event: FetchEvent) {
    let request = event.request();

    // キャッシュファースト戦略
    let response_promise = async move {
        let cache = caches().open("v1").await?;

        if let Some(cached) = cache.match_request(&request).await? {
            return Ok(cached);
        }

        let response = fetch(&request).await?;
        cache.put(&request, response.clone()).await?;
        Ok(response)
    };

    event.respond_with(&response_promise.into());
}