Actix

Actix #

Actix-web adalah framework web Rust yang secara konsisten berada di puncak benchmark web framework dunia — melampaui Go, Java, dan Node.js dalam throughput dan latensi. Di balik performa ini bukan sihir: Actix-web dibangun di atas actix-rt (tokio-based runtime) dan menggunakan worker thread per CPU core. Setiap worker menjalankan satu event loop, menghindari overhead koordinasi antar thread. Artikel ini membahas Actix-web 4 secara menyeluruh: routing dengan macro, semua jenis extractor, middleware, error handling, WebSocket, dan testing.

Instalasi #

[dependencies]
actix-web = "4"
actix-files = "0.6"
actix-ws = "0.3"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio-native-tls", "postgres"] }

Aplikasi Dasar #

use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};

#[get("/")]
async fn index() -> impl Responder {
    HttpResponse::Ok().body("Selamat datang di Actix-web!")
}

#[get("/halo/{nama}")]
async fn halo(path: web::Path<String>) -> impl Responder {
    let nama = path.into_inner();
    HttpResponse::Ok().body(format!("Halo, {}!", nama))
}

#[derive(Serialize)]
struct InfoAplikasi {
    nama: &'static str,
    versi: &'static str,
    aktif: bool,
}

#[get("/info")]
async fn info() -> web::Json<InfoAplikasi> {
    web::Json(InfoAplikasi {
        nama: "API Server",
        versi: "1.0.0",
        aktif: true,
    })
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .service(index)
            .service(halo)
            .service(info)
            // Route tanpa macro
            .route("/ping", web::get().to(|| async { "pong" }))
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

Routing Lanjutan #

use actix_web::{delete, get, post, put, web, HttpResponse, Responder, Scope};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
struct Pengguna {
    pub id: u64,
    pub nama: String,
    pub email: String,
}

#[derive(Deserialize)]
struct InputPengguna {
    pub nama: String,
    pub email: String,
}

// Path parameter tunggal
#[get("/pengguna/{id}")]
async fn ambil_pengguna(path: web::Path<u64>) -> impl Responder {
    let id = path.into_inner();
    web::Json(Pengguna { id, nama: format!("User {}", id), email: format!("user{}@test.com", id) })
}

// Multiple path parameters
#[get("/pengguna/{id}/artikel/{slug}")]
async fn artikel_pengguna(path: web::Path<(u64, String)>) -> impl Responder {
    let (user_id, slug) = path.into_inner();
    HttpResponse::Ok().json(serde_json::json!({
        "pengguna_id": user_id,
        "slug": slug
    }))
}

// Query string
#[derive(Deserialize)]
struct ParameterDaftar {
    halaman: Option<u32>,
    per_halaman: Option<u32>,
    cari: Option<String>,
    aktif: Option<bool>,
}

#[get("/pengguna")]
async fn daftar_pengguna(query: web::Query<ParameterDaftar>) -> impl Responder {
    let halaman = query.halaman.unwrap_or(1);
    let per_halaman = query.per_halaman.unwrap_or(20);

    HttpResponse::Ok().json(serde_json::json!({
        "halaman": halaman,
        "per_halaman": per_halaman,
        "cari": query.cari,
        "aktif": query.aktif,
        "data": []
    }))
}

// JSON body
#[post("/pengguna")]
async fn buat_pengguna(body: web::Json<InputPengguna>) -> impl Responder {
    let pengguna = Pengguna {
        id: rand_id(),
        nama: body.nama.clone(),
        email: body.email.clone(),
    };
    HttpResponse::Created().json(pengguna)
}

fn rand_id() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .subsec_nanos() as u64
}

#[put("/pengguna/{id}")]
async fn perbarui_pengguna(
    path: web::Path<u64>,
    body: web::Json<InputPengguna>,
) -> impl Responder {
    let id = path.into_inner();
    HttpResponse::Ok().json(Pengguna {
        id,
        nama: body.nama.clone(),
        email: body.email.clone(),
    })
}

#[delete("/pengguna/{id}")]
async fn hapus_pengguna(path: web::Path<u64>) -> impl Responder {
    let id = path.into_inner();
    HttpResponse::Ok().json(serde_json::json!({"dihapus": id}))
}

