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, 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/// Centralized downcast-based error mapping.
23///
24/// Converts known framework and gear error types into `CanonicalError`. The
25/// descriptive detail for `Internal`-category mappings flows into the
26/// canonical's `ctx.description` (recoverable via `diagnostic()`) so the
27/// `canonical_error_middleware` can log it server-side without leaking onto
28/// the wire (`detail` stays opaque per DESIGN.md §3.6).
29#[must_use]
30pub fn map_error_to_canonical(error: &dyn Any) -> CanonicalError {
31    if let Some(odata_err) = error.downcast_ref::<ODataError>() {
32        return CanonicalError::from(odata_err.clone());
33    }
34
35    if let Some(config_err) = error.downcast_ref::<ConfigError>() {
36        let detail = match config_err {
37            ConfigError::GearNotFound { gear } => {
38                format!("Gear '{gear}' configuration not found")
39            }
40            ConfigError::InvalidGearStructure { gear } => {
41                format!("Gear '{gear}' has invalid configuration structure")
42            }
43            ConfigError::MissingConfigSection { gear } => {
44                format!("Gear '{gear}' is missing required config section")
45            }
46            ConfigError::InvalidConfig { gear, .. } => {
47                format!("Gear '{gear}' has invalid configuration")
48            }
49            ConfigError::VarExpand { gear, cause } => {
50                // The `cause` carries the failing env-var name. It is logged
51                // locally for operators but intentionally NOT placed into the
52                // canonical's diagnostic — `diagnostic()` is exposed through
53                // `canonical_error_middleware` and we keep the env-var name
54                // out of any path that could reach a downstream consumer.
55                tracing::error!(
56                    gear =  %gear,
57                    error = %cause,
58                    "Environment variable expansion failed in gear config"
59                );
60                format!("Gear '{gear}' has invalid environment-backed configuration")
61            }
62        };
63        return CanonicalError::internal(detail).create();
64    }
65
66    if let Some(anyhow_err) = error.downcast_ref::<anyhow::Error>() {
67        return CanonicalError::internal(format!("{anyhow_err:#}")).create();
68    }
69
70    CanonicalError::internal("unknown error type in error mapping layer").create()
71}
72
73/// Helper trait for converting concrete error types into `CanonicalError`.
74///
75/// Prefer `impl From<E> for CanonicalError` for new error types — this trait
76/// exists for cases where the conversion is performed through a dynamic
77/// downcast (see [`map_error_to_canonical`]).
78pub trait IntoCanonical {
79    fn into_canonical(self) -> CanonicalError;
80}
81
82impl IntoCanonical for ODataError {
83    fn into_canonical(self) -> CanonicalError {
84        CanonicalError::from(self)
85    }
86}
87
88impl IntoCanonical for ConfigError {
89    fn into_canonical(self) -> CanonicalError {
90        map_error_to_canonical(&self as &dyn Any)
91    }
92}
93
94impl IntoCanonical for anyhow::Error {
95    fn into_canonical(self) -> CanonicalError {
96        map_error_to_canonical(&self as &dyn Any)
97    }
98}
99
100#[cfg(test)]
101#[cfg_attr(coverage_nightly, coverage(off))]
102mod tests {
103    use super::*;
104    use toolkit_canonical_errors::Problem;
105
106    #[test]
107    fn odata_error_maps_to_invalid_argument() {
108        let canonical = ODataError::InvalidFilter("malformed".to_owned()).into_canonical();
109        assert_eq!(canonical.status_code(), 400);
110        assert!(canonical.gts_type().contains("invalid_argument"));
111
112        // Wire serialization shape — instance / trace_id are filled by middleware.
113        let problem = Problem::from(canonical);
114        assert_eq!(problem.status, Some(400));
115        assert!(problem.instance.is_none());
116    }
117
118    #[test]
119    fn config_gear_not_found_preserves_diagnostic() {
120        let canonical = ConfigError::GearNotFound {
121            gear: "test_gear".to_owned(),
122        }
123        .into_canonical();
124
125        assert_eq!(canonical.status_code(), 500);
126        assert!(canonical.gts_type().contains("internal"));
127
128        // Gear name reaches `diagnostic()` (logged by middleware) but never
129        // the wire `detail` (which stays the canonical opaque string).
130        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
131        assert!(diag.contains("test_gear"), "diagnostic was {diag:?}");
132
133        let problem = Problem::from(canonical);
134        assert!(
135            !problem.detail.contains("test_gear"),
136            "wire detail leaked gear name: {}",
137            problem.detail
138        );
139    }
140
141    #[test]
142    fn anyhow_error_preserves_diagnostic() {
143        let canonical = anyhow::anyhow!("Something went wrong").into_canonical();
144        assert_eq!(canonical.status_code(), 500);
145        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
146        assert!(diag.contains("Something went wrong"));
147    }
148
149    #[test]
150    fn config_var_expand_redacts_env_and_source_from_diagnostic() {
151        let source = toolkit_utils::var_expand::ExpandVarsError::Var {
152            name: "SECRET_API_KEY".to_owned(),
153            source: std::env::VarError::NotPresent,
154        };
155        let canonical = ConfigError::VarExpand {
156            gear: "my_mod".to_owned(),
157            cause: source,
158        }
159        .into_canonical();
160
161        assert_eq!(canonical.status_code(), 500);
162
163        // The env-var name and the source error message MUST NOT reach either
164        // the wire or the diagnostic — only the gear name is allowed
165        // through, and only via `diagnostic()`.
166        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
167        assert!(
168            !diag.contains("SECRET_API_KEY"),
169            "diagnostic leaked env var name: {diag}"
170        );
171        assert!(
172            !diag.contains("not present"),
173            "diagnostic leaked source text: {diag}"
174        );
175        assert!(
176            diag.contains("my_mod"),
177            "diagnostic dropped gear name: {diag}"
178        );
179
180        let problem = Problem::from(canonical);
181        assert!(!problem.detail.contains("SECRET_API_KEY"));
182        assert!(!problem.detail.contains("not present"));
183        assert!(!problem.detail.contains("my_mod"));
184    }
185}