aro-web 1.0.0

HTTP/ADR layer for the Aro web framework
Documentation
//! JSON body extractor with consistent error responses.
//!
//! [`JsonBody<T>`] wraps Axum's `Json<T>` extractor and converts
//! parse failures into the standard Aro JSON error envelope format,
//! rather than Axum's default plain-text error responses.
//!
//! # Example
//!
//! ```ignore
//! use aro_web::json_body::JsonBody;
//!
//! #[aro::post("/users")]
//! async fn create_user(JsonBody(payload): JsonBody<CreateUser>) -> Json<User> {
//!     // payload is already deserialized; parse errors returned as JSON
//!     Json(User { name: payload.name })
//! }
//! ```

use std::ops::Deref;

use axum::extract::FromRequest;
use axum::extract::rejection::JsonRejection;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::de::DeserializeOwned;

use crate::fallback::json_error_response;

/// A JSON body extractor that produces Aro-consistent error responses.
///
/// On success, wraps the deserialized value of type `T`.
/// On failure, returns a JSON error response with the appropriate status code.
pub struct JsonBody<T>(pub T);

impl<T> Deref for JsonBody<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

impl<T> std::fmt::Debug for JsonBody<T>
where
    T: std::fmt::Debug,
{
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("JsonBody").field(&self.0).finish()
    }
}

impl<T> Clone for JsonBody<T>
where
    T: Clone,
{
    fn clone(&self) -> Self {
        Self(self.0.clone())
    }
}

/// Rejection type for [`JsonBody`] extraction failures.
#[derive(Debug)]
pub enum JsonBodyRejection {
    /// The Content-Type header is missing or not `application/json`.
    UnsupportedMediaType,
    /// The request body could not be parsed as valid JSON.
    InvalidJson(String),
    /// The request body exceeded the configured size limit.
    PayloadTooLarge,
}

impl IntoResponse for JsonBodyRejection {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            Self::UnsupportedMediaType => (
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
                "Content-Type must be application/json".to_string(),
            ),
            Self::InvalidJson(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
            Self::PayloadTooLarge => (
                StatusCode::PAYLOAD_TOO_LARGE,
                "request body too large".to_string(),
            ),
        };
        json_error_response(status, message)
    }
}

fn has_json_content_type(headers: &HeaderMap) -> bool {
    headers
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|v| {
            let mime = v.split(';').next().unwrap_or("").trim();
            mime.eq_ignore_ascii_case("application/json")
        })
}

impl<S, T> FromRequest<S> for JsonBody<T>
where
    S: Send + Sync,
    T: DeserializeOwned,
{
    type Rejection = JsonBodyRejection;

    async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
        // Check Content-Type before attempting parse
        if !has_json_content_type(req.headers()) {
            return Err(JsonBodyRejection::UnsupportedMediaType);
        }

        match axum::Json::<T>::from_request(req, state).await {
            Ok(axum::Json(value)) => Ok(Self(value)),
            Err(JsonRejection::BytesRejection(e)) => {
                // Preserve 413 for body limit errors
                if e.status() == StatusCode::PAYLOAD_TOO_LARGE {
                    Err(JsonBodyRejection::PayloadTooLarge)
                } else {
                    Err(JsonBodyRejection::InvalidJson(format!(
                        "failed to read body: {e}"
                    )))
                }
            }
            Err(rejection) => Err(JsonBodyRejection::InvalidJson(rejection_message(rejection))),
        }
    }
}

fn rejection_message(rejection: JsonRejection) -> String {
    match rejection {
        JsonRejection::JsonDataError(e) => format!("invalid JSON: {e}"),
        JsonRejection::JsonSyntaxError(e) => format!("malformed JSON: {e}"),
        JsonRejection::MissingJsonContentType(_) => {
            "Content-Type must be application/json".to_string()
        }
        JsonRejection::BytesRejection(e) => format!("failed to read body: {e}"),
        other => format!("request body error: {other}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::Router;
    use axum::body::Body;
    use axum::http::Request;
    use axum::routing::post;
    use http_body_util::BodyExt;
    use serde::{Deserialize, Serialize};
    use tower::ServiceExt;

    #[derive(Debug, Deserialize, Serialize, PartialEq)]
    struct Payload {
        name: String,
        age: u32,
    }

    async fn echo_handler(JsonBody(payload): JsonBody<Payload>) -> axum::Json<Payload> {
        axum::Json(payload)
    }

    fn app() -> Router {
        Router::new().route("/test", post(echo_handler))
    }

    async fn response_json(resp: Response) -> serde_json::Value {
        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
        serde_json::from_slice(&bytes).unwrap()
    }

    #[tokio::test]
    async fn valid_json_body_deserializes() {
        let body = r#"{"name":"Alice","age":30}"#;
        let req = Request::builder()
            .method("POST")
            .uri("/test")
            .header("content-type", "application/json")
            .body(Body::from(body))
            .unwrap();

        let resp = app().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
        let json = response_json(resp).await;
        assert_eq!(json["name"], "Alice");
        assert_eq!(json["age"], 30);
    }

    #[tokio::test]
    async fn invalid_json_produces_422() {
        let body = r#"{"name":"Alice","age":"not_a_number"}"#;
        let req = Request::builder()
            .method("POST")
            .uri("/test")
            .header("content-type", "application/json")
            .body(Body::from(body))
            .unwrap();

        let resp = app().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
        assert_eq!(
            resp.headers().get("content-type").unwrap(),
            "application/json"
        );
        let json = response_json(resp).await;
        assert_eq!(json["status"], 422);
        assert!(json["error"].as_str().unwrap().contains("invalid JSON"));
    }

    #[tokio::test]
    async fn malformed_json_produces_422() {
        let body = r"{not valid json";
        let req = Request::builder()
            .method("POST")
            .uri("/test")
            .header("content-type", "application/json")
            .body(Body::from(body))
            .unwrap();

        let resp = app().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
        let json = response_json(resp).await;
        assert_eq!(json["status"], 422);
        assert!(json["error"].as_str().unwrap().contains("malformed JSON"));
    }

    #[tokio::test]
    async fn missing_content_type_produces_415() {
        let body = r#"{"name":"Alice","age":30}"#;
        let req = Request::builder()
            .method("POST")
            .uri("/test")
            .body(Body::from(body))
            .unwrap();

        let resp = app().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
        assert_eq!(
            resp.headers().get("content-type").unwrap(),
            "application/json"
        );
        let json = response_json(resp).await;
        assert_eq!(json["status"], 415);
        assert!(json["error"].as_str().unwrap().contains("application/json"));
    }

    #[tokio::test]
    async fn wrong_content_type_produces_415() {
        let body = r#"{"name":"Alice","age":30}"#;
        let req = Request::builder()
            .method("POST")
            .uri("/test")
            .header("content-type", "text/plain")
            .body(Body::from(body))
            .unwrap();

        let resp = app().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
        let json = response_json(resp).await;
        assert_eq!(json["status"], 415);
    }

    #[test]
    fn deref_to_inner_type() {
        let body = JsonBody(Payload {
            name: "Bob".into(),
            age: 25,
        });
        // Deref allows direct field access
        assert_eq!(body.name, "Bob");
        assert_eq!(body.age, 25);
    }

    #[tokio::test]
    async fn content_type_with_charset_accepted() {
        let body = r#"{"name":"Alice","age":30}"#;
        let req = Request::builder()
            .method("POST")
            .uri("/test")
            .header("content-type", "application/json; charset=utf-8")
            .body(Body::from(body))
            .unwrap();

        let resp = app().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }
}