Kita telah menuntaskan 33 section Course Rust: dari ownership dan borrowing, trait dan generics, sampai async, testing, dan serde. Sekarang saatnya merangkai semuanya menjadi satu program yang benar-benar melayani pengguna lain: REST API manajemen tugas. Program ini berjalan sebagai server, menerima HTTP request dari client seperti curl, menyimpan tugas di memori, dan membalas setiap request dengan status code dan body JSON yang terdefinisi rapi.

Gaya arsitektur REST memberi setiap sumber daya sebuah alamat URL — /api/tugas untuk kumpulan tugas, /api/tugas/1 untuk satu tugas — lalu memanfaatkan method HTTP sebagai kata kerja: GET membaca, POST membuat, PUT mengganti, DELETE menghapus. Perjanjian sederhana ini membuat layanan kita dapat diakses oleh client apa pun tanpa dokumentasi rahasia: browser, skrip Bash, aplikasi mobile, semua berbicara HTTP. Yang perlu kita jaga hanyalah konsistensi bentuk respons — dan di situlah materi serde, penanganan error, serta testing Course ini masuk.

Layanan yang akan kita bangun memiliki enam endpoint:

  • GET /api/health — memeriksa server hidup,
  • GET /api/tugas — menampilkan seluruh tugas,
  • POST /api/tugas — menyimpan tugas baru dari field judul,
  • GET /api/tugas/{id} — menampilkan satu tugas,
  • PUT /api/tugas/{id} — memperbarui judul dan selesai,
  • DELETE /api/tugas/{id} — menghapus satu tugas.

Seluruh kode sumber proyek tersedia pada bagian Berkas Sumber di bawah halaman ini. Setiap bagian pengerjaan merujuk ke berkas yang sama, sehingga kondisi proyek tetap utuh di setiap tahap.

Prasyarat dan Perkakas #

