Query database pertama tidak terjadi saat request datang, melainkan jauh lebih awal: saat container aplikasi menyala. Aplikasi kita membuka pool koneksi, menerapkan skema, baru kemudian melayani request — urutan yang membuat docker compose up cukup satu perintah tanpa langkah migrasi manual. Bagian ini membedah tiga lapisan integrasi tersebut: pool deadpool-postgres yang mengelola sepuluh koneksi, fungsi terapkan_migrasi yang mencatat versi skema ke tabel schema_migrations, dan handler yang memakai parameter terikat $1/$2 untuk setiap nilai pengguna. Sepanjang jalan kita bandingkan tiap keputusan dengan pendekatan versi Go, karena perbedaan keduanya justru yang paling mengajar.
Pool Koneksi deadpool-postgres #
Di dalam main(), tepat setelah Konfigurasi::dari_env(), pool dibangun dengan blok berikut:
// Pool koneksi dengan reuse agar query tidak membuka koneksi baru
// setiap saat; kapasitas 10 koneksi seperti versi Go.
let manager_config = ManagerConfig {
recycling_method: RecyclingMethod::Fast,
};
let manager = Manager::from_config(cfg.pg.clone(), NoTls, manager_config);
let pool = Pool::builder(manager)
.max_size(10)
.build()
.expect("gagal membangun pool koneksi");Mari kita telaah empat keputusan di dalamnya:
ManagerConfigdenganRecyclingMethod::Fast— kebijakan saat koneksi dikembalikan ke pool: pemeriksaan minimal tanpa ping ulang. Cukup untuk query pendek milik API kita; beban ping ulang justru memperlambat sirkulasi koneksi.Manager::from_config(cfg.pg.clone(), NoTls, manager_config)— manager memegang konfigurasi PostgreSQL dan tahu cara membuka koneksi baru saat pool minta.NoTlsdipilih karena jaringan internal Compose adalah jaringan terpercaya — enkripsi TLS di antar service dalam satu host adalah biaya tanpa manfaat.Pool::builder(manager).max_size(10)— paling banyak sepuluh koneksi hidup serentak; permintaan kesebelas menunggu hingga ada yang dikembalikan. Angka ini sama persis dengan versi Go — batas yang sopan bagi PostgreSQL dari satu instance aplikasi.- Pool dibangun sekali, ditanam ke router lewat
with_state— padanan membukasql.DBsekali lalu menutupnya per request di handler Go. Keduanya menolak anti-pola membuka koneksi baru per request: handshake TCP plus autentikasi PostgreSQL itu puluhan milidetik, terlalu mahal dibayar tiap kali.
max_size(10) juga berfungsi sebagai pagar arsitektur: berapa pun request yang berdesakan, container db tidak pernah diserbu lebih dari sepuluh koneksi dari satu instance aplikasi. Skala naik? Tambah instance aplikasi — tiap instance membawa poolnya sendiri, dan PostgreSQL mengatur jumlah klien total.
Migrasi Skema saat Startup #
Skema lahir dari satu berkas SQL kecil. Simpan sebagai migrations/001_create_items.sql:
-- Migrasi pertama: tabel utama items.
CREATE TABLE IF NOT EXISTS items (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Empat kolomnya memetakan struct Item dari Bagian 1: BIGSERIAL mengisi id bertambah sendiri, note diberi default string kosong selaras #[serde(default)], dan created_at diisi server lewat now(). Fungsi terapkan_migrasi menerapkannya sebelum router melayani request pertama — inti fungsinya begini:
for nama in berkas {
let sudah: bool = client
.query_one(
"SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)",
&[&nama],
)
.await?
.get(0);
if sudah {
eprintln!("migrasi {nama} sudah diterapkan, dilewati");
continue;
}
let isi = std::fs::read_to_string(format!("{direktori}/{nama}"))?;
let tx = client.transaction().await?;
tx.batch_execute(&isi).await?;
tx.execute("INSERT INTO schema_migrations (version) VALUES ($1)", &[&nama])
.await?;
tx.commit().await?;
eprintln!("migrasi {nama} diterapkan");
}Mari kita telaah alurnya langkah demi langkah:
- Sebelum loop, fungsi menjalankan
CREATE TABLE IF NOT EXISTS schema_migrations— buku catatan versi dengan kolomversion TEXT PRIMARY KEYdanapplied_at— lalu mendaftar seluruh berkas*.sqldiMIGRATIONS_DIRdan mengurutkannya menurut nama. Prefix001_,002_pada nama berkas itulah yang menentukan urutan terapan. SELECT EXISTS ... WHERE version = $1— berkas yang sudah tercatat langsung dilewati. Inilah idempotensi: menjalankan ulang migrasi tidak melukai apa pun.tx.batch_execute(&isi)laluINSERT INTO schema_migrationslalutx.commit()— SQL migrasi dan pencatatan versinya berbagi SATU transaksi. Gagal di tengah, keduanya batal bersama; tidak ada skema setengah jalan, tidak ada versi tercatat palsu.- Log ke stderr lewat
eprintln!— jejak migrasi masuk log container sehinggadocker compose logs app(Bagian 4) memperlihatkan persis berkas mana yang diterapkan.
Jejaknya memang terlihat langsung di log container ketika stack menyala — dua baris pertama dari seluruh upacara startup:
migrasi 001_create_items.sql diterapkan
server mendengarkan di :8080Restart kedua memperlihatkan sifat idempotennya — berkas yang sama dilewati, data tidak tersentuh:
migrasi 001_create_items.sql sudah diterapkan, dilewati
server mendengarkan di :8080Dan dari sisi database, buku catatannya bisa kita lihat langsung dari dalam container db (stack-nya baru menyala penuh di Bagian 4, tapi jejaknya begini):
$ docker compose exec db psql -U katalog -d katalog -c '\dt'Output yang Dihasilkan:
List of tables
Schema | Name | Type | Owner
--------+-------------------+-------+---------
public | items | table | katalog
public | schema_migrations | table | katalog
(2 rows)$ docker compose exec db psql -U katalog -d katalog -c 'SELECT version, applied_at FROM schema_migrations;'Output yang Dihasilkan:
version | applied_at
----------------------+-------------------------------
001_create_items.sql | 2026-10-13 15:13:51.752172+00
(1 row)Dua tabel, satu baris catatan — persis dua artefak yang dibanggakan pendekatan ini: skema yang menciptakan dirinya sendiri dan jejak kapan ia diciptakan. Tidak ada CREATE TABLE manual, tidak ada skrip initdb di luar image.
Dibanding Runner Migrasi Go #
Versi Go menjalankan pola yang sama lewat berkas database.go terpisah dengan schema_migrations-nya sendiri. Tabel berikut merangkum kedekatan dan perbedaan kedua pendekatan:
| Aspek | Proyek Go | Proyek Rust |
|---|---|---|
| Penanda versi | Tabel schema_migrations (version + applied_at) | Sama persis |
| Satu berkas, satu transaksi | Ya — BeginTx + catat versi + Commit | Ya — client.transaction() + catat versi + commit |
| Lokasi kode | database.go — berkas terpisah khusus koneksi dan migrasi | terapkan_migrasi inline di main.rs |
| Menunggu database siap | Open() mencoba ulang 10x dengan jeda 500 ms di dalam aplikasi | Tanpa retry — diserahkan ke depends_on: service_healthy |
| Pustaka migrasi eksternal | Tidak — runner tulisan tangan | Tidak — runner tulisan tangan |
Tiga poin terakhir layak dibedah. Pertama, keduanya sama-sama menolak library migrasi eksternal demi runner lima puluh baris yang perilakunya bisa dibaca tuntas — untuk satu tabel, dependensi tambahan justru biaya; chapter Migrasi Versi Database membahas kapan library mulai berbalas budi (down-migration, checksum, migrasi bercabang). Kedua, perbedaan paling nyata ada pada strategi menunggu database siap: Go memilih retry di dalam aplikasi sehingga tangguh bahkan di luar Compose, sedangkan Rust memilih percayai Compose — depends_on dengan condition: service_healthy menahan start sampai pg_isready lolos, dan bila database tetap tak tersedia, aplikasi gagal cepat lalu dihidupkan ulang oleh restart policy. Ketiga, Go memecah kode ke berkas sendiri sejak awal; Rust membiarkan semuanya di main.rs karena muat — pemisahan menyusul secara alami saat kode membengkak, bukan sebelum dibutuhkan.
Query dengan Parameter Terikat #
Seluruh query berbagi satu konstanta daftar kolom — dideklarasikan di puncak main.rs:
/// Kolom item yang dipakai semua query. created_at diformat RFC 3339 UTC
/// lewat to_char agar bentuknya sama seperti time.Time pada versi Go.
const KOLOM_ITEM: &str = "id, name, note, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"')";Konstanta ini yang membuat created_at tampil sebagai string RFC 3339 UTC — bentuk yang sama dengan time.Time versi Go, sehingga output JSON kedua API identik. Handler daftar memakainya begitu saja:
let baris = client
.query(
&format!("SELECT {KOLOM_ITEM} FROM items ORDER BY id"),
&[],
)
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal membaca items"))?;Handler pembuatan menyisipkan data pengguna dengan parameter terikat dan mengambil hasilnya dalam satu kali bolak-balik:
let baris = client
.query_one(
&format!("INSERT INTO items (name, note) VALUES ($1, $2) RETURNING {KOLOM_ITEM}"),
&[&name, &input.note],
)
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal menyimpan item"))?;Mari kita telaah cara kerja keduanya:
format!menyusun teks SQL dari konstantaKOLOM_ITEM— aman karena isinya kode kita sendiri yang ikut terkompilasi dan ter-review, bukan input pengguna. Bagian SQL yang dinamis dan bagian data pengguna dipisahkan dengan disiplin yang tegas.$1dan$2adalah placeholder terikat — nilai dikirim terpisah dari teks SQL melalui protokol PostgreSQL extended query. Server menerimanamedannotesebagai data mentah: tanda kutip, titik koma, dan komentar di dalamnya tidak pernah ditafsirkan sebagai SQL.RETURNING {KOLOM_ITEM}— PostgreSQL mengembalikan baris yang baru dibuat, lengkap denganidhasilBIGSERIALdancreated_athasilnow(), tanpa query kedua.query_onelangsung memetakan baris itu ke structItemdan respons 201 terbentuk sekali jalan.- Versi Go memakai
$1/$2yang sama lewat pgx — mekanisme parameter binding adalah fitur protokol PostgreSQL, bukan fitur bahasa; yang berbeda hanya pembungkus tipenya.
Skema, pool, dan query kini saling mengunci dalam satu binary statis. Yang belum ada adalah rumahnya: bagaimana binary ini dijalankan sebagai service, bagaimana ia menemukan database, kapan ia dinyatakan sehat, dan bagaimana ia berhenti dengan sopan. Bagian berikutnya menyusun seluruh definisi itu dalam compose.yaml — lengkap dengan keputusan kecil berdampak besar: port host 8081 yang membuat stack Rust ini hidup berdampingan dengan stack Go.
Referensi #
- PostgreSQL Documentation
— semantik
INSERT ... RETURNING, parameter binding protokol extended query, dan transaksi. - PostgreSQL di Docker Hub
— perilaku image resmi, inisialisasi
POSTGRES_*, dan utilitaspg_isready. - Control startup and shutdown order in Compose
— pola
depends_ondengancondition: service_healthyyang menggantikan retry aplikasi.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
Cargo.toml
Unduh berkas[package]
name = "api-katalog"
version = "1.0.0"
edition = "2021"
description = "REST API katalog item dengan Rust, axum, dan PostgreSQL"
license = "MIT"
[dependencies]
# Runtime async standar de facto.
tokio = { version = "1", features = ["full"] }
# Web framework ringan berbasis tower.
axum = "0.8"
# Driver PostgreSQL async.
tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] }
# Pool koneksi di atas tokio-postgres.
deadpool-postgres = "0.14"
# Serialisasi JSON untuk body request dan respons.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
# HTTP client untuk integration test black-box. default-features
# dimatikan agar tidak menarik native-tls (butuh OpenSSL): test hanya
# menembak http:// sehingga TLS tidak diperlukan.
reqwest = { version = "0.12", default-features = false, features = ["json"] }
# Fitur macros + rt-multi-thread untuk #[tokio::test] di tests/.
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
[profile.release]
lto = true
strip = true
opt-level = "s"
compose.yaml
Unduh berkasname: katalog-rust
services:
app:
build: .
image: katalog-rust-app:1.0
ports:
- "8081:8080"
environment:
PGHOST: db
PGPORT: "5432"
PGUSER: katalog
PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diisi}
PGDATABASE: katalog
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: katalog
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?wajib diisi}
POSTGRES_DB: katalog
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
interval: 2s
timeout: 2s
retries: 15
restart: unless-stopped
# Service test hanya berjalan dengan profile khusus:
# docker compose --profile test run --rm test
test:
profiles: ["test"]
image: rust:1.99-alpine
working_dir: /src
volumes:
- ./:/src
environment:
APP_URL: http://app:8080
PGHOST: db
PGPORT: "5432"
PGUSER: katalog
PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diisi}
PGDATABASE: katalog
depends_on:
app:
condition: service_started
db:
condition: service_healthy
command: cargo test
volumes:
db-data:
migrations/001_create_items.sql
Unduh berkas-- Migrasi pertama: tabel utama items.
CREATE TABLE IF NOT EXISTS items (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
src/main.rs
Unduh berkas//! Aplikasi katalog: REST API sederhana dengan axum yang menyimpan
//! daftar item di PostgreSQL lewat pool koneksi deadpool-postgres.
//!
//! Konfigurasi dibaca dari environment variable (pola twelve-factor):
//! APP_PORT menentukan port HTTP, MIGRATIONS_DIR lokasi berkas migrasi,
//! dan variabel PG* (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE)
//! menentukan koneksi PostgreSQL — sama seperti aplikasi Go pendahulunya.
use std::time::Duration;
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio_postgres::NoTls;
/// Kolom item yang dipakai semua query. created_at diformat RFC 3339 UTC
/// lewat to_char agar bentuknya sama seperti time.Time pada versi Go.
const KOLOM_ITEM: &str = "id, name, note, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"')";
/// Batas panjang field note, sama seperti versi Go.
const BATAS_CATATAN: usize = 200;
/// Konfigurasi koneksi database dan server HTTP.
struct Konfigurasi {
app_port: String,
migrations_dir: String,
pg: tokio_postgres::Config,
}
impl Konfigurasi {
/// Membaca APP_PORT, MIGRATIONS_DIR, dan variabel PG* dari environment.
/// Nilai default dipilih agar cocok dengan service `db` di compose.yaml.
fn dari_env() -> Self {
let ambil = |kunci: &str, standar: &str| {
std::env::var(kunci).unwrap_or_else(|_| standar.to_string())
};
let mut pg = tokio_postgres::Config::new();
pg.host(&ambil("PGHOST", "db"))
.port(ambil("PGPORT", "5432").parse().unwrap_or(5432))
.user(&ambil("PGUSER", "katalog"))
.password(&ambil("PGPASSWORD", "rahasia123"))
.dbname(&ambil("PGDATABASE", "katalog"));
Self {
app_port: ambil("APP_PORT", "8080"),
migrations_dir: ambil("MIGRATIONS_DIR", "migrations"),
pg,
}
}
}
/// Item adalah satu baris tabel items; derive Serialize menentukan bentuk JSON.
#[derive(Serialize)]
struct Item {
id: i64,
name: String,
note: String,
created_at: String,
}
impl Item {
/// Memetakan satu baris hasil query ke bentuk respons.
fn dari_baris(baris: &tokio_postgres::Row) -> Self {
Self {
id: baris.get(0),
name: baris.get(1),
note: baris.get(2),
created_at: baris.get(3),
}
}
}
/// Body pembuatan item baru; note boleh tidak ada (default string kosong).
#[derive(Deserialize)]
struct ItemBaru {
name: String,
#[serde(default)]
note: String,
}
/// Tipe galat handler: status HTTP + body JSON {"error": "..."}.
type GalatApi = (StatusCode, Json<Value>);
/// Menyusun respons error standar — padanan tulisError pada versi Go.
fn tulis_error(status: StatusCode, pesan: &str) -> GalatApi {
(status, Json(serde_json::json!({ "error": pesan })))
}
#[tokio::main]
async fn main() {
let cfg = Konfigurasi::dari_env();
// Pool koneksi dengan reuse agar query tidak membuka koneksi baru
// setiap saat; kapasitas 10 koneksi seperti versi Go.
let manager_config = ManagerConfig {
recycling_method: RecyclingMethod::Fast,
};
let manager = Manager::from_config(cfg.pg.clone(), NoTls, manager_config);
let pool = Pool::builder(manager)
.max_size(10)
.build()
.expect("gagal membangun pool koneksi");
terapkan_migrasi(&pool, &cfg.migrations_dir)
.await
.expect("gagal menjalankan migrasi");
// Pool ditanam sebagai state; axum meng-clone-nya untuk setiap
// request sehingga handler cukup meminta State<Pool>.
let app = Router::new()
.route("/healthz", get(handle_healthz))
.route("/api/items", get(handle_list_items).post(handle_create_item))
.route("/api/items/{id}", get(handle_get_item))
.with_state(pool);
let alamat = format!("0.0.0.0:{}", cfg.app_port);
let listener = tokio::net::TcpListener::bind(&alamat)
.await
.expect("gagal mengikat alamat");
eprintln!("server mendengarkan di :{}", cfg.app_port);
// Graceful shutdown: saat SIGTERM/SIGINT diterima, axum berhenti
// menerima koneksi baru lalu menyelesaikan request yang sedang
// berjalan. select! memberi masa tenggang 10 detik — padanan
// srv.Shutdown dengan timeout 10 detik pada aplikasi Go.
tokio::select! {
hasil = axum::serve(listener, app).with_graceful_shutdown(sinyal_berakhir()) => {
if let Err(galat) = hasil {
eprintln!("server berhenti dengan galat: {galat}");
}
}
_ = async {
sinyal_berakhir().await;
eprintln!("sinyal berakhir diterima, mematikan server...");
tokio::time::sleep(Duration::from_secs(10)).await;
} => {
eprintln!("masa tenggang 10 detik tercapai, memaksa berhenti");
}
}
}
/// Menangkap SIGTERM atau SIGINT (Ctrl-C) untuk shutdown bersih.
async fn sinyal_berakhir() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("gagal memasang handler Ctrl-C");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("gagal memasang handler SIGTERM")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
/// Menerapkan setiap berkas *.sql di direktori migrasi yang belum tercatat
/// di schema_migrations. Satu berkas berjalan dalam satu transaksi bersama
/// pencatatan versinya, sehingga keduanya sukses atau gagal bersama.
async fn terapkan_migrasi(
pool: &Pool,
direktori: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut client = pool.get().await?;
client
.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)",
&[],
)
.await?;
let mut berkas: Vec<String> = std::fs::read_dir(direktori)?
.filter_map(|entri| entri.ok())
.filter(|entri| entri.path().is_file())
.map(|entri| entri.file_name().to_string_lossy().into_owned())
.filter(|nama| nama.ends_with(".sql"))
.collect();
berkas.sort();
for nama in berkas {
let sudah: bool = client
.query_one(
"SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)",
&[&nama],
)
.await?
.get(0);
if sudah {
eprintln!("migrasi {nama} sudah diterapkan, dilewati");
continue;
}
let isi = std::fs::read_to_string(format!("{direktori}/{nama}"))?;
let tx = client.transaction().await?;
tx.batch_execute(&isi).await?;
tx.execute("INSERT INTO schema_migrations (version) VALUES ($1)", &[&nama])
.await?;
tx.commit().await?;
eprintln!("migrasi {nama} diterapkan");
}
Ok(())
}
/// GET /healthz — memastikan aplikasi DAN database sama-sama hidup.
async fn handle_healthz(State(pool): State<Pool>) -> Result<Json<Value>, GalatApi> {
let client = pool
.get()
.await
.map_err(|_| tulis_error(StatusCode::SERVICE_UNAVAILABLE, "database tidak merespons"))?;
client
.query_one("SELECT 1", &[])
.await
.map_err(|_| tulis_error(StatusCode::SERVICE_UNAVAILABLE, "database tidak merespons"))?;
Ok(Json(serde_json::json!({ "status": "ok" })))
}
/// GET /api/items — seluruh item sebagai array JSON.
async fn handle_list_items(State(pool): State<Pool>) -> Result<Json<Vec<Item>>, GalatApi> {
let client = pool
.get()
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal membuka koneksi"))?;
let baris = client
.query(
&format!("SELECT {KOLOM_ITEM} FROM items ORDER BY id"),
&[],
)
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal membaca items"))?;
Ok(Json(baris.iter().map(Item::dari_baris).collect()))
}
/// POST /api/items — validasi body lalu simpan item baru.
async fn handle_create_item(
State(pool): State<Pool>,
Json(body): Json<Value>,
) -> Result<(StatusCode, Json<Item>), GalatApi> {
// Bentuk body diperiksa manual agar pesan galat tetap konsisten.
let input: ItemBaru = serde_json::from_value(body)
.map_err(|_| tulis_error(StatusCode::BAD_REQUEST, "body JSON tidak valid"))?;
let name = input.name.trim().to_string();
if name.is_empty() {
return Err(tulis_error(StatusCode::BAD_REQUEST, "name wajib diisi"));
}
if input.note.chars().count() > BATAS_CATATAN {
return Err(tulis_error(
StatusCode::BAD_REQUEST,
"note maksimal 200 karakter",
));
}
let client = pool
.get()
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal membuka koneksi"))?;
let baris = client
.query_one(
&format!("INSERT INTO items (name, note) VALUES ($1, $2) RETURNING {KOLOM_ITEM}"),
&[&name, &input.note],
)
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal menyimpan item"))?;
Ok((StatusCode::CREATED, Json(Item::dari_baris(&baris))))
}
/// GET /api/items/{id} — satu item; 404 bila tidak ditemukan.
async fn handle_get_item(
State(pool): State<Pool>,
Path(id): Path<i64>,
) -> Result<Json<Item>, GalatApi> {
let client = pool
.get()
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal membuka koneksi"))?;
let baris = client
.query_opt(
&format!("SELECT {KOLOM_ITEM} FROM items WHERE id = $1"),
&[&id],
)
.await
.map_err(|_| tulis_error(StatusCode::INTERNAL_SERVER_ERROR, "gagal membaca item"))?;
match baris {
Some(baris) => Ok(Json(Item::dari_baris(&baris))),
None => Err(tulis_error(StatusCode::NOT_FOUND, "item tidak ditemukan")),
}
}
tests/integration_test.rs
Unduh berkas//! Integration test black-box untuk API katalog.
//!
//! Test ini tidak memanggil fungsi internal aplikasi; yang diperiksa adalah
//! perilaku HTTP persis seperti yang dirasakan klien sungguhan. Aplikasi
//! harus sudah berjalan di APP_URL (default http://localhost:8080) dengan
//! database yang dapat dijangkau lewat TEST_DATABASE_URL atau variabel PG*.
//! Jalankan selama stack Compose berjalan:
//!
//! docker compose --profile test run --rm test
//!
//! Hanya `create_list_get_item` yang mereset tabel items: dua test lainnya
//! tidak menulis data sehingga seluruh test aman dijalankan paralel.
use std::time::{Duration, Instant};
use serde_json::{json, Value};
/// Alamat aplikasi yang diuji (bisa ditimpa APP_URL).
fn app_url() -> String {
std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".into())
}
/// Menyusun konfigurasi koneksi PostgreSQL untuk reset data:
/// TEST_DATABASE_URL diutamakan, jika kosong variabel PG* dirangkai
/// dengan nilai default lokal.
fn konfigurasi_db() -> tokio_postgres::Config {
if let Ok(url) = std::env::var("TEST_DATABASE_URL") {
return url.parse().expect("TEST_DATABASE_URL tidak valid");
}
let ambil = |kunci: &str, standar: &str| {
std::env::var(kunci).unwrap_or_else(|_| standar.to_string())
};
let mut cfg = tokio_postgres::Config::new();
cfg.host(&ambil("PGHOST", "localhost"))
.port(ambil("PGPORT", "5432").parse().unwrap_or(5432))
.user(&ambil("PGUSER", "katalog"))
.password(&ambil("PGPASSWORD", "rahasia123"))
.dbname(&ambil("PGDATABASE", "katalog"));
cfg
}
/// Mengosongkan tabel items dan mengembalikan counter id ke 1 sehingga
/// test dimulai dari kondisi yang sama.
async fn reset_items() {
let (client, koneksi) = konfigurasi_db()
.connect(tokio_postgres::NoTls)
.await
.expect("gagal membuka koneksi database test");
tokio::spawn(async move {
if let Err(galat) = koneksi.await {
eprintln!("koneksi database test terputus: {galat}");
}
});
client
.execute("TRUNCATE items RESTART IDENTITY", &[])
.await
.expect("gagal truncate items");
}
/// Menunggu /healthz siap, paling lama 30 detik — container sering butuh
/// beberapa detik sebelum benar-benar siap menerima request.
async fn tunggu_server() {
let tenggat = Instant::now() + Duration::from_secs(30);
while Instant::now() < tenggat {
if let Ok(resp) = reqwest::get(format!("{}/healthz", app_url())).await {
if resp.status().is_success() {
return;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
panic!("aplikasi di {} tidak siap setelah 30 detik", app_url());
}
/// Mengirim POST dengan body JSON ke path dan mengembalikan responsnya.
async fn post_json(path: &str, body: Value) -> reqwest::Response {
reqwest::Client::new()
.post(format!("{}{}", app_url(), path))
.json(&body)
.send()
.await
.expect("permintaan POST gagal")
}
#[tokio::test]
async fn healthz_ok() {
tunggu_server().await;
let resp = reqwest::get(format!("{}/healthz", app_url()))
.await
.expect("permintaan healthz gagal");
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.expect("healthz bukan JSON");
assert_eq!(body["status"], "ok");
}
#[tokio::test]
async fn create_list_get_item() {
tunggu_server().await;
reset_items().await;
// Buat item baru: id pertama setelah reset harus 1.
let resp = post_json(
"/api/items",
json!({"name": "Kopi Torabika", "note": "untuk pengujian"}),
)
.await;
assert_eq!(resp.status(), 201);
let dibuat: Value = resp.json().await.expect("create bukan JSON");
assert_eq!(dibuat["id"], 1);
assert_eq!(dibuat["name"], "Kopi Torabika");
assert_eq!(dibuat["note"], "untuk pengujian");
assert!(dibuat["created_at"].as_str().is_some());
// Daftar memuat tepat satu item.
let resp = reqwest::get(format!("{}/api/items", app_url()))
.await
.expect("permintaan list gagal");
assert_eq!(resp.status(), 200);
let items: Value = resp.json().await.expect("list bukan JSON");
assert_eq!(items.as_array().map(Vec::len), Some(1));
assert_eq!(items[0]["name"], "Kopi Torabika");
// Ambil kembali berdasarkan id.
let resp = reqwest::get(format!("{}/api/items/1", app_url()))
.await
.expect("permintaan get gagal");
assert_eq!(resp.status(), 200);
let didapat: Value = resp.json().await.expect("get bukan JSON");
assert_eq!(didapat["id"], 1);
assert_eq!(didapat["name"], "Kopi Torabika");
assert_eq!(didapat["note"], "untuk pengujian");
}
#[tokio::test]
async fn get_item_404() {
tunggu_server().await;
let resp = reqwest::get(format!("{}/api/items/999", app_url()))
.await
.expect("permintaan get gagal");
assert_eq!(resp.status(), 404);
let body: Value = resp.json().await.expect("error bukan JSON");
assert_eq!(body["error"], "item tidak ditemukan");
}
#[tokio::test]
async fn create_validasi_400() {
tunggu_server().await;
// name kosong (hanya spasi) ditolak sebelum menyentuh database.
let resp = post_json("/api/items", json!({"name": " ", "note": ""})).await;
assert_eq!(resp.status(), 400);
let body: Value = resp.json().await.expect("error bukan JSON");
assert_eq!(body["error"], "name wajib diisi");
// note melebihi batas 200 karakter.
let resp = post_json(
"/api/items",
json!({"name": "Kopi Gayo", "note": "x".repeat(201)}),
)
.await;
assert_eq!(resp.status(), 400);
let body: Value = resp.json().await.expect("error bukan JSON");
assert_eq!(body["error"], "note maksimal 200 karakter");
}