Secara default, extractor web::Json<T> pada Actix Web memotong request dengan payload tidak valid dan mengembalikan respons HTTP 400 Bad Request. Namun, respons default tersebut memiliki dua masalah struktural: header Content-Type bernilai text/plain; charset=utf-8 dan body pesan mengekspos representasi string mentah dari error deserialisasi Serde. Format ini melanggar kontrak API berbasis JSON dan berisiko membocorkan struktur internal struct Rust ke publik.

Solusi yang tepat adalah mengonversi seluruh kegagalan extractor payload menjadi format RFC 7807 (Problem Details for HTTP APIs) menggunakan konfigurasi custom pada web::JsonConfig.

Kelemahan Default web::Json Extractor

Saat request JSON gagal diparsing (misalnya kesalahan tipe data atau field wajib tidak ada), Actix Web menghasilkan respon teks polos seperti berikut:

HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 74

Json deserialize error: invalid type: string "abc", expected u32 at line 1

Perilaku ini menimbulkan sejumlah konsekuensi buruk:

  • Pelanggaran Parsing Client: Client aplikasi frontend atau mobile yang mengasumsikan seluruh respons API berformat JSON akan mengalami runtime error saat mencoba melakukan response.json().
  • Information Leakage: Pesan Serde membeberkan nama struct, tipe data primitif, dan detail implementasi model domain backend.
  • Inkonsistensi Error Contract: Tidak ada skema seragam untuk pemetaan kode error, dokumentasi URL, atau konteks endpoint yang gagal.

Struktur Data RFC 7807 di Rust

RFC 7807 mendefinisikan skema JSON standar untuk membawa informasi error machine-readable dengan media type application/problem+json. Implementasi struct dalam Rust memerlukan derive serde::Serialize dan serde::Deserialize (berguna untuk pengujian).

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProblemDetails {
    pub r#type: String,
    pub title: String,
    pub status: u16,
    pub detail: String,
    pub instance: String,
}

impl ProblemDetails {
    pub fn new(
        status: actix_web::http::StatusCode,
        title: &str,
        detail: impl Into<String>,
        instance: &str,
    ) -> Self {
        Self {
            r#type: format!("https://httpstatuses.com/{}", status.as_u16()),
            title: title.to_string(),
            status: status.as_u16(),
            detail: detail.into(),
            instance: instance.to_string(),
        }
    }
}

Implementasi Custom JsonConfig::error_handler

Actix Web menyediakan enum actix_web::error::JsonPayloadError yang menangani semua skenario kegagalan parsing JSON. Kita memetakan masing-masing varian enum tersebut ke HTTP Status Code yang relevan sebelum membungkusnya ke dalam struct ProblemDetails.

use actix_web::error::{InternalError, JsonPayloadError};
use actix_web::http::StatusCode;
use actix_web::web::JsonConfig;
use actix_web::{HttpRequest, HttpResponse};

pub fn custom_json_config() -> JsonConfig {
    JsonConfig::default().error_handler(|err: JsonPayloadError, req: &HttpRequest| {
        let (status, title, detail) = match &err {
            JsonPayloadError::ContentType => (
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
                "Unsupported Media Type",
                "Header Content-Type harus berupa application/json".to_string(),
            ),
            JsonPayloadError::OverflowKnownLength { limit, .. }
            | JsonPayloadError::Overflow { limit, .. } => (
                StatusCode::PAYLOAD_TOO_LARGE,
                "Payload Too Large",
                format!("Ukuran payload melebihi batas maksimum {} bytes", limit),
            ),
            JsonPayloadError::Deserialize(serde_err) => (
                StatusCode::BAD_REQUEST,
                "Invalid JSON Payload",
                // ponytail: format error langsung; upgrade ke custom parser jika butuh path field spesifik.
                serde_err.to_string(),
            ),
            _ => (
                StatusCode::BAD_REQUEST,
                "Bad Request",
                "Payload JSON tidak valid".to_string(),
            ),
        };

        let problem = ProblemDetails::new(status, title, detail, req.uri().path());

        let response = HttpResponse::build(status)
            .content_type("application/problem+json")
            .json(problem);

        InternalError::from_response(err, response).into()
    })
}

Mendaftarkan JsonConfig pada App State

Konfigurasi didaftarkan via method .app_data() saat inisialisasi App::new(). Registrasi ini harus ditempatkan pada root atau scope sebelum route handler dideklarasikan.

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

#[derive(Deserialize)]
pub struct CreateUserDto {
    pub username: String,
    pub age: u32,
}

async fn create_user(body: web::Json<CreateUserDto>) -> impl Responder {
    HttpResponse::Created().json(&body.into_inner())
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .app_data(custom_json_config())
            .route("/users", web::post().to(create_user))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Unit Testing Kontrak RFC 7807

Gunakan module actix_web::test untuk memvalidasi bahwa payload yang tidak valid memicu status 400 Bad Request, header Content-Type: application/problem+json, dan deserialisasi struct ProblemDetails yang cocok.

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

    #[actix_web::test]
    async fn test_invalid_payload_returns_rfc7807() {
        let app = test::init_service(
            App::new()
                .app_data(custom_json_config())
                .route("/users", web::post().to(create_user)),
        )
        .await;

        // Kirim tipe data string pada field age yang seharusnya integer
        let req = test::TestRequest::post()
            .uri("/users")
            .insert_header(("content-type", "application/json"))
            .set_payload(r#"{"username": "john_doe", "age": "invalid_age"}"#)
            .to_request();

        let resp = test::call_service(&app, req).await;

        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            resp.headers().get("content-type").unwrap(),
            "application/problem+json"
        );

        let problem: ProblemDetails = test::read_body_json(resp).await;
        assert_eq!(problem.status, 400);
        assert_eq!(problem.title, "Invalid JSON Payload");
        assert_eq!(problem.instance, "/users");
    }
}

Pertimbangan Praktis

  • Pembersihan Pesan Deserialisasi: Pesan serde_err.to_string() dapat diparsing lebih lanjut menggunakan regex jika ingin menyembunyikan referensi baris kode dan hanya mengekspos nama field yang bermasalah.
  • Extractor Lain: QueryConfig dan PathConfig pada Actix Web juga memiliki hook error_handler serupa. Terapkan pola yang sama ke kedua extractor tersebut untuk memastikan seluruh input validation menghasilkan format RFC 7807 yang seragam.