rust-fullstack - 解答

実装コード

バックエンド

main.rs

// backend/src/main.rs
use axum::{
    routing::{get, post, put, delete},
    Router,
    Extension,
};
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use tower_http::cors::{CorsLayer, Any};

mod config;
mod handlers;
mod models;
mod auth;
mod error;

use config::Config;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenvy::dotenv().ok();
    tracing_subscriber::init();

    let config = Config::from_env();

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&config.database_url)
        .await?;

    sqlx::migrate!().run(&pool).await?;

    let app_state = Arc::new(AppState {
        pool,
        config: config.clone(),
    });

    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any);

    let app = Router::new()
        // Auth routes
        .route("/api/auth/register", post(handlers::auth::register))
        .route("/api/auth/login", post(handlers::auth::login))
        .route("/api/auth/me", get(handlers::auth::me))
        // Task routes
        .route("/api/tasks", get(handlers::tasks::list))
        .route("/api/tasks", post(handlers::tasks::create))
        .route("/api/tasks/:id", get(handlers::tasks::get))
        .route("/api/tasks/:id", put(handlers::tasks::update))
        .route("/api/tasks/:id", delete(handlers::tasks::delete))
        .layer(cors)
        .layer(Extension(app_state));

    let addr = format!("{}:{}", config.server_host, config.server_port);
    println!("Server running on {}", addr);

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

pub struct AppState {
    pub pool: sqlx::PgPool,
    pub config: Config,
}

認証ハンドラ

// backend/src/handlers/auth.rs
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use crate::{AppState, auth::*, error::AppError, models::User};

#[derive(Deserialize)]
pub struct RegisterRequest {
    pub username: String,
    pub email: String,
    pub password: String,
}

#[derive(Deserialize)]
pub struct LoginRequest {
    pub email: String,
    pub password: String,
}

#[derive(Serialize)]
pub struct AuthResponse {
    pub token: String,
    pub user: UserResponse,
}

#[derive(Serialize)]
pub struct UserResponse {
    pub id: i64,
    pub username: String,
    pub email: String,
}

pub async fn register(
    Extension(state): Extension<Arc<AppState>>,
    Json(req): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, AppError> {
    // バリデーション
    if req.username.len() < 3 {
        return Err(AppError::Validation("Username must be at least 3 characters".into()));
    }
    if req.password.len() < 8 {
        return Err(AppError::Validation("Password must be at least 8 characters".into()));
    }

    // 既存ユーザーチェック
    let existing = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE email = $1"
    )
    .bind(&req.email)
    .fetch_optional(&state.pool)
    .await?;

    if existing.is_some() {
        return Err(AppError::Validation("Email already registered".into()));
    }

    // パスワードハッシュ化
    let password_hash = hash_password(&req.password)?;

    // ユーザー作成
    let user = sqlx::query_as::<_, User>(
        r#"
        INSERT INTO users (username, email, password_hash)
        VALUES ($1, $2, $3)
        RETURNING *
        "#
    )
    .bind(&req.username)
    .bind(&req.email)
    .bind(&password_hash)
    .fetch_one(&state.pool)
    .await?;

    // JWT生成
    let token = create_token(user.id, &state.config.jwt_secret)?;

    Ok(Json(AuthResponse {
        token,
        user: UserResponse {
            id: user.id,
            username: user.username,
            email: user.email,
        },
    }))
}

pub async fn login(
    Extension(state): Extension<Arc<AppState>>,
    Json(req): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, AppError> {
    let user = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE email = $1"
    )
    .bind(&req.email)
    .fetch_optional(&state.pool)
    .await?
    .ok_or(AppError::Auth("Invalid credentials".into()))?;

    if !verify_password(&req.password, &user.password_hash)? {
        return Err(AppError::Auth("Invalid credentials".into()));
    }

    let token = create_token(user.id, &state.config.jwt_secret)?;

    Ok(Json(AuthResponse {
        token,
        user: UserResponse {
            id: user.id,
            username: user.username,
            email: user.email,
        },
    }))
}

pub async fn me(
    Extension(state): Extension<Arc<AppState>>,
    claims: Claims,
) -> Result<Json<UserResponse>, AppError> {
    let user = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE id = $1"
    )
    .bind(claims.user_id)
    .fetch_optional(&state.pool)
    .await?
    .ok_or(AppError::NotFound)?;

    Ok(Json(UserResponse {
        id: user.id,
        username: user.username,
        email: user.email,
    }))
}

