aro-web 1.0.0

HTTP/ADR layer for the Aro web framework
Documentation
//! Framework-level fallback handlers for unmatched routes.
//!
//! These handlers ensure that 404 and 405 responses return JSON
//! bodies consistent with [`AroError`](crate::error::AroError) responses,
//! rather than Axum's default plain-text responses.
//!
//! [`App::build`](crate::App::build) wires [`fallback_404`] and
//! [`fallback_405`] by default. They remain public for manual
//! [`axum::Router`] composition when not using the app builder.

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;

/// Canonical JSON error envelope: `{"error": "<message>", "status": <code>}`.
#[derive(Debug, Serialize)]
struct ErrorBody {
    error: String,
    status: u16,
}

/// Build a JSON error response using the canonical Aro error envelope.
pub(crate) fn json_error_response(status: StatusCode, message: impl Into<String>) -> Response {
    let body = ErrorBody {
        error: message.into(),
        status: status.as_u16(),
    };
    let json = serde_json::to_string(&body).unwrap_or_else(|_| {
        format!(
            r#"{{"error":"internal server error","status":{}}}"#,
            status.as_u16()
        )
    });
    (status, [("content-type", "application/json")], json).into_response()
}

/// Fallback handler for routes that do not match any registered route.
///
/// Returns a 404 JSON response: `{"error": "not found", "status": 404}`.
///
/// Use with [`axum::Router::fallback`]:
/// ```no_run
/// use aro_web::fallback::fallback_404;
/// use axum::Router;
/// use axum::routing::get;
///
/// let _app: Router = Router::new()
///     .route("/users", get(|| async { "ok" }))
///     .fallback(fallback_404);
/// ```
pub async fn fallback_404() -> Response {
    json_error_response(StatusCode::NOT_FOUND, "not found")
}

/// Handler for method-not-allowed responses.
///
/// Returns a 405 JSON response: `{"error": "method not allowed", "status": 405}`.
///
/// Prefer [`axum::Router::method_not_allowed_fallback`] for router-wide 405s:
/// ```no_run
/// use aro_web::fallback::fallback_405;
/// use axum::Router;
/// use axum::routing::get;
///
/// let _app: Router = Router::new()
///     .route("/users", get(|| async { "ok" }))
///     .method_not_allowed_fallback(fallback_405);
/// ```
pub async fn fallback_405() -> Response {
    json_error_response(StatusCode::METHOD_NOT_ALLOWED, "method not allowed")
}

#[cfg(test)]
mod tests {
    use super::*;
    use http_body_util::BodyExt;

    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 fallback_404_returns_json() {
        let resp = fallback_404().await;
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
        assert_eq!(
            resp.headers().get("content-type").unwrap(),
            "application/json"
        );
        let json = response_json(resp).await;
        assert_eq!(json["error"], "not found");
        assert_eq!(json["status"], 404);
    }

    #[tokio::test]
    async fn fallback_405_returns_json() {
        let resp = fallback_405().await;
        assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
        assert_eq!(
            resp.headers().get("content-type").unwrap(),
            "application/json"
        );
        let json = response_json(resp).await;
        assert_eq!(json["error"], "method not allowed");
        assert_eq!(json["status"], 405);
    }
}