📜

Rust | utoipa で OpenAPI ドキュメントを自動生成する

2023/10/10に公開

ドキュメントを自動生成

Rust で実装したバックエンドの API ドキュメントを楽に作成するには、utoipa というクレートが便利そうだったので試してみました。

https://github.com/juhaku/utoipa

utoipa について

ざっと特徴を挙げると、こんなところでしょうか。

  • コードファーストでドキュメントを作成
  • シンプルな proc マクロを使用し、コードに注釈を付けてドキュメント化できる
  • 様々な Web フレームワークに対応

Swagger UI では、このような画面で確認できます。

TODO アプリで試してみる

百聞は一見に如かず... 簡単なアプリケーションを作成して試してみます!

今回は Web フレームワークとして axum を使います。

サンプルコード

ソースコードは Github でも確認できます。

https://github.com/codemountains/utoipa-example-with-axum

cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add utoipa --features axum_extras
cargo add utoipa-swagger-ui --features axum

以下のような構成になってます。

src
├── main.rs
├── todo
│   ├── delete.rs
│   ├── get.rs
│   ├── list.rs
│   ├── post.rs
│   └── put.rs
└── todo.rs

main.rs

ApiDoc を定義しています。

http://localhost:8080/swagger-ui にアクセスし、Swagger UI でドキュメントを確認することが可能です。

tags((name = "Todo")) でタグを指定して、/todos エンドポイントをまとめています。

main.rs
mod todo;

use crate::todo::delete::delete_todo;
use crate::todo::get::get_todo;
use crate::todo::list::list_todos;
use crate::todo::post::post_todo;
use crate::todo::put::put_todo;
use axum::http::StatusCode;
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

#[tokio::main]
async fn main() {
    let hc_router = Router::new().route("/", get(health_check));
    let todo_router = Router::new()
        .route("/", get(list_todos).post(post_todo))
        .route("/:todo_id", get(get_todo).put(put_todo).delete(delete_todo));

    let app = Router::new()
        .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
        .nest("/hc", hc_router)
        .nest("/todos", todo_router);

    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap_or_else(|_| panic!("Server cannot launch."));
}

async fn health_check() -> StatusCode {
    StatusCode::OK
}

#[derive(OpenApi)]
#[openapi(
    paths(
        todo::list::list_todos,
        todo::get::get_todo,
        todo::post::post_todo,
        todo::put::put_todo,
        todo::delete::delete_todo
    ),
    components(schemas(
        todo::Todo,
        todo::NewTodo,
        todo::UpdateTodo,
        todo::list::ListTodoResponse,
        todo::get::GetTodoResponse,
        todo::post::PostTodoRequest,
        todo::post::PostTodoResponse,
        todo::put::PutTodoRequest,
        todo::put::PutTodoResponse,
    )),
    tags((name = "Todo"))
)]
struct ApiDoc;

todo.rs

Todo などの Struct はスキーマに定義され $ref で参照されるため、ToSchema を追加しています。

todo.rs
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

pub mod delete;
pub mod get;
pub mod list;
pub mod post;
pub mod put;

#[derive(Serialize, Deserialize, ToSchema)]
pub struct Todo {
    id: i32,
    title: String,
    done: bool,
}

#[derive(Serialize, Deserialize, ToSchema)]
pub struct NewTodo {
    title: String,
}

#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateTodo {
    title: String,
    done: bool,
}

GET

/todos

TODO を一覧で取得できるエンドポイントです。

  • get : HTTP リクエストメソッドを指定
  • path : パスを指定
  • responses : レスポンスを指定
    • 400 など、複数のレスポンスを定義可能

レスポンスの Struct もスキーマに定義され $ref で参照されるため、ToSchema を追加する必要があります。

todo/list.rs
use crate::todo::Todo;
use axum::http::StatusCode;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;

#[derive(Serialize, ToSchema)]
pub struct ListTodoResponse {
    todos: Vec<Todo>,
}

#[utoipa::path(
    get,
    path = "/todos",
    responses(
        (status = 200, description = "List all todos successfully", body = ListTodoResponse)
    ),
    tag = "Todo",
)]
pub async fn list_todos() -> (StatusCode, Json<ListTodoResponse>) {
    let todos = vec![
        Todo {
            id: 0,
            title: "Todo 1".to_string(),
            done: false,
        },
        Todo {
            id: 1,
            title: "Todo 2".to_string(),
            done: true,
        },
        Todo {
            id: 2,
            title: "Todo 3".to_string(),
            done: false,
        },
    ];

    let response: ListTodoResponse = ListTodoResponse { todos };
    (StatusCode::OK, Json(response))
}

/todos/todo_id

指定した TODO を取得できるエンドポイントです。

上記 /todos との違いは path = "/todos/{todo_id}" ぐらいです。

todo/get.rs
use crate::todo::Todo;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;

#[derive(Serialize, ToSchema)]
pub struct GetTodoResponse {
    todo: Todo,
}

#[utoipa::path(
    get,
    path = "/todos/{todo_id}",
    responses(
        (status = 200, description = "Get one todo successfully", body = GetTodoResponse)
    ),
    tag = "Todo",
)]
pub async fn get_todo(Path(todo_id): Path<i32>) -> (StatusCode, Json<GetTodoResponse>) {
    let todo = Todo {
        id: todo_id,
        title: format!("Todo No.{}", todo_id),
        done: false,
    };

    let response: GetTodoResponse = GetTodoResponse { todo };
    (StatusCode::OK, Json(response))
}

POST

TODO を作成できるエンドポイントです。

  • post : HTTP リクエストメソッドを指定
  • path : パスを指定
  • request_body : リクエストの Struct を指定
  • responses : レスポンスの Struct を指定
    • 400 など、複数のレスポンスを定義可能

リクエストとレスポンスの Struct もスキーマに定義され $ref で参照されるため、ToSchema を追加する必要があります。

todo/post.rs
use crate::todo::{NewTodo, Todo};
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Serialize, Deserialize, ToSchema)]
pub struct PostTodoRequest {
    todo: NewTodo,
}

#[derive(Serialize, ToSchema)]
pub struct PostTodoResponse {
    todo: Todo,
}

#[utoipa::path(
    post,
    path = "/todos",
    request_body = PostTodoRequest,
    responses(
        (status = 201, description = "Todo item created successfully", body = PostTodoResponse)
    ),
    tag = "Todo",
)]
pub async fn post_todo(
    Json(source): Json<PostTodoRequest>,
) -> (StatusCode, Json<PostTodoResponse>) {
    let request: PostTodoRequest = source.into();

    let response = PostTodoResponse {
        todo: Todo {
            id: 100,
            title: request.todo.title,
            done: false,
        },
    };

    (StatusCode::CREATED, Json(response))
}

PUT

TODO を作成できるエンドポイントです。

  • put : HTTP リクエストメソッドを指定
  • path : パスを指定
  • request_body : リクエストの Struct を指定
  • responses : レスポンスの Struct を指定
    • 400 など、複数のレスポンスを定義可能

リクエストとレスポンスの Struct もスキーマに定義され $ref で参照されるため、ToSchema を追加する必要があります。

todo/put.rs
use crate::todo::{Todo, UpdateTodo};
use axum::extract::Path;
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Serialize, Deserialize, ToSchema)]
pub struct PutTodoRequest {
    todo: UpdateTodo,
}

#[derive(Serialize, ToSchema)]
pub struct PutTodoResponse {
    todo: Todo,
}

#[utoipa::path(
    put,
    path = "/todos/{todo_id}",
    request_body = PutTodoRequest,
    responses(
        (status = 200, description = "Todo item updated successfully", body = PutTodoResponse)
    ),
    tag = "Todo",
)]
pub async fn put_todo(
    Path(todo_id): Path<i32>,
    Json(source): Json<PutTodoRequest>,
) -> (StatusCode, Json<PutTodoResponse>) {
    let request: PutTodoRequest = source.into();

    let response = PutTodoResponse {
        todo: Todo {
            id: todo_id,
            title: request.todo.title,
            done: request.todo.done,
        },
    };

    (StatusCode::OK, Json(response))
}

DELETE

TODO を作成できるエンドポイントです。

  • delete : HTTP リクエストメソッドを指定
  • path : パスを指定
  • responses : 空のレスポンスなので、特に Struct の指定はなし
    • 400 など、複数のレスポンスを定義可能
todo/delete.rs
use axum::extract::Path;
use axum::http::StatusCode;

#[utoipa::path(
    delete,
    path = "/todos/{todo_id}",
    responses(
        (status = 204, description = "Deleted a todo successfully")
    ),
    tag = "Todo",
)]
pub async fn delete_todo(Path(_todo_id): Path<i32>) -> StatusCode {
    StatusCode::NO_CONTENT
}

