RBelajar Rust
19 bab · Ubuntu 24.04 · Axum + MariaDB
PANDUAN BELAJAR · BAHASA INDONESIA

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.

19 bab berurutan107+ contoh kode1 proyek API lengkap
Peta belajar

Satu perjalanan, empat tahap

Tak perlu menghafal semuanya. Ikuti bab sesuai urutan dan lihat bagaimana proyek task manager berkembang menjadi API.

01 / MULAI

Fondasi Rust

Bab 0–6 · Terminal, Cargo, tipe data, fungsi, ownership, koleksi.

02 / PAHAMI

Model & logika

Bab 7–11 · Struct, error, module, trait, testing.

03 / BANGUN

Aplikasi & server

Bab 12–16 · CLI, async, JSON, Axum, CRUD in-memory.

04 / SELESAIKAN

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.

Bab 00 · Persiapan

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 ..
pwd

Lokasi pertama berakhir /0-inisialisasi, lokasi kedua /belajar-rust.

Lanjut: instalasi →

Bab 01 · Ubuntu 24.04

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 cargo

command -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-certificates

sudo 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 --version

curl 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 --version

Jika 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.

Lanjut: program pertama →

Bab 02 · Program pertama

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() { — fn adalah kata kunci untuk membuat fungsi. main adalah 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; — let membuat variabel bernama tahun yang menyimpan nilai angka 2026. let berarti "beri nama pada nilai ini".
  • println!("Belajar di tahun {tahun}."); — {tahun} adalah placeholder: tempat kosong di dalam teks yang akan diisi nilai variabel tahun. Hasil cetak: Belajar di tahun 2026.
  • println!("2 + 3 = {}", 2 + 3); — {} adalah placeholder kosong yang diisi nilai setelah koma, yaitu hasil perhitungan 2 + 3 (yaitu 5). Hasil cetak: 2 + 3 = 5.
  • let pesan = sapaan("Rustacean baru"); — sapaan(...) memanggil fungsi lain bernama sapaan dengan argumen teks "Rustacean baru". Hasil dari fungsi itu disimpan di variabel pesan.
  • println!("{pesan}"); — mencetak isi variabel pesan (hasil dari fungsi sapaan).
  • } — menutup blok isi fungsi main.

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 bernama sapaan.
    • 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 menghasilkan String (teks yang dimiliki, bukan pinjaman). Kenapa String bukan &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 parameter nama. 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 dijanjikan String.
  • } — 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 test

run 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));.

Lanjut: variabel →

Bab 03 · Data

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; — const membuat konstanta (nilai tetap yang tidak pernah berubah). MAKS_JUDUL adalah namanya (huruf kapital sesuai kebiasaan Rust untuk konstanta). : usize adalah tipenya: usize adalah bilangan bulat tak-negatif untuk ukuran/indeks (cocok untuk "maksimal 200 karakter"). 200 adalah nilainya. Konstanta dihitung saat kompilasi, bukan saat program berjalan.
  • let id: i64 = 1; — let membuat variabel id. : i64 adalah tipenya: bilangan bulat 64-bit bertanda (bisa negatif). Kenapa i64? Karena ID database nanti memakai BIGINT yang cocok dengan i64. 1 adalah nilai awalnya.
  • let judul = "Belajar variabel"; — let membuat variabel judul. Kali ini tipe tidak ditulis — Rust menebak sendiri dari nilai: teks literal "..." bertipe &str. Ini namanya type inference.
  • let mut selesai = false; — let mut membuat variabel selesai yang boleh diubah (mutable). mut adalah kata kunci yang mengizinkan perubahan. false adalah nilai awalnya (boolean: benar/salah).
  • selesai = true; — mengubah nilai selesai menjadi true. Ini hanya bisa karena variabel diberi mut. Kalau tanpa mut, 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"; — let membuat variabel jumlah yang menyimpan teks "42" (tipe &str). Perhatikan: ini teks, bukan angka.
  • let jumlah: u32 = jumlah.parse().expect("bukan angka"); — ini shadowing: let kedua 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 method parse() pada teks jumlah untuk mengubahnya menjadi angka. Tipe tujuan ditentukan oleh anotasi : u32.
    • .expect("bukan angka") — parse() mengembalikan Result (bisa berhasil Ok atau gagal Err). expect mengambil nilai Ok; kalau Err, program berhenti (panic) dengan pesan "bukan angka". Untuk input dari pengguna, jangan pakai expect — pakai Result (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; — let membuat variabel pecahan. : f64 adalah tipenya: bilangan pecahan (floating point) 64-bit. 3.9 adalah nilainya.
  • let bulat = pecahan as i32; — as i32 mengubah tipe dari f64 (pecahan) ke i32 (bilangan bulat). Hasilnya 3 — perhatikan: as memotong desimal, bukan membulatkan. Jadi 3.9 menjadi 3, bukan 4.
  • let tanda: char = '✓'; — char adalah 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 test
Latihan 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.

Lanjut: fungsi →

Bab 04 · Alur program

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 = [ ... ]; — let membuat variabel tugas yang 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 kedua false adalah status selesai (boolean). Tuple memungkinkan menyimpan dua nilai berbeda tipe dalam satu kesatuan.
  • ("Belajar variabel", true), — tuple kedua: judul "Belajar variabel", status true (sudah selesai).
  • ("Belajar loop", false), — tuple ketiga: judul "Belajar loop", status false.
  • ]; — menutup array. Array ini berisi 3 tuple.
  • tampilkan(&tugas); — memanggil fungsi tampilkan dengan argumen &tugas.
    • & — meminjamkan array tugas ke fungsi, bukan menyerahkannya. Fungsi hanya boleh membaca array, tidak boleh mengubahnya. Pemilik aslinya tetap main.
    • Kenapa pinjaman? Karena fungsi hanya perlu melihat daftar untuk mencetaknya, tidak perlu memilikinya. Setelah fungsi selesai, tugas masih bisa dipakai lagi di main.

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 fungsi tampilkan.
    • 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 &str dan boolean bool.
      Kenapa slice bukan array langsung? Slice &[T] bisa menerima array maupun Vec — lebih fleksibel.
    • { — membuka blok isi fungsi.
  • for (judul, selesai) in tugas { — perulangan for: untuk setiap elemen di tugas, lakukan sesuatu.
    • (judul, selesai) — destructuring: membuka tuple menjadi dua variabel. judul berisi teks, selesai berisi boolean.
    • in tugas — sumber perulangan: daftar yang dipinjam tadi.
  • let tanda = if *selesai { "x" } else { " " }; — if sebagai ekspresi (menghasilkan nilai, bukan hanya pernyataan).
    • *selesai — dereference: membaca nilai boolean yang dipinjam. Karena selesai adalah referensi ke bool, tanda * mengambil nilai aslinya.
    • if *selesai { "x" } else { " " } — kalau selesai benar, hasilnya teks "x"; kalau salah, hasilnya spasi " ".
    • Hasil if disimpan di variabel tanda.
  • println!("- [{tanda}] {judul}"); — mencetak satu baris tugas. {tanda} dan {judul} diisi nilai variabel. Hasil misalnya - [x] Belajar variabel atau - [ ] Belajar fungsi.
  • } — menutup blok for (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 fungsi prioritas.
    • nomor: u32 — parameter: bilangan bulat tak-negatif.
    • -> &'static str — tipe kembalian: pinjaman teks dengan lifetime 'static. 'static artinya 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 nilai nomor dan menjalankan cabang yang cocok. match harus menangani semua kemungkinan nilai.
  • 1 => "tinggi", — kalau nomor adalah 1, hasilnya teks "tinggi". => artinya "menghasilkan".
  • 2 | 3 => "sedang", — kalau nomor adalah 2 atau 3 (| = atau), hasilnya "sedang".
  • _ => "biasa", — _ menangkap semua nilai lainnya (selain 1, 2, 3). Hasilnya "biasa".
  • } — menutup blok match. Hasil match adalah 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 fungsi hitung_mundur.
    • mut dari: u32 — parameter dari bertipe u32 dan boleh diubah di dalam fungsi (mut). Kenapa mut? Karena kita akan menurunkan nilainya dalam loop.
  • while dari > 0 { — perulangan while: ulangi selama syarat dari > 0 benar. Kalau dari sudah 0, loop berhenti.
  • print!(" {dari}"); — mencetak nilai dari. print! tidak pindah baris (berbeda dengan println!), jadi angka-angka tercetak bersebelahan.
  • dari -= 1; — menurunkan nilai dari sebesar 1 (-= = kurangi). Ini penting: tanpa ini dari tidak pernah berubah dan loop tidak pernah berhenti (infinite loop).
  • } — menutup blok while (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 test
Latihan 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);.

Lanjut: ownership →

Bab 05 · Aturan memori Rust

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") membuat String baru (teks yang dimiliki). let a membuat variabel a sebagai pemilik teks itu.
  • let b = a; — ini memindahkan kepemilikan (move) dari a ke b. a tidak lagi memiliki String itu. Rust tidak menyalin teksnya — hanya memindahkan siapa pemiliknya.
  • // println!("{a}"); — baris ini dikomentari karena akan error. a sudah tidak memiliki String, jadi tidak boleh dipakai lagi. Compiler akan menolak dengan pesan "use of moved value".
  • println!("{b}"); — mencetak b yang 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 fungsi hitung_panjang.
    • teks — nama parameter.
    • : &String — tipe parameter: pinjaman baca ke String. & 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 ; — nilai usize ini yang dikembalikan fungsi.
  • } — menutup blok isi fungsi.
  • let judul = String::from("Belajar Rust"); — membuat variabel judul sebagai pemilik teks.
  • let n = hitung_panjang(&judul); — memanggil fungsi dengan &judul: meminjamkan judul untuk dibaca. Hasilnya (panjang teks) disimpan di n.
  • println!("Panjang \"{judul}\": {n}"); — mencetak. Perhatikan: judul masih 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 fungsi tandai_selesai.
    • teks — nama parameter.
    • : &mut String — tipe parameter: pinjaman tulis ke String. &mut artinya 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 variabel judul yang boleh diubah (mut). Tanpa mut, kita tidak bisa meminjamkannya sebagai &mut.
  • tandai_selesai(&mut judul); — memanggil fungsi dengan &mut judul: meminjamkan judul untuk diubah. Setelah fungsi selesai, isi judul sudah 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 fungsi judul_pertama.
    • daftar — nama parameter (daftar yang diterima).
    • : &[String] — tipe parameter: slice (pinjaman daftar) yang berisi String. Rinciannya:
      • & — pinjaman (fungsi hanya meminjam, tidak memiliki).
      • [String] — daftar yang berisi teks String.
      Kenapa slice &[String] bukan Vec<String>? Karena slice bisa menerima Vec maupun 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 &str yang 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 test
Latihan 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.

Lanjut: koleksi →

Bab 06 · Data banyak

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 variabel tugas yang 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 berisi String (judul) dan bool (status selesai).
      Kenapa Vec bukan array [...]? Karena Vec bisa tumbuh; array panjangnya tetap.
    • = Vec::new() — membuat Vec baru 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 &str menjadi String yang dimiliki daftar. Kenapa to_owned()? Karena daftar Vec harus 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", status false (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 fungsi judul_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), selesai mengambil bagian kedua (status).
      • Kenapa _? Karena untuk menyaring kita hanya butuh status, bukan judul.
    • *selesai — dereference: membaca nilai boolean yang dipinjam. Hanya elemen dengan selesai == true yang lolos.
  • .map(|(judul, _)| judul.as_str()) — .map(...) mengubah setiap elemen yang lolos menjadi bentuk lain.
    • |(judul, _)| — closure lagi: judul mengambil bagian pertama (judul), _ mengabaikan bagian kedua (status).
    • judul.as_str() — mengubah String menjadi &str (pinjaman teks). Kenapa? Karena tipe kembalian kita adalah Vec<&str>, bukan Vec<String>.
  • .collect() — .collect() mengumpulkan semua hasil menjadi Vec baru. 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; — use mengimpor tipe HashMap dari pustaka standar Rust (std::collections). Tanpa ini, kita harus menulis std::collections::HashMap setiap kali memakainya — panjang.
  • let mut per_id: HashMap<i64, String> = HashMap::new(); — membuat variabel per_id yang boleh diubah (mut).
    • : HashMap<i64, String> — tipe: kamus (peta kunci → nilai). i64 adalah tipe kunci (ID), String adalah 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. Kunci 2 (ID), nilai "Belajar HashMap" yang diubah ke String dengan .to_owned().
  • println!("{:?}", per_id.get(&2)); — .get(&kunci) mencari nilai berdasarkan kunci.
    • Hasilnya Option<&String>: Some(...) kalau kunci ditemukan, None kalau tidak. Ini cara aman mencari tanpa panic.
    • {:?} adalah format debug (menampilkan nilai apa adanya, termasuk Some("...") atau None).
  • let teks = "hàlo"; — membuat variabel teks berisi 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 test
Latihan 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.

Lanjut: struct dan enum →

