Skip to main content

aro_web/
fallback.rs

1//! Framework-level fallback handlers for unmatched routes.
2//!
3//! These handlers ensure that 404 and 405 responses return JSON
4//! bodies consistent with [`AroError`](crate::error::AroError) responses,
5//! rather than Axum's default plain-text responses.
6//!
7//! [`App::build`](crate::App::build) wires [`fallback_404`] and
8//! [`fallback_405`] by default. They remain public for manual
9//! [`axum::Router`] composition when not using the app builder.
10
11use axum::http::StatusCode;
12use axum::response::{IntoResponse, Response};
13use serde::Serialize;
14
15/// Canonical JSON error envelope: `{"error": "<message>", "status": <code>}`.
16#[derive(Debug, Serialize)]
17struct ErrorBody {
18    error: String,
19    status: u16,
20}
21
22/// Build a JSON error response using the canonical Aro error envelope.
23pub(crate) fn json_error_response(status: StatusCode, message: impl Into<String>) -> Response {
24    let body = ErrorBody {
25        error: message.into(),
26        status: status.as_u16(),
27    };
28    let json = serde_json::to_string(&body).unwrap_or_else(|_| {
29        format!(
30            r#"{{"error":"internal server error","status":{}}}"#,
31            status.as_u16()
32        )
33    });
34    (status, [("content-type", "application/json")], json).into_response()
35}
36
37/// Fallback handler for routes that do not match any registered route.
38///
39/// Returns a 404 JSON response: `{"error": "not found", "status": 404}`.
40///
41/// Use with [`axum::Router::fallback`]:
42/// ```no_run
43/// use aro_web::fallback::fallback_404;
44/// use axum::Router;
45/// use axum::routing::get;
46///
47/// let _app: Router = Router::new()
48///     .route("/users", get(|| async { "ok" }))
49///     .fallback(fallback_404);
50/// ```
51pub async fn fallback_404() -> Response {
52    json_error_response(StatusCode::NOT_FOUND, "not found")
53}
54
55/// Handler for method-not-allowed responses.
56///
57/// Returns a 405 JSON response: `{"error": "method not allowed", "status": 405}`.
58///
59/// Prefer [`axum::Router::method_not_allowed_fallback`] for router-wide 405s:
60/// ```no_run
61/// use aro_web::fallback::fallback_405;
62/// use axum::Router;
63/// use axum::routing::get;
64///
65/// let _app: Router = Router::new()
66///     .route("/users", get(|| async { "ok" }))
67///     .method_not_allowed_fallback(fallback_405);
68/// ```
69pub async fn fallback_405() -> Response {
70    json_error_response(StatusCode::METHOD_NOT_ALLOWED, "method not allowed")
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use http_body_util::BodyExt;
77
78    async fn response_json(resp: Response) -> serde_json::Value {
79        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
80        serde_json::from_slice(&bytes).unwrap()
81    }
82
83    #[tokio::test]
84    async fn fallback_404_returns_json() {
85        let resp = fallback_404().await;
86        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
87        assert_eq!(
88            resp.headers().get("content-type").unwrap(),
89            "application/json"
90        );
91        let json = response_json(resp).await;
92        assert_eq!(json["error"], "not found");
93        assert_eq!(json["status"], 404);
94    }
95
96    #[tokio::test]
97    async fn fallback_405_returns_json() {
98        let resp = fallback_405().await;
99        assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
100        assert_eq!(
101            resp.headers().get("content-type").unwrap(),
102            "application/json"
103        );
104        let json = response_json(resp).await;
105        assert_eq!(json["error"], "method not allowed");
106        assert_eq!(json["status"], 405);
107    }
108}