Sebelum mulai, pastikan perangkat kerja kita siap dengan daftar berikut:

  • Rust stable (proyek ini diuji dengan Rust 1.99.0, edition 2021) beserta cargo yang menyertainya,
  • koneksi internet untuk mengunduh crate saat cargo build pertama,
  • curl di terminal untuk menguji setiap endpoint,
  • dua terminal — satu menjalankan server dengan cargo run, satu lagi mengirim request — karena server menempati terminal sampai dihentikan dengan Ctrl+C,
  • pemahaman materi Course Rust, khususnya Section Async (runtime tokio, #[tokio::main]), Smart Pointers (Arc), Koncurrency (RwLock, atomic), Penanganan Error (Result, ?, match), Serde (derive Serialize/Deserialize), dan Testing (#[tokio::test]).

Tidak ada database dan tidak ada layanan eksternal — seluruh kebutuhan berada di dalam satu crate. Pilih direktori kerja bebas, misalnya rest-api/, dan pastikan port 8080 tidak sedang dipakai proses lain.

Perilaku yang Diharapkan #

Sebelum menulis kode, mari kita tetapkan dulu perilaku akhir layanan ini. Contoh sesi pemakaian berikut menunjukkan hasil yang ingin kita capai. Server kita jalankan dengan cargo run, lalu setiap request dikirim memakai curl dengan flag -s agar keluaran bersih dan -w "\nHTTP %{http_code}\n" agar status code ikut tercetak.

Dua tugas pertama kita buat lewat POST:

bash
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
    -H "Content-Type: application/json" \
    -d '{"judul": "Belajar Rust dasar"}'
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/api/tugas \
    -H "Content-Type: application/json" \
    -d '{"judul": "Kirim laporan bulanan"}'

Output yang Dihasilkan:

text
{"id":1,"judul":"Belajar Rust dasar","selesai":false}
HTTP 201
{"id":2,"judul":"Kirim laporan bulanan","selesai":false}
HTTP 201

Server memberi setiap tugas id berurutan dan field selesai bernilai false. Selanjutnya kita menampilkan seluruh tugas, mengambil satu tugas, lalu memperbarui tugas pertama:

bash
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas/1
curl -s -w "\nHTTP %{http_code}\n" -X PUT http://127.0.0.1:8080/api/tugas/1 \
    -H "Content-Type: application/json" \
    -d '{"judul": "Belajar Rust sampai akhir", "selesai": true}'

Output yang Dihasilkan:

text
[{"id":1,"judul":"Belajar Rust dasar","selesai":false},{"id":2,"judul":"Kirim laporan bulanan","selesai":false}]
HTTP 200
{"id":1,"judul":"Belajar Rust dasar","selesai":false}
HTTP 200
{"id":1,"judul":"Belajar Rust sampai akhir","selesai":true}
HTTP 200

Terakhir, kita menghapus tugas kedua lalu membuktikan tugas tersebut benar-benar hilang:

bash
curl -s -w "\nHTTP %{http_code}\n" -X DELETE http://127.0.0.1:8080/api/tugas/2
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:8080/api/tugas/2

Output yang Dihasilkan:

text

HTTP 204
{"error":"Tugas dengan id 2 tidak ditemukan"}
HTTP 404

Respons 204 tidak membawa body apa pun — baris kosong sebelum HTTP 204 memang berasal dari format curl kita. Begitu tugas dihapus, permintaan berikutnya membalas 404 dengan body JSON berisi pesan galat, bukan halaman HTML atau teks acak.

Kontrak Endpoint #

Seluruh perilaku di atas kita kunci sebagai kontrak endpoint. Tabel berikut menjadi acuan saat menulis handler di setiap bagian sekaligus daftar periksa saat menguji:

MethodPathSuksesGagal
GET/api/health200 + {"status":"ok"}—
GET/api/tugas200 + array tugas—
POST/api/tugas201 + header Location + body tugas400 judul kosong atau lebih dari 140 karakter
GET/api/tugas/{id}200 + body tugas404 id tidak ada; 400 id bukan angka
PUT/api/tugas/{id}200 + body tugas404 id tidak ada; 400 judul tidak valid atau id bukan angka
DELETE/api/tugas/{id}204 tanpa body404 id tidak ada; 400 id bukan angka

Arsitektur Proyek #

Layanan ini tersusun atas lapisan kecil yang masing-masing punya satu tugas. Client mengirim request ke router, router mengarahkan request ke handler yang cocok, handler memvalidasi masukan, lalu handler membaca atau menulis penyimpanan melalui AppState:

Alur data di atas diwujudkan dalam tujuh berkas sumber. Pemetaan berkas dan perannya sebagai berikut:

BerkasPeran
src/main.rsTitik masuk: membuka TcpListener dan menjalankan server
src/lib.rsModul publik dan fungsi bangun_router()
src/handlers.rsHandler untuk seluruh endpoint
src/models.rsstruct Tugas, DTO request, dan validasi judul
src/state.rsAppState — penyimpanan in-memory bersama
src/error.rsenum ApiError dan pemetaannya ke status code
tests/integration.rsTest integration seluruh kontrak endpoint

Dua pilihan desain layak digarisbawahi sejak awal. Pertama, penyimpanan memakai Arc<RwLock<HashMap<u64, Tugas>>> sehingga banyak request dapat membaca data bersamaan tanpa saling menunggu, dan AtomicU64 membagi nomor id tanpa perlu mengunci. Kedua, handler tidak pernah membuat response galat secara manual satu per satu; seluruh galat domain mengalir lewat enum ApiError yang mengubah dirinya menjadi status code plus body JSON. Kedua teknik ini adalah penerapan langsung materi Section Smart Pointers, Koncurrency, dan Penanganan Error.

Struktur tujuh berkas tersebut tidak muncul sekaligus — struktur itu tumbuh mengikuti tahapan pengerjaan. Bagian 1 memulai dari satu main.rs berisi router dan dua handler. Bagian 2 memecahnya menjadi modul models, state, dan handlers saat penyimpanan CRUD masuk. Bagian 3 menambahkan error.rs bersama validasi judul, dan Bagian 4 memisahkan lib.rs dari main.rs agar test integration dapat mengimpor router, lalu menambahkan tests/integration.rs. Membaca urutan pertumbuhan ini sama pentingnya dengan membaca hasil akhirnya: setiap keputusan struktur punya alasan yang muncul dari kebutuhan nyata pada tahap tersebut.

Tahapan Pengerjaan #

Proyek ini dikerjakan dalam empat bagian berurutan. Setiap bagian menghasilkan server yang dapat dijalankan dan diuji dengan curl:

  1. Bagian 1 menyiapkan proyek, dependensi tokio dan axum, router, serta handler pertama — server hidup dan membalas dua endpoint.
  2. Bagian 2 membangun penyimpanan in-memory dengan Arc RwLock dan endpoint CRUD — data kini bertahan selama server berjalan.
  3. Bagian 3 menambahkan validasi input dan respons error yang konsisten — seluruh galat domain berformat JSON.
  4. Bagian 4 menguji seluruh endpoint secara integration dan membangun biner release — tujuh test otomatis plus biner siap deploy.

Mulailah dari Bagian 1 dan ikuti urutannya karena setiap bagian dibangun di atas berkas bagian sebelumnya.

Berkas Sumber

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

src/Cargo.toml

Unduh berkas
toml
[package]
name = "rest-api"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }

