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

Big Picture

スクリーンショット 2024-11-03 10.34.08.png

Design Level

design level.png

image.png

Onion Architecture

オニオンアーキテクチャ.png

その他の3層アーキテクチャ

2. 開発環境構築

対象環境

Rust

SQLite3

新しいプロジェクトの作成

3. ドメインモデル

  1. 必要なcrateをインストール

    cargo add -F serde/derive serde serde-json
    cargo add -F chrono/serde chrono
    cargo add thiserror
  2. /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>>,
    }
  3. 新しく追加する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),
    }
  4. テスト作成

    #[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);
    }
    }
  5. src/todo/domain.rssrc/main.rs でインポートするため、 src/todo.rs を作成

    pub mod domain;
  6. src/main.rs で todoモジュールを読み込み

    mod todo;
    fn main() {
    println!("Hello, world!");
    }
  7. テスト実行

    cargo test

4. ビジネスロジック

  1. タイトルのチェックを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!( => String
    r#"{{
    "title": "{}"
    }}"#,
    title
    );
    serde_json::from_str(&data).map_err(|e| TodoErr::ParseNewTodoFailure(e.to_string()))
    }
    }
  2. 完了チェックの関数を追加

    impl Todo {
    pub fn is_complete(&self) -> bool {
    self.done
    }
    }
  3. テスト追加

    #[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. ユースケース

  1. 必要なcrateを追加

    cargo add -F tokio/full tokio
  2. /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!()
    }
  3. /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>;
    }
  4. /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),
    }
  5. 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
    }
  6. ユースケースのテストを追加

    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()));
    }
    }
  7. テストカバレッジを測定する

    cargo install cargo-tarpaulin
    cargo tarpaulin --out html

6. Httpサーバー

  1. 必要なcrateをインストール

    cargo add -F macros axum
  2. /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))
    }
  3. /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())
    }
    }
  4. /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()
    }
  5. cargo run でサーバーを起動

  6. 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))
    }
  7. cargo run でWebサーバーを立ち上げ、 http://localhost:3000/todos/ にアクセス

7. リポジトリ

  1. 必要なcrateのインストール

    cargo add -F sqlite -F macros -F runtime-tokio sqlx
    cargo add dotenv
  2. schema.sql を作成

    CREATE TABLE todo (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    done INTEGER DEFAULT 0,
    done_at INTEGER
    );
  3. .env ファイルを作成

    DATABASE_URL=sqlite:database.sqlite
  4. データベースの作成

    cat schema.sql | sqlite3 database.sqlite
  5. 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")
    }
  6. 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()
    }
  7. 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))
    }
  8. 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_at
    FROM todo
    LIMIT ? 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_at
    FROM todo
    WHERE 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 todo
    SET done = TRUE, done_at = ?
    WHERE id = ?
    RETURNING id, title, done, done_at
    "#,
    now,
    id
    )
    .fetch_one(&self.db)
    .await?;
    Ok(row.into())
    }
    }
  9. 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),
    }
  10. 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


  1. 必要なクレートをインストール

    cargo add -F utoipa/axum_extras -F utoipa/chrono utoipa
    cargo add utoipa-axum
    cargo add -F axum utoipa-swagger-ui
  2. 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()
    }
  3. 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))
    }
  4. 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>>,
    }
    ...
  5. http://localhost:3000/swagger-ui にアクセス