Dua berkas pertama proyek ini belum berbau Docker sama sekali, dan memang seharusnya begitu. Aplikasi yang sadar-container tidak menulis kode khusus untuk Docker; ia membaca konfigurasi dari environment, mendengarkan port yang bisa disetel, dan tidak peduli di mana ia dikompilasi — pola twelve-factor yang sama dengan versi Go. Bagian ini menyusun manifest dependensi Cargo.toml, memetakan lima kelompok kode di src/main.rs, lalu membedah dua di antaranya yang paling dekat dengan konfigurasi container: pembacaan environment dan router axum. Docker baru masuk di Bagian 2; fondasi bahasanya kita pasang dulu supaya setiap keputusan build nanti punya konteks.

Manifest Dependensi #

Buat folder proyek aplikasi-rust-dan-postgresql lalu simpan Cargo.toml berikut di akarnya. Berkas ini adalah padanan go.mod di proyek Go — tempat seluruh dependensi dideklarasikan:

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"

Mari kita telaah isi manifest itu kelompok demi kelompok:

  1. [package] — nama api-katalog menentukan nama binary hasil kompilasi: target/release/api-katalog. Nama inilah yang disalin Dockerfile di Bagian 2, jadi mengubahnya berarti mengubah Dockerfile juga. Versi 1.0.0 sengaja sejajar dengan tag image katalog-rust-app:1.0 supaya keduanya mudah dirunut.
  2. tokio dengan fitur full — runtime async standar de facto ekosistem Rust. Axum dan tokio-postgres sama-sama async, jadi seluruh aplikasi berjalan di atas satu runtime multi-thread ini.
  3. axum = "0.8" — web framework ringan berbasis tower yang mengurus routing, extractor, dan respons JSON. Versi 0.8 adalah versi mayor yang kita pakai sepanjang proyek — dan satu sintaksnya berubah dibanding 0.7, kita bahas di ujung bagian ini.
  4. tokio-postgres dengan fitur with-serde_json-1 — driver PostgreSQL async yang ditulis murni dalam Rust, bukan pembungkus pustaka C libpq. Fitur ini menjembatani tipe JSON di database dengan serde_json milik aplikasi.
  5. deadpool-postgres — pool koneksi di atas tokio-postgres: sekumpulan koneksi siap pakai yang dipinjam saat query dan dikembalikan setelahnya. Kita bedah penuh di Bagian 3.
  6. serde dan serde_json — pasangan ini mengurus dua arah JSON: Serialize membentuk respons, Deserialize mengurai body request.
  7. reqwest sebagai dev-dependency — HTTP client khusus integration test. Perhatikan default-features = false: TLS bawaan reqwest menarik native-tls yang bergantung pada OpenSSL, padahal test kita hanya menembak HTTP polos di dalam jaringan Compose. Mematikannya membuat service test di Bagian 5 bisa memakai image rust:1.99-alpine polos tanpa memasang openssl-dev — keputusan kecil di manifest yang berdampak besar di Dockerfile.
  8. tokio dev-dependency macros + rt-multi-thread — mengaktifkan makro #[tokio::test] untuk berkas di folder tests/.
  9. [profile.release] — lto = true mengoptimalkan lintas crate, strip = true membuang simbol debug, opt-level = "s" mengoptimalkan untuk ukuran. Tiga baris ini mengecilkan binary — buahnya terlihat pada ukuran image di Bagian 2, kelanjutan kebiasaan dari chapter Mengurangi Ukuran Image.

Satu konsekuensi watak Rust langsung terasa: kompilasi pertama itu mahal. Sebagai kalibrasi, berikut tangkapan build pertama pada mesin uji course dengan toolchain Rust 1.99 — seluruh pohon dependensi diunduh lalu dikompilasi:

bash
$ cargo build --release

Output yang Dihasilkan:

text
   Compiling api-katalog v1.0.0 (/home/budi/katalog-rust)
    Finished `release` profile [optimized] target(s) in 50.31s

Angka 50 detik itu nyata dan akan berulang setiap kali cache kompilasi hilang — termasuk di dalam container builder yang selalu mulai dari nol. Bagian 2 menghadapi masalah ini langsung dengan trik dummy main yang menjauhkan 50 detik itu dari siklus edit-hari-kita.

Lima Kelompok main.rs #

Seluruh aplikasi hidup dalam satu berkas src/main.rs sepanjang 308 baris. Jangan khawatir — kode terbagi rapi menjadi lima kelompok, dan masing-masing dibedah di bagian yang tepat:

KelompokBentuk kodePeranDibedah di
Konfigurasistruct Konfigurasi + dari_env()Membaca APP_PORT, MIGRATIONS_DIR, dan variabel PG*Bagian ini
Pool koneksiblok di main()deadpool manager, kapasitas 10 koneksiBagian 3
Migrasiasync fn terapkan_migrasiSkema awal + tabel schema_migrationsBagian 3
Routerblok di main()Empat endpoint + with_state(pool)Bagian ini
Shutdowntokio::select! + sinyal_berakhir()SIGTERM/SIGINT dengan masa tenggang 10 detikBagian 4

Urutan eksekusi di main() mengikuti tabel di atas persis: baca environment, bangun pool, terapkan migrasi, pasang router, lalu melayani request sambil menunggu sinyal berakhir. Alurnya sama dengan aplikasi Go — hanya ditulis dengan warna Rust, di mana galat ditangani lewat Result dan konfigurasi berupa struct bertipe, bukan string DSN.

Konfigurasi dari Environment #

Kelompok pertama membaca seluruh konfigurasi saat startup. Inilah struct dan pembangunnya:

rust
/// 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,
        }
    }
}

Mari kita telaah empat keputusan di dalamnya:

  1. Closure ambil — pembantu kecil yang membaca satu variabel environment dengan nilai standar. Semua konfigurasi lewat jalur yang sama, jadi tidak ada environment yang terlupakan di satu cabang kode.
  2. tokio_postgres::Config dibangun lewat method chaining — berbeda dari versi Go yang merangkai string DSN (host=... port=...), di sini konfigurasi adalah objek bertipe. Salah ketik nama field tertangkap kompilator, dan port di-parse menjadi angka dengan cadangan unwrap_or(5432).
  3. Nilai standar dipilih cocok dengan service db Compose — PGHOST=db menunjuk nama service lewat DNS Compose. Nilai inilah yang juga dipanggang sebagai ENV bawaan di Dockerfile Bagian 2, sehingga image jalan tanpa konfigurasi pun sudah mengarah ke database yang benar.
  4. APP_PORT standar 8080 dan MIGRATIONS_DIR standar migrations — keduanya sengaja bisa ditimpa. Image nanti menimpa MIGRATIONS_DIR ke /app/migrations karena berkas SQL disalin ke sana, sementara APP_PORT 8080 tetap — port host 8081 urusan Compose, bukan aplikasi.

Pola “semua konfigurasi dari environment” ini persis materi chapter Environment Variable dan Environment dan env File: aplikasi tidak membedakan di mana ia berjalan, dan Compose menyuntikkan nilai lewat kunci environment saat runtime. Tidak ada password di dalam kode — PGPASSWORD bahkan sengaja tidak diberi ENV bawaan di Dockerfile.

Router axum #

Kelompok keempat memasang empat endpoint kita ke dalam satu router. Setelah pool dibangun (Bagian 3), blok berikut menyusun aplikasi:

rust
    // 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);

Mari kita telaah empat baris routenya:

  1. /healthz dengan get — satu metode, satu handler; endpoint kesehatan yang akan dipakai HEALTHCHECK image di Bagian 4.
  2. /api/items dengan get(...).post(...) — satu jalur, dua metode dirangkai dalam satu route. Method lain (misal DELETE) otomatis ditolak 405 tanpa perlu kode tambahan.
  3. /api/items/{id} — parameter jalur {id} otomatis didekode oleh extractor Path<i64> di handler; id yang bukan angka ditolak sebelum menyentuh database.
  4. with_state(pool) — pool ditanam sebagai state router. Axum meng-clone state untuk setiap request, sehingga handler cukup mendeklarasikan State<Pool> sebagai parameter dan pool berpindah tangan tanpa Arc manual — padanan penutupan sql.DB di handler Go.

Aplikasi kini lengkap di atas kertas: dependensi dideklarasikan, konfigurasi terbaca dari environment, dan router siap melayani empat endpoint. Belum ada satu baris Docker pun, dan justru itu bagus — semua keputusan yang membuatnya ramah container (environment variable, port bisa disetel, migrasi dari berkas) sudah tertanam di kode. Bagian berikutnya membungkusnya: Dockerfile multi-stage dengan trik dummy main yang menjaga 50 detik kompilasi dependensi tetap di cache, .dockerignore yang menyaring context, dan binary statis musl seukuran beberapa megabyte di atas Alpine.

Referensi #

  • Situs Resmi Rust — dokumentasi bahasa, Cargo, dan ekosistem crates sebagai rujukan utama.
  • Learn Rust — daftar buku resmi, termasuk The Book untuk fondasi kepemilikan dan sistem tipe.

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");
}