[profile.release]
strip = true
lto = true

src/error.rs

Unduh berkas
rust
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

pub enum ApiError {
    TidakDitemukan { id: u64 },
    Validasi(String),
    IdTidakValid { nilai: String },
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, pesan) = match self {
            ApiError::TidakDitemukan { id } => (
                StatusCode::NOT_FOUND,
                format!("Tugas dengan id {} tidak ditemukan", id),
            ),
            ApiError::Validasi(pesan) => (StatusCode::BAD_REQUEST, pesan),
            ApiError::IdTidakValid { nilai } => (
                StatusCode::BAD_REQUEST,
                format!("Id \"{}\" bukan angka yang valid", nilai),
            ),
        };

        (status, Json(json!({ "error": pesan }))).into_response()
    }
}

src/handlers.rs

Unduh berkas
rust
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

use crate::error::ApiError;
use crate::models::{validasi_judul, BuatTugas, PerbaruiTugas, Tugas};
use crate::state::AppState;

pub async fn health() -> Json<serde_json::Value> {
    Json(json!({ "status": "ok" }))
}

pub async fn daftar_tugas(State(state): State<AppState>) -> Json<Vec<Tugas>> {
    Json(state.ambil_semua())
}

pub async fn buat_tugas(
    State(state): State<AppState>,
    Json(input): Json<BuatTugas>,
) -> Result<Response, ApiError> {
    let judul = validasi_judul(&input.judul).map_err(ApiError::Validasi)?;
    let tugas = state.simpan(judul);
    let lokasi = format!("/api/tugas/{}", tugas.id);
    Ok((
        StatusCode::CREATED,
        [(header::LOCATION, lokasi)],
        Json(tugas),
    )
        .into_response())
}

pub async fn ambil_tugas(
    State(state): State<AppState>,
    Path(nilai): Path<String>,
) -> Result<Json<Tugas>, ApiError> {
    let id = parse_id(nilai)?;
    match state.cari(id) {
        Some(tugas) => Ok(Json(tugas)),
        None => Err(ApiError::TidakDitemukan { id }),
    }
}

pub async fn perbarui_tugas(
    State(state): State<AppState>,
    Path(nilai): Path<String>,
    Json(input): Json<PerbaruiTugas>,
) -> Result<Json<Tugas>, ApiError> {
    let id = parse_id(nilai)?;
    let judul = validasi_judul(&input.judul).map_err(ApiError::Validasi)?;
    let input = PerbaruiTugas {
        judul,
        selesai: input.selesai,
    };
    match state.perbarui(id, input) {
        Some(tugas) => Ok(Json(tugas)),
        None => Err(ApiError::TidakDitemukan { id }),
    }
}

pub async fn hapus_tugas(
    State(state): State<AppState>,
    Path(nilai): Path<String>,
) -> Result<StatusCode, ApiError> {
    let id = parse_id(nilai)?;
    match state.hapus(id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(ApiError::TidakDitemukan { id }),
    }
}