JSON データも取得

http://localhost:8080/api-docs/openapi.json にアクセスすることで、JSON データを取得できます。

openapi.json
{
    "openapi": "3.0.3",
    "info": {
        "title": "axum-utoipa-example",
        "description": "",
        "license": {
            "name": ""
        },
        "version": "0.1.0"
    },
    "paths": {
        "/todos": {
            "get": {
                "tags": [
                    "Todo"
                ],
                "operationId": "list_todos",
                "responses": {
                    "200": {
                        "description": "List all todos successfully",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/ListTodoResponse"
                                }
                            }
                        }
                    }
                }
            },
            "post": {
                "tags": [
                    "Todo"
                ],
                "operationId": "post_todo",
                "requestBody": {
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/PostTodoRequest"
                            }
                        }
                    },
                    "required": true
                },
                "responses": {
                    "201": {
                        "description": "Todo item created successfully",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/PostTodoResponse"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/todos/{todo_id}": {
            "get": {
                "tags": [
                    "Todo"
                ],
                "operationId": "get_todo",
                "parameters": [
                    {
                        "name": "todo_id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Get one todo successfully",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/GetTodoResponse"
                                }
                            }
                        }
                    }
                }
            },
            "put": {
                "tags": [
                    "Todo"
                ],
                "operationId": "put_todo",
                "parameters": [
                    {
                        "name": "todo_id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "requestBody": {
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/PutTodoRequest"
                            }
                        }
                    },
                    "required": true
                },
                "responses": {
                    "200": {
                        "description": "Todo item updated successfully",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/PutTodoResponse"
                                }
                            }
                        }
                    }
                }
            },
            "delete": {
                "tags": [
                    "Todo"
                ],
                "operationId": "delete_todo",
                "parameters": [
                    {
                        "name": "todo_id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "responses": {
                    "204": {
                        "description": "Deleted a todo successfully"
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "GetTodoResponse": {
                "type": "object",
                "required": [
                    "todo"
                ],
                "properties": {
                    "todo": {
                        "$ref": "#/components/schemas/Todo"
                    }
                }
            },
            "ListTodoResponse": {
                "type": "object",
                "required": [
                    "todos"
                ],
                "properties": {
                    "todos": {
                        "type": "array",
                        "items": {
                            "$ref": "#/components/schemas/Todo"
                        }
                    }
                }
            },
            "NewTodo": {
                "type": "object",
                "required": [
                    "title"
                ],
                "properties": {
                    "title": {
                        "type": "string"
                    }
                }
            },
            "PostTodoRequest": {
                "type": "object",
                "required": [
                    "todo"
                ],
                "properties": {
                    "todo": {
                        "$ref": "#/components/schemas/NewTodo"
                    }
                }
            },
            "PostTodoResponse": {
                "type": "object",
                "required": [
                    "todo"
                ],
                "properties": {
                    "todo": {
                        "$ref": "#/components/schemas/Todo"
                    }
                }
            },
            "PutTodoRequest": {
                "type": "object",
                "required": [
                    "todo"
                ],
                "properties": {
                    "todo": {
                        "$ref": "#/components/schemas/UpdateTodo"
                    }
                }
            },
            "PutTodoResponse": {
                "type": "object",
                "required": [
                    "todo"
                ],
                "properties": {
                    "todo": {
                        "$ref": "#/components/schemas/Todo"
                    }
                }
            },
            "Todo": {
                "type": "object",
                "required": [
                    "id",
                    "title",
                    "done"
                ],
                "properties": {
                    "done": {
                        "type": "boolean"
                    },
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "title": {
                        "type": "string"
                    }
                }
            },
            "UpdateTodo": {
                "type": "object",
                "required": [
                    "title",
                    "done"
                ],
                "properties": {
                    "done": {
                        "type": "boolean"
                    },
                    "title": {
                        "type": "string"
                    }
                }
            }
        }
    },
    "tags": [
        {
            "name": "Todo"
        }
    ]
}

まとめ

derive macro を使うことで、ドキュメント化することができました🥳🎉

コードファーストで自動生成されたドキュメントのため、ドキュメントを別途作成する手間やドキュメントの更新が遅れるという心配もありません!

参考

コラボスタイル Developers

Discussion