1use axum::{
13 body::{Body, to_bytes},
14 extract::Request,
15 http::{HeaderMap, HeaderValue, header},
16 middleware::Next,
17 response::Response,
18};
19use toolkit_canonical_errors::{CanonicalError, Problem};
20
21const PROBLEM_JSON: &str = "application/problem+json";
22
23pub async fn canonical_error_middleware(request: Request, next: Next) -> Response {
29 let uri_path = request.uri().path().to_owned();
30 let request_headers = request.headers().clone();
31
32 let response = next.run(request).await;
33
34 if !is_problem_response(&response) {
35 return response;
36 }
37
38 let (parts, body) = response.into_parts();
39
40 let canonical_err = parts.extensions.get::<CanonicalError>().cloned();
45
46 let bytes = match to_bytes(body, usize::MAX).await {
47 Ok(b) => b,
48 Err(e) => {
49 tracing::error!(error = %e, "canonical error middleware: failed to read response body");
50 return Response::from_parts(parts, Body::empty());
51 }
52 };
53
54 let mut problem: Problem = match serde_json::from_slice(&bytes) {
55 Ok(p) => p,
56 Err(e) => {
57 tracing::error!(error = %e, "canonical error middleware: failed to deserialize problem+json body");
58 return Response::from_parts(parts, Body::from(bytes));
59 }
60 };
61
62 if problem.instance.is_none() {
63 problem.instance = Some(uri_path);
64 }
65 if problem.trace_id.is_none() {
66 problem.trace_id = extract_trace_id(&request_headers);
67 }
68
69 log_problem(&problem, canonical_err.as_ref());
70
71 let new_bytes = match serde_json::to_vec(&problem) {
72 Ok(b) => b,
73 Err(e) => {
74 tracing::error!(
75 error = %e,
76 "canonical error middleware: failed to re-serialize problem+json body"
77 );
78 return Response::from_parts(parts, Body::from(bytes));
79 }
80 };
81
82 let mut response = Response::from_parts(parts, Body::from(new_bytes.clone()));
83 response
84 .headers_mut()
85 .insert(header::CONTENT_LENGTH, HeaderValue::from(new_bytes.len()));
86 response
87}
88
89fn is_problem_response(response: &Response) -> bool {
90 response
91 .headers()
92 .get(header::CONTENT_TYPE)
93 .and_then(|v| v.to_str().ok())
94 .is_some_and(|ct| ct.starts_with(PROBLEM_JSON))
95}
96
97fn extract_trace_id(headers: &HeaderMap) -> Option<String> {
106 if let Some(tp) = headers.get("traceparent").and_then(|v| v.to_str().ok())
107 && let Some(trace_id) = parse_w3c_trace_id(tp)
108 {
109 return Some(trace_id);
110 }
111 for name in ["x-trace-id", "x-request-id"] {
112 if let Some(v) = headers.get(name).and_then(|v| v.to_str().ok()) {
113 return Some(v.to_owned());
114 }
115 }
116 tracing::Span::current()
117 .id()
118 .map(|id| id.into_u64().to_string())
119}
120
121fn parse_w3c_trace_id(traceparent: &str) -> Option<String> {
125 let parts: Vec<&str> = traceparent.split('-').collect();
126 if parts.len() >= 4 && parts[0] == "00" {
127 Some(parts[1].to_owned())
128 } else {
129 None
130 }
131}
132
133fn log_problem(problem: &Problem, canonical: Option<&CanonicalError>) {
134 let status = problem.status;
135 let problem_type = problem.problem_type.as_str();
136 let instance = problem.instance.as_deref().unwrap_or("");
137 let trace_id = problem.trace_id.as_deref().unwrap_or("");
138 let description = canonical.and_then(CanonicalError::diagnostic).unwrap_or("");
142
143 if (400..500).contains(&status) {
144 tracing::warn!(
145 status,
146 problem_type,
147 instance,
148 trace_id,
149 "canonical error response (client)"
150 );
151 } else if (500..600).contains(&status) {
152 tracing::error!(
153 status,
154 problem_type,
155 instance,
156 trace_id,
157 description,
158 "canonical error response (server)"
159 );
160 }
161}
162
163#[cfg(test)]
164#[cfg_attr(coverage_nightly, coverage(off))]
165mod tests {
166 use super::*;
167 use axum::{
168 Router,
169 body::Body,
170 http::{Request, StatusCode},
171 middleware::from_fn,
172 routing::get,
173 };
174 use serde_json::Value;
175 use toolkit_canonical_errors::CanonicalError;
176 use tower::ServiceExt;
177
178 fn problem_response(problem: &Problem, status: StatusCode) -> Response {
179 let body = serde_json::to_vec(problem).expect("serialize problem");
180 Response::builder()
181 .status(status)
182 .header(header::CONTENT_TYPE, PROBLEM_JSON)
183 .body(Body::from(body))
184 .expect("build response")
185 }
186
187 fn build_app(responder: impl Fn() -> Response + Clone + Send + Sync + 'static) -> Router {
188 Router::new()
189 .route(
190 "/api/v1/widgets/42",
191 get(move || {
192 let responder = responder.clone();
193 async move { responder() }
194 }),
195 )
196 .layer(from_fn(canonical_error_middleware))
197 }
198
199 async fn body_to_problem(response: Response) -> Problem {
200 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
201 .await
202 .expect("read body");
203 serde_json::from_slice(&bytes).expect("parse problem+json")
204 }
205
206 #[tokio::test]
207 async fn fills_instance_and_trace_id_from_headers() {
208 let problem: Problem = CanonicalError::internal("boom").create().into();
209 let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
210
211 let req = Request::builder()
212 .uri("/api/v1/widgets/42")
213 .header(
214 "traceparent",
215 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
216 )
217 .body(Body::empty())
218 .unwrap();
219 let res = app.oneshot(req).await.unwrap();
220 assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
221 let problem = body_to_problem(res).await;
222
223 assert_eq!(problem.instance.as_deref(), Some("/api/v1/widgets/42"));
224 assert_eq!(
228 problem.trace_id.as_deref(),
229 Some("4bf92f3577b34da6a3ce929d0e0e4736")
230 );
231 }
232
233 #[tokio::test]
234 async fn does_not_overwrite_existing_instance() {
235 let preset: Problem =
236 Problem::from(CanonicalError::internal("boom").create()).with_instance("/handler-set");
237 let app = build_app(move || problem_response(&preset, StatusCode::INTERNAL_SERVER_ERROR));
238
239 let req = Request::builder()
240 .uri("/api/v1/widgets/42")
241 .body(Body::empty())
242 .unwrap();
243 let res = app.oneshot(req).await.unwrap();
244 let problem = body_to_problem(res).await;
245
246 assert_eq!(problem.instance.as_deref(), Some("/handler-set"));
247 }
248
249 #[tokio::test]
250 async fn does_not_overwrite_existing_trace_id() {
251 let preset: Problem =
252 Problem::from(CanonicalError::internal("boom").create()).with_trace_id("handler-trace");
253 let app = build_app(move || problem_response(&preset, StatusCode::INTERNAL_SERVER_ERROR));
254
255 let req = Request::builder()
256 .uri("/api/v1/widgets/42")
257 .header("traceparent", "should-be-ignored")
258 .body(Body::empty())
259 .unwrap();
260 let res = app.oneshot(req).await.unwrap();
261 let problem = body_to_problem(res).await;
262
263 assert_eq!(problem.trace_id.as_deref(), Some("handler-trace"));
264 }
265
266 #[tokio::test]
267 async fn passes_through_non_problem_responses_verbatim() {
268 let payload = b"{\"hello\":\"world\"}";
269 let app = Router::new()
270 .route(
271 "/plain",
272 get(|| async {
273 Response::builder()
274 .status(StatusCode::OK)
275 .header(header::CONTENT_TYPE, "application/json")
276 .body(Body::from(&b"{\"hello\":\"world\"}"[..]))
277 .unwrap()
278 }),
279 )
280 .layer(from_fn(canonical_error_middleware));
281
282 let req = Request::builder()
283 .uri("/plain")
284 .body(Body::empty())
285 .unwrap();
286 let res = app.oneshot(req).await.unwrap();
287 assert_eq!(res.status(), StatusCode::OK);
288 assert_eq!(
289 res.headers()
290 .get(header::CONTENT_TYPE)
291 .and_then(|v| v.to_str().ok()),
292 Some("application/json")
293 );
294 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
295 .await
296 .unwrap();
297 assert_eq!(bytes.as_ref(), payload);
298 }
299
300 #[tokio::test]
301 #[tracing_test::traced_test]
302 async fn malformed_problem_passes_through_with_error_log() {
303 let raw = b"{not-json}";
304 let app = Router::new()
305 .route(
306 "/api/v1/widgets/42",
307 get(|| async {
308 Response::builder()
309 .status(StatusCode::INTERNAL_SERVER_ERROR)
310 .header(header::CONTENT_TYPE, PROBLEM_JSON)
311 .body(Body::from(&b"{not-json}"[..]))
312 .unwrap()
313 }),
314 )
315 .layer(from_fn(canonical_error_middleware));
316
317 let req = Request::builder()
318 .uri("/api/v1/widgets/42")
319 .body(Body::empty())
320 .unwrap();
321 let res = app.oneshot(req).await.unwrap();
322 assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
323 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
324 .await
325 .unwrap();
326 assert_eq!(bytes.as_ref(), raw);
327 assert!(logs_contain(
328 "canonical error middleware: failed to deserialize problem+json body"
329 ));
330 }
331
332 #[tokio::test]
333 #[tracing_test::traced_test]
334 async fn logs_warn_for_4xx_and_error_for_5xx() {
335 let problem_4xx: Problem = CanonicalError::unauthenticated()
337 .with_reason("MISSING_TOKEN")
338 .create()
339 .into();
340 let app_4xx = build_app(move || problem_response(&problem_4xx, StatusCode::UNAUTHORIZED));
341 let req = Request::builder()
342 .uri("/api/v1/widgets/42")
343 .body(Body::empty())
344 .unwrap();
345 let _ = app_4xx.oneshot(req).await.unwrap();
346 assert!(logs_contain("canonical error response (client)"));
347
348 let problem_5xx: Problem = CanonicalError::internal("boom").create().into();
350 let app_5xx =
351 build_app(move || problem_response(&problem_5xx, StatusCode::INTERNAL_SERVER_ERROR));
352 let req = Request::builder()
353 .uri("/api/v1/widgets/42")
354 .body(Body::empty())
355 .unwrap();
356 let _ = app_5xx.oneshot(req).await.unwrap();
357 assert!(logs_contain("canonical error response (server)"));
358 }
359
360 #[tokio::test]
361 async fn extract_trace_id_prefers_traceparent_over_other_headers() {
362 let problem: Problem = CanonicalError::internal("boom").create().into();
363 let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
364
365 let req = Request::builder()
366 .uri("/api/v1/widgets/42")
367 .header(
368 "traceparent",
369 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
370 )
371 .header("x-trace-id", "from-x-trace-id")
372 .header("x-request-id", "from-x-request-id")
373 .body(Body::empty())
374 .unwrap();
375 let res = app.oneshot(req).await.unwrap();
376 let problem = body_to_problem(res).await;
377
378 assert_eq!(
380 problem.trace_id.as_deref(),
381 Some("4bf92f3577b34da6a3ce929d0e0e4736")
382 );
383 }
384
385 #[tokio::test]
386 async fn malformed_traceparent_falls_through_to_x_trace_id() {
387 let problem: Problem = CanonicalError::internal("boom").create().into();
388 let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
389
390 let req = Request::builder()
391 .uri("/api/v1/widgets/42")
392 .header("traceparent", "not-a-w3c-traceparent")
393 .header("x-trace-id", "from-x-trace-id")
394 .body(Body::empty())
395 .unwrap();
396 let res = app.oneshot(req).await.unwrap();
397 let problem = body_to_problem(res).await;
398
399 assert_eq!(problem.trace_id.as_deref(), Some("from-x-trace-id"));
401 }
402
403 #[tokio::test]
404 async fn falls_back_to_span_id_when_no_trace_headers_present() {
405 use tracing::Instrument;
410 use tracing_subscriber::fmt;
411
412 let subscriber = fmt().with_test_writer().finish();
416 let _guard = tracing::subscriber::set_default(subscriber);
417
418 let span = tracing::info_span!("span_id_fallback_test");
419 let span_id = span
420 .id()
421 .expect("the test subscriber must assign an ID to the span")
422 .into_u64()
423 .to_string();
424
425 let problem: Problem = CanonicalError::internal("boom").create().into();
426 let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
427
428 let req = Request::builder()
429 .uri("/api/v1/widgets/42")
430 .body(Body::empty())
431 .unwrap();
432
433 let res = app.oneshot(req).instrument(span).await.unwrap();
437 let problem = body_to_problem(res).await;
438
439 assert_eq!(
440 problem.trace_id.as_deref(),
441 Some(span_id.as_str()),
442 "trace_id should fall back to the active span's id when no header is present",
443 );
444 }
445
446 #[tokio::test]
447 async fn body_is_valid_json_after_rewrite() {
448 let problem: Problem = CanonicalError::internal("boom").create().into();
449 let app = build_app(move || problem_response(&problem, StatusCode::INTERNAL_SERVER_ERROR));
450
451 let req = Request::builder()
452 .uri("/api/v1/widgets/42")
453 .header("x-trace-id", "abc123")
454 .body(Body::empty())
455 .unwrap();
456 let res = app.oneshot(req).await.unwrap();
457 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
458 .await
459 .unwrap();
460 let v: Value = serde_json::from_slice(&bytes).expect("rewritten body must be valid JSON");
461 assert_eq!(v["instance"].as_str(), Some("/api/v1/widgets/42"));
462 assert_eq!(v["trace_id"].as_str(), Some("abc123"));
463 }
464
465 #[tokio::test]
466 #[tracing_test::traced_test]
467 async fn logs_internal_description_from_extension() {
468 use axum::response::IntoResponse;
473
474 let app = Router::new()
475 .route(
476 "/api/v1/widgets/42",
477 get(|| async {
478 CanonicalError::internal("db connection refused: secret-host:5432")
479 .create()
480 .into_response()
481 }),
482 )
483 .layer(from_fn(canonical_error_middleware));
484
485 let req = Request::builder()
486 .uri("/api/v1/widgets/42")
487 .body(Body::empty())
488 .unwrap();
489 let res = app.oneshot(req).await.unwrap();
490 assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
491
492 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
494 .await
495 .unwrap();
496 let body_str = std::str::from_utf8(&bytes).unwrap();
497 assert!(
498 !body_str.contains("secret-host:5432"),
499 "diagnostic must not appear on the wire"
500 );
501
502 assert!(logs_contain("canonical error response (server)"));
504 assert!(logs_contain("db connection refused: secret-host:5432"));
505 }
506}