fn parse_id(nilai: String) -> Result<u64, ApiError> {
    nilai
        .parse::<u64>()
        .map_err(|_| ApiError::IdTidakValid { nilai })
}

src/lib.rs

Unduh berkas
rust
pub mod error;
pub mod handlers;
pub mod models;
pub mod state;

use axum::routing::get;
use axum::Router;
use state::AppState;

pub fn bangun_router() -> Router {
    Router::new()
        .route("/api/health", get(handlers::health))
        .route(
            "/api/tugas",
            get(handlers::daftar_tugas).post(handlers::buat_tugas),
        )
        .route(
            "/api/tugas/{id}",
            get(handlers::ambil_tugas)
                .put(handlers::perbarui_tugas)
                .delete(handlers::hapus_tugas),
        )
        .with_state(AppState::baru())
}

src/main.rs

Unduh berkas
rust
use rest_api::bangun_router;

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
        .await
        .expect("gagal membuka alamat 127.0.0.1:8080");
    println!("Server berjalan di http://127.0.0.1:8080");

    axum::serve(listener, bangun_router())
        .await
        .expect("server berhenti");
}

src/models.rs

Unduh berkas
rust
use serde::{Deserialize, Serialize};

pub const JUDUL_MAX: usize = 140;

#[derive(Debug, Clone, Serialize)]
pub struct Tugas {
    pub id: u64,
    pub judul: String,
    pub selesai: bool,
}

#[derive(Debug, Deserialize)]
pub struct BuatTugas {
    pub judul: String,
}

#[derive(Debug, Deserialize)]
pub struct PerbaruiTugas {
    pub judul: String,
    pub selesai: bool,
}

pub fn validasi_judul(judul: &str) -> Result<String, String> {
    let bersih = judul.trim();
    if bersih.is_empty() {
        return Err(String::from("judul tidak boleh kosong"));
    }
    if bersih.chars().count() > JUDUL_MAX {
        return Err(format!("judul tidak boleh lebih dari {} karakter", JUDUL_MAX));
    }
    Ok(String::from(bersih))
}

src/state.rs

Unduh berkas
rust
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};

use crate::models::{PerbaruiTugas, Tugas};

#[derive(Clone)]
pub struct AppState {
    daftar: Arc<RwLock<HashMap<u64, Tugas>>>,
    next_id: Arc<AtomicU64>,
}

impl AppState {
    pub fn baru() -> Self {
        Self {
            daftar: Arc::new(RwLock::new(HashMap::new())),
            next_id: Arc::new(AtomicU64::new(1)),
        }
    }

    pub fn ambil_semua(&self) -> Vec<Tugas> {
        let daftar = self.daftar.read().expect("RwLock daftar teracuni");
        let mut tugas: Vec<Tugas> = daftar.values().cloned().collect();
        tugas.sort_by_key(|t| t.id);
        tugas
    }

    pub fn simpan(&self, judul: String) -> Tugas {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let tugas = Tugas {
            id,
            judul,
            selesai: false,
        };
        self.daftar
            .write()
            .expect("RwLock daftar teracuni")
            .insert(id, tugas.clone());
        tugas
    }

    pub fn cari(&self, id: u64) -> Option<Tugas> {
        self.daftar.read().expect("RwLock daftar teracuni").get(&id).cloned()
    }

    pub fn perbarui(&self, id: u64, input: PerbaruiTugas) -> Option<Tugas> {
        let mut daftar = self.daftar.write().expect("RwLock daftar teracuni");
        match daftar.get_mut(&id) {
            None => None,
            Some(tugas) => {
                tugas.judul = input.judul;
                tugas.selesai = input.selesai;
                Some(tugas.clone())
            }
        }
    }

    pub fn hapus(&self, id: u64) -> Option<Tugas> {
        self.daftar.write().expect("RwLock daftar teracuni").remove(&id)
    }
}

tests/integration.rs

