Skip to main content

modkit/api/
error_layer.rs

1//! Centralized error mapping for Axum
2//!
3//! This module provides utilities for automatically converting all framework
4//! and module errors into consistent RFC 9457 Problem+JSON responses, eliminating
5//! per-route boilerplate.
6
7use axum::{extract::Request, http::HeaderMap, middleware::Next, response::Response};
8use http::StatusCode;
9use std::any::Any;
10
11use crate::api::problem::Problem;
12use crate::config::ConfigError;
13use modkit_odata::Error as ODataError;
14
15/// Middleware function that provides centralized error mapping
16///
17/// This middleware can be applied to routes to automatically extract request context
18/// and provide it to error handlers. The actual error conversion happens in the
19/// `IntoProblem` trait implementations and `map_error_to_problem` function.
20pub async fn error_mapping_middleware(request: Request, next: Next) -> Response {
21    let _uri = request.uri().clone();
22    let _headers = request.headers().clone();
23
24    let response = next.run(request).await;
25
26    // If the response is already successful or is already a Problem response, pass it through
27    if response.status().is_success() || is_problem_response(&response) {
28        return response;
29    }
30
31    // For error responses, the actual error conversion should happen in the handlers
32    // using the IntoProblem trait or map_error_to_problem function
33    // This middleware provides the infrastructure for extracting request context
34    response
35}
36
37/// Check if a response is already a Problem+JSON response
38fn is_problem_response(response: &Response) -> bool {
39    response
40        .headers()
41        .get(axum::http::header::CONTENT_TYPE)
42        .and_then(|v| v.to_str().ok())
43        .is_some_and(|ct| ct.contains("application/problem+json"))
44}
45
46/// Extract trace ID from headers or generate one
47pub fn extract_trace_id(headers: &HeaderMap) -> Option<String> {
48    // Try to get trace ID from various common headers
49    headers
50        .get("x-trace-id")
51        .or_else(|| headers.get("x-request-id"))
52        .or_else(|| headers.get("traceparent"))
53        .and_then(|v| v.to_str().ok())
54        .map(ToString::to_string)
55        .or_else(|| {
56            // Try to get from current tracing span
57            tracing::Span::current()
58                .id()
59                .map(|id| id.into_u64().to_string())
60        })
61}
62
63/// Centralized error mapping function
64///
65/// This function provides a single place to convert all framework and module errors
66/// into consistent Problem responses with proper trace IDs and instance paths.
67pub fn map_error_to_problem(error: &dyn Any, instance: &str, trace_id: Option<String>) -> Problem {
68    // Try to downcast to known error types
69    if let Some(odata_err) = error.downcast_ref::<ODataError>() {
70        return crate::api::odata::error::odata_error_to_problem(odata_err, instance, trace_id);
71    }
72
73    if let Some(config_err) = error.downcast_ref::<ConfigError>() {
74        let mut problem = match config_err {
75            ConfigError::ModuleNotFound { module } => Problem::new(
76                StatusCode::INTERNAL_SERVER_ERROR,
77                "Configuration Error",
78                format!("Module '{module}' configuration not found"),
79            )
80            .with_code("CONFIG_MODULE_NOT_FOUND")
81            .with_type("https://errors.example.com/CONFIG_MODULE_NOT_FOUND"),
82
83            ConfigError::InvalidModuleStructure { module } => Problem::new(
84                StatusCode::INTERNAL_SERVER_ERROR,
85                "Configuration Error",
86                format!("Module '{module}' has invalid configuration structure"),
87            )
88            .with_code("CONFIG_INVALID_STRUCTURE")
89            .with_type("https://errors.example.com/CONFIG_INVALID_STRUCTURE"),
90
91            ConfigError::MissingConfigSection { module } => Problem::new(
92                StatusCode::INTERNAL_SERVER_ERROR,
93                "Configuration Error",
94                format!("Module '{module}' is missing required config section"),
95            )
96            .with_code("CONFIG_MISSING_SECTION")
97            .with_type("https://errors.example.com/CONFIG_MISSING_SECTION"),
98
99            ConfigError::InvalidConfig { module, .. } => Problem::new(
100                StatusCode::INTERNAL_SERVER_ERROR,
101                "Configuration Error",
102                format!("Module '{module}' has invalid configuration"),
103            )
104            .with_code("CONFIG_INVALID")
105            .with_type("https://errors.example.com/CONFIG_INVALID"),
106        };
107
108        problem = problem.with_instance(instance);
109        if let Some(tid) = trace_id {
110            problem = problem.with_trace_id(tid);
111        }
112        return problem;
113    }
114
115    // Handle anyhow::Error
116    if let Some(anyhow_err) = error.downcast_ref::<anyhow::Error>() {
117        let mut problem = Problem::new(
118            StatusCode::INTERNAL_SERVER_ERROR,
119            "Internal Server Error",
120            "An internal error occurred",
121        )
122        .with_code("INTERNAL_ERROR")
123        .with_type("https://errors.example.com/INTERNAL_ERROR");
124
125        problem = problem.with_instance(instance);
126        if let Some(tid) = trace_id {
127            problem = problem.with_trace_id(tid);
128        }
129
130        // Log the full error for debugging
131        tracing::error!(error = %anyhow_err, "Internal server error");
132        return problem;
133    }
134
135    // Fallback for unknown error types
136    let mut problem = Problem::new(
137        StatusCode::INTERNAL_SERVER_ERROR,
138        "Unknown Error",
139        "An unknown error occurred",
140    )
141    .with_code("UNKNOWN_ERROR")
142    .with_type("https://errors.example.com/UNKNOWN_ERROR");
143
144    problem = problem.with_instance(instance);
145    if let Some(tid) = trace_id {
146        problem = problem.with_trace_id(tid);
147    }
148
149    tracing::error!("Unknown error type in error mapping layer");
150    problem
151}
152
153/// Helper trait for converting errors to Problem responses with context
154pub trait IntoProblem {
155    fn into_problem(self, instance: &str, trace_id: Option<String>) -> Problem;
156}
157
158impl IntoProblem for ODataError {
159    fn into_problem(self, instance: &str, trace_id: Option<String>) -> Problem {
160        crate::api::odata::error::odata_error_to_problem(&self, instance, trace_id)
161    }
162}
163
164impl IntoProblem for ConfigError {
165    fn into_problem(self, instance: &str, trace_id: Option<String>) -> Problem {
166        map_error_to_problem(&self as &dyn Any, instance, trace_id)
167    }
168}
169
170impl IntoProblem for anyhow::Error {
171    fn into_problem(self, instance: &str, trace_id: Option<String>) -> Problem {
172        map_error_to_problem(&self as &dyn Any, instance, trace_id)
173    }
174}
175
176#[cfg(test)]
177#[cfg_attr(coverage_nightly, coverage(off))]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_odata_error_mapping() {
183        let error = ODataError::InvalidFilter("malformed".to_owned());
184        let problem = error.into_problem("/tests/v1/test", Some("trace123".to_owned()));
185
186        assert_eq!(problem.status, StatusCode::UNPROCESSABLE_ENTITY);
187        assert!(problem.code.contains("invalid_filter"));
188        assert_eq!(problem.instance, "/tests/v1/test");
189        assert_eq!(problem.trace_id, Some("trace123".to_owned()));
190    }
191
192    #[test]
193    fn test_config_error_mapping() {
194        let error = ConfigError::ModuleNotFound {
195            module: "test_module".to_owned(),
196        };
197        let problem = error.into_problem("/tests/v1/test", None);
198
199        assert_eq!(problem.status, StatusCode::INTERNAL_SERVER_ERROR);
200        assert_eq!(problem.code, "CONFIG_MODULE_NOT_FOUND");
201        assert_eq!(problem.instance, "/tests/v1/test");
202        assert!(problem.detail.contains("test_module"));
203    }
204
205    #[test]
206    fn test_anyhow_error_mapping() {
207        let error = anyhow::anyhow!("Something went wrong");
208        let problem = error.into_problem("/tests/v1/test", Some("trace456".to_owned()));
209
210        assert_eq!(problem.status, StatusCode::INTERNAL_SERVER_ERROR);
211        assert_eq!(problem.code, "INTERNAL_ERROR");
212        assert_eq!(problem.instance, "/tests/v1/test");
213        assert_eq!(problem.trace_id, Some("trace456".to_owned()));
214    }
215
216    #[test]
217    fn test_extract_trace_id_from_headers() {
218        let mut headers = HeaderMap::new();
219        headers.insert("x-trace-id", "test-trace-123".parse().unwrap());
220
221        let trace_id = extract_trace_id(&headers);
222        assert_eq!(trace_id, Some("test-trace-123".to_owned()));
223    }
224}