Seluruh keputusan tiga bagian sebelumnya — environment, migrasi, binary statis — bermuara pada satu pertanyaan praktis: apa cara paling pendek menghidupkan aplikasi beserta databasenya? Jawabannya compose.yaml tiga service yang kita susun di bagian ini. Ada pula dua hal yang hidup di luar compose.yaml tapi bekerja bergandengan dengannya: HEALTHCHECK yang terpanggang di dalam image sejak Bagian 2, dan graceful shutdown yang tertanam di kode sejak Bagian 1. Kita bedah ketiganya, lalu menutup dengan satu siklus hidup lengkap: stack menyala berurutan, dinyatakan sehat, melayani request dari host, dan berhenti dengan sopan saat menerima SIGTERM.
Service app dalam compose.yaml #
Definisi lengkap service aplikasi — delapan belas baris pertama compose.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-stoppedService db di bawahnya — postgres:18-alpine, environment POSTGRES_*, volume db-data, healthcheck pg_isready — identik dengan proyek Go dan sudah kita bedah tuntas di sana, jadi tidak diulang; fokus kita service app. Mari kita telaah tujuh kunci yang membentuknya:
name: katalog-rust— nama project Compose. Container otomatis bernamakatalog-rust-app-1dankatalog-rust-db-1, network menjadikatalog-rust_default, volume mendapat prefikskatalog-rust_— semuanya dari satu baris ini, dan semuanya terlihat di outputdocker psbawah.build: .+image: katalog-rust-app:1.0— image dibangun dari Dockerfile folder ini lalu diberi tag eksplisit.docker compose up --buildmembangun ulang bila perlu; tag yang sama dengan hasildocker buildBagian 2 membuat keduanya saling menyambung.ports: "8081:8080"— port host 8081 menerbitkan port container 8080. Angka sisi kirinya sengaja berbeda dari 8080 milik proyek Go: kedua stack bisa hidup berdampingan di satu host, sehingga kita bisa membandingkan respons Go dan Rust secara berdampingan dengancurltanpa mematikan salah satunya. Nilai dikutip sebagai string agar YAML tidak menafsirkan angka-angka ini sebagai bilangan desimal — jebakan kuno yang kita bahas di chapter Port Publishing.environmentPGHOST, PGPORT, PGUSER, PGDATABASE — menimpa nilai bawaan ENV image dengan nilai yang sama kecuali satu: penulisan eksplisit di compose membuat konfigurasi stack terbaca dari satu tempat tanpa membuka Dockerfile.PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diisi}— interpolasi variabel dengan operator:?: Compose menolak berjalan bilaPOSTGRES_PASSWORDkosong, lengkap dengan pesan kesalahan yang kita tulis sendiri. Nilainya dibaca dari berkas.envdi akar project — mekanisme yang sama dengan proyek Go, materi chapter Variable Interpolation dan Environment dan env File.depends_on: db: condition: service_healthy— aplikasi tidak dinyalakan sebelum healthcheckdblolos. Inilah lapisan kesiapan yang membuatterapkan_migrasiboleh percaya database sudah hidup — padanan retryOpen()milik versi Go yang kita bahas di Bagian 3.restart: unless-stopped— bila aplikasi crash (misalnya database menghilang), daemon menghidupkan ulang dengan jeda berlipat ganda;docker compose stopmanual tetap dihormati. Kita saksikan keduanya di akhir bagian ini.
Password untuk latihan cukup ditulis ke .env sekali di awal:
$ echo 'POSTGRES_PASSWORD=rahasia123' > .envBerkas ini tersaring oleh .dockerignore sejak Bagian 2, jadi isinya tidak pernah menyentuh image — ia hanya dibaca Compose di host untuk keperluan interpolasi.
Menghidupkan Stack #
Image sudah dibangun di Bagian 2, jadi build di sini sepenuhnya menyapa cache:
$ docker compose up -d --buildOutput yang Dihasilkan:
[+] Building 7.6s (14/14) FINISHED
=> CACHED [5/12] RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release 0.0s
...
=> => naming to docker.io/library/katalog-rust-app:1.0 0.0s
[+] Running 3/3
✔ Network katalog-rust_default Created 0.3s
✔ Container katalog-rust-db-1 Healthy 6.2s
✔ Container katalog-rust-app-1 Started 1.2sTiga baris ✔ itu adalah urutan kejadian yang kita rancang: network dibuat, database menunggu hingga Healthy (6.2 detik — healthcheck db berjalan tiap 2 detik), baru aplikasi Started. Kata Healthy dan Started sengaja berbeda — yang pertama menandakan healthcheck Compose lolos, yang kedua hanya proses hidup. Log aplikasi memperlihatkan upacara startup dari Bagian 3:
$ docker compose logs appOutput yang Dihasilkan:
migrasi 001_create_items.sql diterapkan
server mendengarkan di :8080Port yang didengarkan adalah 8080 — port di dalam container dari APP_PORT; angka 8081 baru muncul saat kita mengetuk dari host. Kondisi kedua container kini terbaca sekali pandang:
$ docker psOutput yang Dihasilkan:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9f2d6c8ba314 katalog-rust-app:1.0 "/usr/local/bin/api-…" 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8081->8080/tcp katalog-rust-app-1
e8a4170c92b6 postgres:18-alpine "docker-entrypoint.s…" 3 minutes ago Up 3 minutes (healthy) 5432/tcp katalog-rust-db-1Perhatikan dua hal pada kolom STATUS dan PORTS. Status (healthy) pada aplikasi berasal dari HEALTHCHECK yang terpanggang di image — Compose tidak mendefinisikan healthcheck untuk app, jadi probe dari Bagian 2 yang bekerja; (healthy) pada db berasal dari healthcheck compose.yaml yang menjalankan pg_isready. Kolom PORTS menegaskan pembagian tugas: aplikasi diterbitkan sebagai 0.0.0.0:8081->8080/tcp, sedangkan 5432/tcp pada database hanyalah catatan EXPOSE — port PostgreSQL tidak diterbitkan ke host, hanya bisa dijangkau dari dalam network, persis kebiasaan aman dari chapter Port Publishing.
Satu Putaran Uji dari Host #
Sekarang giliran bukti dari luar. Mengetuk pintu kesehatan lewat port host 8081:
$ curl -i http://localhost:8081/healthzOutput yang Dihasilkan:
HTTP/1.1 200 OK
content-type: application/json
content-length: 15
date: Tue, 13 Oct 2026 15:13:53 GMT
{"status":"ok"}Empat header itu bisa dibaca tanpa kacamata: content-length: 15 menghitung tepat panjang {"status":"ok"}, dan content-type: application/json adalah hasil axum membungkus Json<Value>. Membuat dua item — kopi dan teh, nama yang sama dengan proyek Go:
$ curl -s -X POST http://localhost:8081/api/items -H 'Content-Type: application/json' -d '{"name":"Kopi Torabika","note":"favorit pelanggan"}'Output yang Dihasilkan:
{"id":1,"name":"Kopi Torabika","note":"favorit pelanggan","created_at":"2026-10-13T15:13:53.335773Z"}$ curl -s -X POST http://localhost:8081/api/items -H 'Content-Type: application/json' -d '{"name":"Teh Melati","note":"non-kopi"}'Output yang Dihasilkan:
{"id":2,"name":"Teh Melati","note":"non-kopi","created_at":"2026-10-13T15:13:53.342270Z"}Respons 201 membawa id dari BIGSERIAL dan created_at dari now() — keduanya lahir di database, dikembalikan lewat RETURNING, diformat RFC 3339 UTC oleh to_char Bagian 3. Daftar dan satu item menyusul:
$ curl -s http://localhost:8081/api/itemsOutput yang Dihasilkan:
[{"id":1,"name":"Kopi Torabika","note":"favorit pelanggan","created_at":"2026-10-13T15:13:53.335773Z"},{"id":2,"name":"Teh Melati","note":"non-kopi","created_at":"2026-10-13T15:13:53.342270Z"}]$ curl -s http://localhost:8081/api/items/1Output yang Dihasilkan:
{"id":1,"name":"Kopi Torabika","note":"favorit pelanggan","created_at":"2026-10-13T15:13:53.335773Z"}Dan jalur yang tidak ada menutup putaran dengan 404 yang berbudi:
$ curl -i http://localhost:8081/api/items/999Output yang Dihasilkan:
HTTP/1.1 404 Not Found
content-type: application/json
content-length: 32
date: Tue, 13 Oct 2026 15:13:53 GMT
{"error":"item tidak ditemukan"}Dari sisi database, kedua item itu duduk rapi di dalam volume db-data — query yang sama dari Bagian 3 membuktikannya:
$ docker compose exec db psql -U katalog -d katalog -c 'SELECT id, name, note, created_at FROM items;'Output yang Dihasilkan:
id | name | note | created_at
----+---------------+-------------------+-------------------------------
1 | Kopi Torabika | favorit pelanggan | 2026-10-13 15:13:53.335773+00
2 | Teh Melati | non-kopi | 2026-10-13 15:13:53.34227+00
(2 rows)Data ini bertahan melewati docker compose down karena volume tidak ikut dibongkar — perilaku yang sudah kita buktikan tuntas di Proyek: Aplikasi Go dan PostgreSQL dan chapter Persistensi Database; docker compose down -v tetap menjadi gerbang pembuangan data bila memang dimaksudkan.
HEALTHCHECK pada Image #
Probe dari Bagian 2 bekerja diam-diam di balik status (healthy) tadi — inilah kembali barisnya dari Dockerfile:
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:8080/healthz || exit 1Mari kita telaah lima bagian yang membentuknya:
--interval=30s— jeda antar probe. Nilai bawaan Docker memang 30 detik; menuliskannya eksplisit membuat niat terbaca dan gampang diubah.--timeout=3s— batas waktu tiap probe sebelum dianggap gagal. Bawaannya 30 detik — terlalu longgar untukhealthzyang semestinya selesai dalam milidetik; probe yang menggantung harus cepat dinyatakan gagal.retriesbawaan 3 — tiga kegagalan beruntun sebelum status berubah menjadiunhealthy. Satu kepungan sesaat tidak menjatuhkan container.start-periodbawaan 0s — tidak ada masa tenggang pengecualian di awal hidup. Aplikasi kita siap dalam hitungan detik (migrasi satu tabel), jadi ini aman; bila aplikasimu lambat siap, naikkanstart-perioddi sini atau timpa lewat Compose — materi chapter HEALTHCHECK.CMD wget -qO-ke/healthzdengan|| exit 1— bentuk shell yang berjalan lewat/bin/sh -c. Exit code 0 berarti sehat;wget -qO-membuang keluaran dan|| exit 1memetakan segala kegagalan unduhan ke kode 1. Perhatikan alamatnya:localhostport 8080 — probe berjalan di dalam container, jadi ia mengetuk port container, bukan port host 8081.
Status hasil probe bisa dibaca dari docker ps (kolom STATUS) dan digali dari docker inspect — field .State.Health beserta riwayatnya, persis yang kita bedah di chapter Container Health. Compose juga bisa menimpa healthcheck per service, seperti yang dilakukan db dengan pg_isready.
Graceful Shutdown SIGTERM #
Bagian 1 menjanjikan shutdown yang sopan; inilah mekanismenya di main() — blok penunggu yang menggiring seluruh hidup server:
// 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");
}
}Mari kita telaah cara kerjanya:
with_graceful_shutdown(sinyal_berakhir())— begitu future sinyal selesai, axum berhenti menerima koneksi baru dan menyelesaikan request yang sedang diproses. Padanan langsungnya adalahsrv.Shutdownmilik aplikasi Go — materi chapter Graceful Shutdown.sinyal_berakhir()menangkap SIGTERM atau SIGINT (Ctrl-C) lewattokio::signal— dua cabangselect!di dalam fungsi itu, mana saja yang datang lebih dulu.- Cabang kedua blok di atas tidur 10 detik setelah sinyal tiba — masa tenggang paksa bila penyelesaian request berlarut; lewat 10 detik, proses berhenti sekalipun masih ada request menyesak. Angka ini cermin timeout 10 detik versi Go.
- Tanpa
shutdown_signaldi compose.yaml — Compose memang sudah mengirim SIGTERM secara default saatstop, dan aplikasi kita menangani justru sinyal itu, jadi tidak ada yang perlu diubah. Kuncistop_grace_periodbaru relevan bila masa tenggang 10 detik kurang panjang untuk request kita — ia memundurkan SIGKILL dari Docker, bukan mengganti sinyalnya.
Bukti dari terminal — hentikan aplikasi, lalu periksa jejaknya:
$ docker compose stop appOutput yang Dihasilkan:
[+] Running 1/1
✔ Container katalog-rust-app-1 Stopped 1.4s$ docker compose logs appOutput yang Dihasilkan:
migrasi 001_create_items.sql sudah diterapkan, dilewati
server mendengarkan di :8080
sinyal berakhir diterima, mematikan server...Baris ketiga adalah suara sinyal_berakhir() yang terpanggil — request yang sedang berjalan diberi kesempatan selesai, lalu proses keluar dengan kode 0, bukan dibunuh SIGKILL. Port host pun benar-benar tertutup:
$ curl -s http://localhost:8081/healthzOutput yang Dihasilkan:
curl: (7) Failed to connect to localhost port 8081 after 0 ms: Could not connect to serverExit code 7 dari curl berarti koneksi ditolak — perilaku yang kita kenal dari chapter Memahami Exit Code. Hidupkan kembali aplikasi dengan docker compose start app: migrasi dilewati, data dua item tetap ada, dan stack siap untuk keperluan Bagian 5.
Empat Kebijakan Restart, Sekali Lagi #
Pilihan unless-stopped pada kedua service bukan keputusan acak — ia satu dari empat kebijakan yang kita bedah di chapter Restart Policy:
| Kebijakan | Perilaku | Cocok untuk |
|---|---|---|
no | Tidak pernah me-restart (bawaan) | Pekerjaan sekali jalan |
on-failure[:maks] | Restart saat exit code non-nol, maksimum opsional | Batch job yang boleh dicoba ulang |
always | Selalu restart, termasuk setelah daemon hidup ulang | Service inti di mesin khusus |
unless-stopped | Seperti always, tapi menghormati stop manual | Service yang kita kelola tangan |
Jeda antar percobaan dimulai 100 ms dan berlipat ganda, jadi crash loop tidak menggerus CPU. unless-stopped dipilih karena pas dengan siklus yang baru saja kita jalankan: crash dihidupkan ulang otomatis (misalnya db sempat menghilang membuat terapkan_migrasi gagal), tapi docker compose stop yang kita lakukan barusan dihormati sebagai niat manusia — aplikasi tidak bangkit sendiri sampai kita memintanya.
Stack kini lengkap dan teruji dari sisi host: menyala berurutan, dinyatakan sehat, melayani empat endpoint, dan berhenti dengan sopan. Namun menguji dengan curl satu per satu adalah pekerjaan manusia — mesin bisa melakukannya lebih disiplin. Bagian terakhir menghadirkan penguji itu: empat test black-box yang berjalan dari container Rust terpisah, menembak HTTP ke aplikasi dan mereset tabel lewat koneksi database langsung, seluruhnya dinyalakan satu perintah docker compose --profile test run --rm test.
Referensi #
- Compose File Reference
— definisi kunci
ports,depends_on,environment, danrestart. - Start containers automatically — semantik empat kebijakan restart beserta jeda backoff-nya.
- docker container stop — CLI Reference — SIGTERM, masa tenggang bawaan 10 detik, dan SIGKILL.
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");
}