Belajar Rust,
pelan-pelan saja.
Dari baris kode pertama hingga REST API Axum + MariaDB. Tiap konsep dijelaskan langkah demi langkah, dilengkapi contoh, latihan, dan jawaban.
Satu perjalanan, empat tahap
Tak perlu menghafal semuanya. Ikuti bab sesuai urutan dan lihat bagaimana proyek task manager berkembang menjadi API.
Fondasi Rust
Bab 0–6 · Terminal, Cargo, tipe data, fungsi, ownership, koleksi.
Model & logika
Bab 7–11 · Struct, error, module, trait, testing.
Aplikasi & server
Bab 12–16 · CLI, async, JSON, Axum, CRUD in-memory.
API dengan database
Bab 17–18 · MariaDB + SQLx dan REST API dengan data persisten.
Mulai tiap bab dari panel “Proyek lengkap”. Buka panel itu, letakkan tiap file di alamat yang tertera (atau buka file yang sudah ada di folder contoh/), lalu jalankan perintah terminal dalam bab. Setelah program berjalan, baca blok-blok kecil di bawah panel untuk memahami baris penting. Blok berlabel “bagian dari …” adalah penjelasan cuplikan, bukan kode yang perlu dirangkai ulang. Coba latihan, baru buka solusinya. Bab 0–1 berupa panduan persiapan sehingga tidak punya proyek Cargo.
Kenali terminal dan editor
Tujuan: tahu di mana mengetik perintah dan di mana mengubah kode. Rust belum diperlukan.
Mulai di VS Code Server
Jika memakai VS Code Remote-SSH: sambungkan ke Ubuntu, pilih File → Open Folder → /home/sysadmin/lab/belajar-rust. Jika memakai code-server melalui browser, buka folder yang sama. Terminal terintegrasi berada di Ubuntu server, bukan di laptopmu.
cd /home/sysadmin/lab/belajar-rust
pwd
ls
cd 0-inisialisasi
pwd
cd ..cd pindah direktori; pwd mencetak lokasi; ls mencetak isi. .. berarti folder induk. Baris terakhir kembali ke folder kursus. Tanda $ di tutorial lain biasanya hanya prompt, bukan bagian perintah.
Peta berkas
README.md memuat materi; LATIHAN.md berisi tantangan; SOLUSI.md berisi jawaban. Mulai bab 2, contoh/Cargo.toml adalah identitas proyek dan contoh/src/main.rs adalah sumber program. Menyimpan berkas di editor tidak otomatis menjalankan program.
Latihan 0 · Pindah folder
Masuk bab 0, tampilkan lokasinya, kembali ke folder kursus; prediksi dua keluaran pwd.
Perlihatkan solusi
cd /home/sysadmin/lab/belajar-rust/0-inisialisasi
pwd
cd ..
pwdLokasi pertama berakhir /0-inisialisasi, lokasi kedua /belajar-rust.
Instalasi Rust dan peralatan
Ini panduan untuk kamu jalankan sendiri saat siap. Membuka HTML tidak memasang apa pun.
Kenali tiga alat
rustup mengelola versi Rust; rustc mengompilasi sumber; Cargo mengelola proyek, dependency, build dan test. Editor VS Code Server bukan pengganti compiler. Cek dahulu:
command -v rustup
command -v rustc
command -v cargocommand -v mencetak lokasi program jika tersedia. Jika tidak keluar apa pun, program mungkin belum terpasang atau belum ada di PATH.
Langkah 1 · Paket dasar Ubuntu
sudo apt update
sudo apt install curl build-essential pkg-config ca-certificatessudo memberi izin admin, apt update memperbarui daftar paket, apt install memasangnya. curl untuk unduhan dan mencoba API; build-essential menyediakan linker/compiler C; pkg-config membantu pencarian pustaka native; sertifikat CA dipakai saat HTTPS.
Langkah 2 · Rust stable lewat rustup
cd /home/sysadmin/lab/belajar-rust/1-instalasi
curl --proto '=https' --tlsv1.2 --fail --show-error --location https://sh.rustup.rs --output rustup-init.sh
# Baca rustup-init.sh di editor sebelum melanjutkan:
sh ./rustup-init.sh --default-toolchain stable
source "$HOME/.cargo/env"
rustup show active-toolchain
rustc --version
cargo --version
cc --versioncurl menyimpan script resmi sebagai berkas; sh menjalankannya hanya sesudah kamu membacanya. Jangan pakai sudo untuk rustup. source mengaktifkan Cargo pada terminal saat ini. Angka versi tidak perlu sama dengan milik orang lain.
Langkah 3 · VS Code Server dan database nanti
Pada Remote-SSH: Extensions → cari rust-analyzer (penerbit rust-lang) → Install in SSH. Pada code-server: pasang ekstensi dari panel Extensions browser. Buka terminal baru setelah rustup agar PATH terbaca. MariaDB baru wajib di bab 17:
sudo apt install mariadb-server mariadb-client
mariadb --versionJika server database sudah ada dan dikelola orang lain, minta satu database/akun belajar; jangan pasang ulang. Git opsional: sudo apt install git.
Latihan 1 · Siapa mengerjakan apa?
Di Remote-SSH, Rust dipasang di laptop atau server? Bedakan rustup, rustc dan Cargo.
Perlihatkan solusi
Di server Ubuntu, karena di sana kode dan terminal berjalan. rustup mengelola toolchain, rustc mengompilasi, Cargo mengatur proyek dan pengujian.
Hello World dan Cargo
Program dimulai dari main. Cargo membaca Cargo.toml lalu membangun sumber di src/main.rs.
Mulai dari proyek utuh berikut: buat dua berkas pada lokasi yang tertera, lalu jalankan perintah di bawah. Setelah melihat hasilnya, baca pembahasan kode yang mengikuti panel ini.
Proyek lengkap bab 2 — Cargo.toml dan src/main.rs
2-hello-world-dan-cargo/contoh/Cargo.toml
[package]
name = "bab02-hello"
version = "0.1.0"
edition = "2021"
[dependencies]2-hello-world-dan-cargo/contoh/src/main.rs
/// Program pertama: menyapa dan mencetak hasil hitungan sederhana.
/// Menjalankan: cargo run
fn main() {
// println! adalah macro (perhatikan tanda seru).
// {} adalah lubang yang diisi nilai setelah koma.
println!("Halo, Rust!");
println!("Perkenalkan, saya program daftar tugas.");
// Lubang bisa diisi langsung dari nama variabel,
// atau dari hasil perhitungan.
let tahun = 2026;
println!("Belajar di tahun {tahun}.");
println!("2 + 3 = {}", 2 + 3);
// Panggil fungsi biasa yang kita buat sendiri.
let pesan = sapaan("Rustacean baru");
println!("{pesan}");
}
/// Fungsi sederhana: menerima &str, mengembalikan String.
fn sapaan(nama: &str) -> String {
format!("Selamat datang, {nama}!")
}
#[cfg(test)]
mod tests {
use super::sapaan;
#[test]
fn sapaan_berisi_nama() {
assert_eq!(sapaan("Andi"), "Selamat datang, Andi!");
}
}Identitas proyek (Cargo.toml)
[package]
name = "bab02-hello"
version = "0.1.0"
edition = "2021"
[dependencies][package] memulai metadata; name juga nama program hasil build; edition menentukan aturan bahasa; daftar dependency masih kosong karena hanya memakai pustaka standar.
Bagian dari src/main.rs: cetak sesuatu
fn main() {
println!("Halo, Rust!");
println!("Perkenalkan, saya program daftar tugas.");
let tahun = 2026;
println!("Belajar di tahun {tahun}.");
println!("2 + 3 = {}", 2 + 3);
let pesan = sapaan("Rustacean baru");
println!("{pesan}");
}Penjelasan per-baris/bagian:
fn main() {—fnadalah kata kunci untuk membuat fungsi.mainadalah nama khusus: fungsi ini yang pertama dijalankan program.{membuka blok isi fungsi.println!("Halo, Rust!");—println!adalah macro (perhatikan tanda seru!— itu wajib). Tugasnya mencetak teks ke layar lalu pindah baris."Halo, Rust!"adalah teks yang dicetak.;mengakhiri satu pernyataan.println!("Perkenalkan, saya program daftar tugas.");— sama seperti di atas, mencetak teks lain.let tahun = 2026;—letmembuat variabel bernamatahunyang menyimpan nilai angka2026.letberarti "beri nama pada nilai ini".println!("Belajar di tahun {tahun}.");—{tahun}adalah placeholder: tempat kosong di dalam teks yang akan diisi nilai variabeltahun. Hasil cetak:Belajar di tahun 2026.println!("2 + 3 = {}", 2 + 3);—{}adalah placeholder kosong yang diisi nilai setelah koma, yaitu hasil perhitungan2 + 3(yaitu 5). Hasil cetak:2 + 3 = 5.let pesan = sapaan("Rustacean baru");—sapaan(...)memanggil fungsi lain bernamasapaandengan argumen teks"Rustacean baru". Hasil dari fungsi itu disimpan di variabelpesan.println!("{pesan}");— mencetak isi variabelpesan(hasil dari fungsisapaan).}— menutup blok isi fungsimain.
Kenapa begini? Setiap program Rust harus punya fn main() — tanpa itu program tidak tahu harus mulai dari mana. println! adalah cara paling sederhana melihat hasil program. let adalah cara menyimpan nilai agar bisa dipakai lagi nanti.
Bagian dari src/main.rs: fungsi sapaan
fn sapaan(nama: &str) -> String {
format!("Selamat datang, {nama}!")
}Penjelasan per-baris/bagian:
fn sapaan(nama: &str) -> String {— membuat fungsi bernamasapaan.nama— parameter (data yang diterima fungsi saat dipanggil). Nama ini dipakai di dalam fungsi.: &str— tipe parameter: pinjaman teks (&str).&artinya fungsi hanya meminjam teks, tidak memilikinya. Kenapa pinjaman? Agar pemanggil tetap memiliki teksnya dan fungsi tidak perlu membuat salinan — lebih hemat.-> String— tipe kembalian: fungsi menghasilkanString(teks yang dimiliki, bukan pinjaman). KenapaStringbukan&str? Karena fungsi membuat teks baru, jadi harus mengembalikan teks yang dimiliki, bukan pinjaman ke sesuatu yang sudah ada.{— membuka blok isi fungsi.
format!("Selamat datang, {nama}!")—format!adalah macro yang membuat String baru dari template.{nama}diisi nilai parameternama. Hasilnya misalnya"Selamat datang, Rustacean baru!".- Baris ini tidak diakhiri titik koma — dalam Rust, baris terakhir fungsi tanpa
;artinya nilai itu yang dikembalikan. Kalau pakai;, fungsi mengembalikan()(kosong) dan compiler error karena tipe kembalian dijanjikanString. }— menutup blok isi fungsi.
Kenapa begini? Fungsi memecah program menjadi bagian yang bisa dipakai ulang. Parameter &str membuat fungsi fleksibel: bisa dipanggil dengan literal "teks" maupun &String. Tipe kembalian String membuat hasil fungsi bisa disimpan dan dipakai lagi.
cd /home/sysadmin/lab/belajar-rust/2-hello-world-dan-cargo/contoh
cargo run
cargo check
cargo testrun mengompilasi dan menjalankan, check cepat memeriksa kode, test menjalankan pengujian.
Latihan 2 · Luas persegi
Buat fungsi yang menerima sisi 4 dan mengembalikan 16.
Perlihatkan solusi
fn luas_persegi(sisi: u32) -> u32 {
sisi * sisi
}u32 adalah bilangan bulat tidak negatif; sisi * sisi tidak diakhiri titik koma agar menjadi hasil fungsi. Panggil dari main dengan println!("Luas: {}", luas_persegi(4));.
Variabel dan tipe
Rust menjadikan variabel tetap sebagai bawaan. Perubahan harus disengaja.
Buka proyek lengkap lebih dulu. Letakkan berkas sesuai namanya, jalankan cargo run dari folder contoh, kemudian baca penjelasan di bawah untuk memahami tiap bagian.
Proyek lengkap bab 3 — Cargo.toml dan src/main.rs
3-variabel-dan-tipe-data/contoh/Cargo.toml
[package]
name = "bab03-variabel"
version = "0.1.0"
edition = "2021"
[dependencies]3-variabel-dan-tipe-data/contoh/src/main.rs
/// Menyimpan satu tugas dengan variabel, lalu mengubahnya.
/// Menjalankan: cargo run
// Konstanta: nama kapital, tipe wajib ditulis, dihitung saat kompilasi.
const MAKS_JUDUL: usize = 200;
fn main() {
// Variabel immutable: label ini tidak bisa dipindah ke nilai lain.
let id: i64 = 1;
let judul = "Belajar variabel"; // tipe ditebak: &str
let mut selesai = false; // mut: isi boleh diganti
println!("Tugas #{id}: {judul} ({})", status_teks(selesai));
selesai = true; // OK karena ada mut
println!("Setelah diubah: ({})", status_teks(selesai));
// Shadowing: variabel BARU menutupi nama lama, tipe boleh beda.
let jumlah = "42";
let jumlah: u32 = jumlah.parse().expect("bukan angka");
println!("Shadowing: \"42\" -> {jumlah}");
println!("Konstanta batas judul: {MAKS_JUDUL}");
// Konversi angka dengan `as`: memotong desimal, bukan membulatkan.
let pecahan: f64 = 3.9;
let bulat = pecahan as i32;
println!("Konversi: {pecahan} -> {bulat}");
// char adalah SATU karakter Unicode.
let tanda: char = '✓';
println!("Tanda selesai: {tanda}");
}
/// Mengubah bool menjadi teks ramah.
fn status_teks(selesai: bool) -> &'static str {
if selesai {
"sudah selesai"
} else {
"belum selesai"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn konversi_memotong_desimal() {
assert_eq!(3.9_f64 as i32, 3);
assert_eq!(-3.9_f64 as i32, -3);
}
#[test]
fn batas_judul_masuk_akal() {
assert!(MAKS_JUDUL > 0);
assert_eq!(status_teks(true), "sudah selesai");
}
}Potongan dari main.rs: tetap vs berubah
const MAKS_JUDUL: usize = 200;
let id: i64 = 1;
let judul = "Belajar variabel";
let mut selesai = false;
selesai = true;Penjelasan per-baris/bagian:
const MAKS_JUDUL: usize = 200;—constmembuat konstanta (nilai tetap yang tidak pernah berubah).MAKS_JUDULadalah namanya (huruf kapital sesuai kebiasaan Rust untuk konstanta).: usizeadalah tipenya:usizeadalah bilangan bulat tak-negatif untuk ukuran/indeks (cocok untuk "maksimal 200 karakter").200adalah nilainya. Konstanta dihitung saat kompilasi, bukan saat program berjalan.let id: i64 = 1;—letmembuat variabelid.: i64adalah tipenya: bilangan bulat 64-bit bertanda (bisa negatif). Kenapai64? Karena ID database nanti memakaiBIGINTyang cocok dengani64.1adalah nilai awalnya.let judul = "Belajar variabel";—letmembuat variabeljudul. Kali ini tipe tidak ditulis — Rust menebak sendiri dari nilai: teks literal"..."bertipe&str. Ini namanya type inference.let mut selesai = false;—let mutmembuat variabelselesaiyang boleh diubah (mutable).mutadalah kata kunci yang mengizinkan perubahan.falseadalah nilai awalnya (boolean: benar/salah).selesai = true;— mengubah nilaiselesaimenjaditrue. Ini hanya bisa karena variabel diberimut. Kalau tanpamut, compiler menolak.
Kenapa begini? Rust menjadikan variabel tetap (immutable) sebagai bawaan — ini mencegah perubahan tak sengaja. Kalau memang perlu berubah, kamu harus menulis mut secara eksplisit. Konstanta const untuk nilai yang memang tidak pernah berubah. Tipe ditulis eksplisit (: i64, : usize) saat perlu jelas; dibiarkan ditebak saat sudah jelas dari nilai.
Shadowing: nama sama, tipe baru
let jumlah = "42";
let jumlah: u32 = jumlah.parse().expect("bukan angka");
println!("Shadowing: \"42\" -> {jumlah}");Penjelasan per-baris/bagian:
let jumlah = "42";—letmembuat variabeljumlahyang menyimpan teks"42"(tipe&str). Perhatikan: ini teks, bukan angka.let jumlah: u32 = jumlah.parse().expect("bukan angka");— ini shadowing:letkedua dengan nama sama (jumlah) membuat variabel baru yang menutupi variabel lama.: u32— tipe variabel baru: bilangan bulat 32-bit tak-negatif. Jadi tipe berubah dari teks menjadi angka.jumlah.parse()— memanggil methodparse()pada teksjumlahuntuk mengubahnya menjadi angka. Tipe tujuan ditentukan oleh anotasi: u32..expect("bukan angka")—parse()mengembalikanResult(bisa berhasilOkatau gagalErr).expectmengambil nilaiOk; kalauErr, program berhenti (panic) dengan pesan"bukan angka". Untuk input dari pengguna, jangan pakaiexpect— pakaiResult(bab 8). Di sini aman karena inputnya literal yang pasti valid.
println!("Shadowing: \"42\" -> {jumlah}");— mencetak hasil.\"adalah escape untuk tanda kutip di dalam teks.{jumlah}diisi nilai variabel baru (angka 42). Hasil:Shadowing: "42" -> 42.
Kenapa begini? Shadowing berguna saat kamu perlu mengubah tipe tanpa membuat nama variabel baru (jumlah_teks, jumlah_angka dst.). Variabel lama tidak bisa dipakai lagi setelah di-shadow. Ini berbeda dengan mut yang mengubah nilai tapi tipe tetap sama.
Konversi dan karakter
let pecahan: f64 = 3.9;
let bulat = pecahan as i32;
let tanda: char = '✓';
println!("Konversi: {pecahan} -> {bulat}");Penjelasan per-baris/bagian:
let pecahan: f64 = 3.9;—letmembuat variabelpecahan.: f64adalah tipenya: bilangan pecahan (floating point) 64-bit.3.9adalah nilainya.let bulat = pecahan as i32;—as i32mengubah tipe darif64(pecahan) kei32(bilangan bulat). Hasilnya3— perhatikan:asmemotong desimal, bukan membulatkan. Jadi 3.9 menjadi 3, bukan 4.let tanda: char = '✓';—charadalah tipe untuk satu karakter Unicode. Ditulis dengan petik tunggal'✓'(bukan petik ganda"..."yang untuk teks).✓adalah karakter ceklis.println!("Konversi: {pecahan} -> {bulat}");— mencetak kedua variabel. Hasil:Konversi: 3.9 -> 3.
Kenapa begini? f64 untuk bilangan pecahan (harga, rata-rata). i32 untuk bilangan bulat (jumlah, indeks). as mengubah tipe tapi bisa memotong nilai — hati-hati. char untuk satu huruf/simbol; teks (lebih dari satu karakter) pakai String atau &str.
cd /home/sysadmin/lab/belajar-rust/3-variabel-dan-tipe-data/contoh
cargo run
cargo testLatihan 3 · Durasi sebagai angka
Ubah teks "90" ke u32 lewat shadowing dan cetak “Durasi: 90 menit”.
Perlihatkan solusi
let durasi = "90";
let durasi: u32 = durasi.parse().expect("bukan angka");
println!("Durasi: {durasi} menit");let kedua sengaja dipakai karena tipe berubah dari teks menjadi bilangan.
Fungsi, if, match, perulangan
Fungsi memecah program menjadi pekerjaan kecil. Kondisi memilih, loop mengulang.
Kode penuh di bawah sudah memuat main, fungsi pendukung, dan test. Simpan ke dua berkas sesuai judul, jalankan, lalu baca penjelasan potongan berikutnya.
Proyek lengkap bab 4 — Cargo.toml dan src/main.rs
4-fungsi-dan-control-flow/contoh/Cargo.toml
[package]
name = "bab04-fungsi"
version = "0.1.0"
edition = "2021"
[dependencies]4-fungsi-dan-control-flow/contoh/src/main.rs
/// Fungsi, keputusan, dan pengulangan pada daftar tugas sederhana.
/// Menjalankan: cargo run
fn main() {
// Daftar tugas: pasangan (judul, sudah_selesai).
let tugas = [
("Belajar fungsi", false),
("Belajar variabel", true),
("Belajar loop", false),
];
tampilkan(&tugas);
let teks = ringkasan(&tugas);
println!("Ringkasan: {teks}");
println!("Prioritas #1: {}", prioritas(1));
println!("Prioritas #5: {}", prioritas(5));
hitung_mundur(3);
}
/// Menampilkan setiap tugas dengan kotak centang.
/// `&[(...)]` adalah slice: daftar yang DIPINJAMKAN, bukan dipindahkan (bab 5).
fn tampilkan(tugas: &[(&str, bool)]) {
for (judul, selesai) in tugas {
// if sebagai expression: memilih nilai tanpa variabel perantara.
let tanda = if *selesai { "x" } else { " " };
println!("- [{tanda}] {judul}");
}
}
/// Membuat kalimat ringkasan. Baris terakhir tanpa titik koma = nilai kembali.
fn ringkasan(tugas: &[(&str, bool)]) -> String {
let mut selesai = 0_u32;
for (_, s) in tugas {
if *s {
selesai += 1;
}
}
let total = tugas.len() as u32;
format!("{total} tugas, {selesai} selesai, sisa {}", total - selesai)
}
/// match pada angka: compiler memastikan semua kemungkinan ditangani.
fn prioritas(nomor: u32) -> &'static str {
match nomor {
1 => "tinggi",
2 | 3 => "sedang", // | artinya "atau"
_ => "biasa", // _ menangkap semua sisanya
}
}
/// while: ulangi selama kondisi benar. Cocok bila jumlah ulang tak pasti.
fn hitung_mundur(mut dari: u32) {
print!("Hitung mundur:");
while dari > 0 {
print!(" {dari}");
dari -= 1;
}
println!(" selesai!");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ringkasan_menghitung_dengan_benar() {
let tugas = [("a", true), ("b", false), ("c", true)];
assert_eq!(ringkasan(&tugas), "3 tugas, 2 selesai, sisa 1");
}
#[test]
fn ringkasan_daftar_kosong() {
assert_eq!(ringkasan(&[]), "0 tugas, 0 selesai, sisa 0");
}
#[test]
fn prioritas_semua_cabang() {
assert_eq!(prioritas(1), "tinggi");
assert_eq!(prioritas(3), "sedang");
assert_eq!(prioritas(99), "biasa");
}
}Potongan main.rs: daftar tugas
let tugas = [
("Belajar fungsi", false),
("Belajar variabel", true),
("Belajar loop", false),
];
tampilkan(&tugas);Penjelasan per-baris/bagian:
let tugas = [ ... ];—letmembuat variabeltugasyang menyimpan array (daftar dengan panjang tetap).[...]adalah tanda kurung siku untuk array.("Belajar fungsi", false),— ini adalah tuple (pasangan nilai). Bagian pertama"Belajar fungsi"adalah judul (teks&str), bagian keduafalseadalah status selesai (boolean). Tuple memungkinkan menyimpan dua nilai berbeda tipe dalam satu kesatuan.("Belajar variabel", true),— tuple kedua: judul"Belajar variabel", statustrue(sudah selesai).("Belajar loop", false),— tuple ketiga: judul"Belajar loop", statusfalse.];— menutup array. Array ini berisi 3 tuple.tampilkan(&tugas);— memanggil fungsitampilkandengan argumen&tugas.&— meminjamkan arraytugaske fungsi, bukan menyerahkannya. Fungsi hanya boleh membaca array, tidak boleh mengubahnya. Pemilik aslinya tetapmain.- Kenapa pinjaman? Karena fungsi hanya perlu melihat daftar untuk mencetaknya, tidak perlu memilikinya. Setelah fungsi selesai,
tugasmasih bisa dipakai lagi dimain.
Kenapa begini? Array [...] untuk daftar dengan panjang tetap (tidak bisa ditambah). Tuple (a, b) untuk menyimpan pasangan nilai berbeda tipe. & untuk meminjamkan data tanpa menyerahkannya — konsep inti Rust yang disebut borrowing.
Bagian dari main.rs: for dan if bernilai
fn tampilkan(tugas: &[(&str, bool)]) {
for (judul, selesai) in tugas {
let tanda = if *selesai { "x" } else { " " };
println!("- [{tanda}] {judul}");
}
}Penjelasan per-baris/bagian:
fn tampilkan(tugas: &[(&str, bool)]) {— membuat fungsitampilkan.tugas— nama parameter (daftar yang diterima).: &[(&str, bool)]— tipe parameter: slice (pinjaman daftar) yang berisi tuple. Rinciannya:&— pinjaman (fungsi hanya meminjam, tidak memiliki).[...]— daftar/slice.(&str, bool)— tipe setiap elemen: tuple berisi teks&strdan booleanbool.
&[T]bisa menerima array maupunVec— lebih fleksibel.{— membuka blok isi fungsi.
for (judul, selesai) in tugas {— perulanganfor: untuk setiap elemen ditugas, lakukan sesuatu.(judul, selesai)— destructuring: membuka tuple menjadi dua variabel.judulberisi teks,selesaiberisi boolean.in tugas— sumber perulangan: daftar yang dipinjam tadi.
let tanda = if *selesai { "x" } else { " " };—ifsebagai ekspresi (menghasilkan nilai, bukan hanya pernyataan).*selesai— dereference: membaca nilai boolean yang dipinjam. Karenaselesaiadalah referensi kebool, tanda*mengambil nilai aslinya.if *selesai { "x" } else { " " }— kalauselesaibenar, hasilnya teks"x"; kalau salah, hasilnya spasi" ".- Hasil
ifdisimpan di variabeltanda.
println!("- [{tanda}] {judul}");— mencetak satu baris tugas.{tanda}dan{judul}diisi nilai variabel. Hasil misalnya- [x] Belajar variabelatau- [ ] Belajar fungsi.}— menutup blokfor(ulangi untuk elemen berikutnya).}— menutup blok isi fungsi.
Kenapa begini? for mengunjungi setiap elemen tanpa perlu indeks manual. if sebagai ekspresi memungkinkan memilih nilai langsung tanpa variabel sementara. Destructuring (judul, selesai) membuat kode lebih mudah dibaca daripada mengakses elemen.0 dan elemen.1.
Bagian dari main.rs: match dan while
fn prioritas(nomor: u32) -> &'static str {
match nomor {
1 => "tinggi",
2 | 3 => "sedang",
_ => "biasa",
}
}Penjelasan per-baris/bagian:
fn prioritas(nomor: u32) -> &'static str {— membuat fungsiprioritas.nomor: u32— parameter: bilangan bulat tak-negatif.-> &'static str— tipe kembalian: pinjaman teks dengan lifetime'static.'staticartinya teks ini hidup sepanjang program (karena literal"..."disimpan di dalam program itu sendiri). Kenapa'static? Karena fungsi mengembalikan teks literal, bukan pinjaman dari parameter.
match nomor {— pencocokan pola: memeriksa nilainomordan menjalankan cabang yang cocok.matchharus menangani semua kemungkinan nilai.1 => "tinggi",— kalaunomoradalah1, hasilnya teks"tinggi".=>artinya "menghasilkan".2 | 3 => "sedang",— kalaunomoradalah2atau3(|= atau), hasilnya"sedang"._ => "biasa",—_menangkap semua nilai lainnya (selain 1, 2, 3). Hasilnya"biasa".}— menutup blokmatch. Hasilmatchadalah hasil fungsi karena tidak ada titik koma di akhir.
Kenapa begini? match lebih tegas daripada if berantai — compiler memastikan semua kemungkinan ditangani. Kalau kamu lupa satu kasus, compiler menolak. Ini mencegah bug "lupa menangani kasus X".
fn hitung_mundur(mut dari: u32) {
while dari > 0 {
print!(" {dari}");
dari -= 1;
}
}Penjelasan per-baris/bagian:
fn hitung_mundur(mut dari: u32) {— membuat fungsihitung_mundur.mut dari: u32— parameterdaribertipeu32dan boleh diubah di dalam fungsi (mut). Kenapamut? Karena kita akan menurunkan nilainya dalam loop.
while dari > 0 {— perulanganwhile: ulangi selama syaratdari > 0benar. Kalaudarisudah 0, loop berhenti.print!(" {dari}");— mencetak nilaidari.print!tidak pindah baris (berbeda denganprintln!), jadi angka-angka tercetak bersebelahan.dari -= 1;— menurunkan nilaidarisebesar 1 (-== kurangi). Ini penting: tanpa inidaritidak pernah berubah dan loop tidak pernah berhenti (infinite loop).}— menutup blokwhile(kembali cek syarat).}— menutup blok isi fungsi.
Kenapa begini? while cocok saat kamu tidak tahu berapa kali harus mengulang — hanya tahu syarat berhentinya. for cocok saat kamu tahu sumber perulangan (daftar). print! untuk mencetak tanpa baris baru; println! untuk baris baru.
cd /home/sysadmin/lab/belajar-rust/4-fungsi-dan-control-flow/contoh
cargo run
cargo testLatihan 4 · Menghitung sisa
Tulis fungsi sisa(total: u32, selesai: u32) -> u32. Jika 5 total dan 2 selesai, hasilnya 3.
Perlihatkan solusi
fn sisa(total: u32, selesai: u32) -> u32 {
total - selesai
}Baris terakhir tanpa ; berarti nilainya dikembalikan. Uji dengan assert_eq!(sisa(5, 2), 3);.
Ownership dan borrowing
Bab ini sering perlu dibaca ulang. Itu normal: konsepnya berbeda dari bahasa yang memakai garbage collector.
Program berikut utuh. Komentar CONTOH GAGAL memang sengaja tidak aktif; jangan hapus // kecuali sedang berlatih membaca pesan compiler.
Proyek lengkap bab 5 — Cargo.toml dan src/main.rs
5-ownership-dan-borrowing/contoh/Cargo.toml
[package]
name = "bab05-ownership"
version = "0.1.0"
edition = "2021"
[dependencies]5-ownership-dan-borrowing/contoh/src/main.rs
/// Ownership, borrowing, dan slice dalam satu cerita kecil.
/// Menjalankan: cargo run
fn main() {
// --- Meminjam untuk membaca ---
let judul = String::from("Belajar Rust");
let n = hitung_panjang(&judul); // dipinjam, bukan dipindah
println!("Panjang \"{judul}\": {n}");
// --- Meminjam untuk mengubah ---
let mut judul_lengkap = judul; // ownership pindah ke judul_lengkap
tandai_selesai(&mut judul_lengkap);
println!("Setelah ditandai: {judul_lengkap}");
// --- Slice: jendela ke sebagian data ---
let daftar = [String::from("Belajar Rust"), String::from("Belajar slice")];
println!("Judul pertama: {}", judul_pertama(&daftar));
println!("Jumlah judul: {}", daftar.len());
// CONTOH GAGAL 1: hapus "//" pada dua baris ini lalu jalankan cargo check.
// let pemilik = String::from("halo");
// let r1 = &pemilik; let r2 = &mut pemilik; println!("{r1} {r2}");
// CONTOH GAGAL 2: nilai yang sudah pindah tidak bisa dipakai lagi.
// let a = String::from("pindah"); let b = a; println!("{a}");
}
/// Membaca lewat pinjaman: `&String` tidak mengambil kepemilikan.
fn hitung_panjang(teks: &String) -> usize {
teks.len()
}
/// Mengubah lewat pinjaman mutabel: hanya boleh SATU pada satu waktu.
fn tandai_selesai(teks: &mut String) {
teks.push_str(" [SELESAI]");
}
/// Mengembalikan pinjaman dari parameter; detail lifetime di bab 10.
fn judul_pertama(daftar: &[String]) -> &str {
&daftar[0]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn menandai_menambah_akhiran() {
let mut s = String::from("tugas");
tandai_selesai(&mut s);
assert_eq!(s, "tugas [SELESAI]");
}
#[test]
fn judul_pertama_adalah_pinjaman() {
let daftar = [String::from("satu"), String::from("dua")];
let pertama = judul_pertama(&daftar);
assert_eq!(pertama, "satu");
// daftar masih utuh karena hanya dipinjam.
assert_eq!(daftar.len(), 2);
}
}Satu nilai, satu pemilik
let a = String::from("halo");
let b = a;
// println!("{a}"); // error: a sudah tidak memiliki String
println!("{b}");Penjelasan per-baris/bagian:
let a = String::from("halo");—String::from("halo")membuatStringbaru (teks yang dimiliki).let amembuat variabelasebagai pemilik teks itu.let b = a;— ini memindahkan kepemilikan (move) dariakeb.atidak lagi memiliki String itu. Rust tidak menyalin teksnya — hanya memindahkan siapa pemiliknya.// println!("{a}");— baris ini dikomentari karena akan error.asudah tidak memiliki String, jadi tidak boleh dipakai lagi. Compiler akan menolak dengan pesan "use of moved value".println!("{b}");— mencetakbyang sekarang memiliki String. Hasil:halo.
Kenapa begini? Rust menjamin satu nilai punya tepat satu pemilik. Ini mencegah dua bagian kode mengubah data yang sama tanpa sadar. Kalau kamu benar-benar butuh dua salinan, pakai a.clone() sebelum memindahkan — itu membuat salinan teks yang independen. Untuk angka kecil seperti i32, Rust menyalin (copy) bukan memindahkan, jadi keduanya tetap bisa dipakai.
Bagian dari main.rs: meminjam untuk membaca
fn hitung_panjang(teks: &String) -> usize {
teks.len()
}
let judul = String::from("Belajar Rust");
let n = hitung_panjang(&judul);
println!("Panjang \"{judul}\": {n}");Penjelasan per-baris/bagian:
fn hitung_panjang(teks: &String) -> usize {— membuat fungsihitung_panjang.teks— nama parameter.: &String— tipe parameter: pinjaman baca keString.&artinya fungsi hanya meminjam teks untuk membacanya, tidak memilikinya. Pemilik aslinya tetap pemanggil.-> usize— tipe kembalian: bilangan bulat tak-negatif untuk ukuran/panjang.
teks.len()—.len()adalah method yang mengembalikan panjang teks. Perhatikan: ini menghitung byte UTF-8, bukan jumlah karakter (bab 6 membahas perbedaannya).- Baris ini tanpa
;— nilaiusizeini yang dikembalikan fungsi. }— menutup blok isi fungsi.let judul = String::from("Belajar Rust");— membuat variabeljudulsebagai pemilik teks.let n = hitung_panjang(&judul);— memanggil fungsi dengan&judul: meminjamkanjuduluntuk dibaca. Hasilnya (panjang teks) disimpan din.println!("Panjang \"{judul}\": {n}");— mencetak. Perhatikan:judulmasih bisa dipakai karena hanya dipinjam, bukan dipindahkan.\"adalah escape untuk tanda kutip.
Kenapa begini? &String memungkinkan fungsi membaca data tanpa mengambil alih kepemilikan. Ini disebut borrowing (meminjam). Keuntungannya: fungsi bisa dipakai berkali-kali pada data yang sama, dan pemilik asli tetap bisa memakai datanya setelah fungsi selesai.
Pinjaman yang boleh mengubah
fn tandai_selesai(teks: &mut String) {
teks.push_str(" [SELESAI]");
}
let mut judul = String::from("Belajar Rust");
tandai_selesai(&mut judul);Penjelasan per-baris/bagian:
fn tandai_selesai(teks: &mut String) {— membuat fungsitandai_selesai.teks— nama parameter.: &mut String— tipe parameter: pinjaman tulis keString.&mutartinya fungsi boleh mengubah isi teks yang dipinjam. Ini berbeda dengan&String(pinjaman baca) yang hanya boleh membaca.
teks.push_str(" [SELESAI]");—.push_str(...)menambahkan teks di akhir String yang dipinjam. Ini mengubah data pemilik aslinya.}— menutup blok isi fungsi.let mut judul = String::from("Belajar Rust");— membuat variabeljudulyang boleh diubah (mut). Tanpamut, kita tidak bisa meminjamkannya sebagai&mut.tandai_selesai(&mut judul);— memanggil fungsi dengan&mut judul: meminjamkanjuduluntuk diubah. Setelah fungsi selesai, isijudulsudah berubah menjadi"Belajar Rust [SELESAI]".
Kenapa begini? Aturan Rust: boleh ada banyak pembaca (&) atau satu penulis (&mut) pada satu waktu — tidak keduanya bersamaan. Ini mencegah data berubah saat sedang dibaca, yang bisa menyebabkan bug sulit dilacak. &mut memberi izin menulis dengan aman karena hanya satu yang boleh menulis pada satu waktu.
Pinjaman terhadap daftar
fn judul_pertama(daftar: &[String]) -> &str {
&daftar[0]
}Penjelasan per-baris/bagian:
fn judul_pertama(daftar: &[String]) -> &str {— membuat fungsijudul_pertama.daftar— nama parameter (daftar yang diterima).: &[String]— tipe parameter: slice (pinjaman daftar) yang berisiString. Rinciannya:&— pinjaman (fungsi hanya meminjam, tidak memiliki).[String]— daftar yang berisi teksString.
&[String]bukanVec<String>? Karena slice bisa menerimaVecmaupun array — lebih fleksibel.-> &str— tipe kembalian: pinjaman teks (&str). Fungsi mengembalikan pinjaman ke judul pertama, bukan salinan teksnya.{— membuka blok isi fungsi.
&daftar[0]— isi fungsi.daftar[0]— mengambil elemen pertama (indeks 0) dari daftar.&di depannya — mengambil pinjaman ke elemen itu, bukan memindahkannya keluar dari daftar.- Hasilnya
&stryang cocok dengan tipe kembalian.
}— menutup blok isi fungsi.
Kenapa begini? Fungsi hanya perlu meminjamkan judul yang sudah ada, bukan membuat salinan baru — lebih cepat dan hemat memori. Kenapa tidak mengembalikan String? Karena itu berarti membuat salinan teks (memakan waktu dan memori). Pinjaman &str cukup kalau pemanggil hanya perlu membacanya. Perhatian: daftar[0] akan panic (program berhenti) kalau daftar kosong — nanti kita pakai Option (bab 8) untuk menangani kasus itu dengan aman.
cd /home/sysadmin/lab/belajar-rust/5-ownership-dan-borrowing/contoh
cargo run
cargo testLatihan 5 · Dua jenis pinjaman
Kenapa let baca = &x; let tulis = &mut x; println!("{baca:?}"); gagal? Bagaimana mengaturnya?
Perlihatkan solusi
let mut x = vec![1];
let baca = &x;
println!("{baca:?}"); // pembaca selesai dipakai
let tulis = &mut x;
tulis.push(2);Pastikan pembaca terakhir selesai sebelum membuat penulis, agar tidak membaca data yang sedang berubah.
String, Vec, HashMap, iterator
Simpan judul sebagai String yang memiliki data; terima &str bila fungsi cukup meminjam teks.
Mulai dari file utuh berikut agar kamu bisa melihat di mana use, main, fungsi, dan test ditempatkan. Setelah itu ikuti penjelasan bagian pentingnya.
Proyek lengkap bab 6 — Cargo.toml dan src/main.rs
6-string-slice-dan-collections/contoh/Cargo.toml
[package]
name = "bab06-collections"
version = "0.1.0"
edition = "2021"
[dependencies]6-string-slice-dan-collections/contoh/src/main.rs
/// String/&str, Vec, HashMap, iterator, dan catatan Unicode.
/// Menjalankan: cargo run
use std::collections::HashMap;
fn main() {
// Vec: daftar tugas yang bisa tumbuh.
let mut tugas: Vec<(String, bool)> = Vec::new();
tugas.push(("Belajar Vec".to_owned(), true));
tugas.push(("Belajar HashMap".to_owned(), false));
tugas.push(("Belajar iterator".to_owned(), false));
let semua: Vec<&str> = tugas.iter().map(|t| t.0.as_str()).collect();
println!("Semua: {semua:?}");
println!("Yang selesai: {:?}", judul_selesai(&tugas));
// HashMap: mencari tugas berdasarkan ID.
let mut per_id: HashMap<i64, String> = HashMap::new();
for (i, (judul, _)) in tugas.iter().enumerate() {
per_id.insert((i + 1) as i64, judul.clone());
}
println!("Cari ID 2: {:?}", cari(&per_id, 2));
// Unicode: byte ≠ karakter.
let teks = "hàlo";
println!(
"Byte vs karakter \"{teks}\": {} byte, {} karakter",
teks.len(),
teks.chars().count()
);
}
/// Mengambil judul tugas yang sudah selesai dengan iterator.
/// Baca: untuk tiap tugas -> saring yang selesai -> ambil judul -> kumpulkan.
fn judul_selesai(tugas: &[(String, bool)]) -> Vec<&str> {
tugas
.iter()
.filter(|(_, selesai)| *selesai)
.map(|(judul, _)| judul.as_str())
.collect()
}
/// get() mengembalikan Option: Some jika ada, None jika tidak (bab 8).
fn cari<'a>(per_id: &'a HashMap<i64, String>, id: i64) -> Option<&'a String> {
per_id.get(&id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_hanya_yang_selesai() {
let tugas = vec![
("a".to_owned(), true),
("b".to_owned(), false),
("c".to_owned(), true),
];
assert_eq!(judul_selesai(&tugas), vec!["a", "c"]);
}
#[test]
fn cari_yang_tidak_ada() {
let map = HashMap::new();
assert_eq!(cari(&map, 99), None);
}
#[test]
fn byte_beda_dari_karakter() {
assert_eq!("hàlo".len(), 5);
assert_eq!("hàlo".chars().count(), 4);
}
}Bagian dari main.rs: daftar dinamis
let mut tugas: Vec<(String, bool)> = Vec::new();
tugas.push(("Belajar Vec".to_owned(), true));
tugas.push(("Belajar HashMap".to_owned(), false));Penjelasan per-baris/bagian:
let mut tugas: Vec<(String, bool)> = Vec::new();— membuat variabeltugasyang boleh diubah (mut).: Vec<(String, bool)>— tipe variabel:Vec(daftar yang bisa tumbuh) yang berisi tuple. Rinciannya:Vec<...>— daftar dinamis (bisa ditambah/dihapus).(String, bool)— tipe setiap elemen: tuple berisiString(judul) danbool(status selesai).
Vecbukan array[...]? KarenaVecbisa tumbuh; array panjangnya tetap.= Vec::new()— membuatVecbaru yang kosong (belum ada isinya).
tugas.push(("Belajar Vec".to_owned(), true));—.push(...)menambahkan elemen baru ke akhir daftar."Belajar Vec".to_owned()—.to_owned()mengubah teks literal&strmenjadiStringyang dimiliki daftar. Kenapato_owned()? Karena daftarVecharus memiliki datanya, bukan hanya meminjam literal yang hidup sepanjang program.true— status selesai untuk tugas ini.;— mengakhiri pernyataan.
tugas.push(("Belajar HashMap".to_owned(), false));— menambahkan elemen kedua: judul"Belajar HashMap", statusfalse(belum selesai).
Kenapa begini? Vec adalah pilihan utama untuk daftar yang jumlahnya tidak diketahui di awal. push menambah elemen; daftar otomatis membesar. to_owned() diperlukan karena daftar harus memiliki datanya sendiri — literal "..." adalah &str yang hidup sepanjang program, tapi daftar butuh String yang bisa dipindahkan dan dimodifikasi.
Bagian dari main.rs: saring judul selesai
fn judul_selesai(tugas: &[(String, bool)]) -> Vec<&str> {
tugas.iter()
.filter(|(_, selesai)| *selesai)
.map(|(judul, _)| judul.as_str())
.collect()
}Penjelasan per-baris/bagian:
fn judul_selesai(tugas: &[(String, bool)]) -> Vec<&str> {— membuat fungsijudul_selesai.tugas— nama parameter (daftar yang diterima).: &[(String, bool)]— tipe parameter: slice (pinjaman daftar) yang berisi tuple(String, bool).-> Vec<&str>— tipe kembalian: daftar (Vec) yang berisi pinjaman teks (&str). Fungsi mengembalikan daftar judul, bukan daftar tugas lengkap.
tugas.iter()—.iter()membuat iterator: alat untuk mengunjungi setiap elemen daftar satu per satu tanpa memindahkannya (meminjam)..filter(|(_, selesai)| *selesai)—.filter(...)menyaring: hanya elemen yang lolos syarat yang diteruskan.|(_, selesai)|— ini closure (fungsi pendek/anonim).|...|adalah parameter closure.(_, selesai)— destructuring tuple:_mengabaikan bagian pertama (judul),selesaimengambil bagian kedua (status).- Kenapa
_? Karena untuk menyaring kita hanya butuh status, bukan judul.
*selesai— dereference: membaca nilai boolean yang dipinjam. Hanya elemen denganselesai == trueyang lolos.
.map(|(judul, _)| judul.as_str())—.map(...)mengubah setiap elemen yang lolos menjadi bentuk lain.|(judul, _)|— closure lagi:judulmengambil bagian pertama (judul),_mengabaikan bagian kedua (status).judul.as_str()— mengubahStringmenjadi&str(pinjaman teks). Kenapa? Karena tipe kembalian kita adalahVec<&str>, bukanVec<String>.
.collect()—.collect()mengumpulkan semua hasil menjadiVecbaru. Tanpa ini, iterator tidak melakukan apa-apa (lazy).}— menutup blok isi fungsi.
Kenapa begini? Rantai iter → filter → map → collect adalah cara idiomatis Rust untuk mengolah daftar: pinjam → saring → ubah → kumpulkan. Ini lebih aman dan lebih mudah dibaca daripada loop manual dengan indeks. Hasil Vec<&str> meminjam judul dari daftar asal — jangan pakai hasil setelah daftar asal hilang (di-drop).
HashMap dan Unicode
use std::collections::HashMap;
let mut per_id: HashMap<i64, String> = HashMap::new();
per_id.insert(2, "Belajar HashMap".to_owned());
println!("{:?}", per_id.get(&2));
let teks = "hàlo";
println!("{} byte, {} karakter", teks.len(), teks.chars().count());Penjelasan per-baris/bagian:
use std::collections::HashMap;—usemengimpor tipeHashMapdari pustaka standar Rust (std::collections). Tanpa ini, kita harus menulisstd::collections::HashMapsetiap kali memakainya — panjang.let mut per_id: HashMap<i64, String> = HashMap::new();— membuat variabelper_idyang boleh diubah (mut).: HashMap<i64, String>— tipe: kamus (peta kunci → nilai).i64adalah tipe kunci (ID),Stringadalah tipe nilai (judul tugas).= HashMap::new()— membuat kamus baru yang kosong.
per_id.insert(2, "Belajar HashMap".to_owned());—.insert(kunci, nilai)menyimpan pasangan ke kamus. Kunci2(ID), nilai"Belajar HashMap"yang diubah keStringdengan.to_owned().println!("{:?}", per_id.get(&2));—.get(&kunci)mencari nilai berdasarkan kunci.- Hasilnya
Option<&String>:Some(...)kalau kunci ditemukan,Nonekalau tidak. Ini cara aman mencari tanpa panic. {:?}adalah format debug (menampilkan nilai apa adanya, termasukSome("...")atauNone).
- Hasilnya
let teks = "hàlo";— membuat variabelteksberisi teks dengan aksen (à).println!("{} byte, {} karakter", teks.len(), teks.chars().count());— mencetak dua ukuran:teks.len()— jumlah byte UTF-8: 5 (karenaàbutuh 2 byte).teks.chars().count()— jumlah karakter: 4 (h,à,l,o).
Kenapa begini? HashMap untuk mencari data cepat berdasarkan kunci (ID) — lebih cepat daripada mencari di Vec satu per satu. get mengembalikan Option untuk menangani "tidak ketemu" tanpa panic. Perbedaan byte dan karakter penting: batas judul 200 karakter di API nanti dihitung dengan chars().count(), bukan len() — supaya teks beraksen tidak salah hitung.
cd /home/sysadmin/lab/belajar-rust/6-string-slice-dan-collections/contoh
cargo run
cargo testLatihan 6 · Judul menjadi kapital
Buat Vec baru berisi judul-judul berhuruf besar dari Vec tugas.
Perlihatkan solusi
let besar: Vec<String> = tugas.iter()
.map(|(judul, _)| judul.to_uppercase())
.collect();to_uppercase() membuat String baru; Vec lama tetap utuh.
Struct, enum dan match
Berikan nama yang jelas pada komponen tugas serta kemungkinan statusnya.
Ini satu program lengkap, dari deklarasi tipe sampai main dan test. Kode di bawah siap kamu coba sebagai satu file src/main.rs.
Proyek lengkap bab 7 — Cargo.toml dan src/main.rs
7-struct-enum-dan-pattern-matching/contoh/Cargo.toml
[package]
name = "bab07-model"
version = "0.1.0"
edition = "2021"
[dependencies]7-struct-enum-dan-pattern-matching/contoh/src/main.rs
#[derive(Debug, Clone, PartialEq)]
enum Status {
Baru,
Dikerjakan { oleh: String },
Selesai,
}
#[derive(Debug, Clone, PartialEq)]
struct Tugas {
id: u32,
judul: String,
status: Status,
}
impl Tugas {
// Fungsi pembuat: belum memiliki sebuah nilai self.
fn baru(id: u32, judul: &str) -> Self {
Self {
id,
judul: judul.to_owned(),
status: Status::Baru,
}
}
fn mulai(&mut self, oleh: &str) {
self.status = Status::Dikerjakan {
oleh: oleh.to_owned(),
};
}
fn selesaikan(&mut self) {
self.status = Status::Selesai;
}
fn sudah_selesai(&self) -> bool {
self.status == Status::Selesai
}
fn ringkasan(&self) -> String {
// Pinjam status agar String di dalamnya tidak dipindahkan.
let keterangan = match &self.status {
Status::Baru => String::from("baru"),
Status::Dikerjakan { oleh } => format!("dikerjakan oleh {oleh}"),
Status::Selesai => String::from("selesai"),
};
format!("#{} {} [{}]", self.id, self.judul, keterangan)
}
}
fn main() {
let mut tugas = Tugas::baru(1, "Belajar struct");
println!("Awal: {tugas:?}");
tugas.mulai("Rani");
if let Status::Dikerjakan { oleh } = &tugas.status {
println!("Penanggung jawab: {oleh}");
}
println!("{}", tugas.ringkasan());
let salinan = tugas.clone();
println!("Salinan sama: {}", salinan == tugas);
tugas.selesaikan();
println!("{}", tugas.ringkasan());
println!("Salinan ikut selesai: {}", salinan.sudah_selesai());
}
#[cfg(test)]
mod tests {
use super::{Status, Tugas};
#[test]
fn tugas_baru_memiliki_status_baru() {
let tugas = Tugas::baru(7, "Latihan");
assert_eq!(tugas.status, Status::Baru);
assert_eq!(tugas.ringkasan(), "#7 Latihan [baru]");
}
#[test]
fn mengubah_asli_tidak_mengubah_salinan() {
let mut asli = Tugas::baru(1, "Membaca");
asli.mulai("Rani");
let salinan = asli.clone();
asli.selesaikan();
assert!(asli.sudah_selesai());
assert!(!salinan.sudah_selesai());
assert_ne!(asli, salinan);
}
}Bagian dari main.rs: definisikan bentuk data
#[derive(Debug, Clone, PartialEq)]
enum Status {
Baru,
Dikerjakan { oleh: String },
Selesai,
}
#[derive(Debug, Clone, PartialEq)]
struct Tugas { id: u32, judul: String, status: Status }Penjelasan per-baris/bagian:
#[derive(Debug, Clone, PartialEq)]— derive untuk enumStatus:Debug(cetak{:?}),Clone(salin),PartialEq(bandingkan==).enum Status {— membuat enumStatus: tipe yang nilainya salah satu dari beberapa varian yang didefinisikan. Cocok untuk "status yang hanya bisa satu dari beberapa kemungkinan".Baru,— varian pertama: tugas baru dibuat (belum dikerjakan). Tidak membawa data tambahan.Dikerjakan { oleh: String },— varian kedua: tugas sedang dikerjakan. Membawa data: fieldolehbertipeString(siapa yang mengerjakan). Kurung kurawal{...}menandakan varian ini punya field.Selesai,— varian ketiga: tugas selesai. Tidak membawa data.}— menutup deklarasi enum.#[derive(Debug, Clone, PartialEq)]— derive untuk structTugas: sama seperti di atas.struct Tugas { id: u32, judul: String, status: Status }— membuat structTugas: tipe bentukan yang menggabungkan beberapa field menjadi satu kesatuan.id: u32— field ID: bilangan bulat tak-negatif 32-bit.judul: String— field judul: teks yang dimiliki.status: Status— field status: bertipe enumStatusyang baru dibuat. Ini menunjukkan enum bisa dipakai sebagai tipe field, sama seperti tipe bawaan.
Kenapa begini? Struct untuk "gabungan field" (tugas punya id, judul, status). Enum untuk "salah satu dari beberapa kemungkinan" (status hanya bisa Baru, Dikerjakan, atau Selesai — tidak bisa dua sekaligus). Varian yang membawa data (Dikerjakan { oleh }) membuat enum lebih kaya daripada sekadar label. derive menghindari menulis implementasi trait manual yang berulang.
Membuat dan mengubah tugas
impl Tugas {
fn baru(id: u32, judul: &str) -> Self {
Self { id, judul: judul.to_owned(), status: Status::Baru }
}
fn mulai(&mut self, oleh: &str) {
self.status = Status::Dikerjakan { oleh: oleh.to_owned() };
}
}Penjelasan per-baris/bagian:
impl Tugas {— blok implementasi: menempelkan fungsi/method ke tipeTugas. Semua yang ada di dalamimplmenjadi milikTugas.fn baru(id: u32, judul: &str) -> Self {— fungsi pembuat (constructor) bernamabaru.- Tidak ada
&self— ini fungsi asosiasi (dipanggil sebagaiTugas::baru(...)), bukan method pada instance yang sudah ada. Itu masuk akal: kita membuat instance baru, belum adaself. id: u32— parameter ID.judul: &str— parameter judul (pinjaman teks).-> Self— mengembalikanSelf, yaituTugas(singkatan tipe yang sedang di-impl).
- Tidak ada
Self { id, judul: judul.to_owned(), status: Status::Baru }— membuat instanceTugasbaru.Self { ... }— syntax membuat struct.id— shorthand untukid: id(fieldiddiisi parameterid, karena namanya sama).judul: judul.to_owned()— fieldjuduldiisiStringdari pinjaman..to_owned()mengubah&strmenjadiStringyang dimiliki struct.status: Status::Baru— status awal selaluBaru(tugas baru belum dikerjakan).
fn mulai(&mut self, oleh: &str) {— method untuk menandai tugas mulai dikerjakan.&mut self— pinjaman tulis ke instance: method boleh mengubah data tugas (di sini mengubah fieldstatus). Tanpamut, method tidak bisa mengubah apa pun.oleh: &str— parameter: siapa yang mengerjakan.- Tidak ada tipe kembalian (
-> ...) — method tidak mengembalikan apa-apa (hanya mengubah status).
self.status = Status::Dikerjakan { oleh: oleh.to_owned() };— mengubah fieldstatusinstance menjadi varianDikerjakandengan fieldolehdiisiStringdari parameter.}— menutup blokimpl.
Kenapa begini? Fungsi asosiasi baru untuk membuat instance (belum ada self); method dengan &mut self untuk mengubah instance yang sudah ada. Self menghindari penulisan ulang nama tipe — kalau nanti diganti nama, cukup ubah di satu tempat. to_owned() diperlukan karena struct harus memiliki datanya.
Match memaksa menangani semua kemungkinan
let keterangan = match &self.status {
Status::Baru => String::from("baru"),
Status::Dikerjakan { oleh } => format!("dikerjakan oleh {oleh}"),
Status::Selesai => String::from("selesai"),
};Penjelasan per-baris/bagian:
let keterangan = match &self.status {— membuat variabelketeranganyang isinya hasil darimatch.match— pencocokan pola: memeriksa nilai dan menjalankan cabang yang cocok.&self.status— nilai yang diperiksa: pinjaman ke fieldstatusmilikself.&artinya kita hanya meminjam status, bukan memindahkannya. Kenapa pinjaman? KarenastatusberisiString(di varianDikerjakan) yang tidak boleh dipindahkan keluar dariself—selfmasih butuh statusnya.{— membuka blok cabang-cabangmatch.
Status::Baru => String::from("baru"),— cabang pertama: kalau status adalahStatus::Baru, hasilnyaString::from("baru")(teksStringbaru berisi "baru").=>artinya "menghasilkan".,memisahkan cabang.Status::Dikerjakan { oleh } => format!("dikerjakan oleh {oleh}"),— cabang untuk varianDikerjakanyang membawa data.{ oleh }— destructuring: membuka data yang dibawa varian.olehadalah nama variabel untuk field itu.format!("dikerjakan oleh {oleh}")— membuatStringbaru dengan isi variabeloleh. Hasil misalnya"dikerjakan oleh Rani".
Status::Selesai => String::from("selesai"),— cabang untukStatus::Selesai, hasilnyaString::from("selesai").};— menutup blokmatch. Hasilmatchdisimpan diketerangan.
Kenapa begini? match memaksa kamu menangani semua varian enum — compiler menolak kalau ada yang terlewat. Kenapa &self.status bukan self.status? Karena self.status akan memindahkan (move) status keluar dari self, yang merusak self. Dengan pinjaman, self tetap utuh. Kenapa tiap cabang mengembalikan String (bukan &str)? Karena format! menghasilkan String, jadi semua cabang harus mengembalikan tipe yang sama.
cd /home/sysadmin/lab/belajar-rust/7-struct-enum-dan-pattern-matching/contoh
cargo run
cargo testLatihan 7 · Tandai selesai
Metode apa yang dibutuhkan untuk mengubah status dan untuk sekadar memeriksanya?
Perlihatkan solusi
fn selesaikan(&mut self) { self.status = Status::Selesai; }
fn sudah_selesai(&self) -> bool { self.status == Status::Selesai }Mengubah perlu &mut self; membaca cukup &self.
Option, Result dan operator ?
Tidak ditemukan dan input salah adalah dua kondisi berbeda; Rust meminta kita menanganinya.
Bab ini memakai dua file Rust. Salin lib.rs dan main.rs secara terpisah. main.rs menggunakan fungsi publik dari lib.rs; keduanya harus ada agar proyek berjalan.
Proyek lengkap bab 8 — Cargo.toml, src/lib.rs, src/main.rs
8-option-result-dan-error-handling/contoh/Cargo.toml
[package]
name = "bab08-hasil"
version = "0.1.0"
edition = "2021"
[dependencies]8-option-result-dan-error-handling/contoh/src/lib.rs
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum ErrorInput {
Kosong,
Angka(ParseIntError),
Nol,
}
impl fmt::Display for ErrorInput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Kosong => write!(f, "ID tidak boleh kosong"),
Self::Angka(_) => write!(f, "ID harus berupa angka dalam rentang u32"),
Self::Nol => write!(f, "ID harus lebih besar dari nol"),
}
}
}
impl Error for ErrorInput {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Angka(sumber) => Some(sumber),
Self::Kosong | Self::Nol => None,
}
}
}
pub fn parse_id(input: &str) -> Result<u32, ErrorInput> {
let input = input.trim();
if input.is_empty() {
return Err(ErrorInput::Kosong);
}
// map_err membungkus error bawaan; ? meneruskan Err ke pemanggil.
let id = input.parse::<u32>().map_err(ErrorInput::Angka)?;
if id == 0 {
return Err(ErrorInput::Nol);
}
Ok(id)
}
pub fn cari_judul(id: u32) -> Option<&'static str> {
// Teks literal hidup selama program berjalan; tidak meminjam input.
match id {
1 => Some("Membaca Option"),
2 => Some("Menulis pengujian"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{cari_judul, parse_id, ErrorInput};
use std::error::Error;
#[test]
fn angka_valid_dan_spasi_diterima() {
assert!(matches!(parse_id(" 2 "), Ok(2)));
assert!(matches!(parse_id("4294967295"), Ok(u32::MAX)));
}
#[test]
fn kosong_dan_nol_ditolak() {
assert!(matches!(parse_id(" \t "), Err(ErrorInput::Kosong)));
assert!(matches!(parse_id("0"), Err(ErrorInput::Nol)));
}
#[test]
fn teks_negatif_dan_angka_terlalu_besar_ditolak() {
for input in ["abc", "-1", "4294967296"] {
assert!(matches!(parse_id(input), Err(ErrorInput::Angka(_))));
}
}
#[test]
fn error_parse_menyimpan_penyebab() {
let error = parse_id("abc").expect_err("abc bukan ID yang valid");
assert!(error.source().is_some());
assert_eq!(error.to_string(), "ID harus berupa angka dalam rentang u32");
}
#[test]
fn pencarian_bisa_tidak_menemukan_tugas() {
assert_eq!(cari_judul(2), Some("Menulis pengujian"));
assert_eq!(cari_judul(99), None);
}
}8-option-result-dan-error-handling/contoh/src/main.rs
use bab08_hasil::{cari_judul, parse_id};
fn main() {
for input in [" 2 ", "", "abc", "0", "9"] {
match parse_id(input) {
Ok(id) => match cari_judul(id) {
Some(judul) => println!("ID {id}: {judul}"),
None => println!("ID {id}: tugas tidak ditemukan"),
},
Err(error) => println!("Input {input:?} ditolak: {error}"),
}
}
}Bagian dari lib.rs: nilai yang mungkin tidak ada
pub fn cari_judul(id: u32) -> Option<&'static str> {
match id {
1 => Some("Membaca Option"),
2 => Some("Menulis pengujian"),
_ => None,
}
}Penjelasan per-baris/bagian:
pub fn cari_judul(id: u32) -> Option<&'static str> {— membuat fungsicari_judulyang publik (pub— bisa dipakai dari luar modul).id: u32— parameter: bilangan bulat tak-negatif (ID tugas).-> Option<&'static str>— tipe kembalian:Optionyang berisi pinjaman teks&'static str. Rinciannya:Option<T>— tipe untuk "mungkin ada nilai, mungkin tidak":Some(T)(ada) atauNone(tidak ada). Ini cara aman untuk "tidak ketemu" tanpa panic.&'static str— pinjaman teks dengan lifetime'static(hidup sepanjang program, karena literal"..."disimpan di dalam program).
match id {— memeriksa nilaiiddan menjalankan cabang yang cocok.1 => Some("Membaca Option"),— kalauidadalah1, hasilnyaSome("Membaca Option")(ada judul untuk ID 1).2 => Some("Menulis pengujian"),— kalauidadalah2, hasilnyaSome("Menulis pengujian")._ => None,—_menangkap semua ID lainnya: hasilnyaNone(tidak ada judul untuk ID itu).}— menutup blokmatch. Hasilmatchadalah hasil fungsi (tanpa;).
Kenapa begini? Option memaksa pemanggil menangani dua kemungkinan: ada (Some) atau tidak ada (None). Ini lebih aman daripada mengembalikan teks kosong "" atau panic — pemanggil tidak bisa lupa mengecek "apakah ketemu?" karena tipe Option memaksanya. Kenapa &'static str bukan String? Karena judul berupa literal yang sudah ada di program — tidak perlu membuat salinan.
Bagian dari lib.rs: validasi dan kegagalan
pub fn parse_id(input: &str) -> Result<u32, ErrorInput> {
let input = input.trim();
if input.is_empty() { return Err(ErrorInput::Kosong); }
let id = input.parse::<u32>().map_err(ErrorInput::Angka)?;
if id == 0 { return Err(ErrorInput::Nol); }
Ok(id)
}Penjelasan per-baris/bagian:
pub fn parse_id(input: &str) -> Result<u32, ErrorInput> {— membuat fungsiparse_idyang publik.input: &str— parameter: pinjaman teks (input dari pengguna).-> Result<u32, ErrorInput>— tipe kembalian:Resultyang berisiu32(berhasil) atauErrorInput(gagal). Rinciannya:Result<T, E>— tipe untuk "bisa berhasil (Ok(T)) atau gagal (Err(E))". Ini cara Rust menangani kegagalan tanpa panic.u32— tipe hasil berhasil: bilangan bulat tak-negatif.ErrorInput— tipe hasil gagal: enum yang didefinisikan dilib.rs(berisiKosong,Angka,Nol).
let input = input.trim();—.trim()menghapus spasi di awal dan akhir teks (bukan di tengah). Hasilnya disimpan di variabelinputyang baru (shadowing).if input.is_empty() { return Err(ErrorInput::Kosong); }— kalau teks kosong setelah di-trim, kembalikanErr(ErrorInput::Kosong)(gagal karena kosong).returnmenghentikan fungsi lebih awal.let id = input.parse::<u32>().map_err(ErrorInput::Angka)?;— baris penting, dipecah:input.parse::<u32>()— mencoba mengubah teks menjadi angkau32. HasilnyaResult<u32, ParseIntError>..map_err(ErrorInput::Angka)— kalau parse gagal, membungkus error bawaan (ParseIntError) menjadi enum kita sendiriErrorInput::Angka. Ini membuat error lebih bermakna.?— operator tanya: kalau hasilnyaErr, kembalikan error itu sekarang dan hentikan fungsi. KalauOk, ambil nilainya dan lanjutkan. Ini singkatan darimatchpanjang.
if id == 0 { return Err(ErrorInput::Nol); }— kalau angka hasil parse adalah0, kembalikanErr(ErrorInput::Nol)(ID tidak boleh nol).Ok(id)— kalau semua pemeriksaan lolos, kembalikanOk(id)(berhasil dengan angkaid).
Kenapa begini? Result memaksa pemanggil menangani kegagalan — tidak bisa diabaikan. ? membuat kode singkat: tanpa itu, kamu harus menulis match panjang untuk setiap langkah yang bisa gagal. Validasi bertahap (kosong → bukan angka → nol) membuat pesan error spesifik dan membantu pengguna memperbaiki input.
Pemanggil membedakan sebabnya
match parse_id("9") {
Ok(id) => match cari_judul(id) {
Some(judul) => println!("{judul}"),
None => println!("tidak ditemukan"),
},
Err(error) => println!("input ditolak: {error}"),
}Penjelasan per-baris/bagian:
match parse_id("9") {— memeriksa hasil dariparse_id("9"). HasilnyaResult<u32, ErrorInput>— bisaOk(berhasil) atauErr(gagal).Ok(id) => match cari_judul(id) {— cabang berhasil: kalauparse_idberhasil,idadalah angka hasil parse.match cari_judul(id) {— di dalam cabang berhasil, kita memeriksa lagi hasilcari_judul(id). HasilnyaOption<&str>— bisaSome(ada judul) atauNone(tidak ada).
Some(judul) => println!("{judul}"),— kalaucari_judulmengembalikanSome(judul)(ada judul), cetak judul itu.None => println!("tidak ditemukan"),— kalauNone(ID valid tapi tidak ada judulnya), cetak "tidak ditemukan".},— menutupmatchdalam (untukcari_judul).Err(error) => println!("input ditolak: {error}"),— cabang gagal: kalauparse_idmengembalikanErr(error)(input salah), cetak error itu.erroradalah nilai dari enumErrorInputyang bisa dicetak karena mengimplementasikanDisplay.}— menutupmatchluar (untukparse_id).
Kenapa begini? Ada dua jenis masalah berbeda yang harus dibedakan: (1) input salah (Err dari parse_id), dan (2) input benar tapi data tidak ada (None dari cari_judul). Keduanya ditangani tanpa panic — pengguna mendapat pesan yang jelas untuk masing-masing. match bersarang (di dalam match) adalah cara menangani hasil bertingkat seperti ini.
cd /home/sysadmin/lab/belajar-rust/8-option-result-dan-error-handling/contoh
cargo run
cargo testLatihan 8 · Prediksi empat hasil
Bagaimana hasil parse_id(" "), parse_id("0"), parse_id("abc"), parse_id("2")?
Perlihatkan solusi
Berurutan Err(Kosong), Err(Nol), Err(Angka(...)) dan Ok(2). ? menghentikan fungsi hanya saat parsing gagal.
Module dan crate
Memisahkan file membuat aturan kepemilikan kode jelas. Folder contoh lengkap berisi src/lib.rs, src/main.rs, src/tugas.rs dan src/daftar.rs.
Inilah contoh multi-file pertama. Buat tiap file sesuai nama di bawah; jangan tempel semua ke satu main.rs. Baca urutannya: tugas.rs → daftar.rs → lib.rs → main.rs.
Proyek lengkap bab 9 — Cargo.toml dan keempat file src
9-module-dan-crate/contoh/Cargo.toml
[package]
name = "bab09-modul"
version = "0.1.0"
edition = "2021"
[dependencies]9-module-dan-crate/contoh/src/tugas.rs
#[derive(Debug)]
pub struct Tugas {
id: usize,
judul: String,
selesai: bool,
}
impl Tugas {
// Hanya kode dalam library crate ini yang boleh membuat Tugas langsung.
pub(crate) fn baru(id: usize, judul: &str) -> Self {
Self {
id,
judul: judul.to_owned(),
selesai: false,
}
}
pub fn id(&self) -> usize {
self.id
}
pub fn judul(&self) -> &str {
&self.judul
}
pub fn sudah_selesai(&self) -> bool {
self.selesai
}
pub(crate) fn selesaikan(&mut self) {
self.selesai = true;
}
}9-module-dan-crate/contoh/src/daftar.rs
use crate::tugas::Tugas;
#[derive(Debug)]
pub struct DaftarTugas {
tugas: Vec<Tugas>,
}
impl DaftarTugas {
pub fn baru() -> Self {
Self { tugas: Vec::new() }
}
pub fn tambah(&mut self, judul: &str) -> usize {
// Contoh ini tidak menghapus tugas, jadi panjang + 1 menjadi ID baru.
let id = self.tugas.len() + 1;
self.tugas.push(Tugas::baru(id, judul));
id
}
pub fn selesaikan(&mut self, id: usize) -> bool {
if let Some(tugas) = self.tugas.iter_mut().find(|tugas| tugas.id() == id) {
tugas.selesaikan();
true
} else {
false
}
}
pub fn semua(&self) -> &[Tugas] {
&self.tugas
}
}
#[cfg(test)]
mod tests {
use super::DaftarTugas;
#[test]
fn id_berurutan_dan_status_terpisah() {
let mut daftar = DaftarTugas::baru();
assert_eq!(daftar.tambah("Pertama"), 1);
assert_eq!(daftar.tambah("Kedua"), 2);
assert!(daftar.selesaikan(1));
assert!(daftar.semua()[0].sudah_selesai());
assert!(!daftar.semua()[1].sudah_selesai());
assert!(!daftar.selesaikan(99));
}
}9-module-dan-crate/contoh/src/lib.rs
mod daftar;
mod tugas;
// Pengguna cukup mengenal dua nama ini, bukan susunan file internal.
pub use daftar::DaftarTugas;
pub use tugas::Tugas;9-module-dan-crate/contoh/src/main.rs
use bab09_modul::DaftarTugas;
fn main() {
let mut daftar = DaftarTugas::baru();
let pertama = daftar.tambah("Pelajari modul");
daftar.tambah("Pisahkan file");
daftar.selesaikan(pertama);
for tugas in daftar.semua() {
let status = if tugas.sudah_selesai() {
"selesai"
} else {
"belum selesai"
};
println!("#{} {} [{status}]", tugas.id(), tugas.judul());
}
println!("ID 99 ditemukan: {}", daftar.selesaikan(99));
}Pintu depan: src/lib.rs
mod daftar;
mod tugas;
pub use daftar::DaftarTugas;
pub use tugas::Tugas;Penjelasan per-baris/bagian:
mod daftar;—modmendaftarkan modul bernamadaftar. Rust mencari filesrc/daftar.rsdan memuat isinya sebagai modul. Tanpapub, modul ini privat — hanya bisa diakses dari dalam crate ini.mod tugas;— sama, mendaftarkan modultugasdari filesrc/tugas.rs.pub use daftar::DaftarTugas;—pub usemengekspor ulang tipeDaftarTugasdari moduldaftarke luar crate. Artinya: pengguna crate ini bisa menulisuse bab09_modul::DaftarTugas;tanpa tahu bahwa sebenarnya tipe itu ada di filedaftar.rs.pub use tugas::Tugas;— sama, mengekspor ulang tipeTugas.
Kenapa begini? mod memecah kode ke file-file agar tidak satu file raksasa. pub use menyediakan pintu depan yang rapi: pengguna cukup tahu nama sederhana (DaftarTugas, Tugas), tidak perlu tahu struktur file internal (daftar.rs, tugas.rs). Ini membuat kode mudah dirawat — kamu bisa mengubah struktur internal tanpa merusak kode pengguna.
Bagian dari src/tugas.rs: field disembunyikan
pub(crate) fn baru(id: usize, judul: &str) -> Self {
Self { id, judul: judul.to_owned(), selesai: false }
}
pub fn judul(&self) -> &str {
&self.judul
}Penjelasan per-baris/bagian:
pub(crate) fn baru(id: usize, judul: &str) -> Self {— membuat fungsibaruyang hanya bisa dipakai di dalam crate ini (pub(crate)).pub(crate)— tingkat visibilitas: bisa diakses dari semua modul dalam crate yang sama, tapi tidak dari crate lain (sepertimain.rsyang merupakan crate binary terpisah). Ini melindungi cara membuatTugas— pengguna luar tidak bisa membuatTugassembarangan.id: usize— parameter: ID tugas (bilangan bulat tak-negatif untuk ukuran).judul: &str— parameter: pinjaman teks judul.-> Self— tipe kembalian:Selfadalah singkatan untuk tipe yang sedang di-impl(di siniTugas). Mengembalikan instance baru dari tipe itu.
Self { id, judul: judul.to_owned(), selesai: false }— membuat instanceTugasbaru.Self { ... }— syntax membuat struct.Self=Tugas.id— singkatan dariid: id(fieldiddiisi nilai parameterid). Ini shorthand kalau nama field dan variabel sama.judul: judul.to_owned()— fieldjuduldiisiStringbaru dari pinjaman teks..to_owned()mengubah&strmenjadiStringyang dimiliki.selesai: false— fieldselesaidiisifalse(tugas baru belum selesai).
pub fn judul(&self) -> &str {— membuat methodjudulyang publik (pub— bisa dipakai dari mana saja).&self— parameter: pinjaman baca ke instanceTugas.&artinya method hanya meminjam, tidak memiliki. Ini membuat method bisa dipanggil tanpa memindahkanTugas.-> &str— tipe kembalian: pinjaman teks ke fieldjudul. BukanString— kita hanya meminjamkan, tidak membuat salinan.
&self.judul— mengembalikan pinjaman ke fieldjudul. Perhatikan:self.juduladalah field, bukan method. Jangan tulisself.judul()di sini — itu akan memanggil methodjudul()dari dalam dirinya sendiri (rekursi tak berujung).
Kenapa begini? Field judul dibuat privat (tidak ada pub di deklarasi struct), jadi pengguna luar tidak bisa mengubahnya langsung — hanya bisa membacanya lewat method judul(). Ini melindungi aturan main data (encapsulation). pub(crate) untuk baru memastikan hanya kode dalam crate yang bisa membuat Tugas, menjaga konsistensi data.
Bagian dari src/main.rs: pakai API publik
use bab09_modul::DaftarTugas;
let mut daftar = DaftarTugas::baru();
let pertama = daftar.tambah("Pelajari modul");
daftar.selesaikan(pertama);Penjelasan per-baris/bagian:
use bab09_modul::DaftarTugas;—usemengimpor tipeDaftarTugasdari crate librarybab09_modul.bab09_modul— nama crate. Perhatikan: diCargo.tomlnama package adalahbab09-modul(dengan tanda minus-), tapi dalam kode Rust menjadibab09_modul(dengan garis bawah_). Rust mengubah-menjadi_secara otomatis.DaftarTugas— tipe yang diekspor olehlib.rs(lewatpub use).
let mut daftar = DaftarTugas::baru();— membuat variabeldaftaryang boleh diubah (mut).DaftarTugas::baru()memanggil fungsi pembuatbarudari tipeDaftarTugas— ini membuat instance baru (daftar kosong).let pertama = daftar.tambah("Pelajari modul");— memanggil methodtambahpadadaftar.daftar.tambah(...)— method ini meminjamdaftaruntuk mengubahnya (&mut selfdi definisi method). Itulah kenapadaftarharusmut."Pelajari modul"— judul tugas yang ditambahkan.- Hasilnya (ID tugas baru) disimpan di variabel
pertama.
daftar.selesaikan(pertama);— memanggil methodselesaikandengan IDpertamauntuk menandai tugas itu selesai.
Kenapa begini? Potongan let ini berada di dalam main() pada contoh lengkap — tidak bisa ditulis di luar fungsi karena Rust hanya menjalankan kode di dalam fungsi. use mempersingkat nama panjang: tanpa itu, kita harus menulis bab09_modul::DaftarTugas::baru() setiap kali. Pengguna hanya bisa memakai API publik — method selesaikan pada Tugas yang pub(crate) tidak bisa dipanggil dari sini.
cd /home/sysadmin/lab/belajar-rust/9-module-dan-crate/contoh
cargo run
cargo testLatihan 9 · Hitung jumlah tugas
Tambah method publik jumlah pada DaftarTugas.
Perlihatkan solusi
// Di dalam impl DaftarTugas pada daftar.rs
pub fn jumlah(&self) -> usize { self.tugas.len() }Field tugas privat; pemakai luar memanggil daftar.jumlah().
Trait, generic dan lifetime
Trait menyatakan kemampuan; generic membuat kode bekerja pada lebih dari satu tipe; lifetime menggambarkan masa berlaku referensi.
Kode reusable berada di src/lib.rs; src/main.rs hanya memakai fungsi dan tipe dari library tersebut. Lihat file lengkap sebelum mempelajari cuplikannya.
Proyek lengkap bab 10 — Cargo.toml, src/lib.rs, src/main.rs
10-trait-generics-dan-lifetime-dasar/contoh/Cargo.toml
[package]
name = "bab10-abstraksi"
version = "0.1.0"
edition = "2021"
[dependencies]10-trait-generics-dan-lifetime-dasar/contoh/src/lib.rs
pub trait Ringkas {
fn ringkas(&self) -> String;
}
#[derive(Debug)]
pub struct Tugas {
judul: String,
selesai: bool,
}
impl Tugas {
pub fn baru(judul: &str, selesai: bool) -> Self {
Self {
judul: judul.to_owned(),
selesai,
}
}
}
impl Ringkas for Tugas {
fn ringkas(&self) -> String {
let status = if self.selesai { "selesai" } else { "belum" };
format!("{} ({status})", self.judul)
}
}
impl Ringkas for String {
fn ringkas(&self) -> String {
format!("Catatan: {self}")
}
}
#[derive(Debug)]
pub struct Wadah<T> {
isi: T,
}
// Metode ini tersedia untuk semua tipe T.
impl<T> Wadah<T> {
pub fn baru(isi: T) -> Self {
Self { isi }
}
pub fn isi(&self) -> &T {
&self.isi
}
}
// Metode ini hanya tersedia jika T mengimplementasikan Ringkas.
impl<T: Ringkas> Wadah<T> {
pub fn ringkasan(&self) -> String {
self.isi.ringkas()
}
}
pub fn buat_label<T: Ringkas>(nilai: &T) -> String {
format!("Ringkasan: {}", nilai.ringkas())
}
pub fn judul_lebih_panjang<'a>(kiri: &'a str, kanan: &'a str) -> &'a str {
// Hitung char, bukan byte; jika sama panjang, pilih kiri.
if kiri.chars().count() >= kanan.chars().count() {
kiri
} else {
kanan
}
}
#[cfg(test)]
mod tests {
use super::{buat_label, judul_lebih_panjang, Tugas, Wadah};
#[test]
fn wadah_tanpa_bound_bisa_menyimpan_angka() {
let wadah = Wadah::baru(42);
assert_eq!(*wadah.isi(), 42);
}
#[test]
fn trait_yang_sama_memiliki_dua_implementasi() {
let tugas = Tugas::baru("Belajar", false);
assert_eq!(buat_label(&tugas), "Ringkasan: Belajar (belum)");
let catatan = Wadah::baru(String::from("Baca ulang"));
assert_eq!(catatan.ringkasan(), "Catatan: Baca ulang");
}
#[test]
fn pilihan_judul_mencakup_kedua_cabang_dan_seri() {
assert_eq!(judul_lebih_panjang("Rust", "Go"), "Rust");
assert_eq!(judul_lebih_panjang("Go", "Rust"), "Rust");
assert_eq!(judul_lebih_panjang("ab", "cd"), "ab");
assert_eq!(judul_lebih_panjang("", ""), "");
}
#[test]
fn panjang_tidak_dihitung_dalam_byte() {
assert_eq!(judul_lebih_panjang("é", "ab"), "ab");
}
}10-trait-generics-dan-lifetime-dasar/contoh/src/main.rs
use bab10_abstraksi::{buat_label, judul_lebih_panjang, Tugas, Wadah};
fn main() {
let tugas = Wadah::baru(Tugas::baru("Belajar trait", false));
println!("{}", tugas.ringkasan());
let catatan = String::from("Ulangi contoh");
println!("{}", buat_label(&catatan));
let angka = Wadah::baru(42);
println!("Isi angka: {}", angka.isi());
let kiri = String::from("Baca");
let kanan = String::from("Tulis pengujian");
let pilihan = judul_lebih_panjang(&kiri, &kanan);
println!("Judul lebih panjang: {pilihan}");
}Bagian dari lib.rs: kemampuan Ringkas
pub trait Ringkas {
fn ringkas(&self) -> String;
}
impl Ringkas for String {
fn ringkas(&self) -> String { format!("Catatan: {self}") }
}Penjelasan per-baris/bagian:
pub trait Ringkas {— membuat trait bernamaRingkasyang publik.trait— kontrak kemampuan: daftar method yang harus dimiliki tipe yang mengimplementasikannya. Trait tidak berisi isi method — hanya bentuknya (nama, parameter, tipe kembalian). Mirip interface di bahasa lain.Ringkas— nama trait ("ringkas" = meringkas).
fn ringkas(&self) -> String;— deklarasi method yang wajib dimiliki tipe yang mengimplementasikan trait ini.fn ringkas— nama method.(&self)— parameter: pinjaman baca ke instance.&selfartinya method dipanggil pada instance (misalobjek.ringkas()).-> String— tipe kembalian: teksStringyang dimiliki.;— mengakhiri deklarasi. Tidak ada isi method di trait — itu diisi oleh tipe yang mengimplementasikannya.
impl Ringkas for String {— mengimplementasikan traitRingkasuntuk tipeString. Artinya:Stringsekarang memenuhi kontrakRingkas.fn ringkas(&self) -> String { format!("Catatan: {self}") }— isi method untukString.format!("Catatan: {self}")— membuatStringbaru dengan isi variabelself(instanceStringyang dipanggil methodnya). Hasil misalnya"Catatan: halo".{ ... }— blok isi method (satu baris, jadi bisa tanpa;di akhir).
}— menutup blok implementasi.
Kenapa begini? Trait memungkinkan satu kontrak, banyak perilaku: String dan Tugas sama-sama punya method ringkas(), tapi hasilnya berbeda sesuai tipenya. Ini membuat kode generic bisa memanggil method yang sama pada tipe berbeda tanpa tahu tipe persisnya — cukup tahu "tipe ini bisa diringkas".
Bagian dari lib.rs: wadah generik
pub struct Wadah<T> { isi: T }
impl<T> Wadah<T> {
pub fn baru(isi: T) -> Self { Self { isi } }
pub fn isi(&self) -> &T { &self.isi }
}
impl<T: Ringkas> Wadah<T> {
pub fn ringkasan(&self) -> String { self.isi.ringkas() }
}Penjelasan per-baris/bagian:
pub struct Wadah<T> { isi: T }— membuat struct generikWadah.<T>— parameter tipe generik:Tadalah "tempat bagi tipe apa saja". Saat dipakai,Tdiganti tipe nyata (misali32,String,Tugas).isi: T— field bernamaisiyang tipenyaT(tergantung tipe yang dipilih saat memakaiWadah).
impl<T> Wadah<T> {— mengimplementasikan method untukWadah<T>untuk semua tipeT(tanpa batasan).pub fn baru(isi: T) -> Self { Self { isi } }— method pembuat.isi: T— parameter: nilai yang akan disimpan. TipenyaT(sama dengan field).-> Self— mengembalikan instanceWadah<T>baru.Self { isi }— membuat struct: fieldisidiisi parameterisi(shorthand karena nama sama).
pub fn isi(&self) -> &T { &self.isi }— method untuk membaca isi.&self— pinjaman baca ke instance.-> &T— mengembalikan pinjaman ke isi, bukan memindahkannya. Kenapa pinjaman? Karena method hanya perlu membaca, tidak perlu memiliki isinya.&self.isi— pinjaman ke fieldisi.
}— menutup blok implementasi pertama.impl<T: Ringkas> Wadah<T> {— mengimplementasikan method hanya untukWadah<T>yangT-nya mengimplementasikan traitRingkas. Ini namanya bound (batasan): method ini tidak tersedia untukWadah<i32>(karenai32tidak punyaRingkas).pub fn ringkasan(&self) -> String { self.isi.ringkas() }— method yang hanya ada untukWadahberisi tipe yang bisa diringkas.self.isi.ringkas()— memanggil methodringkas()(dari traitRingkas) pada isi wadah. Ini bisa karena boundT: RingkasmenjaminTpunya method itu.
}— menutup blok implementasi kedua.
Kenapa begini? Generik membuat satu bentuk kode untuk banyak tipe: Wadah<i32>, Wadah<String>, Wadah<Tugas> semuanya pakai struct yang sama. Bound T: Ringkas membuat method kondisional: hanya tersedia untuk tipe yang memenuhi syarat. Compiler memeriksa ini saat kompilasi — kalau kamu mencoba Wadah::baru(42).ringkasan(), compiler menolak karena i32 tidak punya Ringkas.
Hubungan lifetime
pub fn judul_lebih_panjang<'a>(kiri: &'a str, kanan: &'a str) -> &'a str {
if kiri.chars().count() >= kanan.chars().count() {
kiri
} else {
kanan
}
}Penjelasan per-baris/bagian:
pub fn judul_lebih_panjang<'a>(kiri: &'a str, kanan: &'a str) -> &'a str {— membuat fungsijudul_lebih_panjang.<'a>— parameter lifetime:'aadalah nama untuk "umur pinjaman". Lifetime bukan memperpanjang hidup data — ia hanya mendeskripsikan hubungan antara pinjaman input dan output.kiri: &'a str— parameter pertama: pinjaman teks dengan umur'a.kanan: &'a str— parameter kedua: pinjaman teks dengan umur'a(sama).-> &'a str— tipe kembalian: pinjaman teks dengan umur'a(sama). Artinya: hasil meminjam dari salah satu input, jadi hasil tidak boleh dipakai setelah input itu hilang.
if kiri.chars().count() >= kanan.chars().count() {— membandingkan panjang kedua teks..chars().count()— menghitung jumlah karakter (bukan byte). Ini penting untuk teks Unicode seperti "hàlo" (4 karakter, 5 byte).>=— "lebih besar atau sama". Kalau kiri lebih panjang atau sama, masuk cabang pertama.
kiri— kalau kiri lebih panjang atau sama, kembalikankiri(pinjaman ke teks kiri).} else {— kalau tidak, masuk cabang kedua.kanan— kembalikankanan(pinjaman ke teks kanan).}— menutup blokif. Hasilifadalah hasil fungsi (tanpa;).
Kenapa begini? Lifetime 'a memberitahu compiler: "hasil fungsi meminjam dari salah satu parameter, jadi jangan pakai hasil setelah parameter itu hilang". Tanpa anotasi ini, compiler tidak tahu dari mana hasil meminjam (dari kiri atau kanan?) dan akan menolak. Lifetime tidak mengubah perilaku program — hanya membantu compiler membuktikan tidak ada pinjaman basi (dangling reference). Ini contoh nyata kapan kamu perlu menulis lifetime eksplisit.
cd /home/sysadmin/lab/belajar-rust/10-trait-generics-dan-lifetime-dasar/contoh
cargo run
cargo testLatihan 10 · Trait Bersuara
Definisikan kemampuan bunyi(&self) -> String untuk i32.
Perlihatkan solusi
trait Bersuara { fn bunyi(&self) -> String; }
impl Bersuara for i32 {
fn bunyi(&self) -> String { format!("angka {self}") }
}Pada nilai 42, hasilnya "angka 42".
Testing dan debugging
Test memeriksa perilaku yang kamu inginkan, termasuk input tidak valid dan ID yang tidak ditemukan.
Bab ini menunjukkan dua macam test. src/lib.rs memuat logika dan unit test, sedangkan tests/alur_tugas.rs menguji dari sudut pandang pengguna library. Simpan tiap berkas sesuai judul.
Proyek lengkap bab 11 — Cargo.toml, src/lib.rs, src/main.rs, tests/alur_tugas.rs
11-testing-dan-debugging/contoh/Cargo.toml
[package]
name = "bab11-uji"
version = "0.1.0"
edition = "2021"
[dependencies]11-testing-dan-debugging/contoh/src/lib.rs
use std::error::Error;
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum ErrorTugas {
JudulKosong,
TidakDitemukan(usize),
}
impl fmt::Display for ErrorTugas {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::JudulKosong => write!(f, "judul tidak boleh kosong"),
Self::TidakDitemukan(id) => write!(f, "tugas dengan ID {id} tidak ditemukan"),
}
}
}
impl Error for ErrorTugas {}
#[derive(Debug, PartialEq)]
pub struct Tugas {
id: usize,
judul: String,
selesai: bool,
}
impl Tugas {
pub fn id(&self) -> usize { self.id }
pub fn judul(&self) -> &str { &self.judul }
pub fn sudah_selesai(&self) -> bool { self.selesai }
}
#[derive(Debug)]
pub struct DaftarTugas { tugas: Vec<Tugas> }
impl DaftarTugas {
pub fn baru() -> Self { Self { tugas: Vec::new() } }
pub fn tambah(&mut self, judul: &str) -> Result<usize, ErrorTugas> {
// Validasi dilakukan sebelum data berubah atau ID dialokasikan.
let judul = bersihkan_judul(judul)?;
let id = self.tugas.len() + 1;
self.tugas.push(Tugas { id, judul, selesai: false });
Ok(id)
}
pub fn cari(&self, id: usize) -> Option<&Tugas> {
self.tugas.iter().find(|tugas| tugas.id == id)
}
pub fn selesaikan(&mut self, id: usize) -> Result<(), ErrorTugas> {
let tugas = self.tugas.iter_mut()
.find(|tugas| tugas.id == id)
.ok_or(ErrorTugas::TidakDitemukan(id))?;
tugas.selesai = true;
Ok(())
}
}
fn bersihkan_judul(judul: &str) -> Result<String, ErrorTugas> {
let judul = judul.trim();
if judul.is_empty() {
Err(ErrorTugas::JudulKosong)
} else {
Ok(judul.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::{bersihkan_judul, DaftarTugas, ErrorTugas};
#[test]
fn judul_dirapikan_tanpa_menghapus_spasi_tengah() {
assert_eq!(bersihkan_judul(" Belajar Rust \n"),
Ok(String::from("Belajar Rust")));
}
#[test]
fn judul_kosong_termasuk_whitespace_ditolak() {
for judul in ["", " ", "\n\t"] {
assert_eq!(bersihkan_judul(judul), Err(ErrorTugas::JudulKosong));
}
}
#[test]
fn daftar_baru_tidak_memiliki_tugas() {
let daftar = DaftarTugas::baru();
assert!(daftar.tugas.is_empty());
assert_eq!(daftar.cari(1), None);
}
#[test]
fn pesan_error_menyertakan_id() {
assert_eq!(ErrorTugas::TidakDitemukan(9).to_string(),
"tugas dengan ID 9 tidak ditemukan");
}
}11-testing-dan-debugging/contoh/src/main.rs
use bab11_uji::{DaftarTugas, ErrorTugas};
fn main() -> Result<(), ErrorTugas> {
let mut daftar = DaftarTugas::baru();
let id = daftar.tambah(" Tulis tes pertama ")?;
daftar.selesaikan(id)?;
if let Some(tugas) = daftar.cari(id) {
println!("#{} {} | selesai: {}", tugas.id(), tugas.judul(), tugas.sudah_selesai());
}
if let Err(error) = daftar.tambah(" ") {
println!("Ditolak: {error}");
}
if let Err(error) = daftar.selesaikan(99) {
println!("Ditolak: {error}");
}
Ok(())
}11-testing-dan-debugging/contoh/tests/alur_tugas.rs
// Integration test: hanya API publik dari library yang terlihat.
use bab11_uji::{DaftarTugas, ErrorTugas};
#[test]
fn tambah_cari_dan_selesaikan_melalui_api_publik() {
let mut daftar = DaftarTugas::baru();
let pertama = daftar.tambah(" Baca modul ").expect("judul valid");
let kedua = daftar.tambah("Tulis tes").expect("judul valid");
assert_eq!((pertama, kedua), (1, 2));
let tugas = daftar.cari(pertama).expect("tugas baru harus ada");
assert_eq!(tugas.id(), pertama);
assert_eq!(tugas.judul(), "Baca modul");
assert!(!tugas.sudah_selesai());
assert_eq!(daftar.selesaikan(pertama), Ok(()));
assert!(daftar.cari(pertama).expect("ID pertama").sudah_selesai());
assert!(!daftar.cari(kedua).expect("ID kedua").sudah_selesai());
}
#[test]
fn input_gagal_tidak_menghabiskan_id_atau_mengubah_tugas() {
let mut daftar = DaftarTugas::baru();
assert_eq!(daftar.tambah(" \t"), Err(ErrorTugas::JudulKosong));
assert_eq!(daftar.tambah("Tetap utuh"), Ok(1));
assert_eq!(daftar.selesaikan(99), Err(ErrorTugas::TidakDitemukan(99)));
assert_eq!(daftar.cari(99), None);
let tugas = daftar.cari(1).expect("tugas valid tetap ada");
assert_eq!(tugas.judul(), "Tetap utuh");
assert!(!tugas.sudah_selesai());
assert_eq!(daftar.tambah("Berikutnya"), Ok(2));
}
#[test]
fn menyelesaikan_dua_kali_tetap_berhasil() {
let mut daftar = DaftarTugas::baru();
let id = daftar.tambah("Sekali saja").expect("judul valid");
assert_eq!(daftar.selesaikan(id), Ok(()));
assert_eq!(daftar.selesaikan(id), Ok(()));
assert!(daftar.cari(id).expect("tugas tetap ada").sudah_selesai());
}Unit test di src/lib.rs
#[cfg(test)]
mod tests {
use super::bersihkan_judul;
#[test]
fn judul_dirapikan() {
assert_eq!(bersihkan_judul(" Belajar Rust "), Ok("Belajar Rust".to_owned()));
}
}Penjelasan per-baris/bagian:
#[cfg(test)]— atribut konfigurasi: kode di bawah ini hanya dikompilasi saat menjalankan test (cargo test), tidak saatcargo buildbiasa. Ini membuat test tidak ikut dalam program final — hemat ukuran dan waktu kompilasi.mod tests {— membuat modul bernamatestsuntuk mengelompokkan fungsi-fungsi test.use super::bersihkan_judul;—usemengimpor fungsibersihkan_juduldari modul induk (super::).superartinya "naik satu tingkat" — daritestske modul di atasnya (tempat fungsibersihkan_juduldidefinisikan). Unit test bisa mengakses fungsi privat karena berada dalam crate yang sama.#[test]— atribut test: menandai fungsi berikutnya sebagai test yang dijalankan olehcargo test.fn judul_dirapikan() {— fungsi test bernamajudul_dirapikan. Nama test yang deskriptif adalah dokumentasi — jelaskan apa yang diuji.assert_eq!(bersihkan_judul(" Belajar Rust "), Ok("Belajar Rust".to_owned()));— pernyataan test.assert_eq!(a, b)— macro yang membandingkan dua nilai: kalaua == b, test lolos; kalau beda, test gagal dan menampilkan kedua nilai.bersihkan_judul(" Belajar Rust ")— memanggil fungsi yang diuji dengan input berisi spasi di awal/akhir.Ok("Belajar Rust".to_owned())— hasil yang diharapkan: fungsi mengembalikanOkberisi teks yang sudah di-trim..to_owned()mengubah&strmenjadiString.
}— menutup blok isi fungsi test.}— menutup blok modultests.
Kenapa begini? Unit test memeriksa fungsi kecil secara terpisah — kalau bersihkan_judul rusak, kamu langsung tahu dari test ini, bukan dari program yang berantakan. assert_eq! adalah cara standar memeriksa hasil: tulis hasil aktual dan hasil yang diharapkan, biarkan test yang membandingkan. expect dan assert! juga tersedia untuk jenis pemeriksaan lain.
Integration test di tests/alur_tugas.rs
use bab11_uji::DaftarTugas;
#[test]
fn membuat_dan_mencari() {
let mut daftar = DaftarTugas::baru();
let id = daftar.tambah("Baca modul").expect("judul valid");
assert_eq!(daftar.cari(id).expect("ada").judul(), "Baca modul");
}Penjelasan per-baris/bagian:
use bab11_uji::DaftarTugas;—usemengimpor tipeDaftarTugasdari crate librarybab11_uji. File ditests/adalah crate terpisah — hanya APIpubyang terlihat, persis seperti pengguna luar library.#[test]— menandai fungsi berikutnya sebagai test.fn membuat_dan_mencari() {— fungsi test bernamamembuat_dan_mencari(menguji alur membuat lalu mencari tugas).let mut daftar = DaftarTugas::baru();— membuat instanceDaftarTugasbaru yang boleh diubah (mut).let id = daftar.tambah("Baca modul").expect("judul valid");— memanggil methodtambah.daftar.tambah("Baca modul")— menambahkan tugas dengan judul "Baca modul". HasilnyaResult<usize, ErrorTugas>(bisa berhasil atau gagal)..expect("judul valid")— mengambil nilaiOk(ID tugas baru). KalauErr, test panic dengan pesan "judul valid" — ini memberi konteks kenapa test gagal. Di test,expectboleh dipakai untuk mempersingkat (karena kalau gagal, test memang harus gagal). Di aplikasi, gunakanResultuntuk kegagalan normal.
assert_eq!(daftar.cari(id).expect("ada").judul(), "Baca modul");— memeriksa hasil pencarian.daftar.cari(id)— mencari tugas dengan IDid. HasilnyaOption<&Tugas>..expect("ada")— mengambil nilaiSome(tugas yang ditemukan). KalauNone, test panic dengan pesan "ada"..judul()— memanggil methodjudul()untuk membaca judul tugas.assert_eq!(..., "Baca modul")— membandingkan judul aktual dengan yang diharapkan ("Baca modul").
}— menutup blok isi fungsi test.
Kenapa begini? Integration test memeriksa alur dari sudut pandang pengguna — hanya API publik yang dipakai, persis seperti kode nyata yang memakai library-mu. Kalau API publik berubah dan merusak perilaku, test ini yang pertama berteriak. Ini melengkapi unit test yang memeriksa fungsi internal secara terpisah.
cd /home/sysadmin/lab/belajar-rust/11-testing-dan-debugging/contoh
cargo check
cargo test
cargo test judul
cargo fmt --check
cargo clippycheck cepat memeriksa tipe; test judul hanya test bernama sesuai filter; fmt --check memeriksa format tanpa mengubahnya. Baca pesan error pertama dan lokasi file:baris:kolom.
Latihan 11 · Uji ID pertama
Uji tugas pertama memperoleh ID 1.
Perlihatkan solusi
#[test]
fn tugas_pertama_id_satu() {
let mut daftar = DaftarTugas::baru();
assert_eq!(daftar.tambah("Belajar"), Ok(1));
}tambah mengembalikan Result: harapannya Ok(1), bukan 1 saja.
Task manager di terminal
CLI (command-line interface) menggabungkan model, Result, JSON dan berkas. Tidak perlu server.
Proyek ini lebih panjang. Mulai dengan membuka panel dan mencari urutan parse_args → load_tasks → add_task/mark_done → save_tasks. Seluruh kode di bawah satu file src/main.rs; test di bagian bawah file ikut dijalankan lewat cargo test.
Proyek lengkap bab 12 — Cargo.toml dan src/main.rs (CLI + test)
12-proyek-cli-task-manager/contoh/Cargo.toml
[package]
name = "bab12-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"12-proyek-cli-task-manager/contoh/src/main.rs
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
// Format perintah yang ditampilkan ketika pengguna salah mengetik argumen.
const USAGE: &str = "Pemakaian: bab12-cli [--file PATH] add \"judul\" | list | done ID";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Task {
id: i64,
title: String,
completed: bool,
}
#[derive(Debug, PartialEq)]
enum Command {
// Setiap varian mewakili tepat satu tindakan; Add/Done juga menyimpan inputnya.
Add(String),
List,
Done(i64),
}
fn title_valid(title: &str) -> bool {
// chars() menghitung karakter Unicode, bukan jumlah byte UTF-8.
(1..=200).contains(&title.chars().count()) && title == title.trim()
}
fn parse_args(args: Vec<OsString>) -> Result<(PathBuf, Command), String> {
// args tidak memuat nama executable (main memanggil .skip(1)).
let mut args = args.into_iter();
let first = args.next().ok_or_else(|| USAGE.to_owned())?;
// Path tetap OsString agar nama file non-UTF-8 tidak membuat program panik.
let (path, command) = if first == "--file" {
let path = PathBuf::from(args.next().ok_or("--file memerlukan PATH")?);
if path.as_os_str().is_empty() {
return Err("PATH tidak boleh kosong".into());
}
(path, args.next().ok_or_else(|| USAGE.to_owned())?)
} else {
(PathBuf::from("tasks.json"), first)
};
let command = command.into_string().map_err(|_| "Perintah harus UTF-8")?;
let result = match command.as_str() {
"add" => {
let title = args.next().ok_or("add memerlukan satu judul dalam tanda kutip")?;
let title = title.into_string().map_err(|_| "Judul harus UTF-8")?;
let title = title.trim().to_owned();
if !title_valid(&title) {
return Err("Judul harus 1..200 karakter setelah trim".into());
}
Command::Add(title)
}
"list" => Command::List,
"done" => {
// Jangan izinkan angka negatif ataupun nol menjadi ID tugas.
let raw = args.next().ok_or("done memerlukan ID")?;
let raw = raw.into_string().map_err(|_| "ID harus UTF-8")?;
let id = raw.parse::<i64>().map_err(|_| "ID harus bilangan bulat positif")?;
if id <= 0 {
return Err("ID harus bilangan bulat positif".into());
}
Command::Done(id)
}
_ => return Err(format!("Perintah tidak dikenal: {command}. {USAGE}")),
};
if args.next().is_some() {
return Err(format!("Argumen berlebih. {USAGE}"));
}
Ok((path, result))
}
fn decode_tasks(contents: &str) -> Result<Vec<Task>, String> {
// JSON yang sudah ada dibaca ke Vec<Task>; format rusak menjadi Err.
let tasks: Vec<Task> = serde_json::from_str(contents)
.map_err(|error| format!("JSON tidak valid: {error}"))?;
let mut ids = HashSet::new();
for task in &tasks {
// Tolak data rusak, bukan diam-diam menimpa atau memperbaikinya.
if task.id <= 0 || !ids.insert(task.id) || !title_valid(&task.title) {
return Err("Data tidak valid: ID harus positif/unik dan judul valid".into());
}
}
Ok(tasks)
}
fn load_tasks(path: &Path) -> Result<Vec<Task>, String> {
// File belum ada berarti tugas masih kosong; error lain jangan disembunyikan.
match fs::read_to_string(path) {
Ok(contents) => decode_tasks(&contents),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(format!("Gagal membaca {}: {error}", path.display())),
}
}
fn add_task(tasks: &mut Vec<Task>, title: String) -> Result<i64, String> {
// Validasi sebelum Vec berubah agar kegagalan tidak meninggalkan tugas setengah jadi.
if !title_valid(&title) {
return Err("Judul harus 1..200 karakter setelah trim".into());
}
// len() + 1 salah jika ID yang tersimpan tidak berurutan.
let id = tasks.iter().map(|task| task.id).max().unwrap_or(0)
.checked_add(1).ok_or("ID sudah mencapai batas i64")?;
tasks.push(Task { id, title, completed: false });
Ok(id)
}
fn mark_done(tasks: &mut [Task], id: i64) -> Result<(), String> {
let task = tasks.iter_mut().find(|task| task.id == id)
.ok_or_else(|| format!("Tugas #{id} tidak ditemukan"))?;
task.completed = true;
Ok(())
}
fn save_tasks(path: &Path, tasks: &[Task]) -> Result<(), String> {
// Ubah Vec menjadi JSON dengan indentasi supaya file mudah dibaca manusia.
let mut bytes = serde_json::to_vec_pretty(tasks)
.map_err(|error| format!("Gagal membuat JSON: {error}"))?;
bytes.push(b'\n');
let mut temporary = path.as_os_str().to_os_string();
temporary.push(".tmp");
let temporary = PathBuf::from(temporary);
// create_new mencegah penimpaan file sementara milik proses lain.
let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)
.map_err(|error| format!("Gagal membuat {}: {error}", temporary.display()))?;
let written = file.write_all(&bytes).and_then(|()| file.sync_all());
drop(file);
// File sementara berada di direktori yang sama: rename atomik pada Linux.
let result = written.and_then(|()| fs::rename(&temporary, path));
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(format!("Gagal menyimpan {}: {error}", path.display()));
}
Ok(())
}
fn run() -> Result<(), String> {
// parse -> muat -> ubah/tampilkan -> simpan bila memang ada perubahan.
let (path, command) = parse_args(std::env::args_os().skip(1).collect())?;
let mut tasks = load_tasks(&path)?;
match command {
Command::Add(title) => {
let id = add_task(&mut tasks, title)?;
save_tasks(&path, &tasks)?;
println!("Ditambahkan #{id}.");
}
Command::List => {
if tasks.is_empty() {
println!("Belum ada tugas.");
}
for task in tasks {
let status = if task.completed { "x" } else { " " };
println!("[{}] #{} {}", status, task.id, task.title);
}
}
Command::Done(id) => {
mark_done(&mut tasks, id)?;
save_tasks(&path, &tasks)?;
println!("Tugas #{id} ditandai selesai.");
}
}
Ok(())
}
fn main() {
// run() yang gagal dicetak ke stderr, lalu proses keluar dengan kode gagal.
if let Err(error) = run() {
eprintln!("Error: {error}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[test]
fn parser_memangkas_judul_dan_memilih_file() {
assert_eq!(parse_args(args(&["--file", "data.json", "add", " Belajar "])),
Ok((PathBuf::from("data.json"), Command::Add("Belajar".into()))));
}
#[test]
fn argumen_salah_ditolak() {
for input in [vec![], vec!["--file"], vec!["add"], vec!["add", " "],
vec!["add", "dua", "kata"], vec!["list", "extra"], vec!["done", "0"],
vec!["done", "abc"], vec!["done", "-1"], vec!["hapus", "1"]] {
assert!(parse_args(args(&input)).is_err());
}
assert!(parse_args(args(&["add", &"é".repeat(200)])).is_ok());
assert!(parse_args(args(&["add", &"é".repeat(201)])).is_err());
}
#[test]
fn id_dari_maksimum_bukan_panjang() {
let mut tasks = vec![Task { id: 7, title: "Lama".into(), completed: false }];
assert_eq!(add_task(&mut tasks, "Baru".into()), Ok(8));
mark_done(&mut tasks, 7).expect("ID ada");
mark_done(&mut tasks, 7).expect("done boleh diulang");
assert!(tasks[0].completed);
assert!(mark_done(&mut tasks, 99).is_err());
}
#[test]
fn overflow_tidak_mengubah_data() {
let mut tasks = vec![Task { id: i64::MAX, title: "Batas".into(), completed: false }];
assert!(add_task(&mut tasks, "Baru".into()).is_err());
assert_eq!(tasks.len(), 1);
}
#[test]
fn json_rusak_dan_id_duplikat_ditolak() {
assert!(decode_tasks("bukan json").is_err());
assert!(decode_tasks(r#"[{"id":1,"title":"A","completed":false},
{"id":1,"title":"B","completed":false}]"#).is_err());
let original = vec![Task { id: 1, title: "Belajar".into(), completed: false }];
let json = serde_json::to_string(&original).expect("serialisasi test");
assert_eq!(decode_tasks(&json).expect("JSON valid"), original);
}
}Bentuk data dan perintah
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Task { id: i64, title: String, completed: bool }
enum Command { Add(String), List, Done(i64) }Penjelasan per-baris/bagian:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]— atribut derive: meminta compiler membuat implementasi trait secara otomatis untuk struct di bawahnya.Debug— memungkinkan mencetak struct dengan{:?}(untuk debugging).Clone— memungkinkan membuat salinan dengan.clone().PartialEq— memungkinkan membandingkan dengan==.Serialize— memungkinkan mengubah struct menjadi JSON (untuk disimpan ke file).Deserialize— memungkinkan membaca JSON menjadi struct (untuk dimuat dari file).
struct Task { id: i64, title: String, completed: bool }— membuat structTask(satu tugas).id: i64— field ID: bilangan bulat 64-bit bertanda. Kenapai64? Karena ID database nanti memakaiBIGINTyang cocok dengani64— konsisten dengan API di bab 16–19.title: String— field judul: teks yang dimiliki (bukan pinjaman).completed: bool— field status:true(selesai) ataufalse(belum).
enum Command { Add(String), List, Done(i64) }— membuat enumCommand(perintah yang bisa dipilih pengguna).Add(String)— varian untuk menambah tugas, membawa judul sebagaiString.List— varian untuk menampilkan daftar tugas (tidak membawa data).Done(i64)— varian untuk menandai selesai, membawa ID tugas sebagaii64.
Kenapa begini? Enum Command membatasi program ke tiga perintah yang sah — tidak mungkin ada perintah keempat yang tidak dikenali. Ini membuat parsing argumen aman: compiler memastikan semua varian ditangani. Struct Task memodelkan data tugas dengan jelas, dan derive Serialize/Deserialize memungkinkan menyimpan/memuatnya sebagai JSON tanpa menulis kode konversi manual.
Bagian dari src/main.rs: validasi dan ID
fn title_valid(title: &str) -> bool {
(1..=200).contains(&title.chars().count()) && title == title.trim()
}
let id = tasks.iter().map(|task| task.id).max().unwrap_or(0)
.checked_add(1).ok_or("ID sudah mencapai batas i64")?;Penjelasan per-baris/bagian:
fn title_valid(title: &str) -> bool {— membuat fungsititle_valid.title: &str— parameter: pinjaman teks judul.-> bool— tipe kembalian:true(valid) ataufalse(tidak valid).
(1..=200).contains(&title.chars().count())— memeriksa apakah jumlah karakter judul dalam rentang1..=200.1..=200— rentang inklusif: 1 sampai 200 (termasuk 200).title.chars().count()— menghitung jumlah karakter (bukan byte). Ini penting untuk teks Unicode..contains(&...)— memeriksa apakah nilai itu ada dalam rentang.
&& title == title.trim()— dan (&&) judul harus sama dengan versi yang sudah di-trim. Artinya: judul tidak boleh ada spasi di awal/akhir.}— menutup blok isi fungsi.let id = tasks.iter().map(|task| task.id).max().unwrap_or(0)— mencari ID terbesar yang sudah ada.tasks.iter()— membuat iterator untuk mengunjungi setiap tugas..map(|task| task.id)— mengambil hanya fieldiddari setiap tugas.|task|adalah closure dengan parametertask..max()— mengambil nilai terbesar dari semua ID. HasilnyaOption<i64>:Some(terbesar)kalau ada tugas,Nonekalau daftar kosong..unwrap_or(0)— kalauNone(daftar kosong), gunakan0sebagai nilai awal.
.checked_add(1).ok_or("ID sudah mencapai batas i64")?;— menambah 1 ke ID terbesar untuk mendapatkan ID baru..checked_add(1)— menambah 1 dengan pemeriksaan overflow: kalau hasilnya melebihi batasi64, mengembalikanNone(aman, tidak panic)..ok_or("...")— kalauNone(overflow), ubah menjadiErr("...").?— kalauErr, kembalikan error itu sekarang.
Kenapa begini? Validasi memastikan judul 1–200 karakter dan tidak ada spasi tepi — aturan bisnis yang konsisten dengan API nanti. ID diperoleh dari terbesar + 1, bukan panjang + 1: kalau ID tidak berurutan (misal ada yang terhapus), cara ini tetap benar. checked_add mencegah overflow tanpa panic — lebih aman daripada + 1 biasa.
Bagian dari src/main.rs: simpan dan baca
let mut tasks = load_tasks(&path)?;
let id = add_task(&mut tasks, title)?;
save_tasks(&path, &tasks)?;
println!("Ditambahkan #{id}.");Penjelasan per-baris/bagian:
let mut tasks = load_tasks(&path)?;— memuat daftar tugas dari file.load_tasks(&path)— memanggil fungsiload_tasksdengan&path: pinjaman ke lokasi file. Fungsi ini membaca file JSON dan mengembalikanResult<Vec<Task>, String>.?— kalauErr(file rusak/tidak bisa dibaca), kembalikan error itu sekarang dan hentikan alur. KalauOk, ambil daftar tugasnya.let mut tasks— hasilnya disimpan di variabeltasksyang boleh diubah (mut).
let id = add_task(&mut tasks, title)?;— menambahkan tugas baru ke daftar.add_task(&mut tasks, title)— memanggil fungsiadd_taskdengan&mut tasks: pinjaman tulis untuk mengubah daftar.titleadalah judul tugas baru.?— kalauErr(validasi gagal/overflow), kembalikan error itu sekarang. KalauOk, ambil ID tugas baru.
save_tasks(&path, &tasks)?;— menyimpan daftar tugas yang sudah diubah kembali ke file.save_tasks(&path, &tasks)— memanggil fungsisave_tasksdengan&path(lokasi file) dan&tasks(pinjaman baca ke daftar).?— kalauErr(gagal menulis file), kembalikan error itu sekarang. Pesan sukses tidak dicetak kalau penyimpanan gagal.
println!("Ditambahkan #{id}.");— mencetak pesan sukses dengan ID tugas baru.{id}diisi nilai variabelid.
Kenapa begini? Urutan muat → ubah → simpan → pesan sukses memastikan data tidak hilang: kalau satu langkah gagal, langkah berikutnya tidak dijalankan berkat ?. Pesan sukses hanya muncul kalau semua langkah berhasil. Implementasi asli menggunakan file sementara dan rename untuk menyimpan: menulis ke tasks.json.tmp lalu mengganti nama menjadi tasks.json — ini mencegah file lama rusak kalau penulisan gagal di tengah.
cd /home/sysadmin/lab/belajar-rust/12-proyek-cli-task-manager/contoh
cargo run -- add "Belajar Rust"
cargo run -- list
cargo run -- done 1
cargo run -- list
cargo test-- memisahkan opsi Cargo dari argumen program. tasks.json ditulis di folder kerja; data ada lagi saat program dijalankan ulang.
Latihan 12 · Kesalahan input
Jika mengetik cargo run -- done 99 saat hanya ada tugas ID 1, apa yang terjadi?
Perlihatkan solusi
mark_done memakai find lalu ok_or_else. Hasilnya Err “Tugas #99 tidak ditemukan”. Fungsi run mengembalikan error; main mencetaknya dan keluar dengan status gagal, tanpa menimpa file.
Async, await, Tokio
Server tak boleh diam saat menunggu jaringan. Async mengizinkan pekerjaan lain berjalan selama suatu tugas menunggu I/O.
Contoh di bawah utuh: simpan dua file, jalankan cargo run, lalu bandingkan durasi dua pekerjaan berurutan dan bersamaan.
Proyek lengkap bab 13 — Cargo.toml dan src/main.rs
13-async-await-dan-tokio/contoh/Cargo.toml
[package]
name = "bab13-async"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }13-async-await-dan-tokio/contoh/src/main.rs
/// async/await dan join! dengan runtime Tokio.
/// Menjalankan: cargo run
use std::time::{Duration, Instant};
use tokio::time::sleep;
/// Menyimulasikan pekerjaan yang menunggu (I/O) selama `detik` detik.
/// sleep dari tokio TIDAK memblokir tugas lain — berbeda dengan std::thread::sleep.
async fn seduh(nama: &'static str, detik: u64) -> &'static str {
sleep(Duration::from_secs(detik)).await;
nama
}
/// Menunggu satu per satu: total waktu = jumlah semua.
async fn berurutan() {
let awal = Instant::now();
seduh("kopi", 3).await;
seduh("teh", 2).await;
println!("Berurutan selesai dalam {} detik", awal.elapsed().as_secs());
}
/// Menunggu bersamaan dengan join!: total waktu = yang terlama.
async fn bersamaan() {
let awal = Instant::now();
// join! menjalankan keduanya bersamaan; hasilnya tuple sesuai urutan future.
let (a, b) = tokio::join!(seduh("kopi", 3), seduh("teh", 2));
println!("Bersamaan selesai dalam {} detik", awal.elapsed().as_secs());
println!("Hasil join: ({a:?}, {b:?})");
}
// #[tokio::main] mengubah main menjadi titik masuk runtime Tokio.
#[tokio::main]
async fn main() {
println!("Mulai...");
berurutan().await;
bersamaan().await;
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
// Test async memakai #[tokio::test] alih-alih #[test].
#[tokio::test]
async fn bersamaan_lebih_cepat_dari_jumlah() {
let awal = Instant::now();
let _ = tokio::join!(seduh("a", 1), seduh("b", 1));
// Bersamaan: ±1 detik, bukan 2.
assert!(awal.elapsed().as_secs() < 2);
}
}Bagian dari main.rs: simulasi menunggu
async fn seduh(nama: &'static str, detik: u64) -> &'static str {
tokio::time::sleep(Duration::from_secs(detik)).await;
nama
}Penjelasan per-baris/bagian:
async fn seduh(nama: &'static str, detik: u64) -> &'static str {— membuat fungsi asyncseduh.async fn— fungsi yang bisa ditunda saat menunggu (I/O). Memanggilnya tidak langsung menjalankan isinya — ia mengembalikan Future (janji hasil di masa depan) yang baru berjalan saat di-.await.nama: &'static str— parameter: pinjaman teks dengan lifetime'static(literal, hidup sepanjang program).detik: u64— parameter: berapa detik menunggu.-> &'static str— mengembalikan pinjaman teks (nama yang diberikan).
tokio::time::sleep(Duration::from_secs(detik)).await;— menunggu selamadetikdetik.Duration::from_secs(detik)— membuat durasi dari angka detik.tokio::time::sleep(...)— "tidur" async dari Tokio. Tidak memblokir thread — saat menunggu, runtime bisa menjalankan tugas lain. Ini berbeda denganstd::thread::sleepyang memblokir thread (jangan dipakai di kode async)..await— menunggu Future sleep selesai, sambil memberi kesempatan tugas lain berjalan.
nama— baris terakhir tanpa;: mengembalikannamasebagai hasil fungsi.
Kenapa begini? Ini simulasi pekerjaan yang menunggu I/O (seperti query database atau request jaringan). Fungsi async memungkinkan server menunggu tanpa menyia-nyiakan thread — itulah alasan server async bisa melayani banyak request dengan thread terbatas. Tokio adalah runtime: mesin yang menjadwalkan kapan tiap tugas async maju.
Berurutan vs bersama
seduh("kopi", 3).await;
seduh("teh", 2).await; // ~5 detik total
let (a, b) = tokio::join!(seduh("kopi", 3), seduh("teh", 2));
// ~3 detik total: dua Future berjalan bersamaanPenjelasan per-baris/bagian:
seduh("kopi", 3).await;— memanggil fungsiseduhdengan argumen"kopi"dan3(detik)..await— menunggu hasil dari fungsi async. Fungsiseduhadalahasync fnyang mengembalikan Future (janji hasil di masa depan)..awaitmenunggu Future itu selesai dan mengambil hasilnya.- Kenapa
.awaittidak memblokir? Karena saat menunggu I/O (di sinisleep), runtime Tokio memberi kesempatan tugas lain berjalan. Ini berbeda denganstd::thread::sleepyang memblokir thread.
seduh("teh", 2).await;— memanggilseduhlagi untuk "teh" (2 detik). Karena ditulis berurutan, total waktu adalah 3 + 2 = ~5 detik: kopi selesai dulu, baru teh mulai.let (a, b) = tokio::join!(seduh("kopi", 3), seduh("teh", 2));—tokio::join!menjalankan kedua Future bersamaan dan menunggu keduanya selesai.join!(...)— macro yang memulai semua Future argumennya dalam waktu bersamaan (kooperatif, bukan paralel CPU).let (a, b)— destructuring hasil:aberisi hasil Future pertama,bberisi hasil Future kedua. Urutan hasil sesuai urutan argumen, bukan urutan selesai.- Total waktu adalah yang terlama: ~3 detik (bukan 5) karena keduanya berjalan bersamaan.
// ~3 detik total...— komentar penjelasan.
Kenapa begini? .await berurutan = satu per satu (lambat). join! = bersamaan (cepat untuk I/O). Ini penting untuk server: saat menunggu database/jaringan, request lain bisa dilayani. Perhatian: ini tentang menunggu I/O secara kooperatif, bukan otomatis memakai dua CPU — untuk pekerjaan berat CPU, gunakan tokio::task::spawn_blocking.
#[tokio::main]
async fn main() {
berurutan().await;
bersamaan().await;
}Penjelasan per-baris/bagian:
#[tokio::main]— atribut macro dari Tokio: mengubahmainmenjadi titik masuk runtime async. Tanpa ini,async fn maintidak bisa dijalankan karena Rust tidak punya runtime bawaan untuk async.async fn main() {— fungsimainyang async (boleh memakai.awaitdi dalamnya).berurutan().await;— memanggil fungsiberurutandan menunggu hasilnya.bersamaan().await;— memanggil fungsibersamaandan menunggu hasilnya.}— menutup blok isimain.
Kenapa begini? #[tokio::main] adalah cara standar membuat program async dengan Tokio. Ia menyiapkan runtime (penjadwal tugas) sebelum main berjalan, lalu menjalankan main sebagai tugas async pertama.
cd /home/sysadmin/lab/belajar-rust/13-async-await-dan-tokio/contoh
cargo run
cargo testLatihan 13 · Tiga pekerjaan sekaligus
Jalankan seduh kopi 3 detik, teh 2 detik, susu 1 detik secara bersamaan. Berapa waktu minimalnya?
Perlihatkan solusi
let (kopi, teh, susu) = tokio::join!(
seduh("kopi", 3), seduh("teh", 2), seduh("susu", 1)
);Ketiganya selesai setelah sekitar 3 detik, waktu tugas terlama; bukan jumlah 6 detik.
HTTP, JSON dan serde
Klien mengirim request HTTP; server menjawab response. JSON adalah teks terstruktur yang bisa dibaca keduanya.
Buka proyek utuh ini lebih dahulu. Contoh memuat struct, konversi JSON dua arah, input rusak, dan test dalam satu src/main.rs.
Proyek lengkap bab 14 — Cargo.toml dan src/main.rs
14-http-json-dan-serde/contoh/Cargo.toml
[package]
name = "bab14-serde"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"14-http-json-dan-serde/contoh/src/main.rs
/// Serialize/Deserialize JSON dengan serde.
/// Menjalankan: cargo run
use serde::{Deserialize, Serialize};
/// Bentuk lengkap sebuah tugas — inilah yang dikirim server ke klien.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Task {
id: i64,
title: String,
completed: bool,
}
/// Yang boleh dikirim klien saat membuat tugas — TANPA id dan completed.
/// Server yang menentukan keduanya.
#[derive(Debug, Deserialize)]
struct NewTask {
title: String,
}
fn main() {
// Struct -> JSON (serialize).
let tugas = Task {
id: 1,
title: "Belajar serde".to_owned(),
completed: false,
};
let json = serde_json::to_string(&tugas).expect("serialize selalu bisa untuk struct ini");
println!("JSON: {json}");
// JSON -> struct (deserialize).
let masuk = r#"{"id":2,"title":"Balas JSON","completed":true}"#;
let dari_json: Task = serde_json::from_str(masuk).expect("JSON valid");
println!("Dari JSON: {dari_json:?}");
// Struct input: hanya title yang diterima.
let kiriman_klien = r#"{"title":"Setor PR"}"#;
let baru: NewTask = serde_json::from_str(kiriman_klien).expect("title wajib ada");
println!("Judul baru dari klien: {}", baru.title);
// JSON rusak -> Err informatif (bukan panic). Nanti dibalas status 400.
match serde_json::from_str::<NewTask>("{judul: tanpa kutip}") {
Ok(_) => println!("tidak akan sampai sini"),
Err(e) => println!("Error input rusak: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bolak_balik_json() {
let t = Task {
id: 7,
title: "Uji".to_owned(),
completed: true,
};
let s = serde_json::to_string(&t).expect("serialize");
let kembali: Task = serde_json::from_str(&s).expect("deserialize");
assert_eq!(t, kembali);
}
#[test]
fn field_hilang_ditolak() {
assert!(serde_json::from_str::<NewTask>("{}").is_err());
}
#[test]
fn field_ekstra_diabaikan() {
// Klien nakal mengirim id — diabaikan karena NewTask tidak punya field itu.
let n: NewTask = serde_json::from_str(r#"{"title":"x","id":999}"#).expect("ok");
assert_eq!(n.title, "x");
}
}Satu percakapan HTTP
POST /tasks HTTP/1.1
Content-Type: application/json
{"title":"Belajar"}
HTTP/1.1 201 Created
Content-Type: application/json
{"id":1,"title":"Belajar","completed":false}POST bermaksud membuat; GET membaca, PUT mengganti, DELETE menghapus. Path /tasks menamai sumber daya. 201 sukses membuat, 200 sukses membaca, 204 sukses tanpa body, 400 input salah, 404 tidak ada, 500 kesalahan server.
Bagian dari main.rs: tipe dan JSON
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Task { id: i64, title: String, completed: bool }
#[derive(Debug, Deserialize)]
struct NewTask { title: String }Penjelasan per-baris/bagian:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]— derive untuk structTask.Serialize— memungkinkan mengubahTaskmenjadi JSON (untuk dikirim sebagai respons API).Deserialize— memungkinkan membaca JSON menjadiTask(untuk diproses dari request).
struct Task { id: i64, title: String, completed: bool }— structTasklengkap: bentuk data yang dikirim server ke klien (respons).#[derive(Debug, Deserialize)]— derive untuk structNewTask: hanyaDebugdanDeserialize.- Tidak ada
Serialize— karenaNewTaskhanya untuk membaca input dari klien, tidak untuk dikirim sebagai respons. Server tidak perlu mengubahnya menjadi JSON.
- Tidak ada
struct NewTask { title: String }— structNewTask: bentuk data yang diterima dari klien saat membuat tugas.- Hanya punya field
title— klien hanya boleh mengirim judul. - Tidak ada
idataucompleted— server yang menetapkan keduanya, bukan klien. Ini mencegah klien membuat tugas dengan ID atau status sembarangan.
- Hanya punya field
Kenapa begini? Memisahkan NewTask (input) dan Task (output) adalah pola keamanan umum: klien tidak bisa menentukan ID atau status sendiri. Field wajib yang hilang menghasilkan error dari serde, bukan Task setengah valid — data yang masuk selalu utuh atau ditolak.
let json = serde_json::to_string(&tugas).expect("serialisasi valid");
let dari_json: Task = serde_json::from_str(&json).expect("JSON valid");Penjelasan per-baris/bagian:
let json = serde_json::to_string(&tugas).expect("serialisasi valid");— mengubahtugasmenjadi teks JSON.serde_json::to_string(&tugas)— memanggil fungsito_stringdari crateserde_jsondengan&tugas: pinjaman ketugas. Kenapa pinjaman? Agartugastidak dipindahkan ke fungsi — kita masih butuhtugassetelahnya..expect("serialisasi valid")— mengambil hasilOk(teks JSON). KalauErr, program panic dengan pesan itu. Untuk struct sederhana sepertiTask, serialisasi hampir tidak mungkin gagal — tapi di aplikasi nyata, tangani denganResultuntuk input pengguna.
let dari_json: Task = serde_json::from_str(&json).expect("JSON valid");— membaca teks JSON menjadiTask.serde_json::from_str(&json)— memanggil fungsifrom_strdengan&json: pinjaman ke teks JSON.: Task— anotasi tipe: memberitahufrom_strbentuk hasil yang diinginkan. Tanpa ini, compiler tidak tahu tipe tujuan..expect("JSON valid")— mengambil hasilOk(instanceTask). Kalau JSON rusak/tidak cocok, panic dengan pesan itu.
Kenapa begini? to_string dan from_str adalah dua arah konversi: struct → JSON → struct. Ini membuktikan data tidak berubah saat dikonversi bolak-balik. Anotasi tipe : Task diperlukan karena from_str adalah fungsi generik yang bisa menghasilkan tipe apa saja yang mengimplementasikan Deserialize.
cd /home/sysadmin/lab/belajar-rust/14-http-json-dan-serde/contoh
cargo run
cargo testLatihan 14 · PUT butuh apa?
Buat struct input untuk mengganti judul dan status sebuah tugas. Apakah JSON tanpa status diterima?
Perlihatkan solusi
#[derive(Deserialize)]
struct UpdateTask { title: String, completed: bool }completed wajib karena bukan Option<bool>. JSON tanpa field ini ditolak serde.
Router dan handler Axum
Router memasangkan URL dan method HTTP ke fungsi async yang mengerjakan request.
Ini server lengkap pertama: satu main.rs memuat router, tiga handler, dan bentuk body JSON. Mulai server di terminal pertama, lalu gunakan curl dari terminal kedua.
Proyek lengkap bab 15 — Cargo.toml dan src/main.rs
15-api-pertama-dengan-axum/contoh/Cargo.toml
[package]
name = "bab15-axum-pertama"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }15-api-pertama-dengan-axum/contoh/src/main.rs
/// API pertama: /health, /hello/{nama}, dan /echo.
/// Menjalankan: cargo run, lalu uji dengan curl (lihat README).
use axum::{
extract::Path,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/health", get(health))
.route("/hello/{nama}", get(sapa))
.route("/echo", post(gema));
// 127.0.0.1: hanya bisa diakses dari komputer ini — aman untuk belajar.
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.expect("port 3000 harus bebas");
println!("Mendengarkan di http://127.0.0.1:3000");
axum::serve(listener, app).await.expect("server berjalan");
}
/// GET /health — tanda server hidup.
async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
/// GET /hello/{nama} — extractor Path membaca bagian URL.
async fn sapa(Path(nama): Path<String>) -> Json<Value> {
Json(json!({ "pesan": format!("Halo, {nama}!") }))
}
#[derive(Debug, Deserialize)]
struct Kiriman {
pesan: String,
}
#[derive(Debug, Serialize)]
struct Jawaban {
pesan: String,
}
/// POST /echo — extractor Json memparse body; JSON rusak otomatis dibalas 400.
async fn gema(Json(kiriman): Json<Kiriman>) -> (StatusCode, Json<Jawaban>) {
let jawaban = Jawaban {
pesan: format!("kamu berkata: {}", kiriman.pesan),
};
(StatusCode::CREATED, Json(jawaban))
}Bagian dari main.rs: peta rute
let app = Router::new()
.route("/health", get(health))
.route("/hello/{nama}", get(sapa))
.route("/echo", post(gema));Penjelasan per-baris/bagian:
let app = Router::new()— membuatRouterbaru yang kosong (belum ada rute).Routeradalah peta yang mencocokkan URL dan method HTTP ke handler..route("/health", get(health))— menambahkan rute pertama."/health"— alamat URL:/health.get(health)— method HTTP GET ditangani oleh handlerhealth.getadalah fungsi pembantu dari Axum untuk method GET.
.route("/hello/{nama}", get(sapa))— menambahkan rute dengan parameter URL.{nama}— placeholder di URL: bagian yang nilainya berubah. Misalnya/hello/Rani→nama = "Rani",/hello/Budi→nama = "Budi".get(sapa)— method GET ditangani oleh handlersapa.
.route("/echo", post(gema));— menambahkan rute dengan method POST."/echo"— alamat URL:/echo.post(gema)— method POST ditangani oleh handlergema. POST biasanya untuk mengirim data (body) ke server.;— mengakhiri rantai pembuatan router.
Kenapa begini? Router memisahkan pemetaan alamat dari logika handler: kamu mendefinisikan alamat di satu tempat, dan handler di tempat lain. {nama} membuat satu rute bisa menangani banyak URL yang polanya sama — tidak perlu menulis rute untuk setiap nama. Rantai .route(...).route(...) adalah gaya pembangun (builder) yang mudah dibaca.
Bagian dari main.rs: dua extractor
async fn sapa(Path(nama): Path<String>) -> Json<Value> {
Json(json!({ "pesan": format!("Halo, {nama}!") }))
}
async fn gema(Json(kiriman): Json<Kiriman>) -> (StatusCode, Json<Jawaban>) {
let jawaban = Jawaban { pesan: format!("kamu berkata: {}", kiriman.pesan) };
(StatusCode::CREATED, Json(jawaban))
}Penjelasan per-baris/bagian:
async fn sapa(Path(nama): Path<String>) -> Json<Value> {— membuat handlersapa.async fn— handler adalah fungsi async (bisa menunggu I/O tanpa memblokir).Path(nama)— extractorPath: mengambil nilai placeholder{nama}dari URL dan mengisinya ke variabelnama. Extractor adalah cara Axum mengambil bagian request secara otomatis.: Path<String>— tipe extractor:Pathyang berisiString(nilai dari URL).-> Json<Value>— tipe kembalian:JsonberisiValue(nilai JSON dari crateserde_json).
Json(json!({ "pesan": format!("Halo, {nama}!") }))— membuat respons JSON.json!({ ... })— macro untuk membuat nilai JSON dengan syntax mirip JavaScript.{ "pesan": ... }adalah objek JSON dengan fieldpesan.format!("Halo, {nama}!")— mengisi fieldpesandengan teks yang berisi nilainamadari URL.Json(...)— membungkus nilai JSON sebagai respons dengan headerContent-Type: application/json.
}— menutup blok isi handlersapa.async fn gema(Json(kiriman): Json<Kiriman>) -> (StatusCode, Json<Jawaban>) {— membuat handlergema.Json(kiriman)— extractorJson: membaca body request sebagai JSON dan mengubahnya menjadi structKiriman. Kalau body rusak/bukan JSON, Axum otomatis membalas400 Bad Requestsebelum handler dipanggil.: Json<Kiriman>— tipe extractor:Jsonyang berisi structKiriman(didefinisikan di contoh lengkap).-> (StatusCode, Json<Jawaban>)— tipe kembalian: tuple berisiStatusCode(kode status HTTP) danJson<Jawaban>(respons JSON). Tuple memungkinkan mengatur status sekaligus body.
let jawaban = Jawaban { pesan: format!("kamu berkata: {}", kiriman.pesan) };— membuat structJawabandengan fieldpesanyang diisi teks dari fieldkiriman.pesan(yang dibaca dari body request).(StatusCode::CREATED, Json(jawaban))— mengembalikan tuple: status201 Createddan respons JSON berisijawaban.
Kenapa begini? Extractor membuat handler deklaratif: kamu menyatakan apa yang dibutuhkan (nilai URL, body JSON) dan Axum mengisinya otomatis — tidak perlu parsing manual. Path untuk bagian URL, Json untuk body. Mengembalikan Json(...) membuat respons dengan header JSON otomatis; tuple (StatusCode, Json(...)) untuk mengatur status sekaligus.
Menyalakan dan mencoba
cd /home/sysadmin/lab/belajar-rust/15-api-pertama-dengan-axum/contoh
cargo run
# Di terminal kedua:
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/hello/Rani
curl -i -X POST http://127.0.0.1:3000/echo -H 'Content-Type: application/json' -d '{"pesan":"halo"}'Server bind pada 127.0.0.1:3000: curl harus dijalankan di terminal server yang sama (termasuk terminal VS Code Server). Hentikan server dengan Ctrl+C. Header Content-Type memberi tahu Axum bahwa body adalah JSON.
Latihan 15 · Status dan endpoint
Tambahkan GET /about yang membalas {"nama":"belajar-rust"}.
Perlihatkan solusi
async fn about() -> Json<Value> {
Json(json!({ "nama": "belajar-rust" }))
}
// Di Router::new(): .route("/about", get(about))Handler membangun JSON; router menentukan URL yang menuju handler.
CRUD lengkap di Axum
CRUD = Create, Read, Update, Delete. Bab ini menyimpan data di RAM sehingga hilang setelah server berhenti.
Proyek lengkap memiliki empat berkas. lib.rs memuat router dan operasi CRUD, main.rs membuka port, dan tests/api.rs menguji router tanpa membuka port. Ikuti urutan nama berkas di panel.
Proyek lengkap bab 16 — Cargo.toml, src/lib.rs, src/main.rs, tests/api.rs
16-crud-in-memory/contoh/Cargo.toml
[package]
name = "bab16-crud-memori"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }16-crud-in-memory/contoh/src/lib.rs
/// Logika CRUD + pembuatan Router, dipisah dari main agar bisa diuji.
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::get,
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task {
pub id: i64,
pub title: String,
pub completed: bool,
}
/// Body untuk POST: klien hanya boleh mengirim judul.
#[derive(Debug, Deserialize)]
pub struct NewTask { pub title: String }
/// Body untuk PUT: mengganti seluruh resource, dua-duanya wajib.
#[derive(Debug, Deserialize)]
pub struct UpdateTask {
pub title: String,
pub completed: bool,
}
#[derive(Debug, Serialize)]
pub struct ErrorBody { pub error: String }
/// State bersama seluruh handler. Mutex std cukup karena kunci TIDAK
/// ditahan melewati .await (tidak ada await di dalam blok kunci).
#[derive(Debug)]
pub struct AppState {
pub tasks: Mutex<Vec<Task>>,
pub next_id: Mutex<i64>,
}
impl Default for AppState {
fn default() -> Self {
Self {
tasks: Mutex::new(Vec::new()),
next_id: Mutex::new(1), // ID pertama adalah 1, sesuai contoh curl.
}
}
}
pub fn app() -> Router {
// Method HTTP berbeda dapat memakai path yang sama.
Router::new()
.route("/tasks", get(daftar).post(buat))
.route("/tasks/{id}", get(ambil).put(ubah).delete(hapus))
.with_state(Arc::new(AppState::default()))
}
/// Validasi aturan bisnis: judul 1..=200 karakter setelah trim.
fn validasi_judul(judul: &str) -> Result<String, String> {
let judul = judul.trim();
let panjang = judul.chars().count();
if panjang == 0 {
Err("title tidak boleh kosong".to_owned())
} else if panjang > 200 {
Err("title maksimal 200 karakter".to_owned())
} else {
Ok(judul.to_owned())
}
}
fn err400(pesan: String) -> (StatusCode, Json<ErrorBody>) {
(StatusCode::BAD_REQUEST, Json(ErrorBody { error: pesan }))
}
fn err404() -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::NOT_FOUND,
Json(ErrorBody { error: "tugas tidak ditemukan".to_owned() }),
)
}
/// GET /tasks
async fn daftar(State(state): State<Arc<AppState>>) -> Json<Vec<Task>> {
let tasks = state.tasks.lock().expect("mutex tidak diracun");
Json(tasks.clone())
}
/// POST /tasks
async fn buat(
State(state): State<Arc<AppState>>,
Json(input): Json<NewTask>,
) -> Result<(StatusCode, Json<Task>), (StatusCode, Json<ErrorBody>)> {
// Validasi judul sebelum memegang kunci atau mengambil ID.
let title = validasi_judul(&input.title).map_err(err400)?;
// next_id pertama bernilai 1; kunci menjamin dua request tidak mendapat ID sama.
let mut next_id = state.next_id.lock().expect("mutex tidak diracun");
let id = *next_id;
*next_id += 1;
let tugas = Task { id, title, completed: false };
// clone karena satu salinan disimpan, salinan lain dikirim sebagai respons.
state.tasks.lock().expect("mutex tidak diracun").push(tugas.clone());
Ok((StatusCode::CREATED, Json(tugas)))
}
/// GET /tasks/{id}
async fn ambil(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> Result<Json<Task>, (StatusCode, Json<ErrorBody>)> {
let tasks = state.tasks.lock().expect("mutex tidak diracun");
// Jika ID tidak ada, ubah None menjadi respons 404 lewat ok_or_else.
tasks.iter().find(|t| t.id == id).cloned().map(Json).ok_or_else(err404)
}
/// PUT /tasks/{id}
async fn ubah(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
Json(input): Json<UpdateTask>,
) -> Result<Json<Task>, (StatusCode, Json<ErrorBody>)> {
let title = validasi_judul(&input.title).map_err(err400)?;
let mut tasks = state.tasks.lock().expect("mutex tidak diracun");
let tugas = tasks.iter_mut().find(|t| t.id == id).ok_or_else(err404)?;
tugas.title = title;
tugas.completed = input.completed;
Ok(Json(tugas.clone()))
}
/// DELETE /tasks/{id}
async fn hapus(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, Json<ErrorBody>)> {
let mut tasks = state.tasks.lock().expect("mutex tidak diracun");
// position mencari indeks; remove menghapus item tepat pada indeks itu.
let posisi = tasks.iter().position(|t| t.id == id).ok_or_else(err404)?;
tasks.remove(posisi);
Ok(StatusCode::NO_CONTENT)
}16-crud-in-memory/contoh/src/main.rs
/// CRUD in-memory. Menjalankan: cargo run, lalu uji dengan curl (lihat README).
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.expect("port 3000 harus bebas");
println!("Mendengarkan di http://127.0.0.1:3000");
axum::serve(listener, bab16_crud_memori::app())
.await
.expect("server berjalan");
}16-crud-in-memory/contoh/tests/api.rs
//! Integration test untuk seluruh alur CRUD, tanpa menyalakan port.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::ServiceExt; // untuk .oneshot()
/// Helper: kirim request ke Router dan kembalikan (status, body sebagai Value).
async fn panggil(
app: &mut axum::Router,
method: &str,
path: &str,
body: Option<Value>,
) -> (StatusCode, Option<Value>) {
let mut builder = Request::builder().method(method).uri(path);
let http_body = match body {
Some(v) => {
builder = builder.header("content-type", "application/json");
Body::from(serde_json::to_vec(&v).expect("serialize"))
}
None => Body::empty(),
};
let respons = app
.oneshot(builder.body(http_body).expect("request valid"))
.await
.expect("router merespons");
let status = respons.status();
let bytes = axum::body::to_bytes(respons.into_body(), usize::MAX)
.await
.expect("membaca body");
let nilai = if bytes.is_empty() {
None
} else {
Some(serde_json::from_slice(&bytes).expect("respons JSON"))
};
(status, nilai)
}
#[tokio::test]
async fn alur_crud_lengkap() {
let mut app = bab16_crud_memori::app();
// Buat tugas; judul awal/akhir yang berspasi harus di-trim.
let (status, body) = panggil(
&mut app, "POST", "/tasks", Some(json!({"title": " Belajar Axum "})),
).await;
assert_eq!(status, StatusCode::CREATED);
let tugas = body.expect("ada body");
assert_eq!(tugas["id"], 1);
assert_eq!(tugas["title"], "Belajar Axum");
assert_eq!(tugas["completed"], false);
// Daftar dan ambil.
let (status, body) = panggil(&mut app, "GET", "/tasks", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("array").as_array().expect("array").len(), 1);
let (status, _) = panggil(&mut app, "GET", "/tasks/1", None).await;
assert_eq!(status, StatusCode::OK);
// Ubah lalu hapus.
let (status, body) = panggil(
&mut app, "PUT", "/tasks/1",
Some(json!({"title": "Revisi", "completed": true})),
).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("body")["completed"], true);
let (status, body) = panggil(&mut app, "DELETE", "/tasks/1", None).await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert!(body.is_none());
let (status, _) = panggil(&mut app, "GET", "/tasks/1", None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn validasi_dan_kasus_gagal() {
let mut app = bab16_crud_memori::app();
let (status, _) = panggil(&mut app, "POST", "/tasks", Some(json!({"title": " "}))).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, body) = panggil(&mut app, "POST", "/tasks", Some(json!({"title": "ok"}))).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(body.expect("body")["id"], 1);
// PUT tanpa completed ditolak oleh extractor JSON.
let (status, _) = panggil(&mut app, "PUT", "/tasks/1", Some(json!({"title": "x"}))).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
for (m, p) in [("GET", "/tasks/99"), ("PUT", "/tasks/99"), ("DELETE", "/tasks/99")] {
let body = if m == "PUT" {
Some(json!({"title": "x", "completed": false}))
} else {
None
};
let (status, _) = panggil(&mut app, m, p, body).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{m} {p}");
}
}Bentuk Task dan aturan validasi (bagian dari lib.rs)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task { pub id: i64, pub title: String, pub completed: bool }
#[derive(Debug, Deserialize)]
pub struct NewTask { pub title: String }
#[derive(Debug, Deserialize)]
pub struct UpdateTask { pub title: String, pub completed: bool }Penjelasan per-baris/bagian:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]— derive untukTask.pub struct— struct ini publik (bisa dipakai dari luar modullib.rs).pub id,pub title,pub completed— semua field juga publik: bisa dibaca dan diubah dari luar. Ini diperlukan agar Axum dan serde bisa mengakses field saat membuat/membaca JSON.
pub struct Task { pub id: i64, pub title: String, pub completed: bool }— structTask: bentuk data yang dibalas ke klien (output).#[derive(Debug, Deserialize)]— derive untukNewTask: hanyaDebugdanDeserialize. Tidak adaSerializekarena ini hanya untuk membaca input.pub struct NewTask { pub title: String }— structNewTask: bentuk data yang diterima saat POST (buat tugas baru). Hanyatitle— klien hanya mengirim judul.#[derive(Debug, Deserialize)]— derive untukUpdateTask: sama, hanya untuk membaca input.pub struct UpdateTask { pub title: String, pub completed: bool }— structUpdateTask: bentuk data yang diterima saat PUT (mengganti tugas).- Memiliki
titledancompleted— seluruh field yang bisa diedit wajib dikirim oleh klien. Ini karena PUT mengganti seluruh resource, bukan sebagian. - Kalau klien mengirim hanya
titletanpacompleted, serde menolak dengan400 Bad Requestkarena field wajib hilang.
- Memiliki
Kenapa begini? Memisahkan NewTask (POST) dan UpdateTask (PUT) membuat aturan jelas: POST hanya butuh judul, PUT butuh seluruh data. Ini mencegah klien mengubah ID atau membuat tugas setengah jadi. Derive Deserialize memungkinkan Axum membaca JSON menjadi struct otomatis.
let judul = judul.trim();
let panjang = judul.chars().count();
if panjang == 0 {
Err("title tidak boleh kosong".to_owned())
} else if panjang > 200 {
Err("title maksimal 200 karakter".to_owned())
} else {
Ok(judul.to_owned())
}Penjelasan per-baris/bagian:
let judul = judul.trim();—.trim()menghapus spasi di awal dan akhir judul. Hasilnya disimpan di variabeljudulyang baru (shadowing).let panjang = judul.chars().count();— menghitung jumlah karakter (bukan byte) untuk validasi batas.if panjang == 0 { Err("title tidak boleh kosong".to_owned()) }— kalau judul kosong setelah di-trim, kembalikanErr(...)(gagal). Pesan error ini menjadi respons400 Bad Requestdi handler.} else if panjang > 200 { Err("title maksimal 200 karakter".to_owned()) }— kalau judul lebih dari 200 karakter, kembalikanErr(...)dengan pesan berbeda.} else { Ok(judul.to_owned()) }— kalau lolos semua pemeriksaan, kembalikanOk(judul.to_owned()):Stringbaru dari judul yang sudah di-trim..to_owned()mengubah&strmenjadiStringyang dimiliki.
Kenapa begini? Ini bagian dari fn validasi_judul(judul: &str) -> Result<String, String> di lib.rs. Validasi memastikan judul 1–200 karakter dan tidak ada spasi tepi — aturan bisnis yang konsisten. Err menjadi respons 400 dengan pesan jelas; Ok menyimpan judul yang sudah dibersihkan ke database/memori.
State yang dibagi dan ID awal
pub struct AppState {
pub tasks: Mutex<Vec<Task>>,
pub next_id: Mutex<i64>,
}
impl Default for AppState {
fn default() -> Self {
Self { tasks: Mutex::new(Vec::new()), next_id: Mutex::new(1) }
}
}Penjelasan per-baris/bagian:
pub struct AppState {— membuat structAppState: data bersama seluruh handler (daftar tugas dan penomor ID).pub tasks: Mutex<Vec<Task>>,— fieldtasks:Mutexyang melindungiVec<Task>(daftar tugas).Mutex— kunci: hanya satu yang memegang data pada satu waktu. Ini mencegah dua request bersamaan menulis daftar sekaligus (yang bisa merusak data).Vec<Task>— daftar tugas yang bisa tumbuh.
pub next_id: Mutex<i64>,— fieldnext_id:Mutexyang melindungi penomor ID berikutnya. Dipisah daritasksagar jelas apa yang dilindungi.}— menutup deklarasi struct.impl Default for AppState {— mengimplementasikan traitDefaultuntukAppState: cara membuat instance dengan nilai awal.fn default() -> Self {— methoddefaultyang mengembalikan instance baru.Self { tasks: Mutex::new(Vec::new()), next_id: Mutex::new(1) }— membuat instance dengan nilai awal:tasks: Mutex::new(Vec::new())— daftar tugas kosong yang dilindungiMutex.next_id: Mutex::new(1)— penomor ID dimulai dari 1 (bukan 0).
}— menutup blok isi method.}— menutup blok implementasi.
Kenapa begini? Mutex memastikan hanya satu request yang menulis daftar/ID pada satu waktu — request lain menunggu. Arc (di router bawah) memberi banyak handler pemilik bersama state yang sama: semua handler melihat data yang sama, bukan salinan terpisah. ID awal 1 membuat contoh mudah diikuti: dua POST menghasilkan ID 1 dan 2. Di kode ini tidak ada .await saat kunci ditahan — itu penting agar tidak deadlock.
pub fn app() -> Router {
Router::new()
.route("/tasks", get(daftar).post(buat))
.route("/tasks/{id}", get(ambil).put(ubah).delete(hapus))
.with_state(Arc::new(AppState::default()))
}Penjelasan per-baris/bagian:
pub fn app() -> Router {— membuat fungsiappyang mengembalikanRouter(peta rute). Ini dipakai olehmain.rs(server) dantests/api.rs(test tanpa port).Router::new()— membuat router kosong..route("/tasks", get(daftar).post(buat))— rute untuk/tasks: methodGETditangani olehdaftar, methodPOSTolehbuat. Satu path bisa punya beberapa method..route("/tasks/{id}", get(ambil).put(ubah).delete(hapus))— rute untuk/tasks/{id}:GETolehambil,PUTolehubah,DELETEolehhapus..with_state(Arc::new(AppState::default()))— menyuntikkan state ke handler.Arc::new(...)— Atomic Reference Counting: pembungkus yang memungkinkan banyak pemilik bersama untukAppState. Setiap handler mendapatkanArc(clone murah) ke state yang sama..with_state(...)— menyediakan state itu ke semua handler lewat extractorState.
}— menutup blok isi fungsi.
Kenapa begini? Memisahkan app() dari main.rs membuat router bisa diuji tanpa menyalakan port (di test). Arc membuat state bisa dibagi banyak handler dengan aman — setiap clone Arc menambah penghitung, dan state dibersihkan saat penghitung terakhir hilang. Rute multi-method membuat API ringkas: /tasks untuk daftar/buat, /tasks/{id} untuk baca/ubah/hapus satu.
Bagian dari lib.rs: buat dan ambil
let title = validasi_judul(&input.title).map_err(err400)?;
let mut next_id = state.next_id.lock().expect("mutex tidak diracun");
let id = *next_id;
*next_id += 1;
let tugas = Task { id, title, completed: false };
state.tasks.lock().expect("mutex tidak diracun").push(tugas.clone());
Ok((StatusCode::CREATED, Json(tugas)))Penjelasan per-baris/bagian:
let title = validasi_judul(&input.title).map_err(err400)?;— memvalidasi judul dari input.validasi_judul(&input.title)— memanggil fungsi validasi dengan&input.title(pinjaman ke judul yang dikirim klien)..map_err(err400)— kalau validasi gagal (Err), membungkus error menjadi respons400 Bad Requestdengan pesan itu.?— kalauErr(setelah dibungkus), kembalikan respons 400 itu sekarang. KalauOk, ambil judul yang sudah divalidasi.
let mut next_id = state.next_id.lock().expect("mutex tidak diracun");— mengambil kunci untuk penomor ID.state.next_id— field penomor ID di state bersama..lock()— mengambil kunciMutex: hanya satu request yang memegang kunci ini pada satu waktu. Request lain menunggu..expect("mutex tidak diracun")— kalau kunci "diracun" (panic sebelumnya saat kunci dipegang), program panic dengan pesan itu. Di proyek belajar ini, kegagalan karena mutex diracun memakaiexpect— bukan error normal pengguna yang perlu ditangani denganResult.let mut next_id— hasilnya adalah guard (penjaga kunci) yang memberi akses ke nilaii64yang dilindungi. Guard ini melepaskan kunci otomatis saat keluar scope.
let id = *next_id;— membaca nilai angka yang dikunci.*next_idadalah dereference: mengakses nilaii64di balik guard.*next_id += 1;— menambah nilai penomor ID untuk tugas berikutnya. Ini mengubah data yang dilindungi kunci.let tugas = Task { id, title, completed: false };— membuat structTaskbaru dengan ID yang baru diambil, judul yang sudah divalidasi, dan statusfalse(belum selesai).state.tasks.lock().expect("mutex tidak diracun").push(tugas.clone());— menambahkan tugas ke daftar.state.tasks.lock()— mengambil kunci untuk daftar tugas (kunci terpisah dari penomor ID)..push(tugas.clone())— menambahkan salinan tugas ke daftar..clone()diperlukan karena data disimpan di daftar sekaligus dikirim sebagai respons — dua tempat butuh data yang sama.
Ok((StatusCode::CREATED, Json(tugas)))— mengembalikan respons sukses: status201 Createddan JSON berisi tugas yang baru dibuat.
Kenapa begini? Validasi dilakukan sebelum menambah ID — judul yang gagal tidak menghabiskan nomor ID. Kunci Mutex diambil terpisah untuk penomor ID dan daftar agar jelas apa yang dilindungi. clone diperlukan karena data dipakai di dua tempat. Status 201 berarti berhasil dibuat; 204 (tanpa body) untuk DELETE yang sukses tanpa mengembalikan data.
Perilaku endpoint
| Method | Path | Hasil |
|---|---|---|
| POST | /tasks | 201 + Task baru |
| GET | /tasks | 200 + array Task |
| GET | /tasks/{id} | 200 / 404 |
| PUT | /tasks/{id} | 200 / 400 / 404 |
| DELETE | /tasks/{id} | 204 tanpa body / 404 |
Penjelasan per-baris/bagian tabel:
POST /tasks→201 + Task baru— method POST ke/tasksmembuat tugas baru. Status201 Createdberarti berhasil dibuat, dan respons berisi data tugas yang baru dibuat (termasuk ID yang ditetapkan server).GET /tasks→200 + array Task— method GET ke/tasksmembaca daftar semua tugas. Status200 OKdan respons berisi array JSON berisi semua tugas.GET /tasks/{id}→200 / 404— method GET ke/tasks/{id}membaca satu tugas dengan ID tertentu. Kalau ID ada:200dengan data tugas. Kalau tidak ada:404 Not Found.PUT /tasks/{id}→200 / 400 / 404— method PUT ke/tasks/{id}mengganti seluruh tugas. Kalau valid dan ID ada:200dengan data yang diubah. Kalau input salah (validasi gagal):400. Kalau ID tidak ada:404.DELETE /tasks/{id}→204 tanpa body / 404— method DELETE ke/tasks/{id}menghapus tugas. Kalau berhasil dihapus:204 No Content(sukses tanpa body — tidak ada data yang perlu dikembalikan). Kalau ID tidak ada:404.
Kenapa begini? Status code HTTP punya makna standar yang membuat API mudah dipahami: 200 sukses membaca, 201 sukses membuat, 204 sukses tanpa data kembalian, 400 input klien salah, 404 resource tidak ada, 500 kesalahan server. Mengikutinya membuat API konsisten dan mudah dipakai klien lain.
cd /home/sysadmin/lab/belajar-rust/16-crud-in-memory/contoh
cargo test
cargo run
# Terminal kedua:
curl -i -X POST http://127.0.0.1:3000/tasks -H 'Content-Type: application/json' -d '{"title":"Belajar Rust"}'
curl http://127.0.0.1:3000/tasks
curl -i http://127.0.0.1:3000/tasks/1
curl -i -X PUT http://127.0.0.1:3000/tasks/1 -H 'Content-Type: application/json' -d '{"title":"Revisi","completed":true}'
curl -i -X DELETE http://127.0.0.1:3000/tasks/1Test tests/api.rs mengirim request langsung ke Router tanpa menyalakan port. Pada restart server, data bab ini hilang (in-memory).
Latihan 16 · Judul 201 karakter
Bagaimana membuktikan judul 201 karakter ditolak?
Perlihatkan solusi
let judul = "a".repeat(201);
let hasil = validasi_judul(&judul);
assert!(hasil.is_err());repeat membuat 201 karakter, sehingga aturan maksimal 200 menghasilkan Err. Dalam integration test asli kirim judul itu lewat POST dan harapkan HTTP 400.
MariaDB dan SQLx
File JSON dan RAM tidak cocok untuk banyak request bersamaan. MariaDB menjaga data persisten; SQLx menghubungkan program async dengan SQL.
Contoh ini satu program lengkap yang bukan server: ia membuat tabel latihan, menambah satu tugas, membaca dan mengubahnya. Siapkan database pada langkah berikut sebelum mencoba cargo run.
Proyek lengkap bab 17 — Cargo.toml dan src/main.rs
17-mariadb-dan-sqlx/contoh/Cargo.toml
[package]
name = "bab17-mariadb"
version = "0.1.0"
edition = "2021"
[dependencies]
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"mysql",
"derive",
] }
tokio = { version = "1", features = ["full"] }17-mariadb-dan-sqlx/contoh/src/main.rs
/// Program kecil (bukan server) untuk mencoba SQLx + MariaDB.
/// Prasyarat: DATABASE_URL sudah di-set (lihat README bab 17).
/// Menjalankan: cargo run
use sqlx::mysql::MySqlPool;
/// Bentuk satu baris tabel latihan_tasks.
/// BIGINT -> i64, VARCHAR -> String, BOOLEAN/TINYINT(1) -> bool.
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// DATABASE_URL tidak ditulis di kode; dibaca dari environment.
let url = std::env::var("DATABASE_URL")
.map_err(|_| "set dulu DATABASE_URL (contoh di README bab 17)")?;
// Pool = kumpulan koneksi siap pakai yang dibagi banyak tugas async.
// .await menunggu jaringan; ? meneruskan error bila koneksi gagal.
let pool = MySqlPool::connect(&url).await?;
println!("Terhubung ke MariaDB.");
// Idempoten: aman dijalankan berulang kali.
sqlx::query(
"CREATE TABLE IF NOT EXISTS latihan_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4",
)
.execute(&pool)
.await?;
println!("Tabel latihan_tasks siap.");
// INSERT dengan parameter terikat (?). Nilai dikirim terpisah dari SQL,
// sehingga input jahat tidak bisa menjadi perintah (anti SQL injection).
// Tanda ? ditempati .bind(), bukan digabung dengan format! ke teks SQL.
let hasil = sqlx::query("INSERT INTO latihan_tasks (title) VALUES (?)")
.bind("Coba SQLx")
.execute(&pool)
.await?;
let id = hasil.last_insert_id() as i64;
// Baca kembali baris yang baru dibuat.
// query_as mengubah kolom hasil SELECT menjadi field struct Task.
let tugas: Task = sqlx::query_as("SELECT id, title, completed FROM latihan_tasks WHERE id = ?")
.bind(id)
.fetch_one(&pool)
.await?;
println!("Tersimpan: {tugas:?}");
// UPDATE lalu baca ulang (MySQL/MariaDB tidak punya UPDATE ... RETURNING generik).
sqlx::query("UPDATE latihan_tasks SET completed = TRUE WHERE id = ?")
.bind(id)
.execute(&pool)
.await?;
let tugas: Task = sqlx::query_as("SELECT id, title, completed FROM latihan_tasks WHERE id = ?")
.bind(id)
.fetch_one(&pool)
.await?;
println!("Setelah ditandai: {tugas:?}");
// Hitung isi tabel. query_scalar mengambil satu nilai (COUNT).
let jumlah: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM latihan_tasks")
.fetch_one(&pool)
.await?;
println!("Isi tabel: {jumlah} baris");
pool.close().await;
Ok(())
}Persiapan di Ubuntu 24.04 (jalankan sendiri)
sudo apt update
sudo apt install mariadb-server mariadb-client
sudo mariadbJika sudah memasang MariaDB pada bab 1, lewati apt. Tiga perintah di bawah diketik di prompt MariaDB (bukan terminal Bash):
CREATE DATABASE belajar_rust CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'task_app'@'localhost' IDENTIFIED BY 'ganti-dengan-sandi-kuat';
GRANT ALL PRIVILEGES ON belajar_rust.* TO 'task_app'@'localhost';
FLUSH PRIVILEGES;
EXIT;CREATE DATABASE membuat ruang data terpisah; utf8mb4 mendukung Unicode. CREATE USER membuat akun aplikasi; GRANT membatasi izin pada database belajar. Ganti sandi contoh; jangan tempel sandi asli di file HTML atau repo.
Driver MariaDB bernama mysql
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "mysql", "derive"] }
tokio = { version = "1", features = ["full"] }MariaDB memakai protokol MySQL, sehingga SQLx menyebut drivernya mysql. runtime-tokio bekerja dengan async Tokio, derive membantu mengubah baris DB menjadi struct.
export DATABASE_URL='mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust'
cd /home/sysadmin/lab/belajar-rust/17-mariadb-dan-sqlx/contoh
cargo runexport berlaku hanya pada terminal ini. Kata sandi yang mengandung karakter khusus perlu di-URL-encode. Jangan bagikan URL dengan sandi sebenarnya.
Bagian dari main.rs: pool, tabel dan tipe
#[derive(Debug, sqlx::FromRow)]
struct Task { id: i64, title: String, completed: bool }
let pool = MySqlPool::connect(&url).await?;
sqlx::query("CREATE TABLE IF NOT EXISTS latihan_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4").execute(&pool).await?;Penjelasan per-baris/bagian:
#[derive(Debug, sqlx::FromRow)]— derive untuk structTask.sqlx::FromRow— memungkinkan SQLx mengisi struct dari baris database secara otomatis. Nama field harus cocok dengan nama kolom.
struct Task { id: i64, title: String, completed: bool }— struct yang bentuknya sama dengan satu baris tabel. Pemetaan tipenya:id: i64— kolomBIGINTdi MariaDB dipetakan kei64di Rust.title: String— kolomVARCHARdipetakan keString.completed: bool— kolomBOOLEAN(aliasTINYINT(1)) dipetakan kebool.
let pool = MySqlPool::connect(&url).await?;— menyambung ke database.MySqlPool— kumpulan koneksi siap pakai yang dibagi banyak operasi. Membuat koneksi baru tiap query itu lambat; pool memakai ulang koneksi yang sudah ada. Drivermysqljuga dipakai untuk MariaDB karena protokolnya sama.::connect(&url)— menyambung dengan URL dari environment (mysql://user:pass@host/db).&urladalah pinjaman ke URL..await— menunggu koneksi jadi (ini operasi jaringan/I/O, jadi async).?— kalau gagal menyambung (DB mati, sandi salah), kembalikan error sekarang.
sqlx::query("CREATE TABLE IF NOT EXISTS ...").execute(&pool).await?;— menjalankan perintah SQL.sqlx::query(...)— menyiapkan perintah SQL sebagai teks.IF NOT EXISTS— hanya membuat tabel kalau belum ada. Ini idempoten: aman dijalankan berulang kali tanpa error.AUTO_INCREMENT PRIMARY KEY— kolomidnaik otomatis dan menjadi kunci utama.CHARACTER SET utf8mb4— karakter Unicode penuh (agar teks beraksen/emoji tersimpan benar)..execute(&pool)— menjalankan perintah lewat pool (perintah yang tidak mengembalikan baris, seperti CREATE/INSERT/DELETE)..await?— menunggu selesai; kalau gagal, kembalikan error.
Kenapa begini? FromRow menghapus kode konversi manual baris→struct. Pool membuat aplikasi cepat dan bisa melayani banyak request. IF NOT EXISTS membuat migrasi sederhana aman diulang.
Bagian dari main.rs: parameter aman
let hasil = sqlx::query("INSERT INTO latihan_tasks (title) VALUES (?)")
.bind("Coba SQLx").execute(&pool).await?;
let id = hasil.last_insert_id() as i64;
let tugas: Task = sqlx::query_as(
"SELECT id, title, completed FROM latihan_tasks WHERE id = ?"
).bind(id).fetch_one(&pool).await?;Penjelasan per-baris/bagian:
let hasil = sqlx::query("INSERT INTO latihan_tasks (title) VALUES (?)")— menyiapkan perintah INSERT.?di dalam SQL — lubang parameter: tempat nilai akan diisi. Nilai dikirim terpisah dari perintah SQL, bukan digabung denganformat!.- Ini melindungi dari SQL injection: input jahat seperti
'); DROP TABLE ...diperlakukan sebagai teks biasa, bukan perintah.
.bind("Coba SQLx")— mengisi lubang?dengan nilai"Coba SQLx"..bindmengirim nilai sebagai data, bukan bagian SQL..execute(&pool).await?;— menjalankan INSERT lewat pool, menunggu selesai, dan mengembalikan error kalau gagal.let id = hasil.last_insert_id() as i64;— mengambil ID baris yang baru dibuat.last_insert_id()mengembalikan ID auto-increment dari MariaDB.as i64mengubahnya ke tipei64(karena hasilnya bisa tipe lain).let tugas: Task = sqlx::query_as("SELECT ... WHERE id = ?")— membaca kembali baris yang baru dibuat.query_as— query yang hasilnya diisi ke struct (di siniTask) secara otomatis viaFromRow.: Task— anotasi tipe tujuan.
.bind(id).fetch_one(&pool).await?;— mengisi?denganid, lalu membaca tepat satu baris.fetch_oneerror kalau tidak ada baris (RowNotFound).
Kenapa begini? MariaDB/MySQL tidak punya INSERT ... RETURNING generik, jadi kita mengambil ID dengan last_insert_id() lalu SELECT barisnya. .bind adalah satu-satunya cara aman mengisi nilai ke SQL — jangan pernah merangkai input pengguna dengan format!.
Latihan 17 · Kenapa bind?
Apa yang terjadi jika judul adalah teks '); DROP TABLE latihan_tasks; -- tetapi dipasangkan lewat .bind?
Perlihatkan solusi
Database memperlakukannya sebagai teks judul biasa; tabel tidak dihapus. SQL dan nilai dikirim terpisah. Query dengan nilai pengguna yang dirangkai menjadi satu string membuka risiko SQL injection.
Task API: Axum + MariaDB
Alur satu request: klien → Router → handler → validasi → store SQLx → MariaDB → respons JSON.
Mulai dari proyek lengkap: buka panel seluruh file aplikasi dan test di bawah bab ini. Buat berkas terpisah menurut nama file pada masing-masing panel, siapkan MariaDB seperti bab 17, lalu jalankan cargo run. Setelah mencobanya, kembali ke sini untuk mengikuti request satu per satu. Jangan gabungkan file-file itu dalam satu main.rs.
Di mana tiap tanggung jawab berada?
src/config.rs -> DATABASE_URL dan PORT dari environment
src/main.rs -> koneksi, membuat tabel, menyalakan server
src/lib.rs -> Router dan alamat endpoint
src/models.rs -> Task + bentuk input + validasi judul
src/handlers.rs -> mengubah request menjadi pemanggilan store
src/store.rs -> SQL dan pool database
src/error.rs -> error Rust menjadi status + JSON
tests/api.rs -> integration test pada database terpisahBuka file-file itu dari VS Code Server saat membaca kode di bawah. Tiap blok HTML menjelaskan bagian yang saling terhubung; kode lengkap siap dicoba berada di folder 18-proyek-akhir-task-api/contoh/.
models.rs: bentuk data dan aturan
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Task { pub id: i64, pub title: String, pub completed: bool }
#[derive(Debug, Deserialize)]
pub struct NewTask { pub title: String }
#[derive(Debug, Deserialize)]
pub struct UpdateTask { pub title: String, pub completed: bool }Penjelasan per-baris/bagian:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]— derive untukTask, sekarang dengan tambahansqlx::FromRowagar struct bisa diisi dari baris database. JadiTaskdipakai untuk hasil DB dan respons JSON sekaligus — satu bentuk untuk dua keperluan.pub struct Task { pub id: i64, pub title: String, pub completed: bool }— structTasklengkap.Deserializejuga memudahkan integration test membaca respons (test bisa mengubah JSON kembali menjadiTask).#[derive(Debug, Deserialize)]— derive untukNewTask: hanya untuk membaca input, tidak perluSerialize.pub struct NewTask { pub title: String }— input POST: hanya judul. Pemisahan input dari output mencegah klien menentukan ID sendiri.#[derive(Debug, Deserialize)]— derive untukUpdateTask: sama, untuk membaca input PUT.pub struct UpdateTask { pub title: String, pub completed: bool }— input PUT: judul dan status (seluruh field yang bisa diedit).
Kenapa begini? Task serbaguna: dari DB (via FromRow) dan ke klien (via Serialize). Input dipisah agar klien tidak bisa mengatur ID atau field yang seharusnya ditetapkan server.
pub fn validasi_judul(judul: &str) -> Result<String, String> {
let judul = judul.trim();
let panjang = judul.chars().count();
if panjang == 0 { Err("title tidak boleh kosong".to_owned()) }
else if panjang > 200 { Err("title maksimal 200 karakter".to_owned()) }
else { Ok(judul.to_owned()) }
}Penjelasan per-baris/bagian:
pub fn validasi_judul(judul: &str) -> Result<String, String> {— fungsi validasi judul yang publik. MengembalikanOk(String)(judul bersih) atauErr(String)(pesan error).let judul = judul.trim();— menghapus spasi di tepi judul. Ini langkah pertama sebelum menghitung — supaya" halo "dihitung sebagai judul valid, dan" "terdeteksi kosong.let panjang = judul.chars().count();— menghitung jumlah karakter untuk batas 200.if panjang == 0 { Err("title tidak boleh kosong".to_owned()) }— judul kosong →Err..to_owned()mengubah&strpesan menjadiString(sesuai tipeErr(String)).else if panjang > 200 { Err("title maksimal 200 karakter".to_owned()) }— judul terlalu panjang →Errdengan pesan berbeda.else { Ok(judul.to_owned()) }— lolos semua →Okberisi judul yang sudah di-trim sebagaiString.
Kenapa begini? Hasil validasi adalah String bersih (bukan sekadar boolean) — jadi handler langsung memakai judul yang sudah dirapikan. Setiap handler memvalidasi sebelum mengakses DB: input buruk tidak pernah sampai ke database.
store.rs: query berparameter
#[derive(Debug, Clone)]
pub struct TaskStore { pool: MySqlPool }
pub async fn cari(&self, id: i64) -> Result<Option<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, title, completed FROM tasks WHERE id = ?")
.bind(id).fetch_optional(&self.pool).await
}Penjelasan per-baris/bagian:
#[derive(Debug, Clone)]— derive untukTaskStore.Clone— memungkinkanTaskStoredisalin murah. Pool di dalamnya berbagi kumpulan koneksi yang sama, jadi clone tidak membuat koneksi baru — hanya menambah referensi ke pool yang sama. Inilah kenapaTaskStoreaman menjadi state Axum yang di-clone tiap request.
pub struct TaskStore { pool: MySqlPool }— struct yang membungkusMySqlPool. Semua SQL berkumpul di sini agar handler tetap tipis.pub async fn cari(&self, id: i64) -> Result<Option<Task>, sqlx::Error> {— fungsi mencari satu tugas.&self— pinjaman baca ke store (method tidak memiliki store).id: i64— ID tugas yang dicari.-> Result<Option<Task>, sqlx::Error>— tipe kembalian berlapis:Option<Task>—Some(Task)kalau ID ditemukan,Nonekalau tidak ada.Result<..., sqlx::Error>—Errkalau database sendiri gagal (koneksi putus, SQL salah).
sqlx::query_as("SELECT id, title, completed FROM tasks WHERE id = ?")— menyiapkan SELECT dengan lubang parameter?..bind(id)— mengisi?denganidsebagai data (aman dari injection)..fetch_optional(&self.pool)— menjalankan query dan mengembalikanOption:Somekalau ada baris,Nonekalau tidak. Berbeda denganfetch_oneyang error kalau tidak ada baris..await— menunggu hasil dari database.
Kenapa begini? fetch_optional membuat "tidak ditemukan" menjadi data biasa (None), bukan error — handler bisa mengubahnya menjadi 404 dengan rapi. Clone murah pada store membuatnya cocok menjadi state bersama.
let hasil = sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id).execute(&self.pool).await?;
Ok(hasil.rows_affected() > 0)Penjelasan per-baris/bagian:
let hasil = sqlx::query("DELETE FROM tasks WHERE id = ?")— menyiapkan DELETE dengan lubang parameter..bind(id)— mengisi?dengan ID sebagai data..execute(&self.pool).await?;— menjalankan DELETE lewat pool, menunggu, dan mengembalikan error kalau DB gagal.Ok(hasil.rows_affected() > 0)— memeriksa berapa baris yang terhapus..rows_affected()— jumlah baris yang terpengaruh perintah.> 0—truekalau ada yang terhapus (ID ada),falsekalau tidak ada (ID tidak ada).Ok(...)— mengembalikan boolean itu sebagai hasil berhasil.
Kenapa begini? rows_affected adalah cara andal membedakan "berhasil menghapus" (→ 204) dari "tidak ada yang dihapus" (→ 404), tanpa perlu SELECT dulu. Untuk buat (INSERT), kita membaca kembali baris karena respons API membutuhkan ID dan status lengkap yang ditetapkan database.
handlers.rs dan error.rs: HTTP
pub async fn buat(
State(store): State<TaskStore>,
Json(input): Json<NewTask>,
) -> Result<(StatusCode, Json<Task>), AppError> {
let title = validasi_judul(&input.title).map_err(AppError::Validasi)?;
let tugas = store.buat(&title).await?;
Ok((StatusCode::CREATED, Json(tugas)))
}Penjelasan per-baris/bagian:
pub async fn buat(— handler untukPOST /tasks.State(store): State<TaskStore>— extractorState: mengambilTaskStoreyang dibagi dari.with_state(...)di router.storeadalah nama variabelnya.Json(input): Json<NewTask>— extractorJson: membaca body request sebagai JSON dan mengubahnya menjadi structNewTask. Body rusak/field hilang → Axum otomatis membalas 400 sebelum handler dipanggil.-> Result<(StatusCode, Json<Task>), AppError>— tipe kembalian:Okberisi tuple (status + JSON), atauErrberisiAppError(error aplikasi).let title = validasi_judul(&input.title).map_err(AppError::Validasi)?;— memvalidasi judul.validasi_judul(&input.title)— memanggil validasi dengan pinjaman ke judul..map_err(AppError::Validasi)— kalau gagal, membungkus errorStringmenjadi varianAppError::Validasi. Ini mengubah error validasi menjadi bentuk error aplikasi yang konsisten.?— kalauErr, kembalikanAppError::Validasiitu sekarang.IntoResponsedierror.rsmengubahnya menjadi respons400dengan JSON{"error":"..."}.
let tugas = store.buat(&title).await?;— menyimpan tugas ke database.store.buat(&title)— memanggil fungsi store dengan judul yang sudah divalidasi..await?— menunggu hasil. KalauErrdarisqlx::Error,?mengubahnya otomatis menjadiAppError::Internalberkatimpl From<sqlx::Error> for AppErrordierror.rs. Ini menangani error DB terpusat (menjadi 500).
Ok((StatusCode::CREATED, Json(tugas)))— mengembalikan status201 Createddan JSON tugas baru.
Kenapa begini? Handler hanya mengurus HTTP: ekstrak → validasi → panggil store → bungkus status. Tidak ada SQL di sini — itu tugas store.rs. ? dengan From membuat error dari berbagai sumber (validasi, DB) mengalir ke satu bentuk AppError yang menjadi respons konsisten (400/404/500 dengan JSON {"error":"..."}).
main.rs: jalankan proyek
let config = Config::dari_env()?;
let pool = MySqlPool::connect(&config.database_url).await?;
let store = TaskStore::baru(pool);
store.migrasi().await?;
let listener = tokio::net::TcpListener::bind(&alamat).await?;
axum::serve(listener, app(store)).await?;Penjelasan per-baris/bagian:
let config = Config::dari_env()?;— membaca konfigurasi dari environment (DATABASE_URL,PORT).?menghentikan program kalauDATABASE_URLbelum di-set — lebih baik gagal cepat dengan pesan jelas daripada error aneh nanti.let pool = MySqlPool::connect(&config.database_url).await?;— menyambung ke MariaDB dengan URL dari config..await?menunggu koneksi dan menghentikan program kalau gagal.let store = TaskStore::baru(pool);— membungkus pool dalamTaskStore(yang membawa semua SQL).store.migrasi().await?;— menjalankan migrasi: membuat tabeltaskskalau belum ada. Ini idempoten — aman dijalankan tiap start.let listener = tokio::net::TcpListener::bind(&alamat).await?;— membuka port TCP (misal127.0.0.1:3000) untuk menerima koneksi.&alamatadalah pinjaman ke alamat yang dibentuk denganformat!..await?menunggu dan gagal kalau port sudah dipakai.axum::serve(listener, app(store)).await?;— menjalankan server Axum dengan listener dan router (app(store)berisi rute + state). Server melayani terus sampai dihentikan (Ctrl+C)..await?menunggu server dan mengembalikan error kalau ada masalah.
Kenapa begini? Urutan config → koneksi → store → migrasi → buka port → serve adalah alur standar memulai server: siapkan konfigurasi dulu, sambung ke DB, siapkan tabel, baru terima request. Semua langkah yang bisa gagal ditandai ? agar program berhenti dengan pesan jelas, bukan jalan setengah rusak.
cd /home/sysadmin/lab/belajar-rust/18-proyek-akhir-task-api/contoh
export DATABASE_URL='mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust'
cargo runGanti sandi placeholder pada terminalmu. .env.example hanyalah contoh; kode memakai environment, jadi menyalin file menjadi .env tidak otomatis memuatnya.
Coba endpoint dari terminal kedua
curl -i -X POST http://127.0.0.1:3000/tasks -H 'Content-Type: application/json' -d '{"title":"Belajar Rust"}'
curl http://127.0.0.1:3000/tasks
curl http://127.0.0.1:3000/tasks/1
curl -i -X PUT http://127.0.0.1:3000/tasks/1 -H 'Content-Type: application/json' -d '{"title":"Revisi","completed":true}'
curl -i -X DELETE http://127.0.0.1:3000/tasks/1Respons sukses berurutan 201, 200, 200, 200, 204. ID hasil POST bisa bukan 1 jika DB pernah dipakai; baca nilai id pada respons dan ganti di perintah berikutnya. Untuk membuktikan data persisten, buat satu tugas yang tidak dihapus, hentikan server, hidupkan lagi, lalu GET daftar.
Integration test dengan database tersendiri
CREATE DATABASE belajar_rust_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL PRIVILEGES ON belajar_rust_test.* TO 'task_app'@'localhost';
FLUSH PRIVILEGES;export TEST_DATABASE_URL='mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust_test'
cargo testSQL di atas diketik di sudo mariadb. Test DB dibedakan dari DB utama karena test mengosongkan tabel tugas. Test dalam satu proses berjalan bergiliran memakai kunci lokal. Jika TEST_DATABASE_URL tidak diisi, test DB dilewati; untuk benar-benar menguji API, pastikan env ini diisi.
Latihan 18 · Tambah filter tugas selesai
Bagaimana query SQL untuk GET /tasks?completed=true jika ingin menyaring di MariaDB?
Perlihatkan solusi
sqlx::query_as::<_, Task>(
"SELECT id, title, completed FROM tasks WHERE completed = ? ORDER BY id"
).bind(true).fetch_all(&self.pool).await? dibind bool agar aman, fetch_all mengembalikan Vec. Handler kemudian membaca query completed dan memanggil fungsi store ini; query tanpa filter tetap memakai semua().
Ikuti satu request sampai selesai
Jika blok kode bab 18 terasa ramai, ikuti request POST /tasks ini dalam bahasa sehari-hari. Jangan langsung menghafal seluruh file.
- Klien mengirim
{"title":" Belajar "}dengan headerContent-Type: application/json. Dilib.rsrouter memilih handlerbuat, karena method-nya POST dan path-nya/tasks. Json(input): Json<NewTask>dihandlers.rsmeminta Axum membaca body. Serde mengisiinput.title; jika fieldtitlehilang atau JSON rusak, handler tidak berjalan dan Axum mengirim respons 400.- Handler memanggil
validasi_judul(&input.title). Tanda&berarti meminjam, bukan memindahkan String.trim()membuang dua spasi kiri dan kanan; yang disimpan menjadi"Belajar". Jika kosong/terlalu panjang,map_err(...)?menghentikan handler dan mengembalikan 400. store.buat(&title).await?menyuruh SQLx melakukan INSERT melaluiMySqlPool..bind(title)memasukkan judul sebagai data, bukan bagian SQL..awaitmenunggu database;?mengalihkan kegagalan koneksi menjadi error aplikasi (500).- SQLx memperoleh ID baru dari database lalu SELECT baris tersebut. Handler mengembalikan
(StatusCode::CREATED, Json(tugas)): status 201 denganid,title, dancompleted: falsedalam JSON.
Jika ID tidak ditemukan pada GET/PUT/DELETE, itu 404, bukan 500. Jika request PUT identik dengan isi yang tersimpan, hasilnya tetap 200: melakukan hal yang sama dua kali tidak dianggap salah. Baca kembali blok bab 18 sambil mencari tiap langkah di atas.
Semua berkas aplikasi dan test
Ini proyek utuh untuk bab 18: Cargo.toml, seluruh berkas di src/, tests/api.rs, dan konfigurasi contoh. Buka panel satu per satu, simpan isinya ke berkas sesuai judul, lalu ikuti langkah database/terminal pada bab 18. Nama file adalah bagian dari pelajaran—jangan gabungkan isinya ke satu main.rs. Setelah mencoba, baca pembahasan kecil-kecil di bab 18.
Cargo.toml dan src/config.rs
Cargo.toml
[package]
name = "bab18-task-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio", "tls-rustls", "mysql", "derive",
] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }src/config.rs
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub port: u16,
}
impl Config {
pub fn dari_env() -> Result<Self, String> {
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL belum di-set (lihat .env.example)".to_owned())?;
let port = std::env::var("PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(3000);
Ok(Self { database_url, port })
}
}src/models.rs dan src/error.rs
src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Task {
pub id: i64,
pub title: String,
pub completed: bool,
}
#[derive(Debug, Deserialize)]
pub struct NewTask { pub title: String }
#[derive(Debug, Deserialize)]
pub struct UpdateTask {
pub title: String,
pub completed: bool,
}
pub fn validasi_judul(judul: &str) -> Result<String, String> {
let judul = judul.trim();
let panjang = judul.chars().count();
if panjang == 0 {
Err("title tidak boleh kosong".to_owned())
} else if panjang > 200 {
Err("title maksimal 200 karakter".to_owned())
} else {
Ok(judul.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::validasi_judul;
#[test]
fn judul_divalidasi_dengan_benar() {
assert_eq!(validasi_judul(" halo ").as_deref(), Ok("halo"));
assert!(validasi_judul(" ").is_err());
assert!(validasi_judul(&"a".repeat(201)).is_err());
assert!(validasi_judul(&"a".repeat(200)).is_ok());
}
}src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use serde::Serialize;
#[derive(Debug)]
pub enum AppError {
Validasi(String),
TidakDitemukan,
Internal(sqlx::Error),
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self { Self::Internal(e) }
}
#[derive(Serialize)]
struct ErrorBody { error: String }
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, pesan) = match self {
Self::Validasi(p) => (StatusCode::BAD_REQUEST, p),
Self::TidakDitemukan =>
(StatusCode::NOT_FOUND, "tugas tidak ditemukan".to_owned()),
Self::Internal(e) => {
eprintln!("error database: {e}");
(StatusCode::INTERNAL_SERVER_ERROR,
"terjadi kesalahan di server".to_owned())
}
};
(status, Json(ErrorBody { error: pesan })).into_response()
}
}src/store.rs
src/store.rs
use crate::models::{Task, UpdateTask};
use sqlx::mysql::MySqlPool;
#[derive(Debug, Clone)]
pub struct TaskStore { pool: MySqlPool }
impl TaskStore {
pub fn baru(pool: MySqlPool) -> Self { Self { pool } }
pub async fn migrasi(&self) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4",
).execute(&self.pool).await?;
Ok(())
}
pub async fn semua(&self) -> Result<Vec<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, title, completed FROM tasks ORDER BY id")
.fetch_all(&self.pool).await
}
pub async fn cari(&self, id: i64) -> Result<Option<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, title, completed FROM tasks WHERE id = ?")
.bind(id).fetch_optional(&self.pool).await
}
pub async fn buat(&self, title: &str) -> Result<Task, sqlx::Error> {
let hasil = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title).execute(&self.pool).await?;
let id = hasil.last_insert_id() as i64;
self.cari(id).await?.ok_or(sqlx::Error::RowNotFound)
}
pub async fn ubah(&self, id: i64, input: &UpdateTask, title: &str)
-> Result<Option<Task>, sqlx::Error> {
let hasil = sqlx::query("UPDATE tasks SET title = ?, completed = ? WHERE id = ?")
.bind(title).bind(input.completed).bind(id)
.execute(&self.pool).await?;
if hasil.rows_affected() == 0 {
return self.cari(id).await;
}
self.cari(id).await
}
pub async fn hapus(&self, id: i64) -> Result<bool, sqlx::Error> {
let hasil = sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id).execute(&self.pool).await?;
Ok(hasil.rows_affected() > 0)
}
pub async fn kosongkan(&self) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM tasks").execute(&self.pool).await?;
Ok(())
}
}src/handlers.rs, src/lib.rs, src/main.rs
src/handlers.rs
use crate::{
error::AppError,
models::{validasi_judul, NewTask, Task, UpdateTask},
store::TaskStore,
};
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
pub async fn daftar(State(store): State<TaskStore>)
-> Result<Json<Vec<Task>>, AppError> {
Ok(Json(store.semua().await?))
}
pub async fn ambil(State(store): State<TaskStore>, Path(id): Path<i64>)
-> Result<Json<Task>, AppError> {
store.cari(id).await?.map(Json).ok_or(AppError::TidakDitemukan)
}
pub async fn buat(State(store): State<TaskStore>, Json(input): Json<NewTask>)
-> Result<(StatusCode, Json<Task>), AppError> {
let title = validasi_judul(&input.title).map_err(AppError::Validasi)?;
let tugas = store.buat(&title).await?;
Ok((StatusCode::CREATED, Json(tugas)))
}
pub async fn ubah(
State(store): State<TaskStore>, Path(id): Path<i64>,
Json(input): Json<UpdateTask>,
) -> Result<Json<Task>, AppError> {
let title = validasi_judul(&input.title).map_err(AppError::Validasi)?;
store.ubah(id, &input, &title).await?
.map(Json).ok_or(AppError::TidakDitemukan)
}
pub async fn hapus(State(store): State<TaskStore>, Path(id): Path<i64>)
-> Result<StatusCode, AppError> {
if store.hapus(id).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::TidakDitemukan)
}
}src/lib.rs
pub mod config;
pub mod error;
pub mod handlers;
pub mod models;
pub mod store;
use axum::{routing::get, Router};
use store::TaskStore;
pub fn app(store: TaskStore) -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/tasks", get(handlers::daftar).post(handlers::buat))
.route("/tasks/{id}",
get(handlers::ambil).put(handlers::ubah).delete(handlers::hapus))
.with_state(store)
}src/main.rs
use bab18_task_api::{app, config::Config, store::TaskStore};
use sqlx::mysql::MySqlPool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::dari_env()?;
let pool = MySqlPool::connect(&config.database_url).await?;
let store = TaskStore::baru(pool);
store.migrasi().await?;
let alamat = format!("127.0.0.1:{}", config.port);
let listener = tokio::net::TcpListener::bind(&alamat).await?;
println!("Migrasi selesai. Mendengarkan di http://{alamat}");
axum::serve(listener, app(store)).await?;
Ok(())
}18-proyek-akhir-task-api/contoh/.env.example — format variabel
Berkas ini contoh konfigurasi. Sebelum cargo run kamu tetap perlu export DATABASE_URL='...' di terminal karena aplikasi tidak membaca .env otomatis.
.env.example
# Salin file ini menjadi .env atau set lewat `export` di terminal.
# GANTI sandinya dengan milikmu. Jangan pernah commit sandi asli.
# URL koneksi MariaDB untuk server API.
DATABASE_URL=mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust
# Database TERPISAH untuk integration test (dipakai oleh cargo test).
TEST_DATABASE_URL=mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust_test
# Port server (opsional, bawaan 3000).
PORT=300018-proyek-akhir-task-api/contoh/tests/api.rs — integration test lengkap
Test ini memakai database belajar_rust_test, bukan database utama. Jika TEST_DATABASE_URL tidak di-export, test database akan dilewati; buat database test sesuai langkah di bab 18 sebelum menjalankan cargo test.
tests/api.rs
//! Test integrasi: mengirim request ke Router dengan database test terpisah.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use bab18_task_api::{app, models::Task, store::TaskStore};
use serde_json::{json, Value};
use sqlx::mysql::MySqlPool;
use tower::ServiceExt;
// Satu tabel dipakai bersama: jangan bersihkan saat test lain sedang berjalan.
static DB_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn siapkan() -> Option<(TaskStore, axum::Router)> {
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
eprintln!("TEST_DATABASE_URL belum di-set: test database dilewati");
return None;
};
let pool = MySqlPool::connect(&url)
.await
.expect("koneksi database test berhasil");
let store = TaskStore::baru(pool);
store.migrasi().await.expect("migrasi berhasil");
store.kosongkan().await.expect("tabel test bersih");
let router = app(store.clone());
Some((store, router))
}
async fn panggil(
app: &mut axum::Router,
method: &str,
path: &str,
body: Option<Value>,
) -> (StatusCode, Option<Value>) {
let mut builder = Request::builder().method(method).uri(path);
let http_body = match body {
Some(v) => {
builder = builder.header("content-type", "application/json");
Body::from(serde_json::to_vec(&v).expect("serialize"))
}
None => Body::empty(),
};
let respons = app
.oneshot(builder.body(http_body).expect("request valid"))
.await
.expect("router merespons");
let status = respons.status();
let bytes = axum::body::to_bytes(respons.into_body(), usize::MAX)
.await
.expect("baca body");
let nilai = if bytes.is_empty() {
None
} else {
Some(serde_json::from_slice(&bytes).expect("respons JSON"))
};
(status, nilai)
}
#[tokio::test]
async fn alur_crud_lengkap() {
let _guard = DB_TEST_LOCK.lock().await;
let Some((_store, mut app)) = siapkan().await else { return };
let (status, body) = panggil(&mut app, "POST", "/tasks",
Some(json!({"title": " Tulis docs "}))).await;
assert_eq!(status, StatusCode::CREATED);
let tugas = body.expect("body");
assert_eq!(tugas["title"], "Tulis docs");
assert_eq!(tugas["completed"], false);
let id = tugas["id"].as_i64().expect("id angka");
let (status, body) = panggil(&mut app, "GET", "/tasks", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("array").as_array().expect("array").len(), 1);
let (status, body) = panggil(&mut app, "GET", &format!("/tasks/{id}"), None).await;
assert_eq!(status, StatusCode::OK);
let tugas_db: Task = serde_json::from_value(body.expect("body")).expect("bentuk Task");
assert_eq!(tugas_db.title, "Tulis docs");
let (status, body) = panggil(&mut app, "PUT", &format!("/tasks/{id}"),
Some(json!({"title": "Revisi", "completed": true}))).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("body")["completed"], true);
let (status, body) = panggil(&mut app, "DELETE", &format!("/tasks/{id}"), None).await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert!(body.is_none());
let (status, _) = panggil(&mut app, "GET", &format!("/tasks/{id}"), None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn validasi_dan_kasus_gagal() {
let _guard = DB_TEST_LOCK.lock().await;
let Some((_store, mut app)) = siapkan().await else { return };
let (status, body) = panggil(&mut app, "POST", "/tasks",
Some(json!({"title": " "}))).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.expect("body")["error"], "title tidak boleh kosong");
let (status, _) = panggil(&mut app, "PUT", "/tasks/1",
Some(json!({"title": "x"}))).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
for (m, p, b) in [
("GET", "/tasks/424242", None),
("PUT", "/tasks/424242", Some(json!({"title": "x", "completed": false}))),
("DELETE", "/tasks/424242", None),
] {
let (status, _) = panggil(&mut app, m, p, b).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{m} {p}");
}
let (status, _) = panggil(&mut app, "POST", "/tasks",
Some(json!({"title": "permanen"}))).await;
assert_eq!(status, StatusCode::CREATED);
}
#[tokio::test]
async fn data_tidak_tercampur_antar_test() {
let _guard = DB_TEST_LOCK.lock().await;
let Some((_store, mut app)) = siapkan().await else { return };
let (status, body) = panggil(&mut app, "GET", "/tasks", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("array").as_array().expect("array").len(), 0);
}
#[tokio::test]
async fn put_identik_tetap_berhasil() {
let _guard = DB_TEST_LOCK.lock().await;
let Some((_store, mut app)) = siapkan().await else { return };
let (status, body) = panggil(&mut app, "POST", "/tasks",
Some(json!({"title": "Tetap sama"}))).await;
assert_eq!(status, StatusCode::CREATED);
let id = body.expect("tugas baru")["id"].as_i64().expect("ID angka");
for _ in 0..2 {
let (status, body) = panggil(&mut app, "PUT", &format!("/tasks/{id}"),
Some(json!({"title": "Tetap sama", "completed": false}))).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("tugas masih ada")["id"], id);
}
}Autentikasi: Login, Logout, dan Tugas Per-User
Semua yang sudah kamu pelajari bertemu di sini. Kali ini kita tambahkan login, logout, dan batas akses — setiap user hanya melihat tugasnya sendiri.
Mulai dari proyek lengkap: buka panel seluruh file aplikasi dan test di bawah bab ini. Buat berkas terpisah menurut nama file pada masing-masing panel, siapkan MariaDB seperti bab 17, lalu jalankan cargo run. Setelah mencobanya, kembali ke sini untuk mengikuti alur autentikasi. Jangan gabungkan file-file itu dalam satu main.rs.
Yang akan kamu bangun
API daftar tugas dengan autentikasi session token di MariaDB:
| Method | Path | Login? | Fungsi |
|---|---|---|---|
| POST | /auth/register | tidak | buat akun baru |
| POST | /auth/login | tidak | masuk, dapat token |
| POST | /auth/logout | ya | keluar, token dihapus |
| GET | /auth/me | ya | siapa yang sedang login |
| GET | /tasks | ya | daftar tugas milikmu |
| POST | /tasks | ya | buat tugas baru |
| GET | /tasks/{id} | ya | baca satu tugas milikmu |
| PUT | /tasks/{id} | ya | ganti tugas milikmu |
| DELETE | /tasks/{id} | ya | hapus tugas milikmu |
Alur autentikasi dalam bahasa sehari-hari
- Kamu
register→ server simpan username + hash password (bukan password asli). - Kamu
login→ server cek password → buat token acak → simpan di tabelsessions→ kirim token ke kamu. - Untuk setiap request berikutnya, kamu kirim header
Authorization: Bearer <token>. - Server baca token → cari di
sessions→ kalau valid dan belum kedaluwarsa, server tahu kamu user nomor berapa → semua query tasks difilterWHERE user_id = ?. - Kamu
logout→ server hapus session dari database → token itu mati selamanya.
Kenapa password tidak disimpan apa adanya?
Karena kalau database bocor, semua password langsung ketahuan. Kita simpan hash — hasil acak satu arah yang tidak bisa dikembalikan ke password asli. Kita pakai Argon2, standar aman untuk hashing password. Salt (bumbu acak) dibuat otomatis, jadi password sama punya hash berbeda.
Kenapa session token di database, bukan JWT?
JWT (JSON Web Token) bersifat stateless — server tidak menyimpan apa-apa. Itu sulit untuk logout: token tetap valid sampai kedaluwarsa meski kamu sudah logout. Session token di MariaDB membuat logout mudah dan pasti. JWT dibahas sebagai langkah lanjutan di akhir bab.
Bedakan 401 dan 403
- 401 Unauthorized — kamu belum login, atau token tidak valid/kedaluwarsa.
- 403 Forbidden — kamu sudah login, tapi mencoba akses data milik orang lain.
Di proyek ini, akses task orang lain kita balas 404 (bukan 403) — supaya orang tidak tahu task itu ada atau tidak. Itu pola keamanan umum.
Struktur proyek
contoh/
├── Cargo.toml # + argon2, rand
├── .env.example
├── src/
│ ├── config.rs # DATABASE_URL dan PORT
│ ├── main.rs # titik masuk: koneksi, migrasi, serve
│ ├── lib.rs # router: /auth/* publik vs /tasks terproteksi
│ ├── models.rs # User, Session, Task + input + validasi
│ ├── error.rs # AppError: 400, 401, 403, 404, 500
│ ├── store.rs # semua SQL (users, sessions, tasks)
│ ├── auth.rs # hash password, buat token, extractor CurrentUser
│ └── handlers.rs # handler auth + handler tasks
└── tests/
└── api.rs # register → login → akses → logout → ditolak 401Struktur ini sama seperti bab 18, ditambah auth.rs untuk urusan login dan session.
Step-by-step: dari nol sampai API terproteksi
Ikuti langkah ini berurutan. Jangan loncat.
Langkah 1 — Siapkan database. Masuk MariaDB (sudo mariadb) dan buat tiga tabel: users, sessions, tasks. Atau biarkan migrasi() di main.rs yang membuatnya otomatis saat start.
Langkah 2 — Tambah dependency. Di Cargo.toml, tambahkan argon2 = "0.5" dan rand = "0.8".
Langkah 3 — models.rs. Definisikan User, Session, Task (dengan user_id), plus input dan validasi.
Langkah 4 — store.rs. Tulis semua SQL untuk users, sessions, dan tasks (semua query tasks difilter user_id = ?).
Langkah 5 — auth.rs. Tulis hash_password(), verifikasi_password(), buat_token(), dan extractor CurrentUser.
Langkah 6 — handlers.rs. Tulis handler auth (register, login, logout, me) dan handler tasks yang pakai CurrentUser.
Langkah 7 — error.rs. Tambah varian Unauthorized (401) dan Forbidden (403).
Langkah 8 — lib.rs. Susun router: route publik dan terproteksi.
Langkah 9 — main.rs. Config → pool → migrasi → serve.
Langkah 10 — Jalankan dan uji dengan curl. Ikuti contoh perintah di bawah.
Uji dengan curl
cd /home/sysadmin/lab/belajar-rust/19-auth-tasks-api/contoh
export DATABASE_URL='mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust'
cargo runDi terminal kedua:
# 1. Register
curl -i -X POST http://127.0.0.1:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"andi","password":"password123"}'
# 2. Login → dapat token
curl -s -X POST http://127.0.0.1:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"andi","password":"password123"}'
# 3. Pakai token untuk akses
curl -s http://127.0.0.1:3000/auth/me \
-H "Authorization: Bearer TOKEN_KAMU"
# 4. Buat task dengan token
curl -s -X POST http://127.0.0.1:3000/tasks \
-H "Authorization: Bearer TOKEN_KAMU" \
-H "Content-Type: application/json" \
-d '{"title":"Belajar auth"}'
# 5. Daftar tasks milikmu
curl -s http://127.0.0.1:3000/tasks \
-H "Authorization: Bearer TOKEN_KAMU"
# 6. Logout
curl -i -X POST http://127.0.0.1:3000/auth/logout \
-H "Authorization: Bearer TOKEN_KAMU"
# 7. Coba pakai token lama lagi → ditolak 401
curl -i http://127.0.0.1:3000/auth/me \
-H "Authorization: Bearer TOKEN_KAMU"Integration test
export TEST_DATABASE_URL='mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust_test'
cargo testTest membuktikan: register → login → akses → logout → akses lagi ditolak 401, dan user lain tidak bisa lihat task milikmu.
Tur kode: auth.rs
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(format!("hash password: {e}")))?;
Ok(hash.to_string())
}Penjelasan per-baris/bagian:
pub fn hash_password(password: &str) -> Result<String, AppError> {— membuat fungsi yang mengubah password asli menjadi hash aman untuk disimpan.password: &str— parameter: pinjaman ke password asli.-> Result<String, AppError>— hasil:Ok(String)berisi hash, atauErrkalau proses hashing gagal.
let salt = SaltString::generate(&mut OsRng);— membuat salt (bumbu acak).SaltString::generate(&mut OsRng)— menghasilkan salt acak baru.OsRngadalah sumber acak dari sistem operasi (aman secara kriptografis).- Kenapa salt? Supaya password yang sama di dua akun menghasilkan hash berbeda — penyerang tidak bisa melihat pola atau pakai tabel hash siap pakai (rainbow table). Salt ikut tertanam di string hash hasil, jadi tidak perlu disimpan terpisah.
let argon2 = Argon2::default();— membuat instance algoritma Argon2 dengan pengaturan bawaan (aman).let hash = argon2.hash_password(password.as_bytes(), &salt)— menjalankan hashing.password.as_bytes()— mengubah password teks menjadi byte (Argon2 bekerja pada byte).&salt— pinjaman ke salt yang baru dibuat.
.map_err(|e| AppError::Internal(format!("hash password: {e}")))?;— kalau hashing gagal, bungkus error menjadiAppError::Internaldengan pesan.?mengembalikan error itu sekarang.|e|adalah closure untuk error.Ok(hash.to_string())— mengubah hasil hash menjadiStringdan mengembalikannya sebagaiOk. String ini yang disimpan di database, bukan password asli.
Kenapa begini? Kita tidak pernah menyimpan password asli. Hash adalah satu arah: tidak bisa dikembalikan ke password. Saat login, kita hash password yang diketik dan bandingkan dengan hash tersimpan — kalau cocok, password benar.
pub fn verifikasi_password(password: &str, hash: &str) -> bool {
let parsed = match PasswordHash::new(hash) {
Ok(p) => p,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}Penjelasan per-baris/bagian:
pub fn verifikasi_password(password: &str, hash: &str) -> bool {— fungsi untuk mengecek: "apakah password ini cocok dengan hash tersimpan?". Mengembalikantrue/false, bukan panik.let parsed = match PasswordHash::new(hash) { ... };— membaca string hash menjadi struktur yang dipahami Argon2 (termasuk salt yang tertanam).PasswordHash::new(hash)— mencoba mem-parsing string hash. HasilnyaResult.Ok(p) => p— kalau parsing berhasil, ambil hasilnya dip.Err(_) => return false— kalau string hash rusak/tidak valid, langsung kembalikanfalse(password dianggap salah)._mengabaikan detail error.
Argon2::default().verify_password(password.as_bytes(), &parsed)— memverifikasi: Argon2 hash password yang diketik dengan salt dariparsed, lalu membandingkan dengan hash tersimpan..is_ok()— mengubah hasilResultmenjadi boolean:truekalau cocok,falsekalau tidak.
Kenapa begini? Verifikasi tidak pernah membandingkan password asli secara langsung — ia menghash input dan membandingkan hash-nya. Mengembalikan boolean (bukan panic) membuat handler bisa memutuskan respons 401 dengan rapi.
pub struct CurrentUser {
pub user: User,
pub token: String,
}Penjelasan per-baris/bagian:
pub struct CurrentUser {— struct untuk extractor: data yang diambil otomatis dari request dan diberikan ke handler.pub user: User,— fielduser: data user yang sedang login (ID, username, dll.).pub token: String,— fieldtoken: token session yang dipakai untuk request ini. Disimpan juga karena logout perlu tahu token mana yang harus dihapus dari database.
Kenapa begini? Extractor ini dipakai handler untuk mendapatkan user yang sedang login beserta tokennya. Kalau handler hanya butuh user, ia pakai user.user. Untuk logout, ia pakai user.token untuk menghapus session yang tepat dari tabel sessions.
Tur kode: handlers.rs
pub async fn login(
State(store): State<Store>,
Json(input): Json<LoginInput>,
) -> Result<Json<LoginResponse>, AppError> {
let (username, password) = validasi_kredensial(...)?;
let user = store.cari_user_by_username(&username).await?
.ok_or_else(|| AppError::Unauthorized("username atau password salah".to_owned()))?;
if !verifikasi_password(&password, &user.password_hash) {
return Err(AppError::Unauthorized("username atau password salah".to_owned()));
}
let token = buat_token();
let expires_at = sekarang_unix() + durasi_session();
store.buat_session(user.id, &token, expires_at).await?;
Ok(Json(LoginResponse { token }))
}Penjelasan per-baris/bagian:
pub async fn login(— handler untukPOST /auth/login.State(store): State<Store>— extractorState: mengambilStore(pool database) yang dibagi.Json(input): Json<LoginInput>— extractorJson: membaca body request ({"username":"...","password":"..."}) menjadi structLoginInput.-> Result<Json<LoginResponse>, AppError>— hasil:Okberisi JSONLoginResponse(token), atauErrberisiAppError.let (username, password) = validasi_kredensial(...)?;— memvalidasi username dan password (panjang minimal).?mengembalikan error validasi kalau gagal.let user = store.cari_user_by_username(&username).await?.ok_or_else(|| AppError::Unauthorized("username atau password salah".to_owned()))?;— mencari user di database.store.cari_user_by_username(&username)— mencari user berdasarkan username. HasilnyaOption<User>..await?— menunggu query; kalau DB gagal, kembalikan error DB..ok_or_else(|| AppError::Unauthorized("username atau password salah".to_owned()))— kalauNone(username tidak ada), ubah menjadiErr401.ok_or_elsemembuat error hanya kalau perlu (lazy).||adalah closure tanpa parameter.- Pesan error disamakan dengan kasus password salah (baris berikutnya) — supaya penyerang tidak bisa menebak username mana yang terdaftar (mencegah username enumeration).
if !verifikasi_password(&password, &user.password_hash) { return Err(...); }— memeriksa password.verifikasi_password(&password, &user.password_hash)— hash password yang diketik dan bandingkan dengan hash tersimpan. Mengembalikantrue/false.!— negasi: kalau hasilnyafalse(password salah), masuk blok.- Pesan error sama persis dengan kasus username tidak ada — lagi-lagi untuk mencegah menebak.
let token = buat_token();— membuat token acak (32 byte, 64 karakter hex) untuk session baru.let expires_at = sekarang_unix() + durasi_session();— menghitung kapan token kedaluwarsa: waktu sekarang (Unix timestamp) + durasi session (7 hari).store.buat_session(user.id, &token, expires_at).await?;— menyimpan session ke database: ID user, token, dan waktu kedaluwarsa. Inilah yang membuat logout bisa menghapus token nanti.Ok(Json(LoginResponse { token }))— mengembalikan token ke klien sebagai JSON{"token":"..."}. Klien memakai token ini di headerAuthorization: Bearer <token>untuk request berikutnya.
Kenapa begini? Alur login: validasi → cari user → cek password → buat token → simpan session → kirim token. Menyamakan pesan error adalah praktik keamanan standar. Session di database membuat logout pasti bekerja.
pub async fn daftar(
State(store): State<Store>,
user: CurrentUser,
) -> Result<Json<Vec<Task>>, AppError> {
Ok(Json(store.semua_tasks(user.user.id).await?))
}Penjelasan per-baris/bagian:
pub async fn daftar(— handler untukGET /tasks(daftar tugas milikmu).State(store): State<Store>— extractorStateuntuk pool database.user: CurrentUser— extractorCurrentUser: membaca headerAuthorization, memvalidasi token di database, dan memberikan user yang login. Kalau token tidak ada/tidak valid/kedaluwarsa, handler tidak dipanggil — Axum otomatis membalas401dari extractor.-> Result<Json<Vec<Task>>, AppError>— hasil:Okberisi JSON array tugas, atauErrerror aplikasi.Ok(Json(store.semua_tasks(user.user.id).await?))— mengambil dan mengembalikan tugas.user.user.id— ID user yang sedang login.useradalahCurrentUser,user.useradalahUserdi dalamnya,.idadalah ID-nya.store.semua_tasks(user.user.id)— query tugas hanya milik user ini (WHERE user_id = ?di SQL)..await?— menunggu query; kalau DB gagal, kembalikan error (menjadi 500).Json(...)— membungkus hasil sebagai respons JSON.
Kenapa begini? Inilah otorisasi per-user: semua query tasks difilter dengan ID user yang login, jadi kamu hanya melihat tugasmu sendiri. Kamu tidak perlu memeriksa kepemilikan manual — query SQL yang membatasinya. Extractor CurrentUser memastikan handler hanya jalan kalau sudah login.
Kesalahan yang sering terjadi
| Keluhan | Penyebab umum |
|---|---|
401 Unauthorized saat pakai token | Token salah, kedaluwarsa, atau header salah format (harus Bearer <token>). |
404 saat akses task yang ada | Task itu milik user lain — kamu hanya bisa akses milikmu. |
username sudah dipakai | Username sudah terdaftar; pilih yang lain. |
username atau password salah | Salah satu keduanya salah; pesan sengaja tidak spesifik. |
Access denied for user | Sandi MariaDB salah di DATABASE_URL. |
Latihan 19.1 — Kenapa hash password?
Jelaskan dengan kalimatmu sendiri: kenapa kita tidak menyimpan password apa adanya di database? Apa yang terjadi kalau database bocor?
Perlihatkan solusi
Password disimpan sebagai hash — hasil acak satu arah dari algoritma Argon2. Hash tidak bisa dikembalikan ke password asli. Kalau database bocor, penyerang hanya melihat hash acak, bukan password aslimu. Mereka tidak bisa login dengan hash itu karena hash bukan password. Argon2 juga menambahkan salt (bumbu acak) otomatis, jadi password yang sama di dua akun berbeda menghasilkan hash berbeda.
Latihan 19.2 — Alur login
Urutkan langkah berikut dengan benar: (a) buat token, (b) verifikasi password, (c) cari user by username, (d) simpan session di DB, (e) kirim token ke klien.
Perlihatkan solusi
Urutan yang benar: c → b → a → d → e
- (c) Cari user by username — kalau tidak ada, berhenti di sini.
- (b) Verifikasi password — kalau salah, berhenti di sini.
- (a) Buat token acak.
- (d) Simpan session di DB (token + user_id + kedaluwarsa).
- (e) Kirim token ke klien.
Latihan 19.3 — Bedakan 401 dan 404
Kapan server membalas 401 dan kapan 404 untuk task yang tidak bisa diakses? Mengapa task orang lain dibalas 404, bukan 403?
Perlihatkan solusi
- 401 = kamu belum login, atau token tidak valid/kedaluwarsa. Server tidak tahu siapa kamu.
- 404 = kamu sudah login, tapi task yang kamu minta bukan milikmu.
Task orang lain dibalas 404 (bukan 403) supaya penyerang tidak tahu task itu ada atau tidak. Kalau dibalas 403, penyerang tahu "task ini ada tapi bukan milikku" — informasi itu bisa dipakai untuk serangan lebih lanjut. 404 menyembunyikan keberadaan data.
Latihan 19.4 — Tambah durasi session
Ubah SESSION_DURASI_DETIK agar session berlaku 30 hari, bukan 7 hari. Konstanta mana yang kamu ubah?
Perlihatkan solusi
// Dari:
const SESSION_DURASI_DETIK: i64 = 7 * 24 * 60 * 60;
// Menjadi:
const SESSION_DURASI_DETIK: i64 = 30 * 24 * 60 * 60;Ubah konstanta di auth.rs.
Latihan 19.5 — Endpoint khusus admin (konsep)
Bayangkan kamu ingin endpoint /admin/users yang hanya bisa diakses user dengan username == "admin". Di mana kamu akan menambah pengecekan itu?
Perlihatkan solusi
Setelah extractor CurrentUser berhasil (artinya user sudah login), tambahkan pengecekan di handler:
pub async fn admin_users(
State(store): State<Store>,
user: CurrentUser,
) -> Result<Json<Vec<User>>, AppError> {
if user.user.username != "admin" {
return Err(AppError::Forbidden("khusus admin".to_owned()));
}
// ... ambil semua users
}Ini pengecekan role sederhana. Untuk sistem lebih kompleks, kamu bisa tambah kolom role di tabel users dan cek user.user.role == "admin".
Semua berkas aplikasi dan test
Ini proyek utuh untuk bab 19: Cargo.toml, seluruh berkas di src/, tests/api.rs, dan konfigurasi contoh. Buka panel satu per satu, simpan isinya ke berkas sesuai judul, lalu ikuti langkah database/terminal pada bab 19. Nama file adalah bagian dari pelajaran—jangan gabungkan isinya ke satu main.rs. Setelah mencoba, baca pembahasan kecil-kecil di bab 19.
Cargo.toml
Cargo.toml
[package]
name = "bab19-auth-api"
version = "0.1.0"
edition = "2021"
[dependencies]
argon2 = "0.5"
axum = "0.8"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"mysql",
"derive",
] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
src/config.rs
src/config.rs
/// Konfigurasi dari environment — tidak ada rahasia yang ditulis di kode.
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub port: u16,
}
impl Config {
pub fn dari_env() -> Result<Self, String> {
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL belum di-set (lihat .env.example)".to_owned())?;
let port = std::env::var("PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(3000);
Ok(Self { database_url, port })
}
}
src/models.rs
src/models.rs
/// Bentuk data untuk user, session, dan task.
/// Tidak ada logika database di sini — hanya bentuk data.
use serde::{Deserialize, Serialize};
/// Satu baris tabel users. password_hash TIDAK pernah dikirim ke klien.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
}
/// Input saat mendaftar akun baru.
#[derive(Debug, Deserialize)]
pub struct RegisterInput {
pub username: String,
pub password: String,
}
/// Input saat login.
#[derive(Debug, Deserialize)]
pub struct LoginInput {
pub username: String,
pub password: String,
}
/// Satu baris tabel sessions. Token inilah yang dipakai klien untuk masuk.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Session {
pub id: i64,
pub user_id: i64,
pub token: String,
pub expires_at: i64, // timestamp Unix (detik)
}
/// Satu baris tabel tasks — sekarang dengan pemilik (user_id).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Task {
pub id: i64,
pub user_id: i64,
pub title: String,
pub completed: bool,
}
/// Body POST /tasks: klien hanya mengirim judul.
#[derive(Debug, Deserialize)]
pub struct NewTask {
pub title: String,
}
/// Body PUT /tasks/{id}: mengganti seluruh tugas.
#[derive(Debug, Deserialize)]
pub struct UpdateTask {
pub title: String,
pub completed: bool,
}
/// Respons saat login berhasil: token untuk dipakai di header Authorization.
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
}
/// Respons untuk GET /auth/me: siapa yang sedang login.
#[derive(Debug, Serialize)]
pub struct MeResponse {
pub id: i64,
pub username: String,
}
/// Validasi aturan bisnis untuk judul tugas (1..=200 karakter setelah trim).
pub fn validasi_judul(judul: &str) -> Result<String, String> {
let judul = judul.trim();
let panjang = judul.chars().count();
if panjang == 0 {
Err("title tidak boleh kosong".to_owned())
} else if panjang > 200 {
Err("title maksimal 200 karakter".to_owned())
} else {
Ok(judul.to_owned())
}
}
/// Validasi username dan password saat register/login.
/// Username 3..=50 karakter, password minimal 8 karakter.
pub fn validasi_kredensial(username: &str, password: &str) -> Result<(String, String), String> {
let username = username.trim();
let jumlah = username.chars().count();
if jumlah < 3 {
return Err("username minimal 3 karakter".to_owned());
}
if jumlah > 50 {
return Err("username maksimal 50 karakter".to_owned());
}
if password.chars().count() < 8 {
return Err("password minimal 8 karakter".to_owned());
}
Ok((username.to_owned(), password.to_owned()))
}
src/error.rs
src/error.rs
/// Satu pintu untuk semua kegagalan -> respons HTTP yang konsisten.
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use serde::Serialize;
#[derive(Debug)]
pub enum AppError {
/// Input klien tidak memenuhi aturan bisnis.
Validasi(String),
/// Belum login atau token tidak valid (401).
Unauthorized(String),
/// Login tapi akses data milik orang lain (403).
Forbidden(String),
/// Resource yang diminta tidak ada (404).
TidakDitemukan,
/// Kegagalan di sisi kami: database atau hashing (500).
Internal(String),
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
Self::Internal(format!("database: {e}"))
}
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, pesan) = match self {
Self::Validasi(p) => (StatusCode::BAD_REQUEST, p),
Self::Unauthorized(p) => (StatusCode::UNAUTHORIZED, p),
Self::Forbidden(p) => (StatusCode::FORBIDDEN, p),
Self::TidakDitemukan => (
StatusCode::NOT_FOUND,
"tugas tidak ditemukan".to_owned(),
),
Self::Internal(p) => {
// Detail teknis hanya dicatat, tidak dibocorkan ke klien.
eprintln!("error internal: {p}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"terjadi kesalahan di server".to_owned(),
)
}
};
(status, Json(ErrorBody { error: pesan })).into_response()
}
}
src/store.rs
src/store.rs
/// Semua SQL berkumpul di sini. Store murah di-clone karena pool
/// berbagi koneksi yang sama — aman menjadi state Axum.
use crate::models::{Session, Task, UpdateTask, User};
use sqlx::mysql::MySqlPool;
#[derive(Debug, Clone)]
pub struct Store {
pool: MySqlPool,
}
impl Store {
pub fn baru(pool: MySqlPool) -> Self {
Self { pool }
}
// ========== MIGRASI ==========
/// Buat ketiga tabel jika belum ada. Aman dijalankan berulang.
pub async fn migrasi(&self) -> Result<(), sqlx::Error> {
// users: akun yang bisa login
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4",
)
.execute(&self.pool)
.await?;
// sessions: token yang aktif. expires_at = timestamp Unix.
sqlx::query(
"CREATE TABLE IF NOT EXISTS sessions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4",
)
.execute(&self.pool)
.await?;
// tasks: daftar tugas per-user.
sqlx::query(
"CREATE TABLE IF NOT EXISTS tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4",
)
.execute(&self.pool)
.await?;
Ok(())
}
// ========== USERS ==========
pub async fn buat_user(&self, username: &str, password_hash: &str) -> Result<User, sqlx::Error> {
sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
.bind(username)
.bind(password_hash)
.execute(&self.pool)
.await?;
self.cari_user_by_username(username)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn cari_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await
}
pub async fn cari_user_by_id(&self, id: i64) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as("SELECT id, username, password_hash FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
// ========== SESSIONS ==========
pub async fn buat_session(&self, user_id: i64, token: &str, expires_at: i64) -> Result<Session, sqlx::Error> {
sqlx::query("INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)")
.bind(user_id)
.bind(token)
.bind(expires_at)
.execute(&self.pool)
.await?;
self.cari_session_by_token(token)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn cari_session_by_token(&self, token: &str) -> Result<Option<Session>, sqlx::Error> {
sqlx::query_as("SELECT id, user_id, token, expires_at FROM sessions WHERE token = ?")
.bind(token)
.fetch_optional(&self.pool)
.await
}
pub async fn hapus_session(&self, token: &str) -> Result<bool, sqlx::Error> {
let hasil = sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(&self.pool)
.await?;
Ok(hasil.rows_affected() > 0)
}
// ========== TASKS (per-user) ==========
pub async fn semua_tasks(&self, user_id: i64) -> Result<Vec<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, user_id, title, completed FROM tasks WHERE user_id = ? ORDER BY id")
.bind(user_id)
.fetch_all(&self.pool)
.await
}
pub async fn cari_task(&self, user_id: i64, id: i64) -> Result<Option<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, user_id, title, completed FROM tasks WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
pub async fn buat_task(&self, user_id: i64, title: &str) -> Result<Task, sqlx::Error> {
let hasil = sqlx::query("INSERT INTO tasks (user_id, title) VALUES (?, ?)")
.bind(user_id)
.bind(title)
.execute(&self.pool)
.await?;
let id = hasil.last_insert_id() as i64;
self.cari_task(user_id, id)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn ubah_task(&self, user_id: i64, id: i64, input: &UpdateTask, title: &str) -> Result<Option<Task>, sqlx::Error> {
let hasil = sqlx::query("UPDATE tasks SET title = ?, completed = ? WHERE id = ? AND user_id = ?")
.bind(title)
.bind(input.completed)
.bind(id)
.bind(user_id)
.execute(&self.pool)
.await?;
if hasil.rows_affected() == 0 {
return self.cari_task(user_id, id).await;
}
self.cari_task(user_id, id).await
}
pub async fn hapus_task(&self, user_id: i64, id: i64) -> Result<bool, sqlx::Error> {
let hasil = sqlx::query("DELETE FROM tasks WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(hasil.rows_affected() > 0)
}
/// Dipakai test untuk membersihkan tabel.
pub async fn kosongkan(&self) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM tasks").execute(&self.pool).await?;
sqlx::query("DELETE FROM sessions").execute(&self.pool).await?;
sqlx::query("DELETE FROM users").execute(&self.pool).await?;
Ok(())
}
}
src/auth.rs
src/auth.rs
/// Fungsi untuk password (Argon2) dan token (acak), plus extractor CurrentUser
/// yang membaca header Authorization dan memvalidasi session.
use crate::{error::AppError, models::User, store::Store};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::{
extract::FromRequestParts,
http::request::Parts,
};
use rand::RngCore;
use std::time::{SystemTime, UNIX_EPOCH};
/// Durasi session: 7 hari dalam detik.
const SESSION_DURASI_DETIK: i64 = 7 * 24 * 60 * 60;
// ========== PASSWORD (Argon2) ==========
/// Hash password menjadi string Argon2 yang aman disimpan.
/// Salt dibuat otomatis dan ikut tertanam di string hash.
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(format!("hash password: {e}")))?;
Ok(hash.to_string())
}
/// Verifikasi: apakah password ini cocok dengan hash yang tersimpan?
/// Mengembalikan true/false, bukan panik.
pub fn verifikasi_password(password: &str, hash: &str) -> bool {
let parsed = match PasswordHash::new(hash) {
Ok(p) => p,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
// ========== TOKEN (acak) ==========
/// Buat token acak 32 byte (64 karakter hex) untuk session.
/// Tidak perlu disimpan di kode — hanya di database saat login.
pub fn buat_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Timestamp Unix sekarang (detik), untuk membandingkan expires_at.
pub fn sekarang_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Berapa detik session berlaku mulai dari sekarang.
pub fn durasi_session() -> i64 {
SESSION_DURASI_DETIK
}
// ========== EXTRACTOR CurrentUser ==========
/// Extractor ini dipakai handler untuk mendapatkan user yang sedang login
/// beserta token yang dipakai (untuk logout).
/// Cara pakai: tambahkan `CurrentUser` sebagai parameter handler.
pub struct CurrentUser {
pub user: User,
pub token: String,
}
impl FromRequestParts<Store> for CurrentUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
store: &Store,
) -> Result<Self, Self::Rejection> {
// 1. Baca header Authorization: Bearer <token>
let header = parts
.headers
.get("authorization")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("header Authorization tidak ada".to_owned()))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("format harus 'Bearer <token>'".to_owned()))?
.trim();
if token.is_empty() {
return Err(AppError::Unauthorized("token kosong".to_owned()));
}
// 2. Cari session di database
let session = store
.cari_session_by_token(token)
.await?
.ok_or_else(|| AppError::Unauthorized("token tidak valid".to_owned()))?;
// 3. Cek kedaluwarsa
if session.expires_at < sekarang_unix() {
return Err(AppError::Unauthorized("token sudah kedaluwarsa".to_owned()));
}
// 4. Ambil data user
let user = store
.cari_user_by_id(session.user_id)
.await?
.ok_or_else(|| AppError::Unauthorized("user tidak ditemukan".to_owned()))?;
Ok(CurrentUser {
user,
token: token.to_owned(),
})
}
}
src/handlers.rs
src/handlers.rs
/// Handler HTTP: auth (register/login/logout/me) dan tasks (per-user).
use crate::{
auth::{
buat_token, durasi_session, hash_password, sekarang_unix, verifikasi_password, CurrentUser,
},
error::AppError,
models::{
validasi_judul, validasi_kredensial, LoginInput, LoginResponse, MeResponse, NewTask,
RegisterInput, Task, UpdateTask,
},
store::Store,
};
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
// ========== AUTH ==========
/// POST /auth/register — buat akun baru.
pub async fn register(
State(store): State<Store>,
Json(input): Json<RegisterInput>,
) -> Result<(StatusCode, Json<MeResponse>), AppError> {
let (username, password) = validasi_kredensial(&input.username, &input.password)
.map_err(AppError::Validasi)?;
// Cek username sudah dipakai?
if store.cari_user_by_username(&username).await?.is_some() {
return Err(AppError::Validasi("username sudah dipakai".to_owned()));
}
let hash = hash_password(&password)?;
let user = store.buat_user(&username, &hash).await?;
Ok((
StatusCode::CREATED,
Json(MeResponse {
id: user.id,
username: user.username,
}),
))
}
/// POST /auth/login — verifikasi password, buat session, balas token.
pub async fn login(
State(store): State<Store>,
Json(input): Json<LoginInput>,
) -> Result<Json<LoginResponse>, AppError> {
let (username, password) = validasi_kredensial(&input.username, &input.password)
.map_err(AppError::Validasi)?;
let user = store
.cari_user_by_username(&username)
.await?
.ok_or_else(|| AppError::Unauthorized("username atau password salah".to_owned()))?;
if !verifikasi_password(&password, &user.password_hash) {
return Err(AppError::Unauthorized(
"username atau password salah".to_owned(),
));
}
// Buat session baru
let token = buat_token();
let expires_at = sekarang_unix() + durasi_session();
store.buat_session(user.id, &token, expires_at).await?;
Ok(Json(LoginResponse { token }))
}
/// POST /auth/logout — hapus session (token mati). Perlu login.
pub async fn logout(
State(store): State<Store>,
user: CurrentUser,
) -> Result<StatusCode, AppError> {
store.hapus_session(&user.token).await?;
Ok(StatusCode::NO_CONTENT)
}
/// GET /auth/me — siapa yang sedang login.
pub async fn me(user: CurrentUser) -> Json<MeResponse> {
Json(MeResponse {
id: user.user.id,
username: user.user.username,
})
}
// ========== TASKS (per-user) ==========
/// GET /tasks — daftar tugas milik user yang login.
pub async fn daftar(
State(store): State<Store>,
user: CurrentUser,
) -> Result<Json<Vec<Task>>, AppError> {
Ok(Json(store.semua_tasks(user.user.id).await?))
}
/// GET /tasks/{id} — baca satu tugas milik sendiri.
pub async fn ambil(
State(store): State<Store>,
Path(id): Path<i64>,
user: CurrentUser,
) -> Result<Json<Task>, AppError> {
store
.cari_task(user.user.id, id)
.await?
.map(Json)
.ok_or(AppError::TidakDitemukan)
}
/// POST /tasks — buat tugas milik sendiri.
pub async fn buat(
State(store): State<Store>,
user: CurrentUser,
Json(input): Json<NewTask>,
) -> Result<(StatusCode, Json<Task>), AppError> {
let title = validasi_judul(&input.title).map_err(AppError::Validasi)?;
let tugas = store.buat_task(user.user.id, &title).await?;
Ok((StatusCode::CREATED, Json(tugas)))
}
/// PUT /tasks/{id} — ganti tugas milik sendiri.
pub async fn ubah(
State(store): State<Store>,
Path(id): Path<i64>,
user: CurrentUser,
Json(input): Json<UpdateTask>,
) -> Result<Json<Task>, AppError> {
let title = validasi_judul(&input.title).map_err(AppError::Validasi)?;
store
.ubah_task(user.user.id, id, &input, &title)
.await?
.map(Json)
.ok_or(AppError::TidakDitemukan)
}
/// DELETE /tasks/{id} — hapus tugas milik sendiri.
pub async fn hapus(
State(store): State<Store>,
Path(id): Path<i64>,
user: CurrentUser,
) -> Result<StatusCode, AppError> {
if store.hapus_task(user.user.id, id).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::TidakDitemukan)
}
}
src/lib.rs
src/lib.rs
/// Perpustakaan proyek: merangkai module dan menyediakan Router.
pub mod auth;
pub mod config;
pub mod error;
pub mod handlers;
pub mod models;
pub mod store;
use axum::{
routing::{get, post},
Router,
};
use store::Store;
/// Router aplikasi dengan Store sebagai state.
/// Route /auth/register dan /auth/login publik; /auth/logout, /auth/me, dan
/// /tasks terproteksi (butuh login).
pub fn app(store: Store) -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
// Auth publik
.route("/auth/register", post(handlers::register))
.route("/auth/login", post(handlers::login))
// Auth terproteksi
.route("/auth/logout", post(handlers::logout))
.route("/auth/me", get(handlers::me))
// Tasks per-user (semua butuh login)
.route("/tasks", get(handlers::daftar).post(handlers::buat))
.route(
"/tasks/{id}",
get(handlers::ambil).put(handlers::ubah).delete(handlers::hapus),
)
.with_state(store)
}
src/main.rs
src/main.rs
/// Titik masuk server: config -> pool -> migrasi -> serve.
/// Menjalankan: export DATABASE_URL=... && cargo run
use bab19_auth_api::{app, config::Config, store::Store};
use sqlx::mysql::MySqlPool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::dari_env()?;
let pool = MySqlPool::connect(&config.database_url).await?;
let store = Store::baru(pool);
store.migrasi().await?;
let alamat = format!("127.0.0.1:{}", config.port);
let listener = tokio::net::TcpListener::bind(&alamat).await?;
println!("Migrasi selesai. Mendengarkan di http://{alamat}");
axum::serve(listener, app(store)).await?;
Ok(())
}
.env.example
.env.example
# Salin file ini menjadi .env atau set lewat `export` di terminal.
# GANTI sandinya dengan milikmu. Jangan pernah commit sandi asli.
# URL koneksi MariaDB untuk server API.
DATABASE_URL=mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust
# Database TERPISAH untuk integration test (dipakai oleh cargo test).
TEST_DATABASE_URL=mysql://task_app:ganti-dengan-sandi-kuat@127.0.0.1:3306/belajar_rust_test
# Port server (opsional, bawaan 3000).
PORT=3000
tests/api.rs
tests/api.rs
//! Integration test untuk autentikasi dan tasks per-user.
//! Memakai database test terpisah (TEST_DATABASE_URL). Jika tidak di-set,
//! test dilewati sehingga unit test lain tetap berjalan.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use bab19_auth_api::{app, store::Store};
use serde_json::{json, Value};
use sqlx::mysql::MySqlPool;
use tower::ServiceExt;
// Semua test DB berbagi satu tabel; kunci mencegah saling mengosongkan.
static DB_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Siapkan store + router bersih. None bila database test belum disiapkan.
async fn siapkan() -> Option<(Store, axum::Router)> {
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
eprintln!("TEST_DATABASE_URL belum di-set: test database dilewati");
return None;
};
let pool = MySqlPool::connect(&url)
.await
.expect("koneksi database test berhasil");
let store = Store::baru(pool);
store.migrasi().await.expect("migrasi berhasil");
store.kosongkan().await.expect("tabel test bersih");
let router = app(store.clone());
Some((store, router))
}
/// Helper: kirim request ke Router dan kembalikan (status, body sebagai Value).
async fn panggil(
app: &mut axum::Router,
method: &str,
path: &str,
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Option<Value>) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
let http_body = match body {
Some(v) => {
builder = builder.header("content-type", "application/json");
Body::from(serde_json::to_vec(&v).expect("serialize"))
}
None => Body::empty(),
};
let respons = app
.oneshot(builder.body(http_body).expect("request valid"))
.await
.expect("router merespons");
let status = respons.status();
let bytes = axum::body::to_bytes(respons.into_body(), usize::MAX)
.await
.expect("baca body");
let nilai = if bytes.is_empty() {
None
} else {
Some(serde_json::from_slice(&bytes).expect("respons JSON"))
};
(status, nilai)
}
/// Register user baru dan kembalikan token login.
async fn register_dan_login(app: &mut axum::Router, username: &str) -> String {
// Register
let (status, _) = panggil(
app,
"POST",
"/auth/register",
None,
Some(json!({"username": username, "password": "password123"})),
)
.await;
assert_eq!(status, StatusCode::CREATED);
// Login
let (status, body) = panggil(
app,
"POST",
"/auth/login",
None,
Some(json!({"username": username, "password": "password123"})),
)
.await;
assert_eq!(status, StatusCode::OK);
body.expect("token login")["token"]
.as_str()
.expect("token string")
.to_owned()
}
#[tokio::test]
async fn alur_auth_dan_tasks() {
let _guard = DB_TEST_LOCK.lock().await;
let Some((_store, mut app)) = siapkan().await else { return };
let token = register_dan_login(&mut app, "andi").await;
// GET /auth/me tanpa token -> 401
let (status, _) = panggil(&mut app, "GET", "/auth/me", None, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
// GET /auth/me dengan token -> 200
let (status, body) = panggil(&mut app, "GET", "/auth/me", Some(&token), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("me")["username"], "andi");
// Buat task dengan token
let (status, body) = panggil(
&mut app,
"POST",
"/tasks",
Some(&token),
Some(json!({"title": "Belajar auth"})),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let task_id = body.expect("task")["id"].as_i64().expect("id angka");
// Daftar tasks milik sendiri
let (status, body) = panggil(&mut app, "GET", "/tasks", Some(&token), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("array").as_array().expect("array").len(), 1);
// User lain: register dan pastikan tidak bisa lihat task andi
let token_lain = register_dan_login(&mut app, "budi").await;
let (status, _) = panggil(
&mut app,
"GET",
&format!("/tasks/{task_id}"),
Some(&token_lain),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND); // 404 karena bukan milik budi
// Logout
let (status, _) = panggil(&mut app, "POST", "/auth/logout", Some(&token), None).await;
assert_eq!(status, StatusCode::NO_CONTENT);
// Setelah logout, token lama ditolak
let (status, _) = panggil(&mut app, "GET", "/auth/me", Some(&token), None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn validasi_kredensial_ditolak() {
let _guard = DB_TEST_LOCK.lock().await;
let Some((_store, mut app)) = siapkan().await else { return };
// Username terlalu pendek
let (status, body) = panggil(
&mut app,
"POST",
"/auth/register",
None,
Some(json!({"username": "ab", "password": "password123"})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.expect("error")["error"], "username minimal 3 karakter");
// Password terlalu pendek
let (status, body) = panggil(
&mut app,
"POST",
"/auth/register",
None,
Some(json!({"username": "andi", "password": "short"})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.expect("error")["error"], "password minimal 8 karakter");
// Username sudah dipakai
let _ = register_dan_login(&mut app, "andi").await;
let (status, body) = panggil(
&mut app,
"POST",
"/auth/register",
None,
Some(json!({"username": "andi", "password": "password123"})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.expect("error")["error"], "username sudah dipakai");
// Login dengan password salah
let (status, body) = panggil(
&mut app,
"POST",
"/auth/login",
None,
Some(json!({"username": "andi", "password": "salahpassword"})),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(
body.expect("error")["error"],
"username atau password salah"
);
}
#[tokio::test]
async fn tasks_per_user_tidak_bisa_diakses_orang_lain() {
let _guard = DB_TEST_LOCK.lock().await;
let Some((_store, mut app)) = siapkan().await else { return };
let token_andi = register_dan_login(&mut app, "andi").await;
let token_budi = register_dan_login(&mut app, "budi").await;
// Andi buat task
let (status, body) = panggil(
&mut app,
"POST",
"/tasks",
Some(&token_andi),
Some(json!({"title": "Rahasia andi"})),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let task_id = body.expect("task")["id"].as_i64().expect("id angka");
// Budi tidak bisa lihat task andi
let (status, _) = panggil(
&mut app,
"GET",
&format!("/tasks/{task_id}"),
Some(&token_budi),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// Budi tidak bisa ubah task andi
let (status, _) = panggil(
&mut app,
"PUT",
&format!("/tasks/{task_id}"),
Some(&token_budi),
Some(json!({"title": "dicolong", "completed": true})),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// Budi tidak bisa hapus task andi
let (status, _) = panggil(
&mut app,
"DELETE",
&format!("/tasks/{task_id}"),
Some(&token_budi),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// Daftar task budi tetap kosong
let (status, body) = panggil(&mut app, "GET", "/tasks", Some(&token_budi), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("array").as_array().expect("array").len(), 0);
}
Istilah penting tanpa jargon
Ownership / borrowing
Satu pemilik; fungsi boleh meminjam dengan & (membaca) atau &mut (menulis).
Option / Result
Option untuk nilai yang mungkin tidak ada; Result untuk operasi yang mungkin gagal.
Crate / module
Crate adalah unit kompilasi Rust; module menyusun isi crate dalam file dan batas visibilitas.
Future / await
Future adalah pekerjaan async yang menghasilkan nilai nanti; .await menunggu tanpa menahan thread selama I/O.
Handler / extractor
Handler menjawab request; extractor Path, Json, dan State mengambil data yang dibutuhkannya.
Pool / bind
Pool membagi koneksi DB; .bind() mengirim nilai terpisah dari SQL untuk mencegah injection.
Glosarium yang lebih luas juga tersedia di /home/sysadmin/lab/belajar-rust/GLOSARIUM.md.
Periksa kemajuanmu
Centang setelah mencoba sendiri. Status disimpan di browser ini melalui localStorage.
Dasar
Server
Database
Kalau tersendat, mulai dari sini
Kenapa cargo tidak ditemukan di terminal VS Code?
Rustup terpasang pada akun Ubuntu, tetapi shell belum memuat PATH. Buka terminal baru atau jalankan source "$HOME/.cargo/env"; cek cargo --version.
Kenapa cargo berkata Cargo.toml tidak ada?
Kamu berada di folder bab, bukan folder contoh/. Jalankan pwd lalu cd .../contoh seperti perintah pada bab tersebut.
Mengapa curl dari laptop tidak bisa tersambung?
Contoh mengikat server di 127.0.0.1 milik Ubuntu. Jalankan curl di terminal Ubuntu/VS Code Server yang sama. Bila perlu akses dari laptop, gunakan port forwarding Remote-SSH sesuai pengaturan VS Code.
Kenapa MariaDB tidak tersambung?
Periksa sudo systemctl status mariadb, URL di DATABASE_URL, nama DB dan password. Jangan kirim password aslimu saat meminta bantuan.
HTML ini bisa dibaca tanpa folder lain?
Ya, materi pada halaman ini ada di satu file; buka HTML secara langsung. Font/CDN butuh internet untuk tampil maksimal. Untuk menjalankan proyek Cargo lengkap, tetap gunakan folder contoh/ yang sudah ada di kursus.