Rust WebAPI 作成入門
2024-2025 Shunsuke Watanabe
このチュートリアルについて
このチュートリアルはRustでREST APIを作成する過程を通じて、EventStormingでまとめたドメイン知識をコードに起こす基礎的な内容に触れてもらうことを目的としています。👨💻サンプルコード → https://github.com/craftgear/rust_web_api_tutorial
discord → https://discord.gg/3havwjWGsw
bluesky → https://bsky.app/profile/craftgear.bsky.social
📝 blog → https://craftgear.github.io/posts/
💡このチュートリアルは Web API作成入門 の内容をRustで置き換えたものです。
1. 開発資料
EventStorming
-
イベントストーミングの利点3つ
Big Picture
Design Level
Onion Architecture
その他の3層アーキテクチャ
- Clean Architecture
- Hexagonal Architecture
2. 開発環境構築
対象環境
- macOS
- Rust 1.8 〜
Rust
-
バージョン確認
Terminal window ❯ rustc --versionrustc 1.84.0 (9fc6b4312 2025-01-07)
SQLite3
-
sqlite3が使えることを確認
Terminal window ❯ sqlite3 --version3.22.0 2018-01-22 18:45:57 0c55d179733b46d8d0ba4d88e01a25e10677046ee3da1d5b1581e86726f2alt2
新しいプロジェクトの作成
-
初期化
cargo new rust-web-api -
コードフォーマッタとLinterのインストール
rustup component add clippy rustfmt rust-analyzer
3. ドメインモデル
-
必要なcrateをインストール
cargo add -F serde/derive serde serde-jsoncargo add -F chrono/serde chronocargo add thiserror -
/src/todo/domain.rs
を作成、型を定義use chrono::{DateTime, Local};use serde::{Deserialize, Serialize};#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]pub struct TodoId(pub usize);#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]pub struct Todo {pub id: TodoId,pub title: String,pub done: bool,pub done_at: Option<DateTime<Local>>,} -
新しく追加するTodoには
id
がないので、NewTodo
という型を追加する。あわせてこのドメインのエラーを定義する。#[derive(PartialEq, Debug, Serialize, Deserialize)]pub struct NewTodo {pub title: String,#[serde(default)]pub is_done: Option<bool>,#[serde(default)]pub done_at: Option<DateTime<Local>>,}impl NewTodo {pub fn parse(title: &str) -> Result<Self, TodoErr> {let data = format!(r#"{{"title": "{}"}}"#,title);serde_json::from_str(&data).map_err(|e| TodoErr::ParseNewTodoFailure(e.to_string()))}}#[derive(PartialEq, thiserror::Error, Debug)]pub enum TodoErr {#[error("parse new todo failed: {0}")]ParseNewTodoFailure(String),} -
テスト作成
#[cfg(test)]mod tests {use super::*;#[test]fn test_parse_new_todo() {let new_todo = NewTodo::parse("fuga").unwrap();let expected_new_todo = NewTodo {title: "fuga".to_string(),is_done: None,done_at: None,};assert_eq!(new_todo, expected_new_todo);}} -
src/todo/domain.rs
をsrc/main.rs
でインポートするため、src/todo.rs
を作成pub mod domain; -
src/main.rs
で todoモジュールを読み込みmod todo;fn main() {println!("Hello, world!");} -
テスト実行
cargo test
4. ビジネスロジック
-
タイトルのチェックをNewTodoの
parse
に追加impl NewTodo {pub fn parse(title: &str) -> Result<Self, TodoErr> {if title.trim().is_empty() {return Err(TodoErr::ParseNewTodoFailure("title should have at least one character".to_string(),));}let data = format!( => Stringr#"{{"title": "{}"}}"#,title);serde_json::from_str(&data).map_err(|e| TodoErr::ParseNewTodoFailure(e.to_string()))}} -
完了チェックの関数を追加
impl Todo {pub fn is_complete(&self) -> bool {self.done}} -
テスト追加
#[cfg(test)]mod tests {use super::*;...#[test]fn test_title_should_not_be_empty() {let err = NewTodo::parse("").unwrap_err();assert_eq!(err,TodoErr::ParseNewTodoFailure("title should have at least one character".to_string()))}#[test]fn test_is_complete() {let todo = Todo {id: TodoId(1),title: "this is done".to_string(),done: true,done_at: None,};let result = todo.is_complete();assert!(result);}}
5. ユースケース
-
必要なcrateを追加
cargo add -F tokio/full tokio -
/src/todo/usecase.rs
を作成use crate::todo::domain::*;pub async fn read_todos<T: TodoRepository>(repository: T,offset: usize,limit: usize,) -> Result<Vec<Todo>, TodoErr> {todo!()}pub async fn create_todo<T: TodoRepository>(repository: T, title: &str) -> Result<Todo, TodoErr> {todo!()}pub async fn complete_todo<T: TodoRepository>(repository: T, id: TodoId) -> Result<Todo, TodoErr> {todo!()} -
/src/todo/domain.rs
にリポジトリのトレイトを追加pub trait TodoRepository {async fn select_all(&self, offset: usize, limit: usize) -> Result<Vec<Todo>, TodoErr>;async fn select_by_id(&self, id: TodoId) -> Result<Todo, TodoErr>;async fn insert(&self, todo: NewTodo) -> Result<Todo, TodoErr>;async fn set_completed(&self, id: TodoId) -> Result<Todo, TodoErr>;} -
/src/todo/domain.rs
にユースケースのエラーを追加#[derive(PartialEq, thiserror::Error, Debug)]pub enum TodoErr {#[error("parse new todo failed: {0}")]ParseNewTodoFailure(String),#[error("usecase error: {0}")]UsecaseErr(String),} -
1で定義したユースケースの実装
pub async fn read_todos<T: TodoRepository>(repository: T,offset: usize,limit: usize,) -> Result<Vec<Todo>, TodoErr> {repository.select_all(offset, limit).await}pub async fn create_todo<T: TodoRepository>(repository: T, title: &str) -> Result<Todo, TodoErr> {let new_todo = NewTodo::parse(title)?;let result = repository.insert(new_todo).await?;Ok(result)}pub async fn complete_todo<T: TodoRepository>(repository: T, id: TodoId) -> Result<Todo, TodoErr> {let todo = repository.select_by_id(id.clone()).await?;if todo.is_complete() {return Ok(todo);}repository.set_completed(id).await} -
ユースケースのテストを追加
mod tests {use super::*;use chrono::Local;#[allow(dead_code)]struct MockRepository {}impl TodoRepository for MockRepository {#[allow(unused_variables)]async fn select_all(&self, offset: usize, number: usize) -> Result<Vec<Todo>, TodoErr> {Ok(vec![Todo {id: TodoId(1),title: "Test todo".to_string(),done: false,done_at: None,}])}async fn select_by_id(&self, id: TodoId) -> Result<Todo, TodoErr> {if id.0 == 999 {return Err(TodoErr::UsecaseErr("Todo not found".to_string()));}if id.0 == 2 {return Ok(Todo {id: id.clone(),title: "Test completed todo".to_string(),done: true,done_at: Some(Local::now()),});}Ok(Todo {id: id.clone(),title: "Test todo".to_string(),done: false,done_at: None,})}async fn insert(&self, todo: NewTodo) -> Result<Todo, TodoErr> {Ok(Todo {id: TodoId(1),title: todo.title,done: false,done_at: None,})}async fn set_completed(&self, id: TodoId) -> Result<Todo, TodoErr> {Ok(Todo {id: id.clone(),title: "Test todo".to_string(),done: true,done_at: Some(Local::now()),})}}#[tokio::test]async fn test_read_todos() {let mock_repo = MockRepository {};let result = read_todos(mock_repo, 1, 10).await;assert!(result.is_ok());let todos = result.unwrap();assert_eq!(todos.len(), 1);assert_eq!(todos[0].id, TodoId(1));assert_eq!(todos[0].title, "Test todo");assert!(!todos[0].done);assert!(todos[0].done_at.is_none());}#[tokio::test]async fn test_create_todo() {let mock_repo = MockRepository {};let result = create_todo(mock_repo, "New task").await;assert!(result.is_ok());let todo = result.unwrap();assert_eq!(todo.id, TodoId(1));assert_eq!(todo.title, "New task");assert!(!todo.done);assert!(todo.done_at.is_none());}#[tokio::test]async fn test_complete_todo() {let mock_repo = MockRepository {};let result = complete_todo(mock_repo, TodoId(1)).await;assert!(result.is_ok());let todo = result.unwrap();assert_eq!(todo.id, TodoId(1));assert_eq!(todo.title, "Test todo");assert!(todo.done);assert!(todo.done_at.is_some());}#[tokio::test]async fn test_completed_todo() {let mock_repo = MockRepository {};let result = complete_todo(mock_repo, TodoId(2)).await;assert!(result.is_ok());let todo = result.unwrap();assert_eq!(todo.id, TodoId(2));assert_eq!(todo.title, "Test completed todo");assert!(todo.done);assert!(todo.done_at.is_some());}#[tokio::test]async fn test_complete_todo_not_found() {let mock_repo = MockRepository {};let result = complete_todo(mock_repo, TodoId(999)).await;assert!(result.is_err());let err = result.unwrap_err();assert_eq!(err, TodoErr::UsecaseErr("Todo not found".to_string()));}} -
テストカバレッジを測定する
cargo install cargo-tarpaulincargo tarpaulin --out html
6. Httpサーバー
-
必要なcrateをインストール
cargo add -F macros axum -
/src/todo/route.rs
を作成し、Todo一覧取得のエンドポイントを定義use axum::{extract::{Query},http::StatusCode,response::{IntoResponse, Response},routing::{get},Json, Router,};use serde::Deserialize;use super::domain::{Todo, TodoErr};use super::infra::TodoRepo;use super::usecase::{read_todos};impl IntoResponse for TodoErr {fn into_response(self) -> Response {let status_code = match &self {TodoErr::UsecaseErr(msg) if msg.contains("not found") => StatusCode::NOT_FOUND,_ => StatusCode::INTERNAL_SERVER_ERROR,};(status_code, self.to_string()).into_response()}}pub fn get_todo_router() -> Router {Router::new().route("/", get(list))}#[derive(Debug, Deserialize)]struct Pagination {page: Option<usize>,limit: Option<usize>,}async fn list(Query(pagination): Query<Pagination>,) -> Result<Json<Vec<Todo>>, TodoErr> {let page = pagination.page.unwrap_or(1);let limit = pagination.limit.unwrap_or(10);let offset = if page < 1 { 0 } else { (page - 1) * limit };let repo = TodoRepo::new()?;let result = read_todos(repo, offset, limit).await?;Ok(Json(result))} -
/src/todo/infra.rs
を作成し、TodoRepoのスケルトンを作成use crate::todo::domain::{Todo, TodoErr, TodoId, TodoRepository};pub struct TodoRepo {}impl TodoRepo {pub fn new() -> Result<Self, TodoErr> {Ok(Self { })}}fn create_dummy_todo() -> Todo {Todo {id: TodoId(1),title: "dummy".to_string(),done: false,done_at: None,}}impl TodoRepository for TodoRepo {async fn select_all(&self, offset: usize, limit: usize) -> Result<Vec<Todo>, TodoErr> {Ok(vec![create_dummy_todo()])}async fn select_by_id(&self, id: crate::todo::domain::TodoId) -> Result<Todo, TodoErr> {Ok(create_dummy_todo())}async fn insert(&self, todo: crate::todo::domain::NewTodo) -> Result<Todo, TodoErr> {Ok(create_dummy_todo())}async fn set_completed(&self,id: crate::todo::domain::TodoId,) -> Result<crate::todo::domain::Todo, TodoErr> {Ok(create_dummy_todo())}} -
/src/main.rs
でaxumを呼び出しmod todo;use axum::Router;use crate::todo::route::get_todo_router;#[tokio::main]async fn main() {let router = Router::new().nest("/todos", get_todo_router());let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();axum::serve(listener, router).await.unwrap()} -
cargo run
でサーバーを起動 -
src/route.rs
に postとpatchを追加use axum::{extract::{Path,Query,},http::StatusCode,response::{IntoResponse, Response},routing::{get, patch, post},Json, Router,};...use super::usecase::{complete_todo, create_todo, read_todos};...use super::domain::{NewTodo, Todo, TodoErr, TodoId};...pub fn get_todo_router() -> Router {Router::new().route("/", get(list)).route("/", post(create)).route("/{todo_id}", patch(complete))}...async fn create(Json(todo): Json<NewTodo>,) -> Result<Json<Todo>, TodoErr> {let repo = TodoRepo::new()?;let result = create_todo(repo, &todo.title).await?;Ok(Json(result))}async fn complete(Path(todo_id): Path<TodoId>,) -> Result<Json<Todo>, TodoErr> {let repo = TodoRepo::new()?;let result = complete_todo(repo, todo_id).await?;Ok(Json(result))} -
cargo run
でWebサーバーを立ち上げ、 http://localhost:3000/todos/ にアクセス
7. リポジトリ
-
必要なcrateのインストール
cargo add -F sqlite -F macros -F runtime-tokio sqlxcargo add dotenv -
schema.sql
を作成CREATE TABLE todo (id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT NOT NULL,done INTEGER DEFAULT 0,done_at INTEGER); -
.env
ファイルを作成DATABASE_URL=sqlite:database.sqlite -
データベースの作成
cat schema.sql | sqlite3 database.sqlite -
src/db.rs
ファイルを作成し、DBに接続use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite};pub type Db = Pool<Sqlite>;pub async fn get_db_connection() -> Db {let db_path = std::env::var("DATABASE_URL").expect("*** No DATABASE_URL env var");SqlitePoolOptions::new().max_connections(5).connect(&db_path).await.expect("cannot connect to database")} -
src/main.rs
でアプリケーションステートにDB接続を保存mod db;mod todo;use axum::Router;use dotenv::dotenv;use crate::todo::route::get_todo_router;use db::{get_db_connection, Db};#[derive(Clone)]struct AppState {db: Db,}#[tokio::main]async fn main() {dotenv().ok();let db = get_db_connection().await;let state = AppState { db };let router = Router::<AppState>::new().nest("/todos", get_todo_router()).with_state(state);let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();axum::serve(listener, router).await.unwrap()} -
src/todo/route.rs
でAppStateを使ってリポジトリを作成use axum::{extract::{Path, Query, State},http::StatusCode,response::{IntoResponse, Response},routing::{get, patch, post},Json, Router,};use serde::Deserialize;use super::domain::{NewTodo, Todo, TodoErr, TodoId};use super::infra::TodoRepo;use super::usecase::{complete_todo, create_todo, read_todos};use crate::AppState;impl IntoResponse for TodoErr {fn into_response(self) -> Response {let status_code = match &self {TodoErr::UsecaseErr(msg) if msg.contains("not found") => StatusCode::NOT_FOUND,_ => StatusCode::INTERNAL_SERVER_ERROR,};(status_code, self.to_string()).into_response()}}pub fn get_todo_router() -> Router<AppState> {Router::<AppState>::new().route("/", get(list)).route("/", post(create)).route("/{todo_id}", patch(complete))}#[derive(Debug, Deserialize)]struct Pagination {page: Option<usize>,limit: Option<usize>,}async fn list(State(state): State<AppState>,Query(pagination): Query<Pagination>,) -> Result<Json<Vec<Todo>>, TodoErr> {let page = pagination.page.unwrap_or(1);let limit = pagination.limit.unwrap_or(10);let offset = if page < 1 { 0 } else { (page - 1) * limit };let repo = TodoRepo::new(state.db)?;let result = read_todos(repo, offset, limit).await?;Ok(Json(result))}async fn create(State(state): State<AppState>,Json(todo): Json<NewTodo>,) -> Result<Json<Todo>, TodoErr> {let repo = TodoRepo::new(state.db)?;let result = create_todo(repo, &todo.title).await?;Ok(Json(result))}async fn complete(State(state): State<AppState>,Path(todo_id): Path<TodoId>,) -> Result<Json<Todo>, TodoErr> {let repo = TodoRepo::new(state.db)?;let result = complete_todo(repo, todo_id).await?;Ok(Json(result))} -
src/todo/infra.rs
でDb接続を受け取り、クエリを発行するuse crate::db::Db;use crate::todo::domain::{Todo, TodoErr, TodoId, TodoRepository};use chrono::{Local, TimeZone};pub struct TodoRepo {db: Db,}impl TodoRepo {pub fn new(db: Db) -> Result<Self, TodoErr> {Ok(Self { db })}}#[derive(sqlx::FromRow)]struct TodoRow {id: i64,title: String,done: Option<i64>,done_at: Option<i64>,}impl From<TodoRow> for Todo {fn from(value: TodoRow) -> Self {Self {id: TodoId(value.id as usize),title: value.title,done: if let Some(done) = value.done {done > 0} else {false},done_at: if let Some(timestamp) = value.done_at {Local.timestamp_opt(timestamp, 0).single()} else {None},}}}impl TodoRepository for TodoRepo {async fn select_all(&self, offset: usize, limit: usize) -> Result<Vec<Todo>, TodoErr> {let offset = offset as i64;let limit = limit as i64;let rows = sqlx::query_as!(TodoRow,r#"SELECT id, title, done, done_atFROM todoLIMIT ? OFFSET ?"#,limit,offset).fetch_all(&self.db).await?;let todos = rows.into_iter().map(|row| row.into()).collect::<Vec<Todo>>();Ok(todos)}async fn select_by_id(&self, id: crate::todo::domain::TodoId) -> Result<Todo, TodoErr> {let id = id.0 as i64;let row = sqlx::query_as!(TodoRow,r#"SELECT id as "id: _", title, done, done_atFROM todoWHERE id = ?"#,id).fetch_one(&self.db).await?;Ok(row.into())}async fn insert(&self, todo: crate::todo::domain::NewTodo) -> Result<Todo, TodoErr> {let row = sqlx::query_as!(TodoRow,r#"INSERT INTO todo (title)VALUES (?)RETURNING id, title, done, done_at"#,todo.title).fetch_one(&self.db).await?;Ok(row.into())}async fn set_completed(&self,id: crate::todo::domain::TodoId,) -> Result<crate::todo::domain::Todo, TodoErr> {let id = id.0 as i64;let now = Local::now().timestamp();let row = sqlx::query_as!(TodoRow,r#"UPDATE todoSET done = TRUE, done_at = ?WHERE id = ?RETURNING id, title, done, done_at"#,now,id).fetch_one(&self.db).await?;Ok(row.into())}} -
src/todo/domain.rs
にエラータイプを追加#[derive(PartialEq, thiserror::Error, Debug)]pub enum TodoErr {#[error("parse new todo failed: {0}")]ParseNewTodoFailure(String),#[error("usecase error: {0}")]UsecaseErr(String),#[error("failed to query data: {0}")]QueryErr(String),#[error("something went wrong: {0}")]UnknownErr(String),} -
src/todo/infra.rs
にエラー変換処理を追加impl From<sqlx::Error> for TodoErr {fn from(err: sqlx::Error) -> Self {match err {sqlx::Error::Database(e) => TodoErr::QueryErr(e.to_string()),_ => TodoErr::UnknownErr(err.to_string()),}}}
8. OpenAPI
-
必要なクレートをインストール
cargo add -F utoipa/axum_extras -F utoipa/chrono utoipacargo add utoipa-axumcargo add -F axum utoipa-swagger-ui -
src/main.rs
にOpenAPI定義を追加mod db;mod todo;use axum::Router;use dotenv::dotenv;use utoipa::OpenApi;use utoipa_axum::router::OpenApiRouter;use utoipa_swagger_ui::SwaggerUi;use db::{get_db_connection, Db};use todo::route::get_todo_router;#[derive(Clone)]struct AppState {db: Db,}#[tokio::main]async fn main() {dotenv().ok();let db = get_db_connection().await.unwrap();#[derive(OpenApi)]#[openapi(tags((name = "Awesome Service API")))]struct ApiDoc;let state = AppState { db };let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()).nest("/todo", get_todo_router()).with_state(state).split_for_parts();let router =router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api.clone()));let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();axum::serve(listener, router).await.unwrap()} -
src/todo/route.rs
にOpenAPI定義を追加use axum::{extract::{Path, Query, State},http::StatusCode,response::{IntoResponse, Response},Json};use serde::Deserialize;use utoipa::IntoParams;use utoipa_axum::{router::OpenApiRouter, routes};use super::domain::{NewTodo, Todo, TodoErr, TodoId};use super::infra::TodoRepo;use super::usecase::{complete_todo, create_todo, read_todos};use crate::AppState;static TAG_NAME: &str = "Todo";impl IntoResponse for TodoErr {fn into_response(self) -> Response {let status_code = match &self {TodoErr::UsecaseErr(msg) if msg.contains("not found") => StatusCode::NOT_FOUND,_ => StatusCode::INTERNAL_SERVER_ERROR,};(status_code, self.to_string()).into_response()}}pub fn get_todo_router() -> OpenApiRouter<AppState> {OpenApiRouter::<AppState>::new().routes(routes!(list, create, complete))}#[derive(Debug, Deserialize, IntoParams)]struct Pagination {page: Option<usize>,limit: Option<usize>,}#[utoipa::path(tag=TAG_NAME,get,path="",params(Pagination),responses((status=200, description="all todos", body = [Todo])))]async fn list(State(state): State<AppState>,Query(pagination): Query<Pagination>,) -> Result<Json<Vec<Todo>>, TodoErr> {let page = pagination.page.unwrap_or(1);let limit = pagination.limit.unwrap_or(10);let offset = if page < 1 { 0 } else { (page - 1) * limit };let repo = TodoRepo::new(state.db)?;let result = read_todos(repo, offset, limit).await?;Ok(Json(result))}#[utoipa::path(tag=TAG_NAME,post,path="",request_body(content=NewTodo),responses((status=200, description="create a todo", body = Todo)))]async fn create(State(state): State<AppState>,Json(todo): Json<NewTodo>,) -> Result<Json<Todo>, TodoErr> {let repo = TodoRepo::new(state.db)?;let result = create_todo(repo, &todo.title).await?;Ok(Json(result))}#[utoipa::path(tag=TAG_NAME,patch,path="/{id}",params(("id" = TodoId, Path, description = "todo id")),request_body(content=NewTodo),responses((status=200, description="complete a todo", body = Todo),(status=404, description="the todo is not found")))]async fn complete(State(state): State<AppState>,Path(todo_id): Path<TodoId>,) -> Result<Json<Todo>, TodoErr> {let repo = TodoRepo::new(state.db)?;let result = complete_todo(repo, todo_id).await?;Ok(Json(result))} -
src/todo/domain.rs
にOpenAPI定義を追加use chrono::{DateTime, Local};use serde::{Deserialize, Serialize};use utoipa::ToSchema;#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]pub struct TodoIdpub usize);#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]pub struct Todo {pub id: TodoId,pub title: String,pub done: bool,pub done_at: Option<DateTime<Local>>,}impl Todo {pub fn is_complete(&self) -> bool {self.done}}#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]pub struct NewTodo {pub title: String,#[serde(default)]pub is_done: Option<bool>,#[serde(default)]pub done_at: Option<DateTime<Local>>,}...