Actix #
Actix-web is a Rust web framework that consistently tops web framework benchmarks worldwide — surpassing Go, Java, and Node.js in throughput and latency. Behind this performance is no magic: Actix-web is built on actix-rt (a tokio-based runtime) and uses worker threads per CPU core. Each worker runs one event loop, avoiding inter-thread coordination overhead. This article covers Actix-web 4 thoroughly: macro-based routing, every kind of extractor, middleware, error handling, WebSockets, and testing.
Installation #
[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"] }
A Basic Application #
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Welcome to Actix-web!")
}
#[get("/halo/{nama}")]
async fn halo(path: web::Path<String>) -> impl Responder {
let nama = path.into_inner();
HttpResponse::Ok().body(format!("Hello, {}!", 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 without a macro
.route("/ping", web::get().to(|| async { "pong" }))
})
.bind("0.0.0.0:8080")?
.run()
.await
}
Advanced Routing #
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,
}
// Single path parameter
#[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: group routes with a 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 with 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("Failed to connect to the 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 into all handlers
.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, "Not found: {}", 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,
}
// Implement ResponseError so it can be returned directly from handlers
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,
}
}
}
// Automatic conversion from sqlx::Error to ApiError
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
match e {
sqlx::Error::RowNotFound => ApiError::TidakDitemukan("Data does not exist".into()),
_ => ApiError::Database(e.to_string()),
}
}
}
// Handlers can 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 must not be 0".into()));
}
// Simulate a DB query
if id > 1000 {
return Err(ApiError::TidakDitemukan(format!("Product {} does not exist", 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;
// Custom middleware: request timing logging
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!("{} {} → {} in {:?}", metode, path, resp.status(), elapsed);
Ok(resp)
})
}
}
// Middleware setup in the 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 and File Uploads #
use actix_web::{post, web, HttpResponse};
use actix_multipart::Multipart;
use futures::StreamExt;
#[derive(serde::Deserialize)]
struct FormLogin {
username: String,
password: String,
}
// URL-encoded form
#[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": "Wrong credentials"}))
}
}
// Multipart file upload
#[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();
// Process the chunk: save to disk, upload to S3, etc.
}
println!("File: {}, size: {} bytes", nama_file, ukuran);
file_count += 1;
}
HttpResponse::Ok().json(serde_json::json!({
"jumlah_file": file_count,
"total_bytes": total_bytes
}))
}
WebSockets #
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!("Received: {}", teks);
// Echo back
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 Handlers #
#[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, "Welcome to 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);
}
}
Production Server Configuration #
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!("Running {} worker threads", jumlah_worker);
HttpServer::new(move || {
App::new()
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default()) // automatic gzip/brotli compression
.wrap(middleware::NormalizePath::trim()) // remove trailing slashes
.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) // one 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)
}
Summary #
- The
#[get],#[post], etc. macros define a route and HTTP method at once. Register them with the App via.service().- Extractors as handler parameters —
web::Path<T>,web::Query<T>,web::Json<T>,web::Form<T>,web::Data<T>. Actix-web automatically handles deserialization and returns 400 if the format doesn’t match.web::Data<T>for shared state — inject state into all handlers with.app_data().Data<T>is anArc<T>— cloneable and thread-safe.- Handlers return
Result<T, ApiError>— implementResponseErroron your error type for automatic mapping to HTTP status codes and JSON error bodies.- Scopes for API versioning —
web::scope("/api/v1")groups routes with a prefix. Useful for versioning (/api/v1/...,/api/v2/...)..workers(num_cpus::get())— defaults to one worker per CPU core. Each worker runs an independent event loop; there’s no inter-worker coordination for regular requests.middleware::Compress::default()— automatic gzip/brotli compression based on the client’sAccept-Encodingheader.actix_web::testfor testing —test::init_service,test::TestRequest,test::call_service,test::read_body_jsonmake handler testing easy and idiomatic.- WebSockets with
actix_ws— lighter thanactix-web-actors; no actor model needed, just spawn an async task that reads the stream.