Rust | Ntex と SQLx で REST API を開発する
Ntex とは
Rust の Web フレームワークである Ntex については以下の記事で説明しているので、割愛します!!
State
アプリケーション内でリソースを共有するために State を使用します。
アプリケーションの初期化時に web::types::State<T>
を渡し、アプリケーションを起動します。
そうすることで、ハンドラー関数からその State にアクセスすることができます。
以下のコードでは、AppState
の app_name
にいつでもアクセスできるようになっています。
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 でもソースコードは確認できます😉
ディレクトリ構成
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 管理のためのテーブルを作成します。
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 を使用します🧰
[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 については以下の記事で触れています。
興味がある方はぜひ見てみてください!
- Rust | SQLx の macro
query!
とquery_as!
を使いこなす - Rust | SQLx の macro
query_file!
とquery_file_as!
で複雑な SQL クエリを分離する - Rust | SQLx で transaction & commit / rollbackを実装する
Shared Mutable State で DB クライアントを共有する
db.rs で PostgreSQL に接続するためのクライアントを生成します。
ここで生成したクライアントを State で扱うことができるようにします。
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
を忘れないように注意しましょう!
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 を定義します。
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>
を使用しています。
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
を実装します。
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
を実装します。
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
}
残りのエンドポイントも実装していきます。
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 を返しています。
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 トピック の記事も増やしていけたらいいなぁ〜
Discussion