こんにちは。Tomoyuki(@tomoyuki65)です。
パフォーマンスやメモリ安全性を最重視してAPIを作りたい場合、「Rust」というプログラミング言語が候補に上がります。
ただRustについてはまだまだ普及しておらず、学習コストも非常に高いため、普及するにはあと3〜5年ぐらいはかかると思われます。
そんなRustについては一部の間で愛されているプログラミング言語とも言われていて、実際にどんなものなのか非常に気になっていたので、axumというフレームワークを用いてAPIを開発する方法を色々と試してみました。
そこでこの記事では、私が色々と試してわかったRustのaxumでバックエンドAPIを開発する方法についてまとめます。
RustのaxumでバックエンドAPIを開発する方法まとめ
まずは以下のコマンドを実行し、各種ファイルを作成します。
$ mkdir rust-sample && cd rust-sample
$ mkdir -p docker/local/rust && touch docker/local/rust/Dockerfile
$ touch compose.yml .env
※開発環境の構築にはDockerを使うため、もしまだ使えないという方はDocker Desktopなどをインストールして事前に使えるようにして下さい。
次に作成した各種ファイルについて、それぞれ以下のように記述します。
・「docker/local/rust/Dockerfile」
FROM rust:1.87
WORKDIR /app
COPY . .
# ホットリロード用のライブラリをインストール
RUN cargo install cargo-watch
# Rust用のリンターをインストール
RUN rustup component add clippy
# Rust用のフォーマッターをインストール
RUN rustup component add rustfmt
# Rustのスクリプト実行用のライブラリをインストール
RUN cargo install rust-script
# DB用ORMのCLIをインストール
RUN cargo install sea-orm-cli@1.1.11
※Rustのバージョンについては、2025年5月時点で最新の「1.87」を使います。また後で使う各種ライブラリも記述しています。
ENV=local
PORT=8080
RUST_LOG=info
ALLOW_ORIGIN=http://localhost:3000
DATABASE_URL=postgres://pg-user:pg-password@pg-db:5432/pg-db?sslmode=disable
・「compose.yml」
services:
api:
container_name: rust-api
build:
context: .
dockerfile: ./docker/local/rust/Dockerfile
command: cargo watch -x run
volumes:
- .:/app
ports:
- "8080:8080"
# .env.testing利用時に上書きしたい環境変数を設定する
environment:
- ENV
- PORT
- RUST_LOG
- ALLOW_ORIGIN
- DATABASE_URL
tty: true
stdin_open: true
※環境変数については、デフォルトでは「.env」ファイルを読み込みますが、後でテスト用の環境変数「.env.testing」を使って起動させる際に環境変数の上書きが必要になるため、「environment」も記述しています。
次に以下のコマンドを実行し、DockerコンテナのビルドおよびRustのプロジェクトの初期化を行います。
$ docker compose build --no-cache
$ docker compose run --rm api cargo init --name rust_api
コマンド実行後、下図のようにRustの各種ファイルが作成されればOKです。
合わせて作成されたファイル「.gitignore」に「.env」を追加しておきます。
各種共通処理用のファイルを追加
次に以下のコマンドを実行し、必要になる各種クレート(ライブラリ)を追加します。
$ docker compose run --rm api cargo add axum
$ docker compose run --rm api cargo add tokio --features full
$ docker compose run --rm api cargo add serde --features derive
$ docker compose run --rm api cargo add envy
$ docker compose run --rm api cargo add thiserror
$ docker compose run --rm api cargo add utoipa --features axum_extras
$ docker compose run --rm api cargo add utoipa-swagger-ui@9.0.0 --features axum
※utoipa-swagger-uiについては2025年5月21日時点での依存関係によりバージョンは「9.0.0」を使います。
次に以下のコマンドを実行し、共通処理用の各種ファイルを作成します。
$ mkdir -p src/api/configs
$ touch src/api/configs/config.rs src/api/configs/mod.rs
$ mkdir -p src/api/contexts
$ touch src/api/contexts/context.rs src/api/contexts/mod.rs
$ mkdir -p src/api/errors
$ touch src/api/errors/error.rs src/api/errors/mod.rs
次に作成した各種ファイルをそれぞれ次のように記述します。
・「src/api/configs/config.rs」
use envy;
use serde::Deserialize;
// 環境変数のデフォルト値を返す関数
fn default_env() -> String {
"local".to_string()
}
fn default_port() -> u16 {
8080
}
fn default_rust_log() -> String {
"info".to_string()
}
fn default_allow_origin() -> String {
"http://localhost:3000".to_string()
}
// 環境変数の構造体
#[derive(Deserialize, Debug)]
pub struct Config {
#[serde(default = "default_env")]
pub env: String,
#[serde(default = "default_port")]
pub port: u16,
#[allow(dead_code)]
#[serde(default = "default_rust_log")]
pub rust_log: String,
#[allow(dead_code)]
#[serde(default = "default_allow_origin")]
pub allow_origin: String,
}
// 環境変数を返す関数
pub fn get_config() -> Config {
match envy::from_env::<Config>() {
Ok(config) => config,
Err(err) => {
println!("環境変数の初期化エラー: {}", err);
// 環境変数にデフォルト値を設定して返す
Config {
env: default_env(),
port: default_port(),
rust_log: default_rust_log(),
allow_origin: default_allow_origin(),
}
}
}
}
※このファイルは各種処理内で環境変数の値を使えるようにするものです。構造体などで使わない要素があると警告がでるため、警告をスキップする場合は「#[allow(dead_code)]」を使います。
・「src/api/configs/mod.rs」
pub mod config;
※ディレクトリを分割する際などは適宜このmod.rsファイルを作ってファイルの公開設定が必要になります。
・「src/api/contexts/context.rs」
// axum
use axum::{extract::Request, http::header::HeaderMap};
// 共通コンテキストの構造体
#[derive(Clone, Debug)]
pub struct Context {
pub header: HeaderMap,
pub method: String,
pub uri: String,
}
// コンテキスト作成関数
pub fn create_context(req: &Request) -> Context {
let mut hm = HeaderMap::new();
for (key, value) in req.headers().iter() {
hm.insert(key.clone(), value.clone());
}
Context {
header: hm,
method: req.method().to_string(),
uri: req.uri().to_string(),
}
}
※処理内で共通の値を使いたい場合はこのコンテキストに設定して使えるようにしています。
・「src/api/contexts/mod.rs」
pub mod context;
・「src/api/errors/error.rs」
use thiserror::Error;
// OpenAPI用
use utoipa::ToSchema;
#[derive(Error, Debug)]
pub enum CommonError {
#[error("Internal Server Error")]
InternalServerError,
}
// OpenAPI用の定義
#[derive(ToSchema)]
pub struct InternalServerErrorResponseBody {
#[allow(dead_code)]
#[schema(example = "Internal Server Error")]
message: String,
}
※これは共通エラー用の設定ファイルで、クレート「thiserror」を使って設定します。
・「src/api/errors/mod.rs」
pub mod error;
ロガーやミドルウェアのファイルを追加
次に以下のコマンドを実行し、ロガーやミドルウェアなどで必要になる各種クレートを追加します。
$ docker compose run --rm api cargo add chrono
$ docker compose run --rm api cargo add env_logger
$ docker compose run --rm api cargo add log
$ docker compose run --rm api cargo add tower-http --features trace --features cors
$ docker compose run --rm api cargo add tracing
$ docker compose run --rm api cargo add uuid --features v4
$ docker compose run --rm api cargo add serde_json
次に以下のコマンドを実行し、ロガー用の各種ファイルを作成します。
$ mkdir -p src/api/loggers
$ touch src/api/loggers/logger.rs src/api/loggers/mod.rs
次に作成したファイルをそれぞれ次のように記述します。
・「src/api/loggers/logger.rs」
use chrono::TimeZone;
use log;
use std::io::Write;
use crate::api::contexts::context::Context;
// ロガーの初期化用関数
pub fn init_logger() {
// 日本時間を取得
let jst = chrono::offset::FixedOffset::east_opt(9 * 3600)
.unwrap()
.from_utc_datetime(&chrono::Utc::now().naive_utc());
// カスタムロガーの初期化
env_logger::builder()
.format(move |buf, record| {
writeln!(
buf,
"{} {} {}",
jst.format("%Y-%m-%d %H:%M:%S"),
record.level(),
record.args()
)
})
.init();
}
// 共通コンテキストからログに追加する情報の文字列を取得する関数
fn get_info_from_request(ctx: &Context) -> String {
// リクエストヘッダーから「X-Request-Id」を取得
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
format!(
"request_id={} method={} uri={}",
request_id, ctx.method, ctx.uri
)
}
// ログ出力用関数
pub fn info(ctx: &Context, msg: &str) {
let info = get_info_from_request(ctx);
log::info!("[{}] {}", info, msg);
}
// TODO: 使用する場合にコメントアウトを外す
// pub fn warn(ctx: &Context, msg: &str) {
// let info = get_info_from_request(ctx);
// log::warn!("[{}] {}", info, msg);
// }
pub fn error(ctx: &Context, msg: &str) {
let info = get_info_from_request(ctx);
log::error!("[{}] {}", info, msg);
}
※分割したディレクトリにあるファイルを読み込みたい場合、「use crate::api::contexts::context::Context;」の部分のようにパスを指定します。「crate」はmain.rsファイルがある場所を指し、そこからどこに対象のファイルがあるかを指定します。このようにファイルを指定する際に各ディレクトリ内に作成したmod.rsでファイルの公開設定をしていると読み込めます。
・「src/api/loggers/mod.rs」
pub mod logger;
次に以下のコマンドを実行し、ミドルウェア用の各種ファイルを作成します。
$ mkdir -p src/api/middleware
$ touch src/api/middleware/common_middleware.rs src/api/middleware/mod.rs
次に作成したファイルをそれぞれ次のように記述します。
・「src/api/middleware/common_middleware.rs」
// axum
use axum::{
extract::{Json, Request},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
// json変換用マクロ
use serde_json::json;
// UUID
use uuid::Uuid;
// 共通コンテキストのモジュール
use crate::api::contexts::context;
// ロガー用のモジュール
use crate::api::loggers::logger::info;
// リクエスト用のミドルウェア
pub async fn request_middleware(mut req: Request, next: Next) -> Response {
// リクエストヘッダー「X-Request-Id」にUUIDを設定
let request_id = Uuid::new_v4().to_string();
req.headers_mut()
.insert("X-Request-Id", request_id.parse().unwrap());
// リクエストに共通コンテキストのExtentionを追加
let ctx = context::create_context(&req);
req.extensions_mut().insert(ctx.clone());
// リクエスト単位でログ出力
info(&ctx, "start request !!");
next.run(req).await
}
// 認証用ミドルウェア
pub async fn auth_middleware(req: Request, next: Next) -> Response {
// 共通コンテキストからX-Request-Idを取得
let request_id = match req.extensions().get::<context::Context>() {
Some(ctx) => {
let x_request_id = ctx.header.get("X-Request-Id");
x_request_id.expect("-").to_str().unwrap()
}
None => "-",
};
// Authorizationヘッダーからトークン値を取得
let token = match req.headers().get("Authorization") {
Some(value) => {
let bearer_token = value.to_str().unwrap();
bearer_token.replace("Bearer ", "")
}
None => {
let msg = Json(json!({ "message": "Bad Request"}));
return (StatusCode::BAD_REQUEST, [("X-Request-Id", request_id)], msg).into_response();
}
};
// トークン値の空文字チェック
if token.is_empty() {
let msg = Json(json!({ "message": "Bad Request"}));
return (StatusCode::BAD_REQUEST, [("X-Request-Id", request_id)], msg).into_response();
}
// TODO: 認証チェック処理を追加する
next.run(req).await
}
※リクエスト用のミドルウェアについては、リクエスト単位で一意のIDを設定し、エラー時に関連するログを特定しやすくしています。そしてミドルウェアでExtentionを設定することでハンドラーにコンテキストを渡せます。また、認証用ミドルウェアの部分の具体的な処理は省略しています。
pub mod common_middleware;
サンプルAPI用のファイルを追加
次に以下のコマンドを実行し、APIで必要になる各種クレートを追加します。
$ docker compose run --rm api cargo add async-trait
$ docker compose run --rm api cargo add mockall
$ docker compose run --rm api cargo add reqwest --features json
$ docker compose run --rm api cargo add test-env-helpers
次に以下のコマンドを実行し、API用の各種ファイルを作成します。
$ mkdir -p src/api/repositories/sample
$ touch src/api/repositories/mod.rs src/api/repositories/sample/sample_repository.rs src/api/repositories/sample/mod.rs
$ mkdir -p src/api/services/sample
$ touch src/api/services/mod.rs src/api/services/sample/sample_service.rs src/api/services/sample/mod.rs
$ mkdir -p src/api/usecases/sample
$ touch src/api/usecases/mod.rs
$ touch src/api/usecases/sample/sample_get_usecase.rs
$ touch src/api/usecases/sample/sample_get_path_query_usecase.rs
$ touch src/api/usecases/sample/sample_post_usecase.rs
$ touch src/api/usecases/sample/mod.rs
$ mkdir -p src/api/handlers/sample
$ touch src/api/handlers/mod.rs
$ touch src/api/handlers/sample/sample_handler.rs src/api/handlers/sample/mod.rs
$ touch src/api/router.rs src/api/mod.rs
※API部分についてはクリーンアーキテクチャを参考に、ハンドラー層、ユースケース層、サービス層、リポジトリ層に分割して作成し、テストコードでモック化しやすいように依存注入できる形で作成します。
次に作成したファイルをそれぞれ次のように記述します。
・「src/api/repositories/mod.rs」
pub mod sample;
・「src/api/repositories/sample/sample_repository.rs」
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// ロガー用のモジュール
use crate::api::loggers::logger::error;
// サンプルリポジトリーの構造体
pub struct SampleRepository;
impl SampleRepository {
// 初期化用メソッド
pub fn new() -> Self {
SampleRepository
}
}
// サンプルリポジトリー用のトレイト(モック化もできるように定義)
#[mockall::automock]
#[async_trait::async_trait]
pub trait SampleRepositoryTrait {
async fn sample_hello(&self, ctx: &Context) -> Result<String, CommonError>;
}
#[async_trait::async_trait]
impl SampleRepositoryTrait for SampleRepository {
// 文字列「Sample Hello !!」を返す関数
async fn sample_hello(&self, ctx: &Context) -> Result<String, CommonError> {
let text = "Sample Hello !!".to_string();
if text.is_empty() {
error(ctx, "textが空です");
return Err(CommonError::InternalServerError);
}
Ok(text)
}
}
※トレイトは他の言語でいうインターフェース定義(メソッドの型を定義)です。テストコード作成時にリポジトリをモック化できるように「#[mockall::automock]」を使っています。そして依存注入できる形で作る際に「#[async_trait::async_trait]」も必要になります。また、戻り値のResult<T, E>型については、Ok()を返すとT、Err()を返すとEとして値を返します。呼び出し元ではOkかErrかチェックが必要です。
・「src/api/repositories/sample/mod.rs」
pub mod sample_repository;
・「src/api/services/mod.rs」
pub mod sample;
・「src/api/services/sample/sample_service.rs」
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// リポジトリ用のモジュール
use crate::api::repositories::sample::sample_repository::SampleRepositoryTrait;
// ロガー用のモジュール
use crate::api::loggers::logger::error;
// 使用するリポジトリーをまとめる構造体
pub struct SampleCommonRepository {
// Box<T>型で動的にメモリ領域確保
// Send: オブジェクトが異なるスレッド間で安全に送信できることを保証
// Sync: オブジェクトが複数のスレッドから同時にアクセスできることを保証
// 'static: オブジェクトのライフタイムがプログラムが終了するまで破棄されない
pub sample_repo: Box<dyn SampleRepositoryTrait + Send + Sync + 'static>,
}
// サンプルサービス
pub struct SampleService {
repo: SampleCommonRepository,
}
impl SampleService {
pub fn new(repo: SampleCommonRepository) -> Self {
SampleService { repo }
}
}
// サンプルサービス用のトレイト(モック化もできるように定義)
#[mockall::automock]
#[async_trait::async_trait]
pub trait SampleServiceTrait {
async fn sample_get_text_hello(&self, ctx: &Context) -> Result<String, CommonError>;
}
#[async_trait::async_trait]
impl SampleServiceTrait for SampleService {
async fn sample_get_text_hello(&self, ctx: &Context) -> Result<String, CommonError> {
let text = match self.repo.sample_repo.sample_hello(ctx).await {
Ok(text) => text,
Err(err) => {
error(ctx, "sample_get_text_helloのsample_hello処理でエラー");
return Err(err);
}
};
Ok(text)
}
}
※依存注入できる形で作る際にBox<T>型を使う必要がありました。
・「src/api/services/sample/mod.rs」
pub mod sample_service;
・「src/api/usecases/mod.rs」
pub mod sample;
・「src/api/usecases/sample/sample_get_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// ロガー用のモジュール
use crate::api::loggers::logger::error;
// サービスのモジュール
use crate::api::services::sample::sample_service::{SampleService, SampleServiceTrait};
// 使用するサービスをまとめる構造体
pub struct SampleCommonService {
pub sample_service: SampleService,
}
// 実行するユースケースの構造体
pub struct SampleGetUsecase {
pub service: SampleCommonService,
}
impl SampleGetUsecase {
pub async fn exec(&self, ctx: Context) -> Response {
// サンプルテキストを取得するサービスを実行
let text = match self
.service
.sample_service
.sample_get_text_hello(&ctx)
.await
{
Ok(text) => text,
Err(err) => {
error(
&ctx,
"sample_get_usecaseのsample_get_text_hello処理でエラー",
);
// json形式のメッセージを設定
let msg = Json(json!({ "message": err.to_string()}));
// レスポンス結果の設定
let res = (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
// 戻り値としてレスポンス結果を返す
return res;
}
};
// json形式のメッセージを設定
let msg = Json(json!({ "message": text}));
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
// レスポンス結果を設定して戻り値として返す
(StatusCode::OK, [("X-Request-Id", request_id)], msg).into_response()
}
}
・「src/api/usecases/sample/sample_get_path_query_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// クエリパラメータ用の構造体
use crate::api::handlers::sample::sample_handler::QueryParams;
// 実行するユースケースの構造体
pub struct SampleGetPathQueryUsecase;
impl SampleGetPathQueryUsecase {
pub async fn exec(&self, id: String, params: QueryParams, ctx: Context) -> Response {
// テキスト設定
let text = format!(
"id: {}, item: {}",
id,
params.item.unwrap_or("".to_string())
);
// json形式のメッセージを設定
let msg = Json(json!({ "message": text}));
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
// レスポンス結果を設定して戻り値として返す
(StatusCode::OK, [("X-Request-Id", request_id)], msg).into_response()
}
}
・「src/api/usecases/sample/sample_post_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// リクエストボディ用の構造体
use crate::api::handlers::sample::sample_handler::RequestBody;
// 実行するユースケースの構造体
pub struct SamplePostUsecase;
impl SamplePostUsecase {
pub async fn exec(&self, ctx: Context, body: RequestBody) -> Response {
// テキスト設定
let text = format!("name: {}", body.name);
// json形式のメッセージを設定
let msg = Json(json!({ "message": text}));
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
// レスポンス結果を設定して戻り値として返す
(StatusCode::OK, [("X-Request-Id", request_id)], msg).into_response()
}
}
・「src/api/usecases/mod.rs」
pub mod sample_get_path_query_usecase;
pub mod sample_get_usecase;
pub mod sample_post_usecase;
・「src/api/handlers/mod.rs」
pub mod sample;
・「src/api/handlers/sample/sample_handler.rs」
// axum
use axum::{
extract::{Extension, Path, Query},
response::{Json, Response},
};
// 変換用のクレート
use serde::Deserialize;
// OpenAPI用
use utoipa::ToSchema;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// リポジトリーのモジュール
use crate::api::repositories::sample::sample_repository::SampleRepository;
// サービスのモジュール
use crate::api::services::sample::sample_service::{SampleCommonRepository, SampleService};
// ユースケースのモジュール
use crate::api::usecases::sample::sample_get_path_query_usecase::SampleGetPathQueryUsecase;
use crate::api::usecases::sample::sample_get_usecase::{SampleCommonService, SampleGetUsecase};
use crate::api::usecases::sample::sample_post_usecase::SamplePostUsecase;
// 共通エラー用モジュール
use crate::api::errors::error;
// クエリパラメータ用の構造体
#[derive(Deserialize, Debug)]
pub struct QueryParams {
pub item: Option<String>,
}
// リクエストボディの構造体
#[derive(Deserialize, Debug, ToSchema)]
pub struct RequestBody {
#[schema(example = "田中")]
pub name: String,
}
// OpenAPI用の定義
#[derive(ToSchema)]
struct SampleGetResponseBody {
#[allow(dead_code)]
#[schema(example = "Sample Hello !!")]
message: String,
}
#[derive(ToSchema)]
struct SampleGetPathQueryResponseBody {
#[allow(dead_code)]
#[schema(example = "id: 11, item: book")]
message: String,
}
#[derive(ToSchema)]
struct SamplePostResponseBody {
#[allow(dead_code)]
#[schema(example = "name: 田中")]
message: String,
}
// GETメソッド用のAPIサンプル
#[utoipa::path(
get,
path = "/api/v1/sample/get",
description = "GETメソッドのサンプルAPI",
responses(
(status = 200, description = "正常終了", body = SampleGetResponseBody),
(status = 500, description = "Internal Server Error", body = error::InternalServerErrorResponseBody)
),
tag = "sample",
)]
pub async fn sample_get(Extension(ctx): Extension<Context>) -> Response {
// サービスのインスタンス化
let sample_repo = Box::new(SampleRepository::new());
let sample_common_repo = SampleCommonRepository { sample_repo };
let sample_service = SampleService::new(sample_common_repo);
let sample_common_service = SampleCommonService { sample_service };
// ユースケースを実行
let sample_get_usecase = SampleGetUsecase {
service: sample_common_service,
};
sample_get_usecase.exec(ctx).await
}
// GETメソッドかつパスパラメータとクエリパラメータ有りのAPIサンプル
#[utoipa::path(
get,
path = "/api/v1/sample/get/{id}",
description = "GETメソッドかつパスパラメータとクエリパラメータ有りのサンプルAPI",
responses(
(status = 200, description = "正常終了", body = SampleGetPathQueryResponseBody),
),
params(
("id" = String, Path, description = "sample id"),
("item" = String, Query, description = "sample item"),
),
tag = "sample",
)]
pub async fn sample_get_path_query(
Path(id): Path<String>,
Query(params): Query<QueryParams>,
Extension(ctx): Extension<Context>,
) -> Response {
// ユースケースを実行
let sample_get_path_query_usecase = SampleGetPathQueryUsecase;
sample_get_path_query_usecase.exec(id, params, ctx).await
}
// POSTメソッド用のAPIサンプル
#[utoipa::path(
post,
path = "/api/v1/sample/post",
description = "POSTメソッドのサンプルAPI",
responses(
(status = 200, description = "正常終了", body = SamplePostResponseBody),
(status = 415, description = "Unsupported Media Type"),
(status = 422, description = "Unprocessable Entity"),
),
tag = "sample",
)]
pub async fn sample_post(
Extension(ctx): Extension<Context>,
Json(body): Json<RequestBody>,
) -> Response {
// ユースケースを実行
let sample_post_usecase = SamplePostUsecase;
sample_post_usecase.exec(ctx, body).await
}
※ハンドラーファイルで各APIのOpenAPI仕様書の定義も記述しています。
・「src/api/handlers/sample/mod.rs」
pub mod sample_handler;
・「src/api/mod.rs」
pub mod configs;
pub mod contexts;
pub mod errors;
pub mod handlers;
pub mod loggers;
pub mod middleware;
pub mod repositories;
pub mod router;
pub mod services;
pub mod usecases;
・「src/api/router.rs」
// axum
use axum::{
Router, middleware,
routing::{get, post},
};
// tower_http
use tower_http::{cors::CorsLayer, trace::TraceLayer};
// OpenAPI用
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme};
use utoipa::{Modify, OpenApi};
use utoipa_swagger_ui::SwaggerUi;
// configsモジュール
use super::configs::config;
// ハンドラー用のモジュール
use super::handlers::sample::sample_handler;
// ミドルウェア用のモジュール
use super::middleware::common_middleware;
// OpenAPIの認証定義
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"bearerAuth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
// OpenAPIの設定
#[derive(OpenApi)]
#[openapi(
paths(
sample_handler::sample_get,
sample_handler::sample_get_path_query,
sample_handler::sample_post,
),
components(),
modifiers(&SecurityAddon),
info(
title = "rust-sample API",
version = "1.0",
description = "Rustのフレームワーク「axum」によるサンプルAPIです。"
)
)]
pub struct ApiDoc;
pub fn router() -> Router {
// 環境変数取得
let config = config::get_config();
// CORS設定
let origin = config.allow_origin;
let cors = CorsLayer::new()
.allow_origin(vec![origin.parse().unwrap()])
.allow_methods(vec![
"GET".parse().unwrap(),
"POST".parse().unwrap(),
"PUT".parse().unwrap(),
"DELETE".parse().unwrap(),
"OPTIONS".parse().unwrap(),
])
.allow_headers(vec![
"Content-Type".parse().unwrap(),
"Authorization".parse().unwrap(),
])
.allow_credentials(true);
// APIのグループ「v1」
let v1 = Router::new()
.route("/sample/get", get(sample_handler::sample_get))
.route(
"/sample/get/{id}",
get(sample_handler::sample_get_path_query),
)
.route("/sample/post", post(sample_handler::sample_post));
// 認証有りのAPIのグループ「v1_auth」
let v1_auth = Router::new()
// 認証用ミドルウェア設定
.layer(middleware::from_fn(common_middleware::auth_middleware));
// ルーター設定
let router = Router::new()
.nest("/api/v1", v1)
.nest("/api/v1", v1_auth)
// 共通ミドルウェアの設定(下から順番に読み込み)
.layer(middleware::from_fn(common_middleware::request_middleware))
.layer(TraceLayer::new_for_http().on_response(
|res: &axum::response::Response,
latency: std::time::Duration,
_span: &tracing::Span| {
// レスポンスヘッダーからX-Request-Idを取得
let request_id = match res.headers().get("X-Request-Id") {
Some(value) => value.to_str().unwrap_or("-").to_string(),
None => "-".to_string(),
};
// ログ出力
log::info!(
"[request_id={} status=({}) latency={}μs] finish request !!",
request_id,
res.status(),
latency.as_micros()
)
},
))
.layer(cors);
// 本番環境でない場合にOpenAPIを設定
if config.env != "production" {
let openapi = SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi());
return router.merge(openapi);
}
router
}
※ルーターの設定でCORSやミドルウェア、そしてOpenAPIの設定もしていますので必要があれば参考にしてみて下さい。ハンドラーファイルとこのルーターファイルでOpenAPI用の定義も記述しています。
mainファイルの修正とコンテナ起動
// axum
use axum::serve;
// apiモジュール
mod api;
// routerモジュール
use api::router::router;
// configsモジュール
use api::configs::config;
// loggerモジュール
use api::loggers::logger::init_logger;
#[tokio::main]
async fn main() {
// 環境変数取得
let config = config::get_config();
// ロガーの初期化
init_logger();
// サーバー起動のログ出力
log::info!("Start rust_api (ENV:{}) !!", config.env);
// サーバー起動
let app = router();
let addr = format!("0.0.0.0:{}", config.port);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
serve(listener, app).await.unwrap();
}
$ docker compose up -d
※コンテナ起動後にホットリロード処理(Dockerfileに記述したcargo-watchを使用し、compose.ymlの「command: cargo watch -x run」で実行)でコンパイルされるため、コンパイル完了するまで少し時間がかかります。
次に以下のコマンドを実行し、コンテナのログ出力を確認します。
$ docker compose logs
コマンド実行後、下図のようにエラーが出ずに完了し、ログ「〜 INFO Start rust_api (ENV:local) !!」が出力されればOKです。
フォーマッターとリンターの実行
Dockerfileで「rustfmt」と「clippy」をインストールしているため、Rust用のフォーマッターとリンター(コードの静的解析)を使えます。
コードの修正をした際などは以下のコマンドをそれぞれ実行し、フォーマットの統一および、リンターで警告やエラーがでないことを確認して下さい。
$ docker compose exec api cargo fmt
$ docker compose exec api cargo clippy
※実務ではフォーマッターやリンターをちゃんと使った方がいいです。
サンプルAPIを試す
上記では3つのサンプルAPIを実装したので、それぞれ実行して試してみますが、この記事でAPIの実行にはPostman(API実行用ツール)を使います。
まずはGETメソッドで「http://localhost:8080/api/v1/sample/get」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
次にGETメソッドでパスパラメータとクエリパラメータを付けた「http://localhost:8080/api/v1/sample/get/11?item=book」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
次にPOSTメソッドで「http://localhost:8080/api/v1/sample/post」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
そして各種APIを実行後、コマンド「docker compose logs」を実行してログ出力を確認すると、下図の通りにリクエスト単位でログ出力が確認できれば仕様通りなのでOKです。
※今回はこのようにログ出力をさせていますが、プロダクトに応じて適切な仕様を検討して下さい。
サンプルAPI用のテストコードを追加
次に上記のサンプルAPI用のテストコードを追加するため、まずは以下のコマンドを実行して必要なファイルを作成します。
$ touch src/api/handlers/sample/sample_handler_1_test.rs
次に作成したファイルを以下のように記述します。
・「src/api/handlers/sample/sample_handler_1_test.rs」
#[cfg(test)]
// sample_getのテスト
mod sample_get_test {
use crate::api::contexts::context::Context;
use crate::api::repositories::sample::sample_repository::SampleRepository;
use crate::api::services::sample::sample_service::{SampleCommonRepository, SampleService};
use crate::api::usecases::sample::sample_get_usecase::{SampleCommonService, SampleGetUsecase};
use axum::body;
use axum::http::header::HeaderMap;
use serde::{Deserialize, Serialize};
// レスポンス結果の構造体
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Message {
message: String,
}
#[tokio::test]
async fn it_response_ok() {
/* ユースケースを実行して検証する場合 */
// サンプルリポシトリーのインスタンス化
let sample_repo = Box::new(SampleRepository::new());
/*
リポジトリーのモック化が必要な場合の例
let mut mock_repo = MockSampleRepositoryTrait::new();
mock_repo
.expect_sample_hello()
.returning(|_| Ok("mock".to_string()));
let sample_repo = Box::new(mock_repo);
*/
// サンプルサービスのインスタンス化
let sample_common_repo = SampleCommonRepository { sample_repo };
let sample_service = SampleService::new(sample_common_repo);
let sample_common_service = SampleCommonService { sample_service };
// ユースケースを実行
let sample_get_usecase = SampleGetUsecase {
service: sample_common_service,
};
// リクエストヘッダーの設定
let mut headers = HeaderMap::new();
headers.insert("X-Request-Id", "XXX-XXX-XXX".parse().unwrap());
// コンテキストの設定
let ctx = Context {
header: headers,
method: "GET".to_string(),
uri: "/api/v1/sample/get".to_string(),
};
// ユースケースの実行
let res = sample_get_usecase.exec(ctx).await;
// レスポンスステータスの検証
assert_eq!(res.status(), 200);
// レスポンスボディの検証
let limit = 1024 * 1024;
let body_bytes = body::to_bytes(res.into_body(), limit).await.unwrap();
let body_str = String::from_utf8_lossy(&body_bytes);
let req_body: Message = serde_json::from_str(&body_str).unwrap();
let result_res_body = Message {
message: "Sample Hello !!".to_string(),
};
assert_eq!(req_body, result_res_body);
}
}
// sample_get_path_queryのテスト
mod sample_get_path_query_test {
use serde::{Deserialize, Serialize};
// レスポンス結果の構造体
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Message {
message: String,
}
#[tokio::test]
async fn it_response_ok() {
/* reqwestを使ってHTTPリクエストによる検証をする場合 */
// リクエストを実行
let url = "http://localhost:8080/api/v1/sample/get/11?item=book";
let client = reqwest::Client::new();
let res = client.get(url).send().await.unwrap();
// レスポンスステータスの検証
assert_eq!(res.status(), 200);
// レスポンスボディの検証
let text_body = res.text().await.unwrap();
let req_body: Message = serde_json::from_str(&text_body).unwrap();
let result_res_body = Message {
message: "id: 11, item: book".to_string(),
};
assert_eq!(req_body, result_res_body);
}
}
// sample_postのテスト
mod sample_post_test {
use serde::{Deserialize, Serialize};
// レスポンス結果の構造体
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Message {
message: String,
}
#[tokio::test]
async fn it_response_ok() {
// リクエストを実行
let url = "http://localhost:8080/api/v1/sample/post";
let data = serde_json::json!({
"name": "田中"
});
let client = reqwest::Client::new();
let res = client.post(url).json(&data).send().await.unwrap();
// レスポンスステータスの検証
assert_eq!(res.status(), 200);
// レスポンスボディの検証
let text_body = res.text().await.unwrap();
let req_body: Message = serde_json::from_str(&text_body).unwrap();
let result_res_body = Message {
message: "name: 田中".to_string(),
};
assert_eq!(req_body, result_res_body);
}
}
※実務ではテストコードを作成する余裕が無いかもしれませんが、最低限ハンドラー単位で作るべきです。(ハンドラーの近くにテストコードを置いているのは可読性を上げるためです。)テストコードを作っておくと後のリファクタリング等で役に立ちます。そしてAPIを依存注入できる形で作っているため、DB操作や外部サービスの実行などをリポジトリとして作ればその部分をモック化することが可能です。上記でテストの実行方法は2種類ありますが、リポジトリのモック化が必要な場合は、ユースケース層を実行する形で作って下さい。
pub mod sample_handler;
// テストコード用のモジュール
mod sample_handler_1_test;
次に以下のコマンドを実行し、テストを実行します。
$ docker compose exec -e CARGO_TEST=testing api cargo test -- --nocapture --test-threads=1
※オプション「–test-threads=1」を付けることで直列でテストを実行できるようになり、後々DBのデータを同期させて実行させたい場合に必要になります。
テスト実行後、以下のようにログ出力され、全てのテストがPASSすればOKです。
OpenAPI仕様書について
上記ではOpenAPI仕様書も実装しているため、ブラウザで「http://localhost:8080/swagger-ui/」にアクセスすると確認することができます。
次にOpenAPI仕様書をファイル出力してGitHubなどで管理できるようにするため、以下のコマンドを実行してディレクトリとファイルを作成します。
$ mkdir -p src/api/openapi
$ touch src/script_openapi.rs
次に作成したファイルを次のように記述します。
#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! async-trait = "0.1.88"
//! axum = "0.8.4"
//! chrono = "0.4.41"
//! env_logger = "0.11.8"
//! envy = "0.4.2"
//! log = "0.4.27"
//! mockall = "0.13.1"
//! reqwest = { version = "0.12.15", features = ["json"] }
//! serde = { version = "1.0.219", features = ["derive"] }
//! serde_json = "1.0.140"
//! test-env-helpers = "0.2.2"
//! thiserror = "2.0.12"
//! tokio = { version = "1.45.0", features = ["full"] }
//! tower-http = { version = "0.6.4", features = ["trace", "cors"] }
//! tracing = "0.1.41"
//! utoipa = { version = "5.3.1", features = ["axum_extras"] }
//! utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
//! uuid = { version = "1.16.0", features = ["v4"] }
//! ```
// OpenAPI用
use utoipa::OpenApi;
// ファイル出力用
use std::fs::File;
use std::io::Write;
use std::path::Path;
use serde_json;
// apiモジュール
mod api;
// routerモジュール
use api::router::ApiDoc;
fn main() {
// OpenAPIをファイルに出力
let output_path = Path::new("./src/api/openapi/openapi.json");
let json_string = serde_json::to_string_pretty(&ApiDoc::openapi()).unwrap();
let file = File::create(output_path);
let _ = file.unwrap().write_all(json_string.as_bytes());
}
※単独のスクリプトファイルになっており、コメント部分のcargo設定が必要になります。
$ docker compose exec api rust-script ./src/script_openapi.rs
※このような単独のスクリプト実行にはDockerfileでインストールした「rust-script」を使っています。
コマンド実行後、ディレクトリ「src/api/openapi」直下にファイル「openapi.json」が出力され、テキストエディタがVSCodeなら拡張機能でOpenAPI仕様書を確認できます。
DB関連設定を追加する【PostgreSQL・SeaORM】
次にデータベース(DB)設定を追加していきますが、この記事ではDBにPostgreSQLを使用します。
まずは以下のコマンドを実行し、一度起動中のコンテナを削除します。
$ docker compose down
次に以下のコマンドを実行し、必要なファイルを作成します。
$ mkdir -p docker/local/db/init
$ touch docker/local/db/Dockerfile docker/local/db/init/init.sql
次に作成したファイルをそれぞれ以下のように記述します。
・「docker/local/db/init/init.sql」
-- テストユーザの作成
CREATE USER testuser;
ALTER USER testuser WITH PASSWORD 'test-password';
-- テストDBの作成
CREATE DATABASE testdb;
-- テストユーザーにDBの接続権限付与
GRANT CONNECT ON DATABASE testdb TO testuser;
-- -- テストDBの権限付与
\c testdb
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO testuser;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO testuser;
GRANT USAGE ON SCHEMA public TO testuser;
GRANT CREATE ON SCHEMA public TO testuser;
※これを使って初回起動時にテスト用のDB設定を追加します。
・「docker/local/db/Dockerfile」
FROM postgres:17.5
ENV LANG ja_JP.utf8
# PostgreSQLの日本語化で「ja_JP.utf8」を使うために必要
RUN apt-get update && \
apt-get install -y locales && \
rm -rf /var/lib/apt/lists/* && \
localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
次にファイル「compose.yml」を以下のように修正します。
services:
api:
container_name: rust-api
build:
context: .
dockerfile: ./docker/local/rust/Dockerfile
command: cargo watch -x run
volumes:
- .:/app
ports:
- "8080:8080"
# .env.testing利用時に上書きしたい環境変数を設定する
environment:
- ENV
- PORT
- RUST_LOG
- ALLOW_ORIGIN
- DATABASE_URL
tty: true
stdin_open: true
depends_on:
- pg-db
pg-db:
container_name: rust-db
build:
context: .
dockerfile: ./docker/local/db/Dockerfile
environment:
- POSTGRES_DB=pg-db
- POSTGRES_USER=pg-user
- POSTGRES_PASSWORD=pg-password
- POSTGRES_INITDB_ARGS=--locale=ja_JP.utf8
- TZ=Asia/Tokyo
ports:
- "5432:5432"
volumes:
- ./docker/local/db/init:/docker-entrypoint-initdb.d
- pg-db-data:/var/lib/postgresql/data
volumes:
pg-db-data:
driver: local
次に以下のコマンドを実行し、コンテナの再ビルドと再起動を行います。
$ docker compose build --no-cache
$ docker compose up -d
マイグレーション設定の追加
次に以下のコマンドを実行し、マイグレーション用の各種ファイルを追加します。
$ docker compose exec api sea-orm-cli migrate init
$ touch migration/.gitignore
※マイグレーション関係はSeaORM用のCLI「sea-orm-cli」を使いますが、Dockerfileの方でインストールさせているのでコマンドが使えます。
コマンド実行後、下図のようにマイグレーション用のディレクトリと各種ファイルが追加されます。
マイグレーション実行時等でコンパイルされるとディレクトリ「migration/target」が作成されるため、Git管理されないようにする必要があるなら「migration/.gitignore」に「/target」を追加します。
次に以下のコマンドを実行し、マイグレーションファイルの新規作成と既存のマイグレーションファイルの削除を行います。
$ docker compose exec api sea-orm-cli migrate generate create_table_users
$ rm migration/src/m20220101_000001_create_table.rs
※この記事では例としてユーザーテーブル(users)を作成します。
次に作成したファイルを次のように修正します。
・「migration/src/m20250523_190503_create_table_users.rs」
※ファイル名の日時は、ファイル作成タイミングの日時になります。
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Users::Table)
.if_not_exists()
.col(
ColumnDef::new(Users::Id)
.big_integer()
.auto_increment()
.primary_key()
.not_null()
)
.col(string(Users::Uid).not_null().unique_key())
.col(
ColumnDef::new(Users::LastName)
.string()
.not_null()
)
.col(
ColumnDef::new(Users::FirstName)
.string()
.not_null()
)
.col(
ColumnDef::new(Users::Email)
.string()
.not_null()
.unique_key()
)
.col(
ColumnDef::new(Users::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp())
)
.col(
ColumnDef::new(Users::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp())
)
.col(
ColumnDef::new(Users::DeletedAt)
.timestamp_with_time_zone()
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Users::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
Uid,
LastName,
FirstName,
Email,
CreatedAt,
UpdatedAt,
DeletedAt,
}
次に既存のファイル「migration/src/lib.rs」、「migration/Cargo.toml」をそれぞれ以下のように修正します。
・「migration/src/lib.rs」
pub use sea_orm_migration::prelude::*;
mod m20250523_190503_create_table_users;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20250523_190503_create_table_users::Migration),
]
}
}
※ファイル名の日時は、ファイル作成タイミングの日時になります。
・「migration/Cargo.toml」
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "1.1.0"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
# "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
# "sqlx-postgres", # `DATABASE_DRIVER` feature
"runtime-tokio-rustls",
"sqlx-postgres",
]
※dependencies.sea-orm-migrationのfeaturesに「runtime-tokio-rustls」と「sqlx-postgres」を追加
次に以下のコマンドを実行し、マイグレーションを実行します。
$ docker compose exec api sea-orm-cli migrate up
※sea-orm-cliのマイグレーション実行時のDB接続先については、環境変数「DATABASE_URL」に設定した値「postgres://pg-user:pg-password@pg-db:5432/pg-db?sslmode=disable」を参照しています。
マイグレーション実行後、下図のように正常終了すればOKです。
次に以下のコマンドを実行し、DBコンテナの接続およびPostgreSQLに接続し、usersテーブルが作成されていることを確認します。
$ docker compose exec pg-db bash
/# PGPASSWORD=pg-password psql -U pg-user pg-db
pg-db=# \dt
コマンドを実行後、下図のようにusersテーブルが作成されていればOKです。
※PostgreSQLとDBコンテナから抜けるにはコマンド「exit」を使って下さい。
次に以下のコマンドを実行し、エンティティファイルを作成します。
$ docker compose exec api sea-orm-cli generate entity -o src/api/entities
※初回のみコマンド「sea-orm-cli generate entity -o src/api/entities」を実行してエンティティファイルを作成します。後からテーブル等を追加した際は、テーブルの指定および一時的にファイル出力させるパスを指定したコマンド「docker compose exec api sea-orm-cli generate entity -o src/api/entities/tmp –tables users」を実行し、作成されたファイルを手動でディレクトリ「src/api/entities」内のファイルへ反映させて下さい。
次に作成されたファイル「src/api/entities/prelude.rs」、「src/api/entities/users.rs」をそれぞれ以下のように修正します。
・「src/api/entities/prelude.rs」
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.11
// ユーザー
pub use super::users::ActiveModel as UsersActiveModel;
pub use super::users::Column as UsersColumn;
pub use super::users::Entity as Users;
pub use super::users::Model as UsersModel;
・「src/api/entities/users.rs」
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.11
use sea_orm::entity::prelude::*;
// 変換用のクレート
use serde::{Deserialize, Serialize};
// DeserializeとSerializeを追加
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Deserialize, Serialize, Default)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub uid: String,
pub last_name: String,
pub first_name: String,
#[sea_orm(unique)]
pub email: String,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub deleted_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
ユーザーAPIのCRUDを作成
次にusersテーブルへのデータ操作をするユーザーAPIのCRUDを作成してみます。
今回はDB操作にSeaORMを利用するため、まずはファイル「Cargo.toml」にクレート「sea-orm」の設定を追加します。
[package]
name = "rust_api"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.88"
axum = "0.8.4"
chrono = "0.4.41"
env_logger = "0.11.8"
envy = "0.4.2"
log = "0.4.27"
mockall = "0.13.1"
reqwest = { version = "0.12.15", features = ["json"] }
sea-orm = { version = "1.1.11", features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros" ] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
test-env-helpers = "0.2.2"
thiserror = "2.0.12"
tokio = { version = "1.45.0", features = ["full"] }
tower-http = { version = "0.6.4", features = ["trace", "cors"] }
tracing = "0.1.41"
utoipa = { version = "5.3.1", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
uuid = { version = "1.16.0", features = ["v4"] }
※今回はsea-ormのバージョンは「1.1.11」ですが必要に応じてバージョンは変えて下さい。
次に以下のコマンドを実行し、バリデーション用のクレートも追加しておきます。
$ docker compose exec api cargo add validator --features derive
次に共通処理用の既存ファイル「src/api/configs/config.rs」、「src/api/errors/error.rs」をそれぞれ以下のように修正します。
・「src/api/configs/config.rs」
use envy;
use serde::Deserialize;
// 環境変数のデフォルト値を返す関数
fn default_env() -> String {
"local".to_string()
}
fn default_port() -> u16 {
8080
}
fn default_rust_log() -> String {
"info".to_string()
}
fn default_allow_origin() -> String {
"http://localhost:3000".to_string()
}
fn default_database_url() -> String {
"postgres://pg-user:pg-password@pg-db:5432/pg-db?sslmode=disable".to_string()
}
// 環境変数の構造体
#[derive(Deserialize, Debug)]
pub struct Config {
#[serde(default = "default_env")]
pub env: String,
#[serde(default = "default_port")]
pub port: u16,
#[allow(dead_code)]
#[serde(default = "default_rust_log")]
pub rust_log: String,
#[allow(dead_code)]
#[serde(default = "default_allow_origin")]
pub allow_origin: String,
#[serde(default = "default_database_url")]
pub database_url: String,
}
// 環境変数を返す関数
pub fn get_config() -> Config {
match envy::from_env::<Config>() {
Ok(config) => config,
Err(err) => {
println!("環境変数の初期化エラー: {}", err);
// 環境変数にデフォルト値を設定して返す
Config {
env: default_env(),
port: default_port(),
rust_log: default_rust_log(),
allow_origin: default_allow_origin(),
database_url: default_database_url(),
}
}
}
}
・「src/api/errors/error.rs」
use thiserror::Error;
use axum::http::StatusCode;
// OpenAPI用
use utoipa::ToSchema;
#[derive(Error, Debug)]
pub enum CommonError {
#[error("Internal Server Error")]
InternalServerError,
#[error("{message}")]
CustomError {
status_code: StatusCode,
message: String,
},
}
// OpenAPI用の定義
#[derive(ToSchema)]
pub struct InternalServerErrorResponseBody {
#[allow(dead_code)]
#[schema(example = "Internal Server Error")]
message: String,
}
#[derive(ToSchema)]
pub struct CustomErrorResponseBody {
#[allow(dead_code)]
#[schema(example = "エラーメッセージ")]
message: String,
}
次に以下のコマンドを実行し、DB接続用の各種ファイルを作成します。
$ mkdir -p src/api/databases
$ touch src/api/databases/database.rs src/api/databases/mod.rs
次に作成したファイルをそれぞれ以下のように記述します。
・「src/api/databases/database.rs」
use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbErr};
// configsモジュール
use crate::api::configs::config;
// DB接続
pub async fn db_connection() -> Result<DatabaseConnection, DbErr> {
// 環境変数取得
let config = config::get_config();
// DB接続
let mut opt = ConnectOptions::new(config.database_url);
// sqlxのログ出力をOFFに変更
opt.sqlx_logging(false);
let db = Database::connect(opt).await?;
Ok(db)
}
・「src/api/databases/mod.rs」
pub mod database;
次にファイル「src/api/mod.rs」を以下のように修正します。
pub mod configs;
pub mod contexts;
pub mod databases;
pub mod entities;
pub mod errors;
pub mod handlers;
pub mod loggers;
pub mod middleware;
pub mod repositories;
pub mod router;
pub mod services;
pub mod usecases;
ユーザーのリポジトリ作成
次に以下のコマンドを実行し、ユーザーのリポジトリを作成します。
$ mkdir -p src/api/repositories/users
$ touch src/api/repositories/users/users_repository.rs src/api/repositories/users/mod.rs
次に作成したファイルをそれぞれ以下のように記述します。
・「src/api/repositories/users/users_repository.rs」
// SeaORM
use sea_orm::{
ActiveModelTrait, ColumnTrait, QueryFilter, Set, TransactionTrait, entity::EntityTrait,
};
// axum
use axum::http::StatusCode;
// chrono
use chrono::{DateTime, FixedOffset, Utc};
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// ロガー用のモジュール
use crate::api::loggers::logger::error;
// DB接続用のモジュール
use crate::api::databases::database::db_connection;
// Usersエンティティのモジュール
use crate::api::entities::prelude::{Users, UsersActiveModel, UsersColumn, UsersModel};
// サンプルリポジトリーの構造体
pub struct UsersRepository;
impl UsersRepository {
// 初期化用メソッド
pub fn new() -> Self {
UsersRepository
}
}
// Usersリポジトリー用のトレイト(モック化もできるように定義)
#[mockall::automock]
#[async_trait::async_trait]
pub trait UsersRepositoryTrait {
async fn create_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError>;
async fn get_users(&self, ctx: &Context) -> Result<Vec<UsersModel>, CommonError>;
async fn get_user_from_uid(
&self,
ctx: &Context,
uid: String,
) -> Result<Option<UsersModel>, CommonError>;
async fn update_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError>;
async fn delete_user(&self, ctx: &Context, uid: String) -> Result<UsersModel, CommonError>;
}
#[async_trait::async_trait]
impl UsersRepositoryTrait for UsersRepository {
// ユーザー作成
async fn create_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError> {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
let msg = format!("[UsersRepository.create_user] DB接続エラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// トランザクション開始
let tx = match db.begin().await {
Ok(tx) => tx,
Err(err) => {
let msg = format!(
"[UsersRepository.create_user] トランザクション開始エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// ユーザー作成
let insert_result = Users::insert(UsersActiveModel {
uid: Set(uid),
last_name: Set(last_name),
first_name: Set(first_name),
email: Set(email),
..Default::default()
})
.exec(&tx)
.await;
match insert_result {
Ok(insert_result) => {
// ユーザー登録結果からid取得
let id = insert_result.last_insert_id;
// ユーザー情報の取得
let result = match Users::find_by_id(id).one(&tx).await {
Ok(result) => result,
Err(err) => {
let msg = format!(
"[UsersRepository.create_user] ユーザー情報の取得に失敗しました。: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// 必ずSomeの想定のためunwrap_or_default()を使う。
let user = result.unwrap_or_default();
// コミット
match tx.commit().await {
Ok(_) => {}
Err(err) => {
let msg = format!("[UsersRepository.create_user] コミットエラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
}
return Ok(user);
}
Err(err) => {
let msg = format!(
"[UsersRepository.create_user] ユーザー登録に失敗しました。: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
}
}
// 全ての有効なユーザーを取得
async fn get_users(&self, ctx: &Context) -> Result<Vec<UsersModel>, CommonError> {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
let msg = format!("[UsersRepository.create_user] DB接続エラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// 全ての有効なユーザーを取得
let select_result = Users::find()
.filter(UsersColumn::DeletedAt.is_null())
.all(&db)
.await;
// 変換処理
let users: Vec<UsersModel> = select_result.unwrap_or_default();
Ok(users)
}
// Uidから対象ユーザーを取得
async fn get_user_from_uid(
&self,
ctx: &Context,
uid: String,
) -> Result<Option<UsersModel>, CommonError> {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
let msg = format!("[UsersRepository.get_user_from_uid] DB接続エラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// Uidから有効な対象のユーザーを取得
let select_result = Users::find()
.filter(UsersColumn::Uid.eq(uid))
.filter(UsersColumn::DeletedAt.is_null())
.one(&db)
.await;
let user = match select_result {
Ok(user) => match user {
Some(user) => user,
None => return Ok(None),
},
Err(err) => {
let msg = format!(
"[UsersRepository.get_user_from_uid] 対象ユーザー取得エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
Ok(Some(user))
}
// 対象ユーザー更新
async fn update_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError> {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
let msg = format!("[UsersRepository.update_user] DB接続エラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// トランザクション開始
let tx = match db.begin().await {
Ok(tx) => tx,
Err(err) => {
let msg = format!(
"[UsersRepository.update_user] トランザクション開始エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// 対象ユーザー取得
let select_user = Users::find()
.filter(UsersColumn::Uid.eq(uid))
.filter(UsersColumn::DeletedAt.is_null())
.one(&tx)
.await;
let mut update_user: UsersActiveModel = match select_user {
Ok(user) => user.unwrap().into(),
Err(err) => {
let msg = format!(
"[UsersRepository.update_user] 対象ユーザー取得エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// 更新する値の設定
if !last_name.is_empty() {
update_user.last_name = Set(last_name);
}
if !first_name.is_empty() {
update_user.first_name = Set(first_name);
}
if !email.is_empty() {
update_user.email = Set(email);
}
// 更新日時の設定
let current_date =
DateTime::<Utc>::from_naive_utc_and_offset(chrono::Utc::now().naive_utc(), Utc).into();
update_user.updated_at = Set(current_date);
// ユーザー更新
let user: UsersModel = match update_user.update(&tx).await {
Ok(user) => user,
Err(err) => {
let msg = format!(
"[UsersRepository.update_user] 対象ユーザー更新エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// コミット
match tx.commit().await {
Ok(_) => {}
Err(err) => {
let msg = format!("[UsersRepository.update_user] コミットエラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
}
Ok(user)
}
// 対象ユーザー削除(論理削除)
async fn delete_user(&self, ctx: &Context, uid: String) -> Result<UsersModel, CommonError> {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
let msg = format!("[UsersRepository.delete_user] DB接続エラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// トランザクション開始
let tx = match db.begin().await {
Ok(tx) => tx,
Err(err) => {
let msg = format!(
"[UsersRepository.update_user] トランザクション開始エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// 対象ユーザー取得
let select_user = Users::find()
.filter(UsersColumn::Uid.eq(uid))
.filter(UsersColumn::DeletedAt.is_null())
.one(&tx)
.await;
let mut delete_user: UsersActiveModel = match select_user {
Ok(user) => user.unwrap().into(),
Err(err) => {
let msg = format!(
"[UsersRepository.delete_user] 対象ユーザー取得エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// 現在日時を取得
let current_date: DateTime<FixedOffset> =
DateTime::<Utc>::from_naive_utc_and_offset(chrono::Utc::now().naive_utc(), Utc).into();
// emailをString型に変換し、シングルクォーテーションを削除
let email_active_value = delete_user.email.into_value().unwrap();
let email_string: String = email_active_value.to_string();
let email_trim: String = email_string.trim_matches('\'').to_string();
// 現在日時をフォーマットしてString型に変換
let formatted_date: String = current_date.format("%Y%m%d%H%M%S").to_string();
// emailの設定
let email: String = format!("{}_{}", email_trim, formatted_date);
delete_user.email = Set(email);
// updated_atとdeleted_atの設定
delete_user.updated_at = Set(current_date);
delete_user.deleted_at = Set(Some(current_date));
// ユーザー更新
let user: UsersModel = match delete_user.update(&tx).await {
Ok(user) => user,
Err(err) => {
let msg = format!(
"[UsersRepository.delete_user] 対象ユーザー削除エラー: {}",
err
);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
};
// コミット
match tx.commit().await {
Ok(_) => {}
Err(err) => {
let msg = format!("[UsersRepository.delete_user] コミットエラー: {}", err);
error(ctx, &msg);
return Err(CommonError::CustomError {
status_code: StatusCode::INTERNAL_SERVER_ERROR,
message: msg,
});
}
}
Ok(user)
}
}
・「src/api/repositories/users/mod.rs」
pub mod users_repository;
次にファイル「src/api/repositories/mod.rs」を以下のように修正します。
pub mod sample;
pub mod users;
ユーザーのサービス作成
次に以下のコマンドを実行し、ユーザーのサービスを作成します。
$ mkdir -p src/api/services/users
$ touch src/api/services/users/users_service.rs src/api/services/users/mod.rs
次に作成したファイルをそれぞれ以下のように記述します。
・「src/api/services/users/users_service.rs」
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// リポジトリ用のモジュール
use crate::api::repositories::users::users_repository::UsersRepositoryTrait;
// Usersモデル
use crate::api::entities::prelude::UsersModel;
// 使用するリポジトリーをまとめる構造体
pub struct UsersCommonRepository {
// Box<T>型で動的にメモリ領域確保
// Send: オブジェクトが異なるスレッド間で安全に送信できることを保証
// Sync: オブジェクトが複数のスレッドから同時にアクセスできることを保証
// 'static: オブジェクトのライフタイムがプログラムが終了するまで破棄されない
pub users_repo: Box<dyn UsersRepositoryTrait + Send + Sync + 'static>,
}
// サンプルサービス
pub struct UsersService {
repo: UsersCommonRepository,
}
impl UsersService {
pub fn new(repo: UsersCommonRepository) -> Self {
UsersService { repo }
}
}
// Usersサービス用のトレイト(モック化もできるように定義)
#[mockall::automock]
#[async_trait::async_trait]
pub trait UsersServiceTrait {
async fn create_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError>;
async fn get_users(&self, ctx: &Context) -> Result<Vec<UsersModel>, CommonError>;
async fn get_user_from_uid(
&self,
ctx: &Context,
uid: String,
) -> Result<Option<UsersModel>, CommonError>;
async fn update_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError>;
async fn delete_user(&self, ctx: &Context, uid: String) -> Result<UsersModel, CommonError>;
}
#[async_trait::async_trait]
impl UsersServiceTrait for UsersService {
// ユーザー作成
async fn create_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError> {
// ユーザー作成処理
let user = match self
.repo
.users_repo
.create_user(ctx, uid, last_name, first_name, email)
.await
{
Ok(user) => user,
Err(err) => {
return Err(err);
}
};
Ok(user)
}
// 全ての有効なユーザー取得
async fn get_users(&self, ctx: &Context) -> Result<Vec<UsersModel>, CommonError> {
let users = match self.repo.users_repo.get_users(ctx).await {
Ok(users) => users,
Err(err) => {
return Err(err);
}
};
Ok(users)
}
// Uidから有効な対象ユーザー取得
async fn get_user_from_uid(
&self,
ctx: &Context,
uid: String,
) -> Result<Option<UsersModel>, CommonError> {
let user = match self.repo.users_repo.get_user_from_uid(ctx, uid).await {
Ok(user) => user,
Err(err) => {
return Err(err);
}
};
Ok(user)
}
// 対象ユーザー更新
async fn update_user(
&self,
ctx: &Context,
uid: String,
last_name: String,
first_name: String,
email: String,
) -> Result<UsersModel, CommonError> {
let user = match self
.repo
.users_repo
.update_user(ctx, uid, last_name, first_name, email)
.await
{
Ok(user) => user,
Err(err) => {
return Err(err);
}
};
Ok(user)
}
// 対象ユーザー削除
async fn delete_user(&self, ctx: &Context, uid: String) -> Result<UsersModel, CommonError> {
let user = match self.repo.users_repo.delete_user(ctx, uid).await {
Ok(user) => user,
Err(err) => {
return Err(err);
}
};
Ok(user)
}
}
・「src/api/services/users/mod.rs」
pub mod users_service;
次にファイル「src/api/services/mod.rs」を以下のように修正します。
pub mod sample;
pub mod users;
ユーザーのユースケース作成
次に以下のコマンドを実行し、ユーザーのユースケースを作成します。
$ mkdir -p src/api/usecases/users
$ touch src/api/usecases/users/create_user_usecase.rs
$ touch src/api/usecases/users/get_users_usecase.rs
$ touch src/api/usecases/users/get_user_from_uid_usecase.rs
$ touch src/api/usecases/users/update_user_usecase.rs
$ touch src/api/usecases/users/delete_user_usecase.rs
$ touch src/api/usecases/users/mod.rs
次に作成したファイルをそれぞれ以下のように記述します。
・「src/api/usecases/users/create_user_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// UUID
use uuid::Uuid;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// リクエストボディ用の構造体
use crate::api::handlers::users::users_handler::CreateUserRequestBody;
// サービスのモジュール
use crate::api::services::users::users_service::{UsersService, UsersServiceTrait};
// 使用するサービスをまとめる構造体
pub struct CreateUserCommonService {
pub users_service: UsersService,
}
// 実行するユースケースの構造体
pub struct CreateUserUsecase {
pub service: CreateUserCommonService,
}
impl CreateUserUsecase {
pub async fn exec(&self, ctx: Context, body: CreateUserRequestBody) -> Response {
// Uidの設定
let uid = Uuid::new_v4().to_string();
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
let res_header = [("X-Request-Id", request_id)];
// ユーザー作成処理
let user = match self
.service
.users_service
.create_user(&ctx, uid, body.last_name, body.first_name, body.email)
.await
{
Ok(user) => user,
Err(err) => {
// json形式のメッセージを設定
let msg = Json(json!({ "message": err.to_string()}));
// ステータスコードの設定
let status_code = match err {
CommonError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
CommonError::CustomError { status_code, .. } => status_code,
};
// レスポンス結果の設定
let res = (status_code, res_header, msg).into_response();
// 戻り値としてレスポンス結果を返す
return res;
}
};
// レスポンスボディの設定
let res_body = Json(json!(user));
// レスポンス結果を設定して戻り値として返す
(StatusCode::CREATED, res_header, res_body).into_response()
}
}
・「src/api/usecases/users/get_users_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// サービスのモジュール
use crate::api::services::users::users_service::{UsersService, UsersServiceTrait};
// 使用するサービスをまとめる構造体
pub struct GetUsersCommonService {
pub users_service: UsersService,
}
// 実行するユースケースの構造体
pub struct GetUsersUsecase {
pub service: GetUsersCommonService,
}
impl GetUsersUsecase {
pub async fn exec(&self, ctx: Context) -> Response {
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
let res_header = [("X-Request-Id", request_id)];
// 全ての有効なユーザー取得処理
let users = match self.service.users_service.get_users(&ctx).await {
Ok(users) => users,
Err(err) => {
// json形式のメッセージを設定
let msg = Json(json!({ "message": err.to_string()}));
// ステータスコードの設定
let status_code = match err {
CommonError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
CommonError::CustomError { status_code, .. } => status_code,
};
// レスポンス結果の設定
let res = (status_code, res_header, msg).into_response();
// 戻り値としてレスポンス結果を返す
return res;
}
};
// レスポンスボディの設定
let res_body = Json(json!(users));
// レスポンス結果を設定して戻り値として返す
(StatusCode::OK, res_header, res_body).into_response()
}
}
・「src/api/usecases/users/get_user_from_uid_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// サービスのモジュール
use crate::api::services::users::users_service::{UsersService, UsersServiceTrait};
// 使用するサービスをまとめる構造体
pub struct GetUserFromUidCommonService {
pub users_service: UsersService,
}
// 実行するユースケースの構造体
pub struct GetUserFromUidUsecase {
pub service: GetUserFromUidCommonService,
}
impl GetUserFromUidUsecase {
pub async fn exec(&self, ctx: Context, uid: String) -> Response {
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
let res_header = [("X-Request-Id", request_id)];
// Uidから有効な対象ユーザー取得処理
let user = match self
.service
.users_service
.get_user_from_uid(&ctx, uid)
.await
{
Ok(user) => {
match user {
Some(user) => user,
None => {
// json形式のメッセージを設定
let msg = Json(json!({}));
// レスポンス結果の設定
let res = (StatusCode::OK, res_header, msg).into_response();
// 戻り値としてレスポンス結果を返す
return res;
}
}
}
Err(err) => {
// json形式のメッセージを設定
let msg = Json(json!({ "message": err.to_string()}));
// ステータスコードの設定
let status_code = match err {
CommonError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
CommonError::CustomError { status_code, .. } => status_code,
};
// レスポンス結果の設定
let res = (status_code, res_header, msg).into_response();
// 戻り値としてレスポンス結果を返す
return res;
}
};
// レスポンスボディの設定
let res_body = Json(json!(user));
// レスポンス結果を設定して戻り値として返す
(StatusCode::OK, res_header, res_body).into_response()
}
}
・「src/api/usecases/users/update_user_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// リクエストボディ用の構造体
use crate::api::handlers::users::users_handler::UpdateUserRequestBody;
// サービスのモジュール
use crate::api::services::users::users_service::{UsersService, UsersServiceTrait};
// 使用するサービスをまとめる構造体
pub struct UpdateUserCommonService {
pub users_service: UsersService,
}
// 実行するユースケースの構造体
pub struct UpdateUserUsecase {
pub service: UpdateUserCommonService,
}
impl UpdateUserUsecase {
pub async fn exec(&self, ctx: Context, uid: String, body: UpdateUserRequestBody) -> Response {
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
let res_header = [("X-Request-Id", request_id)];
// リクエストボディから更新する値を取得
let last_name = match body.last_name {
Some(last_name) => last_name,
None => "".to_string(),
};
let first_name = match body.first_name {
Some(first_name) => first_name,
None => "".to_string(),
};
let email = match body.email {
Some(email) => email,
None => "".to_string(),
};
// 対象ユーザー更新処理
let user = match self
.service
.users_service
.update_user(&ctx, uid, last_name, first_name, email)
.await
{
Ok(user) => user,
Err(err) => {
// json形式のメッセージを設定
let msg = Json(json!({ "message": err.to_string()}));
// ステータスコードの設定
let status_code = match err {
CommonError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
CommonError::CustomError { status_code, .. } => status_code,
};
// レスポンス結果の設定
let res = (status_code, res_header, msg).into_response();
// 戻り値としてレスポンス結果を返す
return res;
}
};
// レスポンスボディの設定
let res_body = Json(json!(user));
// レスポンス結果を設定して戻り値として返す
(StatusCode::OK, res_header, res_body).into_response()
}
}
・「src/api/usecases/users/delete_user_usecase.rs」
// axum
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// json変換用マクロ
use serde_json::json;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// 共通エラー用モジュール
use crate::api::errors::error::CommonError;
// サービスのモジュール
use crate::api::services::users::users_service::{UsersService, UsersServiceTrait};
// 使用するサービスをまとめる構造体
pub struct DeleteUserCommonService {
pub users_service: UsersService,
}
// 実行するユースケースの構造体
pub struct DeleteUserUsecase {
pub service: DeleteUserCommonService,
}
impl DeleteUserUsecase {
pub async fn exec(&self, ctx: Context, uid: String) -> Response {
// レスポンスヘッダーに付与する値の設定
let x_request_id = ctx.header.get("X-Request-Id");
let request_id = x_request_id.expect("-").to_str().unwrap();
let res_header = [("X-Request-Id", request_id)];
// 対象ユーザー削除処理
match self.service.users_service.delete_user(&ctx, uid).await {
Ok(_user) => {
// json形式のメッセージを設定
let msg = Json(json!({ "message": "OK".to_string()}));
// レスポンス結果を設定して戻り値として返す
(StatusCode::OK, res_header, msg).into_response()
}
Err(err) => {
// json形式のメッセージを設定
let msg = Json(json!({ "message": err.to_string()}));
// ステータスコードの設定
let status_code = match err {
CommonError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
CommonError::CustomError { status_code, .. } => status_code,
};
// レスポンス結果を設定して戻り値として返す
(status_code, res_header, msg).into_response()
}
}
}
}
・「src/api/usecases/users/mod.rs」
pub mod create_user_usecase;
pub mod delete_user_usecase;
pub mod get_user_from_uid_usecase;
pub mod get_users_usecase;
pub mod update_user_usecase;
次にファイル「src/api/usecases/mod.rs」を以下のように修正します。
pub mod sample;
pub mod users;
ユーザーのハンドラー作成
次に以下のコマンドを実行し、ユーザーのハンドラーを作成します
$ mkdir -p src/api/handlers/users
$ touch src/api/handlers/users/users_handler.rs src/api/handlers/users/mod.rs
$ touch src/api/handlers/users/users_handler_1_test.rs
$ touch src/api/handlers/users/users_handler_2_test.rs
$ touch src/api/handlers/users/users_handler_3_test.rs
$ touch src/api/handlers/users/users_handler_4_test.rs
$ touch src/api/handlers/users/users_handler_5_test.rs
次に作成したファイルをそれぞれ以下のように記述します。
・「src/api/handlers/users/users_handler.rs」
// axum
use axum::{
extract::{Extension, Path},
http::StatusCode,
response::{IntoResponse, Json, Response},
};
// 変換用のクレート
use serde::Deserialize;
// バリデーション用のクレート
use validator::Validate;
// json変換用マクロ
use serde_json::json;
// OpenAPI用
use utoipa::ToSchema;
// 共通コンテキストの構造体
use crate::api::contexts::context::Context;
// リポジトリーのモジュール
use crate::api::repositories::users::users_repository::UsersRepository;
// サービスのモジュール
use crate::api::services::users::users_service::{UsersCommonRepository, UsersService};
// ユースケースのモジュール
use crate::api::usecases::users::create_user_usecase::{
CreateUserCommonService, CreateUserUsecase,
};
use crate::api::usecases::users::delete_user_usecase::{
DeleteUserCommonService, DeleteUserUsecase,
};
use crate::api::usecases::users::get_user_from_uid_usecase::{
GetUserFromUidCommonService, GetUserFromUidUsecase,
};
use crate::api::usecases::users::get_users_usecase::{GetUsersCommonService, GetUsersUsecase};
use crate::api::usecases::users::update_user_usecase::{
UpdateUserCommonService, UpdateUserUsecase,
};
// 共通エラー用モジュール
use crate::api::errors::error;
// ユーザー作成のリクエストボディの構造体
#[derive(Deserialize, Debug, Validate, ToSchema)]
pub struct CreateUserRequestBody {
#[schema(example = "田中")]
#[validate(length(min = 1, message = "必須項目です。"))]
pub last_name: String,
#[schema(example = "太郎")]
#[validate(length(min = 1, message = "必須項目です。"))]
pub first_name: String,
#[schema(example = "t.tanaka@example.com")]
#[validate(
email(message = "メールアドレス形式で入力して下さい。"),
length(min = 1, message = "必須項目です。")
)]
pub email: String,
}
// ユーザー更新のリクエストボディの構造体
#[derive(Deserialize, Debug, Validate, ToSchema)]
pub struct UpdateUserRequestBody {
#[schema(example = "田中")]
pub last_name: Option<String>,
#[schema(example = "太郎")]
pub first_name: Option<String>,
#[schema(example = "t.tanaka@example.com")]
#[validate(email(message = "メールアドレス形式で入力して下さい。"))]
pub email: Option<String>,
}
// OpenAPI用の定義
#[derive(ToSchema)]
pub struct UserModelResponseBody {
#[allow(dead_code)]
#[schema(example = 1)]
pub id: i64,
#[allow(dead_code)]
#[schema(example = "719cc8f3-6309-4b5a-b554-b8034358c471")]
pub uid: String,
#[allow(dead_code)]
#[schema(example = "田中")]
pub last_name: String,
#[allow(dead_code)]
#[schema(example = "太郎")]
pub first_name: String,
#[allow(dead_code)]
#[schema(example = "t.tanaka@example.com")]
pub email: String,
#[allow(dead_code)]
#[schema(format = "date-time", example = "2025-05-15T13:39:39.348822Z")]
pub created_at: String,
#[allow(dead_code)]
#[schema(format = "date-time", example = "2025-05-15T13:39:39.348822Z")]
pub updated_at: String,
#[allow(dead_code)]
#[schema(format = "date-time", example = "null")]
pub deleted_at: Option<String>,
}
#[derive(ToSchema)]
struct DeleteUserResponseBody {
#[allow(dead_code)]
#[schema(example = "OK")]
message: String,
}
// ユーザー作成
#[utoipa::path(
post,
path = "/api/v1/user",
description = "ユーザー作成",
responses(
(status = 201, description = "正常終了", body = UserModelResponseBody),
(status = 415, description = "Unsupported Media Type"),
(status = 422, description = "Unprocessable Entity"),
(status = 500, description = "Internal Server Error", body = error::CustomErrorResponseBody),
),
tag = "users",
)]
pub async fn create_user(
Extension(ctx): Extension<Context>,
Json(body): Json<CreateUserRequestBody>,
) -> Response {
// バリデーションチェックを実行
if let Err(e) = body.validate() {
let msg = Json(json!({ "message": e.to_string()}));
return (StatusCode::UNPROCESSABLE_ENTITY, msg).into_response();
}
// サービスのインスタンス化
let users_repo = Box::new(UsersRepository::new());
let users_common_repo = UsersCommonRepository { users_repo };
let users_service = UsersService::new(users_common_repo);
let users_common_service = CreateUserCommonService { users_service };
// ユースケースを実行
let usecase = CreateUserUsecase {
service: users_common_service,
};
usecase.exec(ctx, body).await
}
// 全ての有効なユーザー取得
#[utoipa::path(
get,
path = "/api/v1/users",
description = "全ての有効なユーザー取得",
security(("bearerAuth" = [])),
responses(
(status = 200, description = "正常終了", body = Vec<UserModelResponseBody>),
(status = 500, description = "Internal Server Error", body = error::CustomErrorResponseBody),
),
tag = "users",
)]
pub async fn get_users(Extension(ctx): Extension<Context>) -> Response {
// サービスのインスタンス化
let users_repo = Box::new(UsersRepository::new());
let users_common_repo = UsersCommonRepository { users_repo };
let users_service = UsersService::new(users_common_repo);
let users_common_service = GetUsersCommonService { users_service };
// ユースケースを実行
let usecase = GetUsersUsecase {
service: users_common_service,
};
usecase.exec(ctx).await
}
// 有効な対象ユーザー取得
#[utoipa::path(
get,
path = "/api/v1/user/{uid}",
description = "有効な対象ユーザー取得",
security(("bearerAuth" = [])),
responses(
(status = 200, description = "正常終了", body = UserModelResponseBody),
(status = 500, description = "Internal Server Error", body = error::CustomErrorResponseBody),
),
tag = "users",
)]
pub async fn get_user_from_uid(
Path(uid): Path<String>,
Extension(ctx): Extension<Context>,
) -> Response {
// サービスのインスタンス化
let users_repo = Box::new(UsersRepository::new());
let users_common_repo = UsersCommonRepository { users_repo };
let users_service = UsersService::new(users_common_repo);
let users_common_service = GetUserFromUidCommonService { users_service };
// ユースケースを実行
let usecase = GetUserFromUidUsecase {
service: users_common_service,
};
usecase.exec(ctx, uid).await
}
// ユーザー更新
#[utoipa::path(
put,
path = "/api/v1/user/{uid}",
description = "対象ユーザー更新",
security(("bearerAuth" = [])),
responses(
(status = 200, description = "正常終了", body = UserModelResponseBody),
(status = 415, description = "Unsupported Media Type"),
(status = 422, description = "Unprocessable Entity"),
(status = 500, description = "Internal Server Error", body = error::CustomErrorResponseBody),
),
tag = "users",
)]
pub async fn update_user(
Path(uid): Path<String>,
Extension(ctx): Extension<Context>,
Json(body): Json<UpdateUserRequestBody>,
) -> Response {
// バリデーションチェックを実行
if let Err(e) = body.validate() {
let msg = Json(json!({ "message": e.to_string()}));
return (StatusCode::UNPROCESSABLE_ENTITY, msg).into_response();
}
// サービスのインスタンス化
let users_repo = Box::new(UsersRepository::new());
let users_common_repo = UsersCommonRepository { users_repo };
let users_service = UsersService::new(users_common_repo);
let users_common_service = UpdateUserCommonService { users_service };
// ユースケースを実行
let usecase = UpdateUserUsecase {
service: users_common_service,
};
usecase.exec(ctx, uid, body).await
}
// ユーザー削除(論理削除)
#[utoipa::path(
delete,
path = "/api/v1/user/{uid}",
description = "対象ユーザー削除(論理削除)",
security(("bearerAuth" = [])),
responses(
(status = 200, description = "正常終了", body = DeleteUserResponseBody),
(status = 500, description = "Internal Server Error", body = error::CustomErrorResponseBody),
),
tag = "users",
)]
pub async fn delete_user(Path(uid): Path<String>, Extension(ctx): Extension<Context>) -> Response {
// サービスのインスタンス化
let users_repo = Box::new(UsersRepository::new());
let users_common_repo = UsersCommonRepository { users_repo };
let users_service = UsersService::new(users_common_repo);
let users_common_service = DeleteUserCommonService { users_service };
// ユースケースを実行
let usecase = DeleteUserUsecase {
service: users_common_service,
};
usecase.exec(ctx, uid).await
}
・「src/api/handlers/users/mod.rs」
pub mod users_handler;
// テストコード用のモジュール
mod users_handler_1_test;
mod users_handler_2_test;
mod users_handler_3_test;
mod users_handler_4_test;
mod users_handler_5_test;
・「src/api/handlers/users/users_handler_1_test.rs」
#[cfg(test)]
use test_env_helpers::*;
#[before_each]
#[after_each]
#[cfg(test)]
// create_userのテスト
mod create_user_test {
use crate::api::databases::database::db_connection;
use crate::api::entities::prelude::{Users, UsersModel};
use sea_orm::EntityTrait;
// テスト前に実行する処理
async fn before_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// usersテーブルのデータを全て削除
Users::delete_many().exec(&db).await.unwrap();
}
// テスト後に実行する処理
async fn after_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// usersテーブルのデータを全て削除
Users::delete_many().exec(&db).await.unwrap();
}
#[tokio::test]
async fn it_response_ok() {
// リクエストを実行
let url = "http://localhost:8080/api/v1/user";
let data = serde_json::json!({
"last_name": "田中",
"first_name": "太郎",
"email": "t.tanaka@example.com"
});
let client = reqwest::Client::new();
let res = client.post(url).json(&data).send().await.unwrap();
// レスポンスステータスの検証
assert_eq!(res.status(), 201);
// レスポンスボディの検証
let text_body = res.text().await.unwrap();
let req_body: UsersModel = serde_json::from_str(&text_body).unwrap();
assert_eq!(req_body.last_name, "田中");
assert_eq!(req_body.first_name, "太郎");
assert_eq!(req_body.email, "t.tanaka@example.com");
}
}
・「src/api/handlers/users/users_handler_2_test.rs」
#[cfg(test)]
use test_env_helpers::*;
#[before_each]
#[after_each]
#[cfg(test)]
// get_usersのテスト
mod get_users_test {
use crate::api::databases::database::db_connection;
use crate::api::entities::prelude::{Users, UsersActiveModel, UsersModel};
use sea_orm::{EntityTrait, Set};
// テスト前に実行する処理
async fn before_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// ユーザー作成
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-001".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("太郎".to_string()),
email: Set("t.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-002".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("次郎".to_string()),
email: Set("ziro.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
}
// テスト後に実行する処理
async fn after_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// usersテーブルのデータを全て削除
Users::delete_many().exec(&db).await.unwrap();
}
#[tokio::test]
async fn it_response_ok() {
// リクエストを実行
let url = "http://localhost:8080/api/v1/users";
let client = reqwest::Client::new();
let res = client
.get(url)
.header("Authorization", format!("Bearer {}", "xxx"))
.send()
.await
.unwrap();
// レスポンスステータスの検証
assert_eq!(res.status(), 200);
// レスポンスボディの検証
let text_body = res.text().await.unwrap();
let req_body: Vec<UsersModel> = serde_json::from_str(&text_body).unwrap();
let data_len = req_body.len();
assert_eq!(data_len, 2);
assert_eq!(req_body[0].uid, "test-xxx-yyy-001");
assert_eq!(req_body[0].last_name, "田中");
assert_eq!(req_body[0].first_name, "太郎");
assert_eq!(req_body[0].email, "t.tanaka@example.com");
assert_eq!(req_body[1].uid, "test-xxx-yyy-002");
assert_eq!(req_body[1].last_name, "田中");
assert_eq!(req_body[1].first_name, "次郎");
assert_eq!(req_body[1].email, "ziro.tanaka@example.com");
}
}
・「src/api/handlers/users/users_handler_3_test.rs」
#[cfg(test)]
use test_env_helpers::*;
#[before_each]
#[after_each]
#[cfg(test)]
// get_userのテスト
mod get_user_test {
use crate::api::databases::database::db_connection;
use crate::api::entities::prelude::{Users, UsersActiveModel, UsersModel};
use sea_orm::{EntityTrait, Set};
// テスト前に実行する処理
async fn before_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// ユーザー作成
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-001".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("太郎".to_string()),
email: Set("t.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-002".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("次郎".to_string()),
email: Set("ziro.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
}
// テスト後に実行する処理
async fn after_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// usersテーブルのデータを全て削除
Users::delete_many().exec(&db).await.unwrap();
}
#[tokio::test]
async fn it_response_ok() {
// リクエストを実行
let url = "http://localhost:8080/api/v1/user/test-xxx-yyy-001";
let client = reqwest::Client::new();
let res = client
.get(url)
.header("Authorization", format!("Bearer {}", "xxx"))
.send()
.await
.unwrap();
// レスポンスステータスの検証
assert_eq!(res.status(), 200);
// レスポンスボディの検証
let text_body = res.text().await.unwrap();
let req_body: UsersModel = serde_json::from_str(&text_body).unwrap();
assert_eq!(req_body.uid, "test-xxx-yyy-001");
assert_eq!(req_body.last_name, "田中");
assert_eq!(req_body.first_name, "太郎");
assert_eq!(req_body.email, "t.tanaka@example.com");
}
}
・「src/api/handlers/users/users_handler_4_test.rs」
#[cfg(test)]
use test_env_helpers::*;
#[before_each]
#[after_each]
#[cfg(test)]
// get_usersのテスト
mod update_user_test {
use crate::api::databases::database::db_connection;
use crate::api::entities::prelude::{Users, UsersActiveModel, UsersModel};
use sea_orm::{EntityTrait, Set};
// テスト前に実行する処理
async fn before_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// ユーザー作成
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-001".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("太郎".to_string()),
email: Set("t.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-002".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("次郎".to_string()),
email: Set("ziro.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
}
// テスト後に実行する処理
async fn after_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// usersテーブルのデータを全て削除
Users::delete_many().exec(&db).await.unwrap();
}
#[tokio::test]
async fn it_response_ok() {
// リクエストを実行
let url = "http://localhost:8080/api/v1/user/test-xxx-yyy-001";
let client = reqwest::Client::new();
let data = serde_json::json!({
"last_name": "更新",
"first_name": "次郎",
"email": "z.update@example.com"
});
let res = client
.put(url)
.json(&data)
.header("Authorization", format!("Bearer {}", "xxx"))
.send()
.await
.unwrap();
// レスポンスステータスの検証
assert_eq!(res.status(), 200);
// レスポンスボディの検証
let text_body = res.text().await.unwrap();
let req_body: UsersModel = serde_json::from_str(&text_body).unwrap();
assert_eq!(req_body.uid, "test-xxx-yyy-001");
assert_eq!(req_body.last_name, "更新");
assert_eq!(req_body.first_name, "次郎");
assert_eq!(req_body.email, "z.update@example.com");
}
}
・「src/api/handlers/users/users_handler_5_test.rs」
#[cfg(test)]
use test_env_helpers::*;
#[before_each]
#[after_each]
#[cfg(test)]
// get_usersのテスト
mod update_user_test {
use crate::api::databases::database::db_connection;
use crate::api::entities::prelude::{Users, UsersActiveModel, UsersColumn, UsersModel};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
// テスト前に実行する処理
async fn before_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// ユーザー作成
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-001".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("太郎".to_string()),
email: Set("t.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
Users::insert(UsersActiveModel {
uid: Set("test-xxx-yyy-002".to_string()),
last_name: Set("田中".to_string()),
first_name: Set("次郎".to_string()),
email: Set("ziro.tanaka@example.com".to_string()),
..Default::default()
})
.exec(&db)
.await
.unwrap();
}
// テスト後に実行する処理
async fn after_each() {
// DB接続
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
// usersテーブルのデータを全て削除
Users::delete_many().exec(&db).await.unwrap();
}
// レスポンス結果の構造体
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Message {
message: String,
}
#[tokio::test]
async fn it_response_ok() {
// リクエストを実行
let url = "http://localhost:8080/api/v1/user/test-xxx-yyy-001";
let client = reqwest::Client::new();
let res = client
.delete(url)
.header("Authorization", format!("Bearer {}", "xxx"))
.send()
.await
.unwrap();
// レスポンスステータスの検証
assert_eq!(res.status(), 200);
// レスポンスボディの検証
let text_body = res.text().await.unwrap();
let req_body: Message = serde_json::from_str(&text_body).unwrap();
let result_res_body = Message {
message: "OK".to_string(),
};
assert_eq!(req_body, result_res_body);
// DBからユーザー件数の確認
let db = match db_connection().await {
Ok(db) => db,
Err(err) => {
panic!("DB接続エラー: {}", err);
}
};
let select_result = Users::find()
.filter(UsersColumn::DeletedAt.is_null())
.all(&db)
.await;
let users: Vec<UsersModel> = select_result.unwrap_or_default();
let data_count = users.len();
assert_eq!(data_count, 1);
}
}
次にファイル「src/api/handlers/mod.rs」を以下のように修正します。
pub mod sample;
pub mod users;
ルーティングファイルの修正
次にルーティングファイルを以下のように修正します。
・「src/api/router.rs」
// axum
use axum::{
Router, middleware,
routing::{delete, get, post, put},
};
// tower_http
use tower_http::{cors::CorsLayer, trace::TraceLayer};
// OpenAPI用
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme};
use utoipa::{Modify, OpenApi};
use utoipa_swagger_ui::SwaggerUi;
// configsモジュール
use super::configs::config;
// ハンドラー用のモジュール
use super::handlers::sample::sample_handler;
use super::handlers::users::users_handler;
// ミドルウェア用のモジュール
use super::middleware::common_middleware;
// OpenAPIの認証定義
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"bearerAuth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
// OpenAPIの設定
#[derive(OpenApi)]
#[openapi(
paths(
sample_handler::sample_get,
sample_handler::sample_get_path_query,
sample_handler::sample_post,
users_handler::create_user,
users_handler::get_users,
users_handler::get_user_from_uid,
users_handler::update_user,
users_handler::delete_user,
),
components(),
modifiers(&SecurityAddon),
info(
title = "rust-sample API",
version = "1.0",
description = "Rustのフレームワーク「axum」によるサンプルAPIです。"
)
)]
pub struct ApiDoc;
pub fn router() -> Router {
// 環境変数取得
let config = config::get_config();
// CORS設定
let origin = config.allow_origin;
let cors = CorsLayer::new()
.allow_origin(vec![origin.parse().unwrap()])
.allow_methods(vec![
"GET".parse().unwrap(),
"POST".parse().unwrap(),
"PUT".parse().unwrap(),
"DELETE".parse().unwrap(),
"OPTIONS".parse().unwrap(),
])
.allow_headers(vec![
"Content-Type".parse().unwrap(),
"Authorization".parse().unwrap(),
])
.allow_credentials(true);
// APIのグループ「v1」
let v1 = Router::new()
.route("/sample/get", get(sample_handler::sample_get))
.route(
"/sample/get/{id}",
get(sample_handler::sample_get_path_query),
)
.route("/sample/post", post(sample_handler::sample_post))
.route("/user", post(users_handler::create_user));
// 認証有りのAPIのグループ「v1_auth」
let v1_auth = Router::new()
.route("/users", get(users_handler::get_users))
.route("/user/{uid}", get(users_handler::get_user_from_uid))
.route("/user/{uid}", put(users_handler::update_user))
.route("/user/{uid}", delete(users_handler::delete_user))
// 認証用ミドルウェア設定
.layer(middleware::from_fn(common_middleware::auth_middleware));
// ルーター設定
let router = Router::new()
.nest("/api/v1", v1)
.nest("/api/v1", v1_auth)
// 共通ミドルウェアの設定(下から順番に読み込み)
.layer(middleware::from_fn(common_middleware::request_middleware))
.layer(TraceLayer::new_for_http().on_response(
|res: &axum::response::Response,
latency: std::time::Duration,
_span: &tracing::Span| {
// レスポンスヘッダーからX-Request-Idを取得
let request_id = match res.headers().get("X-Request-Id") {
Some(value) => value.to_str().unwrap_or("-").to_string(),
None => "-".to_string(),
};
// ログ出力
log::info!(
"[request_id={} status=({}) latency={}μs] finish request !!",
request_id,
res.status(),
latency.as_micros()
)
},
))
.layer(cors);
// 本番環境でない場合にOpenAPIを設定
if config.env != "production" {
let openapi = SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi());
return router.merge(openapi);
}
router
}
ユーザーAPIを試す
上記で5つのユーザーAPIを実装したので、それぞれ実行して試してみます。
まずはPOSTメソッドで「http://localhost:8080/api/v1/user」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
次にBearerトークンを適当に付与してGETメソッドで「http://localhost:8080/api/v1/users」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
次にBearerトークンを適当に付与してGETメソッドで「http://localhost:8080/api/v1/user/{対象ユーザーのuid}」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
次にBearerトークンを適当に付与してPUTメソッドで「http://localhost:8080/api/v1/user/{対象ユーザーのuid}」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
再度対象ユーザー取得APIを実行すると、ユーザー情報が更新されていることを確認できます。
次にBearerトークンを適当に付与してDELETEメソッドで「http://localhost:8080/api/v1/user/{対象ユーザーのuid}」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
再度全てのユーザー取得APIを実行すると、論理削除済みユーザーが取得できないことを確認できます。
次に以下のコマンドを実行し、DBコンテナの接続およびPostgreSQLに接続し、usersテーブルに論理削除済みのユーザーデータが存在することを確認します。
$ docker compose exec pg-db bash
/# PGPASSWORD=pg-password psql -U pg-user pg-db
pg-db=# \pset pager off
pg-db=# select uid, last_name, first_name, email, deleted_at from users;
※コマンド「\pset pager off」でSELECT文の表を見やすくしています。
SQL実行後、下図のようにデータが取得できればOKです。
OpenAPIのファイルを修正
次にOpenAPIのファイルを修正するため、まずはファイル「src/script_openapi.rs」にクレートを追加します。
#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! async-trait = "0.1.88"
//! axum = "0.8.4"
//! chrono = "0.4.41"
//! env_logger = "0.11.8"
//! envy = "0.4.2"
//! log = "0.4.27"
//! mockall = "0.13.1"
//! reqwest = { version = "0.12.15", features = ["json"] }
//! sea-orm = { version = "1.1.11", features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros" ] }
//! serde = { version = "1.0.219", features = ["derive"] }
//! serde_json = "1.0.140"
//! test-env-helpers = "0.2.2"
//! thiserror = "2.0.12"
//! tokio = { version = "1.45.0", features = ["full"] }
//! tower-http = { version = "0.6.4", features = ["trace", "cors"] }
//! tracing = "0.1.41"
//! utoipa = { version = "5.3.1", features = ["axum_extras"] }
//! utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
//! uuid = { version = "1.16.0", features = ["v4"] }
//! validator = { version = "0.20.0", features = ["derive"] }
//! ```
// OpenAPI用
use utoipa::OpenApi;
// ファイル出力用
use std::fs::File;
use std::io::Write;
use std::path::Path;
use serde_json;
// apiモジュール
mod api;
// routerモジュール
use api::router::ApiDoc;
fn main() {
// OpenAPIをファイルに出力
let output_path = Path::new("./src/api/openapi/openapi.json");
let json_string = serde_json::to_string_pretty(&ApiDoc::openapi()).unwrap();
let file = File::create(output_path);
let _ = file.unwrap().write_all(json_string.as_bytes());
}
次に以下のコマンドを実行し、ファイル「src/api/openapi/openapi.json」を更新します。
$ docker compose exec api rust-script ./src/script_openapi.rs
次に更新したOpenAPI仕様書を確認し、ユーザーAPIの仕様が追加されていればOKです。
テスト用DBに接続してユーザーAPIのテストを実行
次にユーザーAPIのテストを試しますが、テストにはテスト用DBに接続して実行させるようにするため、まずは以下のコマンドを実行し、テスト用の環境変数ファイルを作成します。
$ touch .env.testing
次にファイル「.env.testing」を以下のように記述します。
ENV=testing
PORT=8080
RUST_LOG=info
ALLOW_ORIGIN=http://localhost:3000
DATABASE_URL=postgres://testuser:test-password@pg-db:5432/testdb?sslmode=disable
次に以下のコマンドを実行し、テスト用環境変数ファイルを指定してDockerコンテナを再起動させます。
$ docker compose down
$ docker compose --env-file ./.env.testing up -d
コンテナのログ出力を確認し、「〜 INFO Start rust_api (ENV:testing) !!」が出力されていればOKです。
次に以下のコマンドを実行し、テスト用DBに対してマイグレーションを実行します。
$ docker compose exec api sea-orm-cli migrate up
次に以下のコマンドを実行し、テストを実行します。
$ docker compose exec -e CARGO_TEST=testing api cargo test -- --nocapture --test-threads=1
テスト実行後、全てのテストがPASSすればOKです。
※テストコードにデータ削除処理もいれているため、n回実行しても同じ結果が得られます。
本番環境用のDockerコンテナを作る
ローカル開発環境ではdocker-composeを使って各種コンテナを立てて開発しましたが、API用のコンテナを本番環境にデプロイしたい場合は、API用のコンテナを単独でビルドする必要があります。
まずは以下のコマンドを実行し、各種ファイルを作成します。
$ touch .env.production
$ mkdir -p docker/prod
$ touch docker/prod/Dockerfile
次に作成したファイルをそれぞれ以下のように記述します。
・「.env.production」
ENV=production
PORT=8080
RUST_LOG=info
ALLOW_ORIGIN=http://localhost
DATABASE_URL=postgres://pg-user:pg-password@host.docker.internal:5432/pg-db
※ここではローカルで試すだけなので問題ありませんが、実際の本番環境用にデプロイする場合は、.env.productionには機密情報(上記のDATABASE_URLなど)を記述しないようにして下さい。機密情報については各種インフラのシークレットサービスなどを利用して下さい。
・「docker/prod/Dockerfile」
# ####################
# # ビルドステージ
# ####################
FROM rust:1.87.0-alpine3.21 AS builder
WORKDIR /build
# ビルドに必要なパッケージをインストール
RUN apk update && \
apk add --no-cache \
openssl-dev \
alpine-sdk \
curl
COPY . .
# ビルド
RUN cargo build --release --locked
# ####################
# # 実行ステージ
# ####################
FROM alpine:3.21 AS runner
WORKDIR /app
# コンテナ用ユーザー作成
RUN addgroup --system --gid 1001 appuser && \
adduser --system --uid 1001 appuser
# ビルドステージで作成したバイナリをコピー
COPY --from=builder --chown=appuser:appuser /build/target/release/rust_api .
# ポートを設定
EXPOSE 8080
# コンテナ起動ユーザー設定
USER appuser
# APIサーバー起動コマンド
CMD ["./rust_api"]
$ docker build --no-cache -f ./docker/prod/Dockerfile -t rust-sample:latest .
$ docker run -d -p 80:8080 --env-file .env.production rust-sample:latest
次に以下のコマンドを実行し、ローカル環境用のDockerコンテナを再起動させます。
$ docker compose down
$ docker compose up -d
次に以下のコマンドを実行し、コンテナが起動されていることを確認します。
$ docker ps
コマンドを実行後、以下のように3つのコンテナが起動されていればOKです。
では単独で起動したAPI用のDockerコンテナからユーザーAPIが利用できるかを試してみます。
まずはPOSTメソッドで「http://localhost/api/v1/user」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
次にGETメソッドで「http://localhost/api/v1/users」を実行し、下図の通り仕様通りのレスポンス結果が出力されればOKです。
次に以下のコマンドを実行し、単独で起動したAPI用のDockerコンテナのログ出力を確認します。
$ docker logs コンテナ名
※コマンドのコンテナ名は対象のものに変更して下さい。
コマンド実行後、下図のように想定通りのログ出力がされていればOKです。
最後に
今回はRustのaxumでバックエンドAPIを開発する方法について解説しました。
Rustに関しては学習コストが高いことに加え、Go言語以上にまだまだ情報が少なく、情報を調べるのがとても大変でしたが、RustでAPIを開発する際に必要となる基本的な情報はまとめれたかなと思います。
これからRustでAPI開発を試したい方は、ぜひ参考にしてみて下さい!
コメント