// Scope: kelompokkan route dengan prefix
fn rute_pengguna() -> Scope {
    web::scope("/api/v1")
        .service(daftar_pengguna)
        .service(buat_pengguna)
        .service(ambil_pengguna)
        .service(perbarui_pengguna)
        .service(hapus_pengguna)
        .service(artikel_pengguna)
}

Shared State dengan Data<T> #

use actix_web::{get, post, web, App, HttpServer};
use std::sync::{Arc, Mutex, atomic::{AtomicU64, Ordering}};
use serde::Serialize;

#[derive(Clone)]
struct AppState {
    nama_aplikasi: String,
    request_count: Arc<AtomicU64>,
    db_pool: sqlx::PgPool,
}

#[derive(Serialize)]
struct StatusAplikasi {
    nama: String,
    total_request: u64,
}

#[get("/status")]
async fn status(state: web::Data<AppState>) -> web::Json<StatusAplikasi> {
    let count = state.request_count.fetch_add(1, Ordering::Relaxed);
    web::Json(StatusAplikasi {
        nama: state.nama_aplikasi.clone(),
        total_request: count,
    })
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let pool = sqlx::PgPool::connect("postgres://localhost/db")
        .await
        .expect("Gagal koneksi database");

    let state = web::Data::new(AppState {
        nama_aplikasi: String::from("Rust API"),
        request_count: Arc::new(AtomicU64::new(0)),
        db_pool: pool,
    });

    HttpServer::new(move || {
        App::new()
            .app_data(state.clone())  // inject state ke semua handler
            .service(status)
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

Error Handling #

use actix_web::{
    error, get,
    http::{header::ContentType, StatusCode},
    web, App, HttpResponse,
};
use serde::Serialize;
use std::fmt;

#[derive(Debug)]
enum ApiError {
    TidakDitemukan(String),
    BadRequest(String),
    Database(String),
    Internal(String),
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ApiError::TidakDitemukan(msg) => write!(f, "Tidak ditemukan: {}", msg),
            ApiError::BadRequest(msg)    => write!(f, "Bad request: {}", msg),
            ApiError::Database(msg)      => write!(f, "Database error: {}", msg),
            ApiError::Internal(msg)      => write!(f, "Internal error: {}", msg),
        }
    }
}

#[derive(Serialize)]
struct ErrorResponse {
    error: String,
    kode: u16,
}

// Implementasikan ResponseError agar bisa dikembalikan langsung dari handler
impl error::ResponseError for ApiError {
    fn error_response(&self) -> HttpResponse {
        let status = self.status_code();
        HttpResponse::build(status)
            .content_type(ContentType::json())
            .json(ErrorResponse {
                error: self.to_string(),
                kode: status.as_u16(),
            })
    }

    fn status_code(&self) -> StatusCode {
        match self {
            ApiError::TidakDitemukan(_) => StatusCode::NOT_FOUND,
            ApiError::BadRequest(_)     => StatusCode::BAD_REQUEST,
            ApiError::Database(_)       => StatusCode::INTERNAL_SERVER_ERROR,
            ApiError::Internal(_)       => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

// Konversi otomatis dari sqlx::Error ke ApiError
impl From<sqlx::Error> for ApiError {
    fn from(e: sqlx::Error) -> Self {
        match e {
            sqlx::Error::RowNotFound => ApiError::TidakDitemukan("Data tidak ada".into()),
            _ => ApiError::Database(e.to_string()),
        }
    }
}

// Handler bisa return Result<T, ApiError>
#[get("/produk/{id}")]
async fn ambil_produk(
    path: web::Path<u64>,
    db: web::Data<sqlx::PgPool>,
) -> Result<web::Json<serde_json::Value>, ApiError> {
    let id = path.into_inner();

    if id == 0 {
        return Err(ApiError::BadRequest("ID tidak boleh 0".into()));
    }

    // Simulasi DB query
    if id > 1000 {
        return Err(ApiError::TidakDitemukan(format!("Produk {} tidak ada", id)));
    }

    Ok(web::Json(serde_json::json!({
        "id": id,
        "nama": format!("Produk {}", id),
        "harga": id * 1000
    })))
}

Middleware #

use actix_web::{
    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
    Error, HttpMessage,
};
use futures::future::{ready, LocalBoxFuture, Ready};
use std::time::Instant;
use actix_web::middleware::Logger;
use actix_cors::Cors;
use actix_web::http::header;

// Middleware kustom: logging waktu request
pub struct TimingMiddleware;

impl<S, B> Transform<S, ServiceRequest> for TimingMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type InitError = ();
    type Transform = TimingService<S>;
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ready(Ok(TimingService { service }))
    }
}

pub struct TimingService<S> {
    service: S,
}

impl<S, B> Service<ServiceRequest> for TimingService<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

    forward_ready!(service);

    fn call(&self, req: ServiceRequest) -> Self::Future {
        let mulai = Instant::now();
        let metode = req.method().to_string();
        let path = req.path().to_string();
        let fut = self.service.call(req);

        Box::pin(async move {
            let resp = fut.await?;
            let elapsed = mulai.elapsed();
            println!("{} {}{} dalam {:?}", metode, path, resp.status(), elapsed);
            Ok(resp)
        })
    }
}

// Setup middleware di App
fn buat_app() -> App<
    impl actix_web::dev::ServiceFactory<
        ServiceRequest,
        Config = (),
        Response = ServiceResponse,
        Error = Error,
        InitError = (),
    >,
> {
    let cors = Cors::default()
        .allowed_origin("https://app.contoh.com")
        .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
        .allowed_headers(vec![header::CONTENT_TYPE, header::AUTHORIZATION])
        .max_age(3600);

    App::new()
        .wrap(TimingMiddleware)
        .wrap(Logger::default())  // log format: "[IP] "METHOD /path" STATUS SIZE"
        .wrap(cors)
        .service(index)
}

#[get("/")]
async fn index() -> &'static str { "OK" }

Form Data dan File Upload #

use actix_web::{post, web, HttpResponse};
use actix_multipart::Multipart;
use futures::StreamExt;

#[derive(serde::Deserialize)]
struct FormLogin {
    username: String,
    password: String,
}

// Form URL-encoded
#[post("/login")]
async fn login(form: web::Form<FormLogin>) -> HttpResponse {
    if form.username == "admin" && form.password == "rahasia" {
        HttpResponse::Ok().json(serde_json::json!({"token": "jwt-token-123"}))
    } else {
        HttpResponse::Unauthorized().json(serde_json::json!({"error": "Kredensial salah"}))
    }
}

// File upload dengan multipart
#[post("/upload")]
async fn upload(mut payload: Multipart) -> HttpResponse {
    let mut file_count = 0;
    let mut total_bytes = 0;

    while let Some(Ok(mut field)) = payload.next().await {
        let nama_file = field.content_disposition()
            .get_filename()
            .unwrap_or("unknown")
            .to_string();

        let mut ukuran = 0;
        while let Some(Ok(chunk)) = field.next().await {
            ukuran += chunk.len();
            total_bytes += chunk.len();
            // Proses chunk: simpan ke disk, upload ke S3, dll.
        }

        println!("File: {}, ukuran: {} byte", nama_file, ukuran);
        file_count += 1;
    }

    HttpResponse::Ok().json(serde_json::json!({
        "jumlah_file": file_count,
        "total_bytes": total_bytes
    }))
}

WebSocket #

use actix_web::{get, web, HttpRequest, HttpResponse};
use actix_ws::Message;
use futures::StreamExt;

#[get("/ws")]
async fn ws_handler(
    req: HttpRequest,
    stream: web::Payload,
) -> Result<HttpResponse, actix_web::Error> {
    let (respons, mut session, mut stream) = actix_ws::handle(&req, stream)?;

    tokio::spawn(async move {
        while let Some(Ok(msg)) = stream.recv().await {
            match msg {
                Message::Text(teks) => {
                    println!("Diterima: {}", teks);
                    // Echo kembali
                    if session.text(format!("Echo: {}", teks)).await.is_err() {
                        break;
                    }
                }
                Message::Ping(data) => {
                    if session.pong(&data).await.is_err() {
                        break;
                    }
                }
                Message::Close(_) => break,
                _ => {}
            }
        }
    });

    Ok(respons)
}

Testing Handler #

#[cfg(test)]
mod tests {
    use actix_web::{test, App};
    use super::*;

    #[actix_web::test]
    async fn test_index() {
        let app = test::init_service(
            App::new().service(index)
        ).await;

        let req = test::TestRequest::get()
            .uri("/")
            .to_request();

        let resp = test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let body = test::read_body(resp).await;
        assert_eq!(body, "Selamat datang di Actix-web!");
    }

    #[actix_web::test]
    async fn test_json_response() {
        let app = test::init_service(
            App::new().service(info)
        ).await;

        let req = test::TestRequest::get()
            .uri("/info")
            .to_request();

        let resp: InfoAplikasi = test::call_and_read_body_json(&app, req).await;
        assert_eq!(resp.nama, "API Server");
        assert!(resp.aktif);
    }

    #[actix_web::test]
    async fn test_post_json() {
        let app = test::init_service(
            App::new().service(buat_pengguna)
        ).await;

        let req = test::TestRequest::post()
            .uri("/pengguna")
            .set_json(InputPengguna {
                nama: "Test User".to_string(),
                email: "[email protected]".to_string(),
            })
            .to_request();

        let resp = test::call_service(&app, req).await;
        assert_eq!(resp.status(), 201);

        let pengguna: Pengguna = test::read_body_json(resp).await;
        assert_eq!(pengguna.nama, "Test User");
    }

    #[actix_web::test]
    async fn test_error_handler() {
        let pool = sqlx::PgPool::connect("postgres://localhost/test")
            .await
            .unwrap();

        let app = test::init_service(
            App::new()
                .app_data(web::Data::new(pool))
                .service(ambil_produk)
        ).await;

        // Test 400 Bad Request (ID = 0)
        let req = test::TestRequest::get()
            .uri("/produk/0")
            .to_request();
        let resp = test::call_service(&app, req).await;
        assert_eq!(resp.status(), 400);

        // Test 404 Not Found (ID > 1000)
        let req = test::TestRequest::get()
            .uri("/produk/9999")
            .to_request();
        let resp = test::call_service(&app, req).await;
        assert_eq!(resp.status(), 404);
    }
}

Konfigurasi Server Produksi #

use actix_web::{middleware, web, App, HttpServer};
use std::time::Duration;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Logging
    env_logger::init_from_env(
        env_logger::Env::new().default_filter_or("info")
    );

    let jumlah_worker = num_cpus::get();
    println!("Menjalankan {} worker thread", jumlah_worker);

    HttpServer::new(move || {
        App::new()
            .wrap(middleware::Logger::default())
            .wrap(middleware::Compress::default())  // kompresi gzip/brotli otomatis
            .wrap(middleware::NormalizePath::trim()) // hapus trailing slash
            .service(
                web::scope("/api/v1")
                    .service(rute_pengguna())
            )
            // Static files
            .service(
                actix_files::Files::new("/static", "./static")
                    .show_files_listing()
                    .use_last_modified(true)
            )
    })
    .workers(jumlah_worker)           // satu worker per CPU core
    .keep_alive(Duration::from_secs(75))
    .client_request_timeout(Duration::from_secs(60))
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

fn rute_pengguna() -> actix_web::Scope {
    web::scope("/pengguna")
        .service(ambil_pengguna)
        .service(daftar_pengguna)
}

Ringkasan #

  • Macro #[get], #[post], dll. mendefinisikan route dan HTTP method sekaligus. Daftarkan ke App dengan .service().
  • Extractor sebagai parameter handlerweb::Path<T>, web::Query<T>, web::Json<T>, web::Form<T>, web::Data<T>. Actix-web otomatis menangani deserialisasi dan error 400 jika format tidak cocok.
  • web::Data<T> untuk shared state — inject state ke semua handler dengan .app_data(). Data<T> adalah Arc<T> — bisa di-clone dan thread-safe.
  • Handler return Result<T, ApiError> — implementasikan ResponseError pada tipe error untuk mapping otomatis ke HTTP status code dan JSON error body.
  • Scope untuk versioning APIweb::scope("/api/v1") mengelompokkan route dengan prefix. Berguna untuk versioning (/api/v1/..., /api/v2/...).
  • .workers(num_cpus::get()) — default satu worker per CPU core. Setiap worker menjalankan event loop independen; tidak ada koordinasi antar worker untuk request biasa.
  • middleware::Compress::default() — kompresi gzip/brotli otomatis berdasarkan header Accept-Encoding dari client.
  • actix_web::test untuk testing — test::init_service, test::TestRequest, test::call_service, test::read_body_json membuat testing handler menjadi mudah dan idiomatis.
  • WebSocket dengan actix_ws — lebih ringan dari actix-web-actors; tidak perlu model aktor, cukup spawn task async yang membaca stream.

← Sebelumnya: Memcached   Berikutnya: Rocket →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact