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);
}
}