Menghubungkan Axum ke PostgreSQL dengan sqlx: compile-time checked query, pool & migration, lalu membangun REST CRUD lengkap yang memadukan routing, extractors, state, dan error handling dari episode sebelumnya.

Ini episode "sungguhan": aplikasi kita mulai menyentuh data. Hingga episode 8, semua handler mengembalikan string atau JSON statis. Mulai sekarang, kita membangun REST CRUD penuh — Create, Read, Update, Delete — dengan PostgreSQL via sqlx, library async yang punya satu keunggulan unik: query diverifikasi terhadap skema database saat kompilasi.
Mengapa sqlx dan bukan ORM? Karena kalian tetap menulis SQL — kontrol penuh atas query — tapi dengan keamanan tipe: hasil query dipetakan ke struct Rust secara otomatis, dan error SQL (nama kolom salah, tipe tidak cocok) tertangkap oleh compiler. Ini selaras dengan filosofi Axum: type-safe tanpa menyembunyikan apa yang terjadi di balik layar.
Pastikan PostgreSQL berjalan (kita sudah menyiapkannya di episode 0 dengan Docker). Buat database:
docker exec -it axum-db psql -U postgres -c "CREATE DATABASE axum_blog;"Tambah dependencies:
cargo add sqlx --features runtime-tokio,tls-rustls,postgres,macros,migrate
cargo add uuid --features v4,serde
cargo add chrono --features serdeFitur yang kita pilih:
| Fitur | Alasan |
|---|---|
runtime-tokio | Berjalan di atas tokio (harus, karena Axum) |
tls-rustls | TLS murni Rust untuk koneksi DB jarak jauh |
postgres | Driver PostgreSQL |
macros | query!, query_as! — query compile-time check |
migrate | Migration bawaan sqlx |
Note
Query compile-time check (query!/query_as!) membutuhkan database yang berjalan saat cargo build. Ini fitur yang hebat sekaligus menyulitkan: CI harus menyediakan DB, atau kalian beralih ke mode offline dengan file .sqlx (dibuat via cargo sqlx prepare). Kita pakai mode online selama development.
sqlx punya sistem migration sederhana berbasis SQL file. Buat lewat CLI:
cargo install sqlx-cli --no-default-features --features postgres,rustls
export DATABASE_URL="postgres://postgres:axum-dev@localhost:5432/axum_blog"
sqlx migrate add create_posts_tableIni membuat folder migrations/ dengan file ber-timestamp. Isi dengan skema tabel posts:
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);gen_random_uuid() membutuhkan ekstensi pgcrypto di PostgreSQL lama; di PostgreSQL 13+ ini built-in. Jalankan migration:
sqlx migrate runPgPool adalah kumpulan koneksi yang dibagikan ke semua handler. Buat pool di main dan simpan di state:
use sqlx::postgres::{PgPool, PgPoolOptions};
async fn create_pool(database_url: &str) -> PgPool {
PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url)
.await
.expect("gagal konek ke database")
}
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL wajib disetel");
let state = AppState {
db: create_pool(&database_url).await,
};
let app = build_app(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}max_connections(10) cukup untuk development; tuning pool yang benar dibahas di episode 25. DATABASE_URL dari env var — formalisasi config dilakukan di episode 16.
Definisikan struct yang merepresentasikan baris tabel:
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Post {
pub id: uuid::Uuid,
pub title: String,
pub body: String,
pub published: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
pub struct NewPost {
pub title: String,
pub body: String,
pub published: Option<bool>,
}
#[derive(Deserialize)]
pub struct UpdatePost {
pub title: Option<String>,
pub body: Option<String>,
pub published: Option<bool>,
}Lalu mapping sqlx::Error ke AppError (dari episode 6) agar handler bisa memakai operator ?:
use sqlx::error::Error as SqlxError;
impl From<SqlxError> for AppError {
fn from(err: SqlxError) -> Self {
tracing::error!("database error: {err}");
match err {
SqlxError::RowNotFound => AppError::NotFound,
_ => AppError::Internal(err.to_string()),
}
}
}Ini contoh nyata From yang kita janjikan di episode 6: sqlx::Error::RowNotFound → 404, error lain → 500.
Sekarang handler lengkapnya. Perhatikan kombinasi State + Path + Json:
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use crate::{
error::{AppError, AppResult},
models::{NewPost, Post, UpdatePost},
state::AppState,
};
use serde_json::json;
pub async fn list_posts(
State(state): State<AppState>,
) -> AppResult<Json<Vec<Post>>> {
let posts = sqlx::query_as::<_, Post>(
"SELECT id, title, body, published, created_at, updated_at FROM posts ORDER BY created_at DESC",
)
.fetch_all(&state.db)
.await?;
Ok(Json(posts))
}
pub async fn get_post(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> AppResult<Json<Post>> {
let post = sqlx::query_as::<_, Post>(
"SELECT id, title, body, published, created_at, updated_at FROM posts WHERE id = $1",
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(post))
}
pub async fn create_post(
State(state): State<AppState>,
Json(body): Json<NewPost>,
) -> AppResult<(StatusCode, Json<Post>)> {
let post = sqlx::query_as::<_, Post>(
"INSERT INTO posts (title, body, published) VALUES ($1, $2, $3)
RETURNING id, title, body, published, created_at, updated_at",
)
.bind(&body.title)
.bind(&body.body)
.bind(body.published.unwrap_or(false))
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(post)))
}
pub async fn update_post(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
Json(body): Json<UpdatePost>,
) -> AppResult<Json<Post>> {
let post = sqlx::query_as::<_, Post>(
"UPDATE posts SET
title = COALESCE($2, title),
body = COALESCE($3, body),
published = COALESCE($4, published),
updated_at = NOW()
WHERE id = $1
RETURNING id, title, body, published, created_at, updated_at",
)
.bind(id)
.bind(&body.title)
.bind(&body.body)
.bind(body.published)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(post))
}
pub async fn delete_post(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> AppResult<StatusCode> {
let result = sqlx::query("DELETE FROM posts WHERE id = $1")
.bind(id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}Poin-poin yang perlu diperhatikan:
query_as::<_, Post> — hasil query dipetakan ke Post dengan compile-time check: jika nama kolom/tipe tidak cocok dengan struct, build gagal.$1 — placeholder PostgreSQL; binding via .bind(). Bukan string concatenation → kebal terhadap SQL injection. Ini keunggulan utama dibanding menulis SQL string manual.COALESCE pada update — hanya update field yang diisi (patch semantics)..ok_or(AppError::NotFound)? — mengubah None menjadi error 404 kita.Warning
Jangan pernah menyusun SQL dengan format string seperti format!("SELECT * FROM users WHERE id = {}", id). Selalu pakai placeholder ($1) + .bind(). Ini pertahanan utama terhadap SQL injection; kita bahas sisanya di episode 18.
Terakhir, susun route (pola REST dari episode 4):
use axum::{routing::{get, patch, post, delete}, Router};
use crate::state::AppState;
pub fn posts_routes() -> Router<AppState> {
Router::new()
.route("/posts", get(list_posts).post(create_post))
.route(
"/posts/:id",
get(get_post).patch(update_post).delete(delete_post),
)
}Di main.rs, tambahkan route ini ke aplikasi. Uji dengan curl:
curl -s -X POST localhost:3000/posts \
-H 'Content-Type: application/json' \
-d '{"title":"Hello Axum","body":"Posting pertama","published":true}'
curl -s localhost:3000/posts
curl -s -X PATCH localhost:3000/posts/<UUID_DARI_HASIL> \
-H 'Content-Type: application/json' \
-d '{"title":"Judul baru"}'
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:3000/posts/<UUID>Urutan ini — create → list → patch → delete — memvalidasi seluruh alur CRUD.
Anda pasti melihat query SELECT yang sama berulang (list, get, create, update). Ini wajar di awal, tapi bisa dirapikan dengan bantuan view atau SELECT yang dibuat const. Untuk series ini biarkan dulu — prioritas adalah memahami pola, bukan menghafal optimasi.
Pada episode 9 ini aplikasi kalian resmi menyentuh database:
runtime-tokio, postgres, macros, migrate.sqlx migrate.PgPool dibagikan via AppState.query_as!/query_as, placeholder $1 + .bind (anti SQL injection), COALESCE untuk patch.From<sqlx::Error> for AppError menghubungkan database ke sistem error kita.Di episode 10 selanjutnya kita uji semua ini dengan testing: unit test handler lewat oneshot, integration test, dan cara mem-mock database — supaya CRUD kalian tidak hanya berjalan, tapi terbukti benar. Sampai jumpa di episode 10!