タスクハンドラ

// backend/src/handlers/tasks.rs
use axum::{Extension, Json, extract::Path};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use crate::{AppState, auth::Claims, error::AppError, models::{Task, TaskStatus, Priority}};

#[derive(Deserialize)]
pub struct CreateTaskRequest {
    pub title: String,
    pub description: Option<String>,
    pub priority: Option<String>,
    pub due_date: Option<String>,
}

#[derive(Deserialize)]
pub struct UpdateTaskRequest {
    pub title: Option<String>,
    pub description: Option<String>,
    pub status: Option<String>,
    pub priority: Option<String>,
    pub due_date: Option<String>,
}

#[derive(Serialize)]
pub struct TaskResponse {
    pub id: i64,
    pub title: String,
    pub description: Option<String>,
    pub status: String,
    pub priority: String,
    pub due_date: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

pub async fn list(
    Extension(state): Extension<Arc<AppState>>,
    claims: Claims,
) -> Result<Json<Vec<TaskResponse>>, AppError> {
    let tasks = sqlx::query_as::<_, Task>(
        "SELECT * FROM tasks WHERE user_id = $1 ORDER BY created_at DESC"
    )
    .bind(claims.user_id)
    .fetch_all(&state.pool)
    .await?;

    let responses: Vec<TaskResponse> = tasks.into_iter()
        .map(|t| TaskResponse {
            id: t.id,
            title: t.title,
            description: t.description,
            status: t.status.to_string(),
            priority: t.priority.to_string(),
            due_date: t.due_date.map(|d| d.to_rfc3339()),
            created_at: t.created_at.to_rfc3339(),
            updated_at: t.updated_at.to_rfc3339(),
        })
        .collect();

    Ok(Json(responses))
}

pub async fn create(
    Extension(state): Extension<Arc<AppState>>,
    claims: Claims,
    Json(req): Json<CreateTaskRequest>,
) -> Result<Json<TaskResponse>, AppError> {
    let priority = req.priority
        .map(|p| Priority::from_str(&p))
        .transpose()?
        .unwrap_or(Priority::Medium);

    let due_date = req.due_date
        .map(|d| chrono::DateTime::parse_from_rfc3339(&d))
        .transpose()
        .map_err(|_| AppError::Validation("Invalid date format".into()))?
        .map(|d| d.with_timezone(&chrono::Utc));

    let task = sqlx::query_as::<_, Task>(
        r#"
        INSERT INTO tasks (user_id, title, description, status, priority, due_date)
        VALUES ($1, $2, $3, $4, $5, $6)
        RETURNING *
        "#
    )
    .bind(claims.user_id)
    .bind(&req.title)
    .bind(&req.description)
    .bind(TaskStatus::Todo.to_string())
    .bind(priority.to_string())
    .bind(due_date)
    .fetch_one(&state.pool)
    .await?;

    Ok(Json(TaskResponse {
        id: task.id,
        title: task.title,
        description: task.description,
        status: task.status.to_string(),
        priority: task.priority.to_string(),
        due_date: task.due_date.map(|d| d.to_rfc3339()),
        created_at: task.created_at.to_rfc3339(),
        updated_at: task.updated_at.to_rfc3339(),
    }))
}

pub async fn get(
    Extension(state): Extension<Arc<AppState>>,
    claims: Claims,
    Path(id): Path<i64>,
) -> Result<Json<TaskResponse>, AppError> {
    let task = sqlx::query_as::<_, Task>(
        "SELECT * FROM tasks WHERE id = $1 AND user_id = $2"
    )
    .bind(id)
    .bind(claims.user_id)
    .fetch_optional(&state.pool)
    .await?
    .ok_or(AppError::NotFound)?;

    Ok(Json(TaskResponse {
        id: task.id,
        title: task.title,
        description: task.description,
        status: task.status.to_string(),
        priority: task.priority.to_string(),
        due_date: task.due_date.map(|d| d.to_rfc3339()),
        created_at: task.created_at.to_rfc3339(),
        updated_at: task.updated_at.to_rfc3339(),
    }))
}

pub async fn update(
    Extension(state): Extension<Arc<AppState>>,
    claims: Claims,
    Path(id): Path<i64>,
    Json(req): Json<UpdateTaskRequest>,
) -> Result<Json<TaskResponse>, AppError> {
    // 既存タスク確認
    let existing = sqlx::query_as::<_, Task>(
        "SELECT * FROM tasks WHERE id = $1 AND user_id = $2"
    )
    .bind(id)
    .bind(claims.user_id)
    .fetch_optional(&state.pool)
    .await?
    .ok_or(AppError::NotFound)?;

    let title = req.title.unwrap_or(existing.title);
    let description = req.description.or(existing.description);
    let status = req.status
        .map(|s| TaskStatus::from_str(&s))
        .transpose()?
        .unwrap_or(existing.status);
    let priority = req.priority
        .map(|p| Priority::from_str(&p))
        .transpose()?
        .unwrap_or(existing.priority);

    let task = sqlx::query_as::<_, Task>(
        r#"
        UPDATE tasks
        SET title = $1, description = $2, status = $3, priority = $4, updated_at = NOW()
        WHERE id = $5 AND user_id = $6
        RETURNING *
        "#
    )
    .bind(&title)
    .bind(&description)
    .bind(status.to_string())
    .bind(priority.to_string())
    .bind(id)
    .bind(claims.user_id)
    .fetch_one(&state.pool)
    .await?;

    Ok(Json(TaskResponse {
        id: task.id,
        title: task.title,
        description: task.description,
        status: task.status.to_string(),
        priority: task.priority.to_string(),
        due_date: task.due_date.map(|d| d.to_rfc3339()),
        created_at: task.created_at.to_rfc3339(),
        updated_at: task.updated_at.to_rfc3339(),
    }))
}

pub async fn delete(
    Extension(state): Extension<Arc<AppState>>,
    claims: Claims,
    Path(id): Path<i64>,
) -> Result<(), AppError> {
    let result = sqlx::query(
        "DELETE FROM tasks WHERE id = $1 AND user_id = $2"
    )
    .bind(id)
    .bind(claims.user_id)
    .execute(&state.pool)
    .await?;

    if result.rows_affected() == 0 {
        return Err(AppError::NotFound);
    }

    Ok(())
}

フロントエンド (Yew)

// frontend/src/main.rs
use yew::prelude::*;
use yew_router::prelude::*;

mod api;
mod components;
mod pages;
mod state;

use pages::{Home, Login, Register, Dashboard};

#[derive(Clone, Routable, PartialEq)]
enum Route {
    #[at("/")]
    Home,
    #[at("/login")]
    Login,
    #[at("/register")]
    Register,
    #[at("/dashboard")]
    Dashboard,
    #[not_found]
    #[at("/404")]
    NotFound,
}

fn switch(routes: Route) -> Html {
    match routes {
        Route::Home => html! { <Home /> },
        Route::Login => html! { <Login /> },
        Route::Register => html! { <Register /> },
        Route::Dashboard => html! { <Dashboard /> },
        Route::NotFound => html! { <h1>{ "404 - Not Found" }</h1> },
    }
}

#[function_component(App)]
fn app() -> Html {
    html! {
        <BrowserRouter>
            <Switch<Route> render={switch} />
        </BrowserRouter>
    }
}

fn main() {
    yew::Renderer::<App>::new().render();
}

ダッシュボードコンポーネント

// frontend/src/pages/dashboard.rs
use yew::prelude::*;
use crate::api;
use crate::components::{TaskList, TaskForm, Header};

#[derive(Clone, PartialEq)]
pub struct Task {
    pub id: i64,
    pub title: String,
    pub description: Option<String>,
    pub status: String,
    pub priority: String,
}

#[function_component(Dashboard)]
pub fn dashboard() -> Html {
    let tasks = use_state(Vec::new);
    let editing_task = use_state(|| None::<Task>);
    let show_form = use_state(|| false);

    // タスク取得
    {
        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 on_create = {
        let show_form = show_form.clone();
        let editing_task = editing_task.clone();
        Callback::from(move |_| {
            editing_task.set(None);
            show_form.set(true);
        })
    };

    let on_edit = {
        let show_form = show_form.clone();
        let editing_task = editing_task.clone();
        Callback::from(move |task: Task| {
            editing_task.set(Some(task));
            show_form.set(true);
        })
    };

    let on_delete = {
        let tasks = tasks.clone();
        Callback::from(move |id: i64| {
            let tasks = tasks.clone();
            wasm_bindgen_futures::spawn_local(async move {
                if api::delete_task(id).await.is_ok() {
                    let current: Vec<Task> = (*tasks).clone();
                    tasks.set(current.into_iter().filter(|t| t.id != id).collect());
                }
            });
        })
    };

    let on_submit = {
        let tasks = tasks.clone();
        let show_form = show_form.clone();
        Callback::from(move |task: Task| {
            let tasks = tasks.clone();
            let show_form = show_form.clone();
            wasm_bindgen_futures::spawn_local(async move {
                // API呼び出し後にリスト更新
                if let Ok(fetched) = api::get_tasks().await {
                    tasks.set(fetched);
                }
                show_form.set(false);
            });
        })
    };

    html! {
        <div class="dashboard">
            <Header />
            <main class="dashboard-content">
                <div class="dashboard-header">
                    <h1>{ "タスク管理" }</h1>
                    <button onclick={on_create} class="btn-primary">
                        { "新規タスク" }
                    </button>
                </div>

                if *show_form {
                    <TaskForm
                        task={(*editing_task).clone()}
                        on_submit={on_submit}
                        on_cancel={Callback::from({
                            let show_form = show_form.clone();
                            move |_| show_form.set(false)
                        })}
                    />
                }

                <TaskList
                    tasks={(*tasks).clone()}
                    on_edit={on_edit}
                    on_delete={on_delete}
                />
            </main>
        </div>
    }
}

データベースマイグレーション

-- migrations/001_create_tables.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE tasks (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    status VARCHAR(20) NOT NULL DEFAULT 'todo',
    priority VARCHAR(20) NOT NULL DEFAULT 'medium',
    due_date TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_status ON tasks(status);

テストコード

#[cfg(test)]
mod tests {
    use super::*;
    use axum::http::StatusCode;
    use axum_test::TestServer;

    async fn setup() -> TestServer {
        // テスト用DBセットアップ
        let pool = PgPoolOptions::new()
            .connect("postgres://test:test@localhost/test_db")
            .await
            .unwrap();

        sqlx::migrate!().run(&pool).await.unwrap();

        let app_state = Arc::new(AppState {
            pool,
            config: Config::test(),
        });

        let app = create_app(app_state);
        TestServer::new(app).unwrap()
    }

    #[tokio::test]
    async fn test_register_and_login() {
        let server = setup().await;

        // 登録
        let res = server.post("/api/auth/register")
            .json(&json!({
                "username": "testuser",
                "email": "test@example.com",
                "password": "password123"
            }))
            .await;

        assert_eq!(res.status_code(), StatusCode::OK);
        let body: AuthResponse = res.json();
        assert!(!body.token.is_empty());

        // ログイン
        let res = server.post("/api/auth/login")
            .json(&json!({
                "email": "test@example.com",
                "password": "password123"
            }))
            .await;

        assert_eq!(res.status_code(), StatusCode::OK);
    }

    #[tokio::test]
    async fn test_task_crud() {
        let server = setup().await;

        // 認証トークン取得
        let token = get_test_token(&server).await;

        // 作成
        let res = server.post("/api/tasks")
            .add_header("Authorization", format!("Bearer {}", token))
            .json(&json!({
                "title": "Test Task",
                "priority": "high"
            }))
            .await;

        assert_eq!(res.status_code(), StatusCode::OK);
        let task: TaskResponse = res.json();
        assert_eq!(task.title, "Test Task");

        // 更新
        let res = server.put(&format!("/api/tasks/{}", task.id))
            .add_header("Authorization", format!("Bearer {}", token))
            .json(&json!({
                "status": "done"
            }))
            .await;

        assert_eq!(res.status_code(), StatusCode::OK);

        // 削除
        let res = server.delete(&format!("/api/tasks/{}", task.id))
            .add_header("Authorization", format!("Bearer {}", token))
            .await;

        assert_eq!(res.status_code(), StatusCode::OK);
    }
}