toolkit/api/
error_layer.rs1use 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
15pub async fn error_mapping_middleware(request: Request, next: Next) -> Response {
19 next.run(request).await
20}
21
22pub fn extract_trace_id(headers: &HeaderMap) -> Option<String> {
24 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 tracing::Span::current()
34 .id()
35 .map(|id| id.into_u64().to_string())
36 })
37}
38
39#[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 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
90pub 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 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 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 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}