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:

rust
    // 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:

  1. ManagerConfig dengan RecyclingMethod::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.
  2. Manager::from_config(cfg.pg.clone(), NoTls, manager_config) — manager memegang konfigurasi PostgreSQL dan tahu cara membuka koneksi baru saat pool minta. NoTls dipilih karena jaringan internal Compose adalah jaringan terpercaya — enkripsi TLS di antar service dalam satu host adalah biaya tanpa manfaat.
  3. 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.
  4. Pool dibangun sekali, ditanam ke router lewat with_state — padanan membuka sql.DB sekali 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:

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:

rust
    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:

  1. Sebelum loop, fungsi menjalankan CREATE TABLE IF NOT EXISTS schema_migrations — buku catatan versi dengan kolom version TEXT PRIMARY KEY dan applied_at — lalu mendaftar seluruh berkas *.sql di MIGRATIONS_DIR dan mengurutkannya menurut nama. Prefix 001_, 002_ pada nama berkas itulah yang menentukan urutan terapan.
  2. SELECT EXISTS ... WHERE version = $1 — berkas yang sudah tercatat langsung dilewati. Inilah idempotensi: menjalankan ulang migrasi tidak melukai apa pun.
  3. tx.batch_execute(&isi) lalu INSERT INTO schema_migrations lalu tx.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.
  4. Log ke stderr lewat eprintln! — jejak migrasi masuk log container sehingga docker 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:

text
migrasi 001_create_items.sql diterapkan
server mendengarkan di :8080

Restart kedua memperlihatkan sifat idempotennya — berkas yang sama dilewati, data tidak tersentuh:

text
migrasi 001_create_items.sql sudah diterapkan, dilewati
server mendengarkan di :8080

Dan dari sisi database, buku catatannya bisa kita lihat langsung dari dalam container db (stack-nya baru menyala penuh di Bagian 4, tapi jejaknya begini):

bash
$ docker compose exec db psql -U katalog -d katalog -c '\dt'

Output yang Dihasilkan:

text
                List of tables
 Schema |       Name        | Type  |  Owner  
--------+-------------------+-------+---------
 public | items             | table | katalog
 public | schema_migrations | table | katalog
(2 rows)
bash
$ docker compose exec db psql -U katalog -d katalog -c 'SELECT version, applied_at FROM schema_migrations;'

Output yang Dihasilkan:

text
       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:

AspekProyek GoProyek Rust
Penanda versiTabel schema_migrations (version + applied_at)Sama persis
Satu berkas, satu transaksiYa — BeginTx + catat versi + CommitYa — client.transaction() + catat versi + commit
Lokasi kodedatabase.go — berkas terpisah khusus koneksi dan migrasiterapkan_migrasi inline di main.rs
Menunggu database siapOpen() mencoba ulang 10x dengan jeda 500 ms di dalam aplikasiTanpa retry — diserahkan ke depends_on: service_healthy
Pustaka migrasi eksternalTidak — runner tulisan tanganTidak — 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:

rust
/// 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:

rust
    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:

rust
    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:

  1. format! menyusun teks SQL dari konstanta KOLOM_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.
  2. $1 dan $2 adalah placeholder terikat — nilai dikirim terpisah dari teks SQL melalui protokol PostgreSQL extended query. Server menerima name dan note sebagai data mentah: tanda kutip, titik koma, dan komentar di dalamnya tidak pernah ditafsirkan sebagai SQL.
  3. RETURNING {KOLOM_ITEM} — PostgreSQL mengembalikan baris yang baru dibuat, lengkap dengan id hasil BIGSERIAL dan created_at hasil now(), tanpa query kedua. query_one langsung memetakan baris itu ke struct Item dan respons 201 terbentuk sekali jalan.
  4. Versi Go memakai $1/$2 yang 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

Cargo.toml

Unduh berkas
toml
[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 berkas
yaml
name: 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
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()
);

src/main.rs

Unduh berkas
rust
//! 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
rust
//! 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");
}