🦖

Rust | Ntex と SQLx で REST API を開発する

2024/04/03に公開

Ntex とは

Rust の Web フレームワークである Ntex については以下の記事で説明しているので、割愛します!!

https://zenn.dev/collabostyle/articles/eeabc851943af2

State

アプリケーション内でリソースを共有するために State を使用します。

アプリケーションの初期化時に web::types::State<T> を渡し、アプリケーションを起動します。

そうすることで、ハンドラー関数からその State にアクセスすることができます。

以下のコードでは、AppStateapp_name にいつでもアクセスできるようになっています。

main.rs
use ntex::web;

struct AppState {
    app_name: String,
}

#[web::get("/")]
async fn index(data: web::types::State<AppState>) -> String {
    let app_name = &data.app_name;
    format!("Hello {app_name}!")
}

#[ntex::main]
async fn main() -> std::io::Result<()> {
    web::HttpServer::new(|| {
        web::App::new()
            .state(AppState {
                app_name: String::from("Ntex"),
            })
            .service(index)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

この State を使うことで、アプリ起動時に生成した DB クライアントなどを共有することができます。

Todo アプリを実装する

State のプロトタイピングとして Todo アプリを実装していきます。

エンドポイントの構成は以下です。

  • GET /todos?status={status_code}:Todo の一覧取得・検索
  • POST /todos:Todo の新規作成
  • GET /todos/{id}:Todo の詳細取得
  • PUT /todos/{id}:Todo の更新
  • DELETE /todos/{id}:Todo の削除

Gtihub でもソースコードは確認できます😉

https://github.com/codemountains/ntex-sqlx-todo

ディレクトリ構成

todo モジュールに各処理ごとのファイルを切っています。

ntex-sqlx-todo-app
├── Cargo.toml
├── migrations
│   └── 20240401035418_todo_table.sql
└── src
    ├── main.rs
    ├── db.rs
    ├── todo
    │   ├── create.rs
    │   ├── delete.rs
    │   ├── find.rs
    │   ├── get.rs
    │   └── update.rs
    └── todo.rs

マイグレーションファイル

簡単な Todo 管理のためのテーブルを作成します。

20240401035418_todo_table.sql
create table if not exists todos (
    id serial,
    title text not null,
    status text not null default 'working',
    constraint pk_todos_id primary key (id)
);

Cargo.toml

今回は DB クライアントライブラリとして SQLx を使用します🧰

Cargo.toml
[dependencies]
ntex = { version = "1.2.1", features = ["tokio"] }
serde = { version = "1.0.197", features = ["derive"] }
sqlx = { version = "0.7.4", features = ["runtime-tokio-rustls", "postgres"] }

SQLx については以下の記事で触れています。
興味がある方はぜひ見てみてください!

Shared Mutable State で DB クライアントを共有する

db.rs で PostgreSQL に接続するためのクライアントを生成します。

ここで生成したクライアントを State で扱うことができるようにします。

db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
use std::env;
use std::sync::Arc;

#[derive(Clone)]
pub struct Db(pub(crate) Arc<Pool<Postgres>>);

impl Db {
    pub async fn new() -> Db {
        let pool = PgPoolOptions::new()
            .max_connections(8)
            .connect(
                &env::var("DATABASE_URL").unwrap_or_else(|_| panic!("DATABASE_URL must be set!")),
            )
            .await
            .unwrap_or_else(|_| {
                panic!("Cannot connect to the database. Please check your configuration.")
            });

        Db(Arc::new(pool))
    }
}

App に対して state()db を渡します。

move を忘れないように注意しましょう!

main.rs
mod db;
mod todo;

use crate::db::Db;
use crate::todo::create::create_todo;
use crate::todo::delete::delete_todo;
use crate::todo::find::find_todos;
use crate::todo::get::get_todo;
use crate::todo::update::update_todo;
use ntex::web;

#[ntex::main]
async fn main() -> std::io::Result<()> {
    let db = Db::new().await;

+   web::HttpServer::new(move || {
        web::App::new()
+           .state(db.clone())
            .service(find_todos)
            .service(get_todo)
            .service(create_todo)
            .service(update_todo)
            .service(delete_todo)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

todo モジュールでハンドラー関数を実装する

核となる todo を定義します。

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

pub mod create;
pub mod delete;
pub mod find;
pub mod get;
pub mod update;

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

web::types::Query の実装

Todo の一覧取得・検索ができる処理を実装します。

検索のためにクエリパラメータを扱いたいので、web::types::Query<T> を使用しています。

todo/find.rs
use crate::db::Db;
use crate::todo::Todo;
use ntex::web;
use serde::{Deserialize, Serialize};
use sqlx::{Error, Pool, Postgres};
use std::fmt;

#[derive(Deserialize)]
struct FindTodosQuery {
    status: Option<FindTodosStatus>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum FindTodosStatus {
    Working,
    Done,
}

impl fmt::Display for FindTodosStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FindTodosStatus::Working => write!(f, "working"),
            FindTodosStatus::Done => write!(f, "done"),
        }
    }
}

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

#[web::get("/todos")]
pub async fn find_todos(
    db: web::types::State<Db>,
    query: web::types::Query<FindTodosQuery>,
) -> Result<impl web::Responder, web::Error> {
    match search(&db.0, query.0.status).await {
        Ok(todos) => {
            let resp = FindTodosResponse { todos };
            Ok(web::HttpResponse::Ok().json(&resp))
        }
        Err(e) => {
            eprintln!("{}", e);
            let resp = FindTodosResponse { todos: vec![] };
            Ok(web::HttpResponse::Ok().json(&resp))
        }
    }
}

async fn search(
    db: &Pool<Postgres>,
    search_param: Option<FindTodosStatus>,
) -> Result<Vec<Todo>, Error> {
    let search_status = if let Some(status) = search_param {
        status.to_string()
    } else {
        "".to_string()
    };

    sqlx::query_as!(
        Todo,
        r#"
            select
                *
            from
                todos
            where
                $1 = ''
                or
                status = $1
        "#,
        search_status
    )
    .fetch_all(db)
    .await
}

web::types::Json の実装

POST でリクエストのボディを受け取るために web::types::Json を実装します。

todo/create.rs
use crate::db::Db;
use crate::todo::Todo;
use ntex::web;
use serde::{Deserialize, Serialize};
use sqlx::{Error, Pool, Postgres};

#[derive(Deserialize)]
struct CreateTodoRequest {
    title: String,
    status: String,
}

#[derive(Serialize)]
struct CreateTodoResponse {
    todo: Todo,
}

#[web::post("/todos")]
pub async fn create_todo(
    db: web::types::State<Db>,
    todo: web::types::Json<CreateTodoRequest>,
) -> Result<impl web::Responder, web::Error> {
    match insert(&db.0, todo.0).await {
        Ok(todo) => {
            let resp = CreateTodoResponse {
                todo: Todo {
                    id: todo.id,
                    title: todo.title,
                    status: todo.status,
                },
            };

            Ok(web::HttpResponse::Ok().json(&resp))
        }
        Err(e) => {
            eprintln!("{}", e);
            Ok(web::HttpResponse::InternalServerError().into())
        }
    }
}

async fn insert(db: &Pool<Postgres>, todo: CreateTodoRequest) -> Result<Todo, Error> {
    sqlx::query_as!(
        Todo,
        r#"
            insert into
                todos ("title", "status")
            values
                ($1, $2)
            returning
                *
        "#,
        todo.title,
        todo.status
    )
    .fetch_one(db)
    .await
}

web::types::Path の実装

Todo の ID をパスパラメータから取得できるように web::types::Path を実装します。

todo/get.rs
use crate::db::Db;
use crate::todo::Todo;
use ntex::web;
use serde::{Deserialize, Serialize};
use sqlx::{Error, Pool, Postgres};

#[derive(Deserialize)]
struct GetTodoPath {
    todo_id: i32,
}

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

#[web::get("/todos/{todo_id}")]
pub async fn get_todo(
    db: web::types::State<Db>,
    path: web::types::Path<GetTodoPath>,
) -> Result<impl web::Responder, web::Error> {
    println!("{:?}", path.todo_id);

    match select_one(&db.0, path.todo_id).await {
        Ok(todo) => {
            let resp = GetTodoResponse {
                todo: Todo {
                    id: todo.id,
                    title: todo.title,
                    status: todo.status,
                },
            };
            Ok(web::HttpResponse::Ok().json(&resp))
        }
        Err(e) => {
            eprintln!("{}", e);
            Ok(web::HttpResponse::NotFound().into())
        }
    }
}

async fn select_one(db: &Pool<Postgres>, id: i32) -> Result<Todo, Error> {
    sqlx::query_as!(
        Todo,
        r#"
            select
                id,
                title,
                status
            from
                todos
            where
                id = $1
        "#,
        id
    )
    .fetch_one(db)
    .await
}

残りのエンドポイントも実装していきます。

todo/update.rs
use crate::db::Db;
use crate::todo::Todo;
use ntex::web;
use serde::{Deserialize, Serialize};
use sqlx::{Error, Pool, Postgres};

#[derive(Deserialize)]
struct UpdateTodoPath {
    todo_id: i32,
}

#[derive(Deserialize)]
struct UpdateTodoRequest {
    title: String,
    status: String,
}

#[derive(Serialize)]
struct UpdateTodoResponse {
    todo: Todo,
}

#[web::put("/todos/{todo_id}")]
pub async fn update_todo(
    db: web::types::State<Db>,
    path: web::types::Path<UpdateTodoPath>,
    todo: web::types::Json<UpdateTodoRequest>,
) -> Result<impl web::Responder, web::Error> {
    let target_todo = Todo {
        id: path.todo_id,
        title: todo.title.to_string(),
        status: todo.status.to_string(),
    };

    match update(&db.0, target_todo).await {
        Ok(todo) => {
            let resp = UpdateTodoResponse {
                todo: Todo {
                    id: todo.id,
                    title: todo.title,
                    status: todo.status,
                },
            };
            Ok(web::HttpResponse::Ok().json(&resp))
        }
        Err(e) => {
            eprintln!("{}", e);
            Ok(web::HttpResponse::InternalServerError().into())
        }
    }
}

async fn update(db: &Pool<Postgres>, todo: Todo) -> Result<Todo, Error> {
    sqlx::query_as!(
        Todo,
        r#"
            update
                todos
            set
                title = $2,
                status = $3
            where
                id = $1
            returning
                *
        "#,
        todo.id,
        todo.title,
        todo.status
    )
    .fetch_one(db)
    .await
}

削除の場合は結果のみを返したいので、
web::HttpResponse::NoContent() で 204 NO CONTENT を返しています。

todo/delete.rs
use crate::db::Db;
use ntex::web;
use serde::Deserialize;
use sqlx::postgres::PgQueryResult;
use sqlx::{Error, Pool, Postgres};

#[derive(Deserialize)]
struct DeleteTodoPath {
    todo_id: i32,
}

#[web::delete("/todos/{todo_id}")]
pub async fn delete_todo(
    db: web::types::State<Db>,
    path: web::types::Path<DeleteTodoPath>,
) -> Result<impl web::Responder, web::Error> {
    match delete(&db.0, path.todo_id).await {
        Ok(_) => Ok(web::HttpResponse::NoContent()),
        Err(e) => {
            eprintln!("{}", e);
            Ok(web::HttpResponse::InternalServerError())
        }
    }
}

async fn delete(db: &Pool<Postgres>, id: i32) -> Result<PgQueryResult, Error> {
    sqlx::query!(
        r#"
            delete from
                todos
            where
                id = $1
        "#,
        id
    )
    .execute(db)
    .await
}

まとめ

State を使って DB クライアントを共有して、DB からデータを取得できるようにしました。

Ntex の深掘りができたので、楽しいプロトタイピングができました🎉✨

まだまだ参考記事が少ないし、Ntex トピック の記事も増やしていけたらいいなぁ〜

コラボスタイル Developers

Discussion