Skip to main content

toolkit/api/
error_layer.rs

1//! Centralized error mapping for Axum
2//!
3//! Converts framework and gear errors into `CanonicalError`. Wire rendering
4//! (RFC 9457 `application/problem+json`), `instance`, and `trace_id` are
5//! attached by `IntoResponse for CanonicalError` plus the
6//! `canonical_error_middleware` (`crate::api::canonical_error_layer`).
7
8use axum::{extract::Request, http::HeaderMap, middleware::Next, response::Response};
9use std::any::Any;
10
11use crate::config::ConfigError;
12use toolkit_canonical_errors::CanonicalError;
13use toolkit_odata::Error as ODataError;
14
15/// Passthrough middleware kept for backwards compatibility with the api-gateway
16/// layer stack. The real work (logging `diagnostic()`, filling `trace_id` /
17/// `instance`) is now done by `canonical_error_middleware`.
18pub async fn error_mapping_middleware(request: Request, next: Next) -> Response {
19    next.run(request).await
20}
21
22/// Extract trace ID from headers or generate one
23pub fn extract_trace_id(headers: &HeaderMap) -> Option<String> {
24    // Try to get trace ID from various common headers
25    headers
26        .get("x-trace-id")
27        .or_else(|| headers.get("x-request-id"))
28        .or_else(|| headers.get("traceparent"))
29        .and_then(|v| v.to_str().ok())
30        .map(str::to_owned)
31        .or_else(|| {
32            // Try to get from current tracing span
33            tracing::Span::current()
34                .id()
35                .map(|id| id.into_u64().to_string())
36        })
37}
38
39/// Centralized downcast-based error mapping.
40///
41/// Converts known framework and gear error types into `CanonicalError`. The
42/// descriptive detail for `Internal`-category mappings flows into the
43/// canonical's `ctx.description` (recoverable via `diagnostic()`) so the
44/// `canonical_error_middleware` can log it server-side without leaking onto
45/// the wire (`detail` stays opaque per DESIGN.md §3.6).
46#[must_use]
47pub fn map_error_to_canonical(error: &dyn Any) -> CanonicalError {
48    if let Some(odata_err) = error.downcast_ref::<ODataError>() {
49        return CanonicalError::from(odata_err.clone());
50    }
51
52    if let Some(config_err) = error.downcast_ref::<ConfigError>() {
53        let detail = match config_err {
54            ConfigError::GearNotFound { gear } => {
55                format!("Gear '{gear}' configuration not found")
56            }
57            ConfigError::InvalidGearStructure { gear } => {
58                format!("Gear '{gear}' has invalid configuration structure")
59            }
60            ConfigError::MissingConfigSection { gear } => {
61                format!("Gear '{gear}' is missing required config section")
62            }
63            ConfigError::InvalidConfig { gear, .. } => {
64                format!("Gear '{gear}' has invalid configuration")
65            }
66            ConfigError::VarExpand { gear, source } => {
67                // The `source` carries the failing env-var name. It is logged
68                // locally for operators but intentionally NOT placed into the
69                // canonical's diagnostic — `diagnostic()` is exposed through
70                // `canonical_error_middleware` and we keep the env-var name
71                // out of any path that could reach a downstream consumer.
72                tracing::error!(
73                    gear =  %gear,
74                    error = %source,
75                    "Environment variable expansion failed in gear config"
76                );
77                format!("Gear '{gear}' has invalid environment-backed configuration")
78            }
79        };
80        return CanonicalError::internal(detail).create();
81    }
82
83    if let Some(anyhow_err) = error.downcast_ref::<anyhow::Error>() {
84        return CanonicalError::internal(format!("{anyhow_err:#}")).create();
85    }
86
87    CanonicalError::internal("unknown error type in error mapping layer").create()
88}
89
90/// Helper trait for converting concrete error types into `CanonicalError`.
91///
92/// Prefer `impl From<E> for CanonicalError` for new error types — this trait
93/// exists for cases where the conversion is performed through a dynamic
94/// downcast (see [`map_error_to_canonical`]).
95pub trait IntoCanonical {
96    fn into_canonical(self) -> CanonicalError;
97}
98
99impl IntoCanonical for ODataError {
100    fn into_canonical(self) -> CanonicalError {
101        CanonicalError::from(self)
102    }
103}
104
105impl IntoCanonical for ConfigError {
106    fn into_canonical(self) -> CanonicalError {
107        map_error_to_canonical(&self as &dyn Any)
108    }
109}
110
111impl IntoCanonical for anyhow::Error {
112    fn into_canonical(self) -> CanonicalError {
113        map_error_to_canonical(&self as &dyn Any)
114    }
115}
116
117#[cfg(test)]
118#[cfg_attr(coverage_nightly, coverage(off))]
119mod tests {
120    use super::*;
121    use toolkit_canonical_errors::Problem;
122
123    #[test]
124    fn odata_error_maps_to_invalid_argument() {
125        let canonical = ODataError::InvalidFilter("malformed".to_owned()).into_canonical();
126        assert_eq!(canonical.status_code(), 400);
127        assert!(canonical.gts_type().contains("invalid_argument"));
128
129        // Wire serialization shape — instance / trace_id are filled by middleware.
130        let problem = Problem::from(canonical);
131        assert_eq!(problem.status, 400);
132        assert!(problem.instance.is_none());
133    }
134
135    #[test]
136    fn config_gear_not_found_preserves_diagnostic() {
137        let canonical = ConfigError::GearNotFound {
138            gear: "test_gear".to_owned(),
139        }
140        .into_canonical();
141
142        assert_eq!(canonical.status_code(), 500);
143        assert!(canonical.gts_type().contains("internal"));
144
145        // Gear name reaches `diagnostic()` (logged by middleware) but never
146        // the wire `detail` (which stays the canonical opaque string).
147        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
148        assert!(diag.contains("test_gear"), "diagnostic was {diag:?}");
149
150        let problem = Problem::from(canonical);
151        assert!(
152            !problem.detail.contains("test_gear"),
153            "wire detail leaked gear name: {}",
154            problem.detail
155        );
156    }
157
158    #[test]
159    fn anyhow_error_preserves_diagnostic() {
160        let canonical = anyhow::anyhow!("Something went wrong").into_canonical();
161        assert_eq!(canonical.status_code(), 500);
162        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
163        assert!(diag.contains("Something went wrong"));
164    }
165
166    #[test]
167    fn config_var_expand_redacts_env_and_source_from_diagnostic() {
168        let source = toolkit_utils::var_expand::ExpandVarsError::Var {
169            name: "SECRET_API_KEY".to_owned(),
170            source: std::env::VarError::NotPresent,
171        };
172        let canonical = ConfigError::VarExpand {
173            gear: "my_mod".to_owned(),
174            source,
175        }
176        .into_canonical();
177
178        assert_eq!(canonical.status_code(), 500);
179
180        // The env-var name and the source error message MUST NOT reach either
181        // the wire or the diagnostic — only the gear name is allowed
182        // through, and only via `diagnostic()`.
183        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
184        assert!(
185            !diag.contains("SECRET_API_KEY"),
186            "diagnostic leaked env var name: {diag}"
187        );
188        assert!(
189            !diag.contains("not present"),
190            "diagnostic leaked source text: {diag}"
191        );
192        assert!(
193            diag.contains("my_mod"),
194            "diagnostic dropped gear name: {diag}"
195        );
196
197        let problem = Problem::from(canonical);
198        assert!(!problem.detail.contains("SECRET_API_KEY"));
199        assert!(!problem.detail.contains("not present"));
200        assert!(!problem.detail.contains("my_mod"));
201    }
202
203    #[test]
204    fn test_extract_trace_id_from_headers() {
205        let mut headers = HeaderMap::new();
206        headers.insert("x-trace-id", "test-trace-123".parse().unwrap());
207
208        let trace_id = extract_trace_id(&headers);
209        assert_eq!(trace_id, Some("test-trace-123".to_owned()));
210    }
211}