Unduh berkas
rust
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Router;
use rest_api::bangun_router;
use serde_json::{json, Value};
use tower::ServiceExt;

fn kirim(method: &str, uri: &str, body: Value) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(uri)
        .header("content-type", "application/json")
        .body(Body::from(body.to_string()))
        .expect("gagal membangun request")
}

async fn panggil(app: &Router, request: Request<Body>) -> (StatusCode, Value) {
    let response = app
        .clone()
        .oneshot(request)
        .await
        .expect("router gagal memproses request");
    let status = response.status();
    let bytes = to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("gagal membaca body");
    if bytes.is_empty() {
        return (status, Value::Null);
    }
    let body: Value = serde_json::from_slice(&bytes).expect("body bukan JSON valid");
    (status, body)
}

#[tokio::test]
async fn daftar_kosong_mengembalikan_array_kosong() {
    let app = bangun_router();
    let (status, body) = panggil(&app, kirim("GET", "/api/tugas", json!(null))).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, json!([]));
}

#[tokio::test]
async fn buat_tugas_mengembalikan_201_dan_lokasi() {
    let app = bangun_router();
    let response = app
        .oneshot(kirim(
            "POST",
            "/api/tugas",
            json!({ "judul": "Belajar Rust" }),
        ))
        .await
        .expect("router gagal memproses request");

    assert_eq!(response.status(), StatusCode::CREATED);
    assert_eq!(response.headers()["location"], "/api/tugas/1");

    let bytes = to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("gagal membaca body");
    let body: Value = serde_json::from_slice(&bytes).expect("body bukan JSON valid");
    assert_eq!(
        body,
        json!({ "id": 1, "judul": "Belajar Rust", "selesai": false })
    );
}

#[tokio::test]
async fn buat_tugas_menolak_judul_kosong() {
    let app = bangun_router();
    let (status, body) =
        panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "   " }))).await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(body, json!({ "error": "judul tidak boleh kosong" }));
}

#[tokio::test]
async fn id_tidak_valid_ditolak() {
    let app = bangun_router();
    let (status, body) = panggil(&app, kirim("GET", "/api/tugas/abc", json!(null))).await;
    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_eq!(
        body,
        json!({ "error": "Id \"abc\" bukan angka yang valid" })
    );
}

#[tokio::test]
async fn ambil_tugas_yang_tidak_ada_mengembalikan_404() {
    let app = bangun_router();
    let (status, body) = panggil(&app, kirim("GET", "/api/tugas/99", json!(null))).await;
    assert_eq!(status, StatusCode::NOT_FOUND);
    assert_eq!(
        body,
        json!({ "error": "Tugas dengan id 99 tidak ditemukan" })
    );
}

#[tokio::test]
async fn perbarui_tugas_mengubah_judul_dan_status() {
    let app = bangun_router();
    panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "Kirim laporan bulanan" }))).await;

    let (status, body) = panggil(
        &app,
        kirim(
            "PUT",
            "/api/tugas/1",
            json!({ "judul": "Kirim laporan mingguan", "selesai": true }),
        ),
    )
    .await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(
        body,
        json!({ "id": 1, "judul": "Kirim laporan mingguan", "selesai": true })
    );

    let (_, body) = panggil(&app, kirim("GET", "/api/tugas/1", json!(null))).await;
    assert_eq!(body["selesai"], json!(true));
}

#[tokio::test]
async fn hapus_tugas_mengembalikan_204_lalu_404() {
    let app = bangun_router();
    panggil(&app, kirim("POST", "/api/tugas", json!({ "judul": "Belajar axum" }))).await;

    let (status, body) = panggil(&app, kirim("DELETE", "/api/tugas/1", json!(null))).await;
    assert_eq!(status, StatusCode::NO_CONTENT);
    assert!(body.is_null());

    let (status, body) = panggil(&app, kirim("GET", "/api/tugas/1", json!(null))).await;
    assert_eq!(status, StatusCode::NOT_FOUND);
    assert_eq!(
        body,
        json!({ "error": "Tugas dengan id 1 tidak ditemukan" })
    );
}