Bab 07 · Model data

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 enum Status: Debug (cetak {:?}), Clone (salin), PartialEq (bandingkan ==).
  • enum Status { — membuat enum Status: 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: field oleh bertipe String (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 struct Tugas: sama seperti di atas.
  • struct Tugas { id: u32, judul: String, status: Status } — membuat struct Tugas: 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 enum Status yang 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 tipe Tugas. Semua yang ada di dalam impl menjadi milik Tugas.
  • fn baru(id: u32, judul: &str) -> Self { — fungsi pembuat (constructor) bernama baru.
    • Tidak ada &self — ini fungsi asosiasi (dipanggil sebagai Tugas::baru(...)), bukan method pada instance yang sudah ada. Itu masuk akal: kita membuat instance baru, belum ada self.
    • id: u32 — parameter ID.
    • judul: &str — parameter judul (pinjaman teks).
    • -> Self — mengembalikan Self, yaitu Tugas (singkatan tipe yang sedang di-impl).
  • Self { id, judul: judul.to_owned(), status: Status::Baru } — membuat instance Tugas baru.
    • Self { ... } — syntax membuat struct.
    • id — shorthand untuk id: id (field id diisi parameter id, karena namanya sama).
    • judul: judul.to_owned() — field judul diisi String dari pinjaman. .to_owned() mengubah &str menjadi String yang dimiliki struct.
    • status: Status::Baru — status awal selalu Baru (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 field status). Tanpa mut, 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 field status instance menjadi varian Dikerjakan dengan field oleh diisi String dari parameter.
  • } — menutup blok impl.

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 variabel keterangan yang isinya hasil dari match.
    • match — pencocokan pola: memeriksa nilai dan menjalankan cabang yang cocok.
    • &self.status — nilai yang diperiksa: pinjaman ke field status milik self. & artinya kita hanya meminjam status, bukan memindahkannya. Kenapa pinjaman? Karena status berisi String (di varian Dikerjakan) yang tidak boleh dipindahkan keluar dari self — self masih butuh statusnya.
    • { — membuka blok cabang-cabang match.
  • Status::Baru => String::from("baru"), — cabang pertama: kalau status adalah Status::Baru, hasilnya String::from("baru") (teks String baru berisi "baru"). => artinya "menghasilkan". , memisahkan cabang.
  • Status::Dikerjakan { oleh } => format!("dikerjakan oleh {oleh}"), — cabang untuk varian Dikerjakan yang membawa data.
    • { oleh } — destructuring: membuka data yang dibawa varian. oleh adalah nama variabel untuk field itu.
    • format!("dikerjakan oleh {oleh}") — membuat String baru dengan isi variabel oleh. Hasil misalnya "dikerjakan oleh Rani".
  • Status::Selesai => String::from("selesai"), — cabang untuk Status::Selesai, hasilnya String::from("selesai").
  • }; — menutup blok match. Hasil match disimpan di keterangan.

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 test
Latihan 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.

Lanjut: Option/Result →

Bab 08 · Kegagalan terkontrol

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 fungsi cari_judul yang publik (pub — bisa dipakai dari luar modul).
    • id: u32 — parameter: bilangan bulat tak-negatif (ID tugas).
    • -> Option<&'static str> — tipe kembalian: Option yang berisi pinjaman teks &'static str. Rinciannya:
      • Option<T> — tipe untuk "mungkin ada nilai, mungkin tidak": Some(T) (ada) atau None (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 nilai id dan menjalankan cabang yang cocok.
  • 1 => Some("Membaca Option"), — kalau id adalah 1, hasilnya Some("Membaca Option") (ada judul untuk ID 1).
  • 2 => Some("Menulis pengujian"), — kalau id adalah 2, hasilnya Some("Menulis pengujian").
  • _ => None, — _ menangkap semua ID lainnya: hasilnya None (tidak ada judul untuk ID itu).
  • } — menutup blok match. Hasil match adalah 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 fungsi parse_id yang publik.
    • input: &str — parameter: pinjaman teks (input dari pengguna).
    • -> Result<u32, ErrorInput> — tipe kembalian: Result yang berisi u32 (berhasil) atau ErrorInput (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 di lib.rs (berisi Kosong, Angka, Nol).
  • let input = input.trim(); — .trim() menghapus spasi di awal dan akhir teks (bukan di tengah). Hasilnya disimpan di variabel input yang baru (shadowing).
  • if input.is_empty() { return Err(ErrorInput::Kosong); } — kalau teks kosong setelah di-trim, kembalikan Err(ErrorInput::Kosong) (gagal karena kosong). return menghentikan fungsi lebih awal.
  • let id = input.parse::<u32>().map_err(ErrorInput::Angka)?; — baris penting, dipecah:
    • input.parse::<u32>() — mencoba mengubah teks menjadi angka u32. Hasilnya Result<u32, ParseIntError>.
    • .map_err(ErrorInput::Angka) — kalau parse gagal, membungkus error bawaan (ParseIntError) menjadi enum kita sendiri ErrorInput::Angka. Ini membuat error lebih bermakna.
    • ? — operator tanya: kalau hasilnya Err, kembalikan error itu sekarang dan hentikan fungsi. Kalau Ok, ambil nilainya dan lanjutkan. Ini singkatan dari match panjang.
  • if id == 0 { return Err(ErrorInput::Nol); } — kalau angka hasil parse adalah 0, kembalikan Err(ErrorInput::Nol) (ID tidak boleh nol).
  • Ok(id) — kalau semua pemeriksaan lolos, kembalikan Ok(id) (berhasil dengan angka id).

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 dari parse_id("9"). Hasilnya Result<u32, ErrorInput> — bisa Ok (berhasil) atau Err (gagal).
  • Ok(id) => match cari_judul(id) { — cabang berhasil: kalau parse_id berhasil, id adalah angka hasil parse.
    • match cari_judul(id) { — di dalam cabang berhasil, kita memeriksa lagi hasil cari_judul(id). Hasilnya Option<&str> — bisa Some (ada judul) atau None (tidak ada).
  • Some(judul) => println!("{judul}"), — kalau cari_judul mengembalikan Some(judul) (ada judul), cetak judul itu.
  • None => println!("tidak ditemukan"), — kalau None (ID valid tapi tidak ada judulnya), cetak "tidak ditemukan".
  • }, — menutup match dalam (untuk cari_judul).
  • Err(error) => println!("input ditolak: {error}"), — cabang gagal: kalau parse_id mengembalikan Err(error) (input salah), cetak error itu. error adalah nilai dari enum ErrorInput yang bisa dicetak karena mengimplementasikan Display.
  • } — menutup match luar (untuk parse_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 test
Latihan 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.

Lanjut: module →

Bab 09 · Organisasi

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; — mod mendaftarkan modul bernama daftar. Rust mencari file src/daftar.rs dan memuat isinya sebagai modul. Tanpa pub, modul ini privat — hanya bisa diakses dari dalam crate ini.
  • mod tugas; — sama, mendaftarkan modul tugas dari file src/tugas.rs.
  • pub use daftar::DaftarTugas; — pub use mengekspor ulang tipe DaftarTugas dari modul daftar ke luar crate. Artinya: pengguna crate ini bisa menulis use bab09_modul::DaftarTugas; tanpa tahu bahwa sebenarnya tipe itu ada di file daftar.rs.
  • pub use tugas::Tugas; — sama, mengekspor ulang tipe Tugas.

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 fungsi baru yang 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 (seperti main.rs yang merupakan crate binary terpisah). Ini melindungi cara membuat Tugas — pengguna luar tidak bisa membuat Tugas sembarangan.
    • id: usize — parameter: ID tugas (bilangan bulat tak-negatif untuk ukuran).
    • judul: &str — parameter: pinjaman teks judul.
    • -> Self — tipe kembalian: Self adalah singkatan untuk tipe yang sedang di-impl (di sini Tugas). Mengembalikan instance baru dari tipe itu.
  • Self { id, judul: judul.to_owned(), selesai: false } — membuat instance Tugas baru.
    • Self { ... } — syntax membuat struct. Self = Tugas.
    • id — singkatan dari id: id (field id diisi nilai parameter id). Ini shorthand kalau nama field dan variabel sama.
    • judul: judul.to_owned() — field judul diisi String baru dari pinjaman teks. .to_owned() mengubah &str menjadi String yang dimiliki.
    • selesai: false — field selesai diisi false (tugas baru belum selesai).
  • pub fn judul(&self) -> &str { — membuat method judul yang publik (pub — bisa dipakai dari mana saja).
    • &self — parameter: pinjaman baca ke instance Tugas. & artinya method hanya meminjam, tidak memiliki. Ini membuat method bisa dipanggil tanpa memindahkan Tugas.
    • -> &str — tipe kembalian: pinjaman teks ke field judul. Bukan String — kita hanya meminjamkan, tidak membuat salinan.
  • &self.judul — mengembalikan pinjaman ke field judul. Perhatikan: self.judul adalah field, bukan method. Jangan tulis self.judul() di sini — itu akan memanggil method judul() 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; — use mengimpor tipe DaftarTugas dari crate library bab09_modul.
    • bab09_modul — nama crate. Perhatikan: di Cargo.toml nama package adalah bab09-modul (dengan tanda minus -), tapi dalam kode Rust menjadi bab09_modul (dengan garis bawah _). Rust mengubah - menjadi _ secara otomatis.
    • DaftarTugas — tipe yang diekspor oleh lib.rs (lewat pub use).
  • let mut daftar = DaftarTugas::baru(); — membuat variabel daftar yang boleh diubah (mut). DaftarTugas::baru() memanggil fungsi pembuat baru dari tipe DaftarTugas — ini membuat instance baru (daftar kosong).
  • let pertama = daftar.tambah("Pelajari modul"); — memanggil method tambah pada daftar.
    • daftar.tambah(...) — method ini meminjam daftar untuk mengubahnya (&mut self di definisi method). Itulah kenapa daftar harus mut.
    • "Pelajari modul" — judul tugas yang ditambahkan.
    • Hasilnya (ID tugas baru) disimpan di variabel pertama.
  • daftar.selesaikan(pertama); — memanggil method selesaikan dengan ID pertama untuk 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 test
Latihan 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().

Lanjut: trait →

Bab 10 · Abstraksi

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 bernama Ringkas yang 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. &self artinya method dipanggil pada instance (misal objek.ringkas()).
    • -> String — tipe kembalian: teks String yang dimiliki.
    • ; — mengakhiri deklarasi. Tidak ada isi method di trait — itu diisi oleh tipe yang mengimplementasikannya.
  • impl Ringkas for String { — mengimplementasikan trait Ringkas untuk tipe String. Artinya: String sekarang memenuhi kontrak Ringkas.
  • fn ringkas(&self) -> String { format!("Catatan: {self}") } — isi method untuk String.
    • format!("Catatan: {self}") — membuat String baru dengan isi variabel self (instance String yang 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 generik Wadah.
    • <T> — parameter tipe generik: T adalah "tempat bagi tipe apa saja". Saat dipakai, T diganti tipe nyata (misal i32, String, Tugas).
    • isi: T — field bernama isi yang tipenya T (tergantung tipe yang dipilih saat memakai Wadah).
  • impl<T> Wadah<T> { — mengimplementasikan method untuk Wadah<T> untuk semua tipe T (tanpa batasan).
  • pub fn baru(isi: T) -> Self { Self { isi } } — method pembuat.
    • isi: T — parameter: nilai yang akan disimpan. Tipenya T (sama dengan field).
    • -> Self — mengembalikan instance Wadah<T> baru.
    • Self { isi } — membuat struct: field isi diisi parameter isi (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 field isi.
  • } — menutup blok implementasi pertama.
  • impl<T: Ringkas> Wadah<T> { — mengimplementasikan method hanya untuk Wadah<T> yang T-nya mengimplementasikan trait Ringkas. Ini namanya bound (batasan): method ini tidak tersedia untuk Wadah<i32> (karena i32 tidak punya Ringkas).
  • pub fn ringkasan(&self) -> String { self.isi.ringkas() } — method yang hanya ada untuk Wadah berisi tipe yang bisa diringkas.
    • self.isi.ringkas() — memanggil method ringkas() (dari trait Ringkas) pada isi wadah. Ini bisa karena bound T: Ringkas menjamin T punya 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 fungsi judul_lebih_panjang.
    • <'a> — parameter lifetime: 'a adalah 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, kembalikan kiri (pinjaman ke teks kiri).
  • } else { — kalau tidak, masuk cabang kedua.
  • kanan — kembalikan kanan (pinjaman ke teks kanan).
  • } — menutup blok if. Hasil if adalah 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 test
Latihan 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".

Lanjut: testing →

Bab 11 · Percaya hasil

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 saat cargo build biasa. Ini membuat test tidak ikut dalam program final — hemat ukuran dan waktu kompilasi.
  • mod tests { — membuat modul bernama tests untuk mengelompokkan fungsi-fungsi test.
  • use super::bersihkan_judul; — use mengimpor fungsi bersihkan_judul dari modul induk (super::). super artinya "naik satu tingkat" — dari tests ke modul di atasnya (tempat fungsi bersihkan_judul didefinisikan). Unit test bisa mengakses fungsi privat karena berada dalam crate yang sama.
  • #[test] — atribut test: menandai fungsi berikutnya sebagai test yang dijalankan oleh cargo test.
  • fn judul_dirapikan() { — fungsi test bernama judul_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: kalau a == 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 mengembalikan Ok berisi teks yang sudah di-trim. .to_owned() mengubah &str menjadi String.
  • } — menutup blok isi fungsi test.
  • } — menutup blok modul tests.

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; — use mengimpor tipe DaftarTugas dari crate library bab11_uji. File di tests/ adalah crate terpisah — hanya API pub yang terlihat, persis seperti pengguna luar library.
  • #[test] — menandai fungsi berikutnya sebagai test.
  • fn membuat_dan_mencari() { — fungsi test bernama membuat_dan_mencari (menguji alur membuat lalu mencari tugas).
  • let mut daftar = DaftarTugas::baru(); — membuat instance DaftarTugas baru yang boleh diubah (mut).
  • let id = daftar.tambah("Baca modul").expect("judul valid"); — memanggil method tambah.
    • daftar.tambah("Baca modul") — menambahkan tugas dengan judul "Baca modul". Hasilnya Result<usize, ErrorTugas> (bisa berhasil atau gagal).
    • .expect("judul valid") — mengambil nilai Ok (ID tugas baru). Kalau Err, test panic dengan pesan "judul valid" — ini memberi konteks kenapa test gagal. Di test, expect boleh dipakai untuk mempersingkat (karena kalau gagal, test memang harus gagal). Di aplikasi, gunakan Result untuk kegagalan normal.
  • assert_eq!(daftar.cari(id).expect("ada").judul(), "Baca modul"); — memeriksa hasil pencarian.
    • daftar.cari(id) — mencari tugas dengan ID id. Hasilnya Option<&Tugas>.
    • .expect("ada") — mengambil nilai Some (tugas yang ditemukan). Kalau None, test panic dengan pesan "ada".
    • .judul() — memanggil method judul() 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 clippy

check 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.

Lanjut: proyek CLI →

Bab 12 · Proyek CLI

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 struct Task (satu tugas).
    • id: i64 — field ID: bilangan bulat 64-bit bertanda. Kenapa i64? Karena ID database nanti memakai BIGINT yang cocok dengan i64 — konsisten dengan API di bab 16–19.
    • title: String — field judul: teks yang dimiliki (bukan pinjaman).
    • completed: bool — field status: true (selesai) atau false (belum).
  • enum Command { Add(String), List, Done(i64) } — membuat enum Command (perintah yang bisa dipilih pengguna).
    • Add(String) — varian untuk menambah tugas, membawa judul sebagai String.
    • List — varian untuk menampilkan daftar tugas (tidak membawa data).
    • Done(i64) — varian untuk menandai selesai, membawa ID tugas sebagai i64.

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 fungsi title_valid.
    • title: &str — parameter: pinjaman teks judul.
    • -> bool — tipe kembalian: true (valid) atau false (tidak valid).
  • (1..=200).contains(&title.chars().count()) — memeriksa apakah jumlah karakter judul dalam rentang 1..=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 field id dari setiap tugas. |task| adalah closure dengan parameter task.
    • .max() — mengambil nilai terbesar dari semua ID. Hasilnya Option<i64>: Some(terbesar) kalau ada tugas, None kalau daftar kosong.
    • .unwrap_or(0) — kalau None (daftar kosong), gunakan 0 sebagai 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 batas i64, mengembalikan None (aman, tidak panic).
    • .ok_or("...") — kalau None (overflow), ubah menjadi Err("...").
    • ? — kalau Err, 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 fungsi load_tasks dengan &path: pinjaman ke lokasi file. Fungsi ini membaca file JSON dan mengembalikan Result<Vec<Task>, String>.
    • ? — kalau Err (file rusak/tidak bisa dibaca), kembalikan error itu sekarang dan hentikan alur. Kalau Ok, ambil daftar tugasnya.
    • let mut tasks — hasilnya disimpan di variabel tasks yang boleh diubah (mut).
  • let id = add_task(&mut tasks, title)?; — menambahkan tugas baru ke daftar.
    • add_task(&mut tasks, title) — memanggil fungsi add_task dengan &mut tasks: pinjaman tulis untuk mengubah daftar. title adalah judul tugas baru.
    • ? — kalau Err (validasi gagal/overflow), kembalikan error itu sekarang. Kalau Ok, ambil ID tugas baru.
  • save_tasks(&path, &tasks)?; — menyimpan daftar tugas yang sudah diubah kembali ke file.
    • save_tasks(&path, &tasks) — memanggil fungsi save_tasks dengan &path (lokasi file) dan &tasks (pinjaman baca ke daftar).
    • ? — kalau Err (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 variabel id.

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.

Lanjut: async →

Bab 13 · I/O bersamaan

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 async seduh.
    • 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 selama detik detik.
    • 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 dengan std::thread::sleep yang memblokir thread (jangan dipakai di kode async).
    • .await — menunggu Future sleep selesai, sambil memberi kesempatan tugas lain berjalan.
  • nama — baris terakhir tanpa ;: mengembalikan nama sebagai 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 bersamaan

Penjelasan per-baris/bagian:

  • seduh("kopi", 3).await; — memanggil fungsi seduh dengan argumen "kopi" dan 3 (detik).
    • .await — menunggu hasil dari fungsi async. Fungsi seduh adalah async fn yang mengembalikan Future (janji hasil di masa depan). .await menunggu Future itu selesai dan mengambil hasilnya.
    • Kenapa .await tidak memblokir? Karena saat menunggu I/O (di sini sleep), runtime Tokio memberi kesempatan tugas lain berjalan. Ini berbeda dengan std::thread::sleep yang memblokir thread.
  • seduh("teh", 2).await; — memanggil seduh lagi 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: a berisi hasil Future pertama, b berisi 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: mengubah main menjadi titik masuk runtime async. Tanpa ini, async fn main tidak bisa dijalankan karena Rust tidak punya runtime bawaan untuk async.
  • async fn main() { — fungsi main yang async (boleh memakai .await di dalamnya).
  • berurutan().await; — memanggil fungsi berurutan dan menunggu hasilnya.
  • bersamaan().await; — memanggil fungsi bersamaan dan menunggu hasilnya.
  • } — menutup blok isi main.

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 test
Latihan 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.

Lanjut: JSON dan HTTP →

Bab 14 · Bahasa API

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 struct Task.
    • Serialize — memungkinkan mengubah Task menjadi JSON (untuk dikirim sebagai respons API).
    • Deserialize — memungkinkan membaca JSON menjadi Task (untuk diproses dari request).
  • struct Task { id: i64, title: String, completed: bool } — struct Task lengkap: bentuk data yang dikirim server ke klien (respons).
  • #[derive(Debug, Deserialize)] — derive untuk struct NewTask: hanya Debug dan Deserialize.
    • Tidak ada Serialize — karena NewTask hanya untuk membaca input dari klien, tidak untuk dikirim sebagai respons. Server tidak perlu mengubahnya menjadi JSON.
  • struct NewTask { title: String } — struct NewTask: bentuk data yang diterima dari klien saat membuat tugas.
    • Hanya punya field title — klien hanya boleh mengirim judul.
    • Tidak ada id atau completed — server yang menetapkan keduanya, bukan klien. Ini mencegah klien membuat tugas dengan ID atau status sembarangan.

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"); — mengubah tugas menjadi teks JSON.
    • serde_json::to_string(&tugas) — memanggil fungsi to_string dari crate serde_json dengan &tugas: pinjaman ke tugas. Kenapa pinjaman? Agar tugas tidak dipindahkan ke fungsi — kita masih butuh tugas setelahnya.
    • .expect("serialisasi valid") — mengambil hasil Ok (teks JSON). Kalau Err, program panic dengan pesan itu. Untuk struct sederhana seperti Task, serialisasi hampir tidak mungkin gagal — tapi di aplikasi nyata, tangani dengan Result untuk input pengguna.
  • let dari_json: Task = serde_json::from_str(&json).expect("JSON valid"); — membaca teks JSON menjadi Task.
    • serde_json::from_str(&json) — memanggil fungsi from_str dengan &json: pinjaman ke teks JSON.
    • : Task — anotasi tipe: memberitahu from_str bentuk hasil yang diinginkan. Tanpa ini, compiler tidak tahu tipe tujuan.
    • .expect("JSON valid") — mengambil hasil Ok (instance Task). 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 test
Latihan 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.

Lanjut: server Axum →

Bab 15 · Server pertama

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() — membuat Router baru yang kosong (belum ada rute). Router adalah 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 handler health. get adalah 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 handler sapa.
  • .route("/echo", post(gema)); — menambahkan rute dengan method POST.
    • "/echo" — alamat URL: /echo.
    • post(gema) — method POST ditangani oleh handler gema. 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 handler sapa.
    • async fn — handler adalah fungsi async (bisa menunggu I/O tanpa memblokir).
    • Path(nama) — extractor Path: mengambil nilai placeholder {nama} dari URL dan mengisinya ke variabel nama. Extractor adalah cara Axum mengambil bagian request secara otomatis.
    • : Path<String> — tipe extractor: Path yang berisi String (nilai dari URL).
    • -> Json<Value> — tipe kembalian: Json berisi Value (nilai JSON dari crate serde_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 field pesan.
    • format!("Halo, {nama}!") — mengisi field pesan dengan teks yang berisi nilai nama dari URL.
    • Json(...) — membungkus nilai JSON sebagai respons dengan header Content-Type: application/json.
  • } — menutup blok isi handler sapa.
  • async fn gema(Json(kiriman): Json<Kiriman>) -> (StatusCode, Json<Jawaban>) { — membuat handler gema.
    • Json(kiriman) — extractor Json: membaca body request sebagai JSON dan mengubahnya menjadi struct Kiriman. Kalau body rusak/bukan JSON, Axum otomatis membalas 400 Bad Request sebelum handler dipanggil.
    • : Json<Kiriman> — tipe extractor: Json yang berisi struct Kiriman (didefinisikan di contoh lengkap).
    • -> (StatusCode, Json<Jawaban>) — tipe kembalian: tuple berisi StatusCode (kode status HTTP) dan Json<Jawaban> (respons JSON). Tuple memungkinkan mengatur status sekaligus body.
  • let jawaban = Jawaban { pesan: format!("kamu berkata: {}", kiriman.pesan) }; — membuat struct Jawaban dengan field pesan yang diisi teks dari field kiriman.pesan (yang dibaca dari body request).
  • (StatusCode::CREATED, Json(jawaban)) — mengembalikan tuple: status 201 Created dan respons JSON berisi jawaban.

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.

Lanjut: CRUD penuh →

Bab 16 · Data di memori

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 untuk Task.
    • pub struct — struct ini publik (bisa dipakai dari luar modul lib.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 } — struct Task: bentuk data yang dibalas ke klien (output).
  • #[derive(Debug, Deserialize)] — derive untuk NewTask: hanya Debug dan Deserialize. Tidak ada Serialize karena ini hanya untuk membaca input.
  • pub struct NewTask { pub title: String } — struct NewTask: bentuk data yang diterima saat POST (buat tugas baru). Hanya title — klien hanya mengirim judul.
  • #[derive(Debug, Deserialize)] — derive untuk UpdateTask: sama, hanya untuk membaca input.
  • pub struct UpdateTask { pub title: String, pub completed: bool } — struct UpdateTask: bentuk data yang diterima saat PUT (mengganti tugas).
    • Memiliki title dan completed — seluruh field yang bisa diedit wajib dikirim oleh klien. Ini karena PUT mengganti seluruh resource, bukan sebagian.
    • Kalau klien mengirim hanya title tanpa completed, serde menolak dengan 400 Bad Request karena field wajib hilang.

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 variabel judul yang 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, kembalikan Err(...) (gagal). Pesan error ini menjadi respons 400 Bad Request di handler.
  • } else if panjang > 200 { Err("title maksimal 200 karakter".to_owned()) } — kalau judul lebih dari 200 karakter, kembalikan Err(...) dengan pesan berbeda.
  • } else { Ok(judul.to_owned()) } — kalau lolos semua pemeriksaan, kembalikan Ok(judul.to_owned()): String baru dari judul yang sudah di-trim. .to_owned() mengubah &str menjadi String yang 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 struct AppState: data bersama seluruh handler (daftar tugas dan penomor ID).
  • pub tasks: Mutex<Vec<Task>>, — field tasks: Mutex yang melindungi Vec<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>, — field next_id: Mutex yang melindungi penomor ID berikutnya. Dipisah dari tasks agar jelas apa yang dilindungi.
  • } — menutup deklarasi struct.
  • impl Default for AppState { — mengimplementasikan trait Default untuk AppState: cara membuat instance dengan nilai awal.
  • fn default() -> Self { — method default yang 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 dilindungi Mutex.
    • 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 fungsi app yang mengembalikan Router (peta rute). Ini dipakai oleh main.rs (server) dan tests/api.rs (test tanpa port).
  • Router::new() — membuat router kosong.
  • .route("/tasks", get(daftar).post(buat)) — rute untuk /tasks: method GET ditangani oleh daftar, method POST oleh buat. Satu path bisa punya beberapa method.
  • .route("/tasks/{id}", get(ambil).put(ubah).delete(hapus)) — rute untuk /tasks/{id}: GET oleh ambil, PUT oleh ubah, DELETE oleh hapus.
  • .with_state(Arc::new(AppState::default())) — menyuntikkan state ke handler.
    • Arc::new(...) — Atomic Reference Counting: pembungkus yang memungkinkan banyak pemilik bersama untuk AppState. Setiap handler mendapatkan Arc (clone murah) ke state yang sama.
    • .with_state(...) — menyediakan state itu ke semua handler lewat extractor State.
  • } — 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 respons 400 Bad Request dengan pesan itu.
    • ? — kalau Err (setelah dibungkus), kembalikan respons 400 itu sekarang. Kalau Ok, 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 kunci Mutex: 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 memakai expect — bukan error normal pengguna yang perlu ditangani dengan Result.
    • let mut next_id — hasilnya adalah guard (penjaga kunci) yang memberi akses ke nilai i64 yang dilindungi. Guard ini melepaskan kunci otomatis saat keluar scope.
  • let id = *next_id; — membaca nilai angka yang dikunci. *next_id adalah dereference: mengakses nilai i64 di 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 struct Task baru dengan ID yang baru diambil, judul yang sudah divalidasi, dan status false (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: status 201 Created dan 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

MethodPathHasil
POST/tasks201 + Task baru
GET/tasks200 + 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 /tasks membuat tugas baru. Status 201 Created berarti berhasil dibuat, dan respons berisi data tugas yang baru dibuat (termasuk ID yang ditetapkan server).
  • GET /tasks → 200 + array Task — method GET ke /tasks membaca daftar semua tugas. Status 200 OK dan 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: 200 dengan 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: 200 dengan 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/1

Test 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.

Lanjut: simpan ke database →

Bab 17 · Penyimpanan permanen

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 mariadb

Jika 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 run

export 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 struct Task.
    • 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 — kolom BIGINT di MariaDB dipetakan ke i64 di Rust.
    • title: String — kolom VARCHAR dipetakan ke String.
    • completed: bool — kolom BOOLEAN (alias TINYINT(1)) dipetakan ke bool.
  • 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. Driver mysql juga dipakai untuk MariaDB karena protokolnya sama.
    • ::connect(&url) — menyambung dengan URL dari environment (mysql://user:pass@host/db). &url adalah 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 — kolom id naik 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 dengan format!.
    • 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". .bind mengirim 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 i64 mengubahnya ke tipe i64 (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 sini Task) secara otomatis via FromRow.
    • : Task — anotasi tipe tujuan.
  • .bind(id).fetch_one(&pool).await?; — mengisi ? dengan id, lalu membaca tepat satu baris. fetch_one error 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.

Lanjut: Task API final →

Bab 18 · Proyek akhir

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 terpisah

Buka 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 untuk Task, sekarang dengan tambahan sqlx::FromRow agar struct bisa diisi dari baris database. Jadi Task dipakai untuk hasil DB dan respons JSON sekaligus — satu bentuk untuk dua keperluan.
  • pub struct Task { pub id: i64, pub title: String, pub completed: bool } — struct Task lengkap. Deserialize juga memudahkan integration test membaca respons (test bisa mengubah JSON kembali menjadi Task).
  • #[derive(Debug, Deserialize)] — derive untuk NewTask: hanya untuk membaca input, tidak perlu Serialize.
  • pub struct NewTask { pub title: String } — input POST: hanya judul. Pemisahan input dari output mencegah klien menentukan ID sendiri.
  • #[derive(Debug, Deserialize)] — derive untuk UpdateTask: 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. Mengembalikan Ok(String) (judul bersih) atau Err(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 &str pesan menjadi String (sesuai tipe Err(String)).
  • else if panjang > 200 { Err("title maksimal 200 karakter".to_owned()) } — judul terlalu panjang → Err dengan pesan berbeda.
  • else { Ok(judul.to_owned()) } — lolos semua → Ok berisi judul yang sudah di-trim sebagai String.

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 untuk TaskStore.
    • Clone — memungkinkan TaskStore disalin murah. Pool di dalamnya berbagi kumpulan koneksi yang sama, jadi clone tidak membuat koneksi baru — hanya menambah referensi ke pool yang sama. Inilah kenapa TaskStore aman menjadi state Axum yang di-clone tiap request.
  • pub struct TaskStore { pool: MySqlPool } — struct yang membungkus MySqlPool. 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, None kalau tidak ada.
      • Result<..., sqlx::Error> — Err kalau database sendiri gagal (koneksi putus, SQL salah).
      Bentuk ini membedakan 404 (None = tidak ada) dari 500 (Err = DB gagal) — dua masalah berbeda.
  • sqlx::query_as("SELECT id, title, completed FROM tasks WHERE id = ?") — menyiapkan SELECT dengan lubang parameter ?.
  • .bind(id) — mengisi ? dengan id sebagai data (aman dari injection).
  • .fetch_optional(&self.pool) — menjalankan query dan mengembalikan Option: Some kalau ada baris, None kalau tidak. Berbeda dengan fetch_one yang 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 — true kalau ada yang terhapus (ID ada), false kalau 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 untuk POST /tasks.
  • State(store): State<TaskStore> — extractor State: mengambil TaskStore yang dibagi dari .with_state(...) di router. store adalah nama variabelnya.
  • Json(input): Json<NewTask> — extractor Json: membaca body request sebagai JSON dan mengubahnya menjadi struct NewTask. Body rusak/field hilang → Axum otomatis membalas 400 sebelum handler dipanggil.
  • -> Result<(StatusCode, Json<Task>), AppError> — tipe kembalian: Ok berisi tuple (status + JSON), atau Err berisi AppError (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 error String menjadi varian AppError::Validasi. Ini mengubah error validasi menjadi bentuk error aplikasi yang konsisten.
    • ? — kalau Err, kembalikan AppError::Validasi itu sekarang. IntoResponse di error.rs mengubahnya menjadi respons 400 dengan 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. Kalau Err dari sqlx::Error, ? mengubahnya otomatis menjadi AppError::Internal berkat impl From<sqlx::Error> for AppError di error.rs. Ini menangani error DB terpusat (menjadi 500).
  • Ok((StatusCode::CREATED, Json(tugas))) — mengembalikan status 201 Created dan 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 kalau DATABASE_URL belum 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 dalam TaskStore (yang membawa semua SQL).
  • store.migrasi().await?; — menjalankan migrasi: membuat tabel tasks kalau belum ada. Ini idempoten — aman dijalankan tiap start.
  • let listener = tokio::net::TcpListener::bind(&alamat).await?; — membuka port TCP (misal 127.0.0.1:3000) untuk menerima koneksi. &alamat adalah pinjaman ke alamat yang dibentuk dengan format!. .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 run

Ganti 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/1

Respons 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 test

SQL 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().

Selesai! Lihat glosarium →

Jembatan ke proyek akhir

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.

  1. Klien mengirim {"title":" Belajar "} dengan header Content-Type: application/json. Di lib.rs router memilih handler buat, karena method-nya POST dan path-nya /tasks.
  2. Json(input): Json<NewTask> di handlers.rs meminta Axum membaca body. Serde mengisi input.title; jika field title hilang atau JSON rusak, handler tidak berjalan dan Axum mengirim respons 400.
  3. 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.
  4. store.buat(&title).await? menyuruh SQLx melakukan INSERT melalui MySqlPool. .bind(title) memasukkan judul sebagai data, bukan bagian SQL. .await menunggu database; ? mengalihkan kegagalan koneksi menjadi error aplikasi (500).
  5. SQLx memperoleh ID baru dari database lalu SELECT baris tersebut. Handler mengembalikan (StatusCode::CREATED, Json(tugas)): status 201 dengan id, title, dan completed: false dalam 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.

Lihat file lengkap yang saling terhubung →

Bab 18 · File proyek lengkap

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=3000
18-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);
    }
}
Bab 19 · Lanjutan

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:

MethodPathLogin?Fungsi
POST/auth/registertidakbuat akun baru
POST/auth/logintidakmasuk, dapat token
POST/auth/logoutyakeluar, token dihapus
GET/auth/meyasiapa yang sedang login
GET/tasksyadaftar tugas milikmu
POST/tasksyabuat tugas baru
GET/tasks/{id}yabaca satu tugas milikmu
PUT/tasks/{id}yaganti tugas milikmu
DELETE/tasks/{id}yahapus tugas milikmu

Alur autentikasi dalam bahasa sehari-hari

  1. Kamu register → server simpan username + hash password (bukan password asli).
  2. Kamu login → server cek password → buat token acak → simpan di tabel sessions → kirim token ke kamu.
  3. Untuk setiap request berikutnya, kamu kirim header Authorization: Bearer <token>.
  4. Server baca token → cari di sessions → kalau valid dan belum kedaluwarsa, server tahu kamu user nomor berapa → semua query tasks difilter WHERE user_id = ?.
  5. 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 401

Struktur 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 run

Di 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 test

Test 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, atau Err kalau proses hashing gagal.
  • let salt = SaltString::generate(&mut OsRng); — membuat salt (bumbu acak).
    • SaltString::generate(&mut OsRng) — menghasilkan salt acak baru. OsRng adalah 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 menjadi AppError::Internal dengan pesan. ? mengembalikan error itu sekarang. |e| adalah closure untuk error.
  • Ok(hash.to_string()) — mengubah hasil hash menjadi String dan mengembalikannya sebagai Ok. 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?". Mengembalikan true/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. Hasilnya Result.
    • Ok(p) => p — kalau parsing berhasil, ambil hasilnya di p.
    • Err(_) => return false — kalau string hash rusak/tidak valid, langsung kembalikan false (password dianggap salah). _ mengabaikan detail error.
  • Argon2::default().verify_password(password.as_bytes(), &parsed) — memverifikasi: Argon2 hash password yang diketik dengan salt dari parsed, lalu membandingkan dengan hash tersimpan.
  • .is_ok() — mengubah hasil Result menjadi boolean: true kalau cocok, false kalau 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, — field user: data user yang sedang login (ID, username, dll.).
  • pub token: String, — field token: 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 untuk POST /auth/login.
  • State(store): State<Store> — extractor State: mengambil Store (pool database) yang dibagi.
  • Json(input): Json<LoginInput> — extractor Json: membaca body request ({"username":"...","password":"..."}) menjadi struct LoginInput.
  • -> Result<Json<LoginResponse>, AppError> — hasil: Ok berisi JSON LoginResponse (token), atau Err berisi AppError.
  • 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. Hasilnya Option<User>.
    • .await? — menunggu query; kalau DB gagal, kembalikan error DB.
    • .ok_or_else(|| AppError::Unauthorized("username atau password salah".to_owned())) — kalau None (username tidak ada), ubah menjadi Err 401. ok_or_else membuat 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. Mengembalikan true/false.
    • ! — negasi: kalau hasilnya false (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 header Authorization: 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 untuk GET /tasks (daftar tugas milikmu).
  • State(store): State<Store> — extractor State untuk pool database.
  • user: CurrentUser — extractor CurrentUser: membaca header Authorization, memvalidasi token di database, dan memberikan user yang login. Kalau token tidak ada/tidak valid/kedaluwarsa, handler tidak dipanggil — Axum otomatis membalas 401 dari extractor.
  • -> Result<Json<Vec<Task>>, AppError> — hasil: Ok berisi JSON array tugas, atau Err error aplikasi.
  • Ok(Json(store.semua_tasks(user.user.id).await?)) — mengambil dan mengembalikan tugas.
    • user.user.id — ID user yang sedang login. user adalah CurrentUser, user.user adalah User di dalamnya, .id adalah 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

KeluhanPenyebab umum
401 Unauthorized saat pakai tokenToken salah, kedaluwarsa, atau header salah format (harus Bearer <token>).
404 saat akses task yang adaTask itu milik user lain — kamu hanya bisa akses milikmu.
username sudah dipakaiUsername sudah terdaftar; pilih yang lain.
username atau password salahSalah satu keduanya salah; pesan sengaja tidak spesifik.
Access denied for userSandi 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

  1. (c) Cari user by username — kalau tidak ada, berhenti di sini.
  2. (b) Verifikasi password — kalau salah, berhenti di sini.
  3. (a) Buat token acak.
  4. (d) Simpan session di DB (token + user_id + kedaluwarsa).
  5. (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".

Lihat file lengkap aplikasi →

Bab 19 · File proyek lengkap

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(&quot;DATABASE_URL&quot;)
            .map_err(|_| &quot;DATABASE_URL belum di-set (lihat .env.example)&quot;.to_owned())?;
        let port = std::env::var(&quot;PORT&quot;)
            .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(&quot;title tidak boleh kosong&quot;.to_owned())
    } else if panjang > 200 {
        Err(&quot;title maksimal 200 karakter&quot;.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(&quot;username minimal 3 karakter&quot;.to_owned());
    }
    if jumlah > 50 {
        return Err(&quot;username maksimal 50 karakter&quot;.to_owned());
    }
    if password.chars().count() < 8 {
        return Err(&quot;password minimal 8 karakter&quot;.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!(&quot;database: {e}&quot;))
    }
}

#[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,
                &quot;tugas tidak ditemukan&quot;.to_owned(),
            ),
            Self::Internal(p) => {
                // Detail teknis hanya dicatat, tidak dibocorkan ke klien.
                eprintln!(&quot;error internal: {p}&quot;);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    &quot;terjadi kesalahan di server&quot;.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(
            &quot;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&quot;,
        )
        .execute(&self.pool)
        .await?;

        // sessions: token yang aktif. expires_at = timestamp Unix.
        sqlx::query(
            &quot;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&quot;,
        )
        .execute(&self.pool)
        .await?;

        // tasks: daftar tugas per-user.
        sqlx::query(
            &quot;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&quot;,
        )
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    // ========== USERS ==========
    pub async fn buat_user(&self, username: &str, password_hash: &str) -> Result<User, sqlx::Error> {
        sqlx::query(&quot;INSERT INTO users (username, password_hash) VALUES (?, ?)&quot;)
            .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(&quot;SELECT id, username, password_hash FROM users WHERE username = ?&quot;)
            .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(&quot;SELECT id, username, password_hash FROM users WHERE id = ?&quot;)
            .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(&quot;INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)&quot;)
            .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(&quot;SELECT id, user_id, token, expires_at FROM sessions WHERE token = ?&quot;)
            .bind(token)
            .fetch_optional(&self.pool)
            .await
    }

    pub async fn hapus_session(&self, token: &str) -> Result<bool, sqlx::Error> {
        let hasil = sqlx::query(&quot;DELETE FROM sessions WHERE token = ?&quot;)
            .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(&quot;SELECT id, user_id, title, completed FROM tasks WHERE user_id = ? ORDER BY id&quot;)
            .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(&quot;SELECT id, user_id, title, completed FROM tasks WHERE id = ? AND user_id = ?&quot;)
            .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(&quot;INSERT INTO tasks (user_id, title) VALUES (?, ?)&quot;)
            .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(&quot;UPDATE tasks SET title = ?, completed = ? WHERE id = ? AND user_id = ?&quot;)
            .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(&quot;DELETE FROM tasks WHERE id = ? AND user_id = ?&quot;)
            .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(&quot;DELETE FROM tasks&quot;).execute(&self.pool).await?;
        sqlx::query(&quot;DELETE FROM sessions&quot;).execute(&self.pool).await?;
        sqlx::query(&quot;DELETE FROM users&quot;).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!(&quot;hash password: {e}&quot;)))?;
    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!(&quot;{b:02x}&quot;)).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(&quot;authorization&quot;)
            .and_then(|h| h.to_str().ok())
            .ok_or_else(|| AppError::Unauthorized(&quot;header Authorization tidak ada&quot;.to_owned()))?;

        let token = header
            .strip_prefix(&quot;Bearer &quot;)
            .ok_or_else(|| AppError::Unauthorized(&quot;format harus &#x27;Bearer <token>&#x27;&quot;.to_owned()))?
            .trim();

        if token.is_empty() {
            return Err(AppError::Unauthorized(&quot;token kosong&quot;.to_owned()));
        }

        // 2. Cari session di database
        let session = store
            .cari_session_by_token(token)
            .await?
            .ok_or_else(|| AppError::Unauthorized(&quot;token tidak valid&quot;.to_owned()))?;

        // 3. Cek kedaluwarsa
        if session.expires_at < sekarang_unix() {
            return Err(AppError::Unauthorized(&quot;token sudah kedaluwarsa&quot;.to_owned()));
        }

        // 4. Ambil data user
        let user = store
            .cari_user_by_id(session.user_id)
            .await?
            .ok_or_else(|| AppError::Unauthorized(&quot;user tidak ditemukan&quot;.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(&quot;username sudah dipakai&quot;.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(&quot;username atau password salah&quot;.to_owned()))?;

    if !verifikasi_password(&password, &user.password_hash) {
        return Err(AppError::Unauthorized(
            &quot;username atau password salah&quot;.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(&quot;/health&quot;, get(|| async { &quot;ok&quot; }))
        // Auth publik
        .route(&quot;/auth/register&quot;, post(handlers::register))
        .route(&quot;/auth/login&quot;, post(handlers::login))
        // Auth terproteksi
        .route(&quot;/auth/logout&quot;, post(handlers::logout))
        .route(&quot;/auth/me&quot;, get(handlers::me))
        // Tasks per-user (semua butuh login)
        .route(&quot;/tasks&quot;, get(handlers::daftar).post(handlers::buat))
        .route(
            &quot;/tasks/{id}&quot;,
            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!(&quot;127.0.0.1:{}&quot;, config.port);
    let listener = tokio::net::TcpListener::bind(&alamat).await?;
    println!(&quot;Migrasi selesai. Mendengarkan di http://{alamat}&quot;);
    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(&quot;TEST_DATABASE_URL&quot;) else {
        eprintln!(&quot;TEST_DATABASE_URL belum di-set: test database dilewati&quot;);
        return None;
    };
    let pool = MySqlPool::connect(&url)
        .await
        .expect(&quot;koneksi database test berhasil&quot;);
    let store = Store::baru(pool);
    store.migrasi().await.expect(&quot;migrasi berhasil&quot;);
    store.kosongkan().await.expect(&quot;tabel test bersih&quot;);
    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(&quot;authorization&quot;, format!(&quot;Bearer {t}&quot;));
    }
    let http_body = match body {
        Some(v) => {
            builder = builder.header(&quot;content-type&quot;, &quot;application/json&quot;);
            Body::from(serde_json::to_vec(&v).expect(&quot;serialize&quot;))
        }
        None => Body::empty(),
    };
    let respons = app
        .oneshot(builder.body(http_body).expect(&quot;request valid&quot;))
        .await
        .expect(&quot;router merespons&quot;);
    let status = respons.status();
    let bytes = axum::body::to_bytes(respons.into_body(), usize::MAX)
        .await
        .expect(&quot;baca body&quot;);
    let nilai = if bytes.is_empty() {
        None
    } else {
        Some(serde_json::from_slice(&bytes).expect(&quot;respons JSON&quot;))
    };
    (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,
        &quot;POST&quot;,
        &quot;/auth/register&quot;,
        None,
        Some(json!({&quot;username&quot;: username, &quot;password&quot;: &quot;password123&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::CREATED);

    // Login
    let (status, body) = panggil(
        app,
        &quot;POST&quot;,
        &quot;/auth/login&quot;,
        None,
        Some(json!({&quot;username&quot;: username, &quot;password&quot;: &quot;password123&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::OK);
    body.expect(&quot;token login&quot;)[&quot;token&quot;]
        .as_str()
        .expect(&quot;token string&quot;)
        .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, &quot;andi&quot;).await;

    // GET /auth/me tanpa token -> 401
    let (status, _) = panggil(&mut app, &quot;GET&quot;, &quot;/auth/me&quot;, None, None).await;
    assert_eq!(status, StatusCode::UNAUTHORIZED);

    // GET /auth/me dengan token -> 200
    let (status, body) = panggil(&mut app, &quot;GET&quot;, &quot;/auth/me&quot;, Some(&token), None).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body.expect(&quot;me&quot;)[&quot;username&quot;], &quot;andi&quot;);

    // Buat task dengan token
    let (status, body) = panggil(
        &mut app,
        &quot;POST&quot;,
        &quot;/tasks&quot;,
        Some(&token),
        Some(json!({&quot;title&quot;: &quot;Belajar auth&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::CREATED);
    let task_id = body.expect(&quot;task&quot;)[&quot;id&quot;].as_i64().expect(&quot;id angka&quot;);

    // Daftar tasks milik sendiri
    let (status, body) = panggil(&mut app, &quot;GET&quot;, &quot;/tasks&quot;, Some(&token), None).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body.expect(&quot;array&quot;).as_array().expect(&quot;array&quot;).len(), 1);

    // User lain: register dan pastikan tidak bisa lihat task andi
    let token_lain = register_dan_login(&mut app, &quot;budi&quot;).await;
    let (status, _) = panggil(
        &mut app,
        &quot;GET&quot;,
        &format!(&quot;/tasks/{task_id}&quot;),
        Some(&token_lain),
        None,
    )
    .await;
    assert_eq!(status, StatusCode::NOT_FOUND); // 404 karena bukan milik budi

    // Logout
    let (status, _) = panggil(&mut app, &quot;POST&quot;, &quot;/auth/logout&quot;, Some(&token), None).await;
    assert_eq!(status, StatusCode::NO_CONTENT);

    // Setelah logout, token lama ditolak
    let (status, _) = panggil(&mut app, &quot;GET&quot;, &quot;/auth/me&quot;, 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,
        &quot;POST&quot;,
        &quot;/auth/register&quot;,
        None,
        Some(json!({&quot;username&quot;: &quot;ab&quot;, &quot;password&quot;: &quot;password123&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(body.expect(&quot;error&quot;)[&quot;error&quot;], &quot;username minimal 3 karakter&quot;);

    // Password terlalu pendek
    let (status, body) = panggil(
        &mut app,
        &quot;POST&quot;,
        &quot;/auth/register&quot;,
        None,
        Some(json!({&quot;username&quot;: &quot;andi&quot;, &quot;password&quot;: &quot;short&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(body.expect(&quot;error&quot;)[&quot;error&quot;], &quot;password minimal 8 karakter&quot;);

    // Username sudah dipakai
    let _ = register_dan_login(&mut app, &quot;andi&quot;).await;
    let (status, body) = panggil(
        &mut app,
        &quot;POST&quot;,
        &quot;/auth/register&quot;,
        None,
        Some(json!({&quot;username&quot;: &quot;andi&quot;, &quot;password&quot;: &quot;password123&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(body.expect(&quot;error&quot;)[&quot;error&quot;], &quot;username sudah dipakai&quot;);

    // Login dengan password salah
    let (status, body) = panggil(
        &mut app,
        &quot;POST&quot;,
        &quot;/auth/login&quot;,
        None,
        Some(json!({&quot;username&quot;: &quot;andi&quot;, &quot;password&quot;: &quot;salahpassword&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::UNAUTHORIZED);
    assert_eq!(
        body.expect(&quot;error&quot;)[&quot;error&quot;],
        &quot;username atau password salah&quot;
    );
}

#[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, &quot;andi&quot;).await;
    let token_budi = register_dan_login(&mut app, &quot;budi&quot;).await;

    // Andi buat task
    let (status, body) = panggil(
        &mut app,
        &quot;POST&quot;,
        &quot;/tasks&quot;,
        Some(&token_andi),
        Some(json!({&quot;title&quot;: &quot;Rahasia andi&quot;})),
    )
    .await;
    assert_eq!(status, StatusCode::CREATED);
    let task_id = body.expect(&quot;task&quot;)[&quot;id&quot;].as_i64().expect(&quot;id angka&quot;);

    // Budi tidak bisa lihat task andi
    let (status, _) = panggil(
        &mut app,
        &quot;GET&quot;,
        &format!(&quot;/tasks/{task_id}&quot;),
        Some(&token_budi),
        None,
    )
    .await;
    assert_eq!(status, StatusCode::NOT_FOUND);

    // Budi tidak bisa ubah task andi
    let (status, _) = panggil(
        &mut app,
        &quot;PUT&quot;,
        &format!(&quot;/tasks/{task_id}&quot;),
        Some(&token_budi),
        Some(json!({&quot;title&quot;: &quot;dicolong&quot;, &quot;completed&quot;: true})),
    )
    .await;
    assert_eq!(status, StatusCode::NOT_FOUND);

    // Budi tidak bisa hapus task andi
    let (status, _) = panggil(
        &mut app,
        &quot;DELETE&quot;,
        &format!(&quot;/tasks/{task_id}&quot;),
        Some(&token_budi),
        None,
    )
    .await;
    assert_eq!(status, StatusCode::NOT_FOUND);

    // Daftar task budi tetap kosong
    let (status, body) = panggil(&mut app, &quot;GET&quot;, &quot;/tasks&quot;, Some(&token_budi), None).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body.expect(&quot;array&quot;).as_array().expect(&quot;array&quot;).len(), 0);
}
Rujukan cepat

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.

Asesmen mandiri

Periksa kemajuanmu

Centang setelah mencoba sendiri. Status disimpan di browser ini melalui localStorage.

Dasar



Server



Database


Pertanyaan yang sering muncul

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.