Skip to main content

modkit/api/odata/
error.rs

1//! Centralized `OData` error mapping
2//!
3//! This module adds HTTP-specific context (instance path, trace ID) to `OData` errors.
4//! The core Error → Problem mapping is owned by modkit-odata.
5
6use crate::api::problem::Problem;
7use modkit_odata::Error as ODataError;
8
9/// Extract trace ID from current tracing span
10#[inline]
11fn current_trace_id() -> Option<String> {
12    tracing::Span::current()
13        .id()
14        .map(|id| id.into_u64().to_string())
15}
16
17/// Returns a fully contextualized Problem for `OData` errors.
18///
19/// This function maps all `modkit_odata::Error` variants to appropriate system
20/// error codes from the framework catalog. The `instance` parameter should
21/// be the request path.
22///
23/// # Arguments
24/// * `err` - The `OData` error to convert
25/// * `instance` - The request path (e.g., "/api/user-management/v1/users")
26/// * `trace_id` - Optional trace ID (uses current span if None)
27pub fn odata_error_to_problem(
28    err: &ODataError,
29    instance: &str,
30    trace_id: Option<String>,
31) -> Problem {
32    use modkit_odata::Error as OE;
33
34    // Add logging for errors that need it before conversion
35    match err {
36        OE::Db(msg) => {
37            tracing::error!(error = %msg, "Unexpected database error in OData layer");
38        }
39        OE::ParsingUnavailable(msg) => {
40            tracing::error!(error = %msg, "OData parsing unavailable");
41        }
42        _ => {}
43    }
44
45    // Delegate to modkit-odata's base mapping (single source of truth)
46    let mut problem: Problem = err.clone().into();
47
48    // Add HTTP-specific context
49    problem = problem.with_instance(instance);
50
51    let trace_id = trace_id.or_else(current_trace_id);
52    if let Some(tid) = trace_id {
53        problem = problem.with_trace_id(tid);
54    }
55
56    problem
57}
58
59#[cfg(test)]
60#[cfg_attr(coverage_nightly, coverage(off))]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn test_filter_error_mapping() {
66        use http::StatusCode;
67
68        let error = ODataError::InvalidFilter("malformed expression".to_owned());
69        let problem = odata_error_to_problem(&error, "/user-management/v1/users", None);
70
71        assert_eq!(problem.status, StatusCode::UNPROCESSABLE_ENTITY);
72        assert!(problem.code.contains("invalid_filter"));
73        assert_eq!(problem.instance, "/user-management/v1/users");
74    }
75
76    #[test]
77    fn test_orderby_error_mapping() {
78        use http::StatusCode;
79
80        let error = ODataError::InvalidOrderByField("unknown_field".to_owned());
81        let problem = odata_error_to_problem(&error, "/user-management/v1/users", None);
82
83        assert_eq!(problem.status, StatusCode::UNPROCESSABLE_ENTITY);
84        assert!(problem.code.contains("invalid_orderby"));
85    }
86
87    #[test]
88    fn test_cursor_error_mapping() {
89        use http::StatusCode;
90
91        let error = ODataError::CursorInvalidBase64;
92        let problem = odata_error_to_problem(
93            &error,
94            "/user-management/v1/users",
95            Some("trace123".to_owned()),
96        );
97
98        assert_eq!(problem.status, StatusCode::UNPROCESSABLE_ENTITY);
99        assert!(problem.code.contains("invalid_cursor"));
100        assert_eq!(problem.trace_id, Some("trace123".to_owned()));
101    }
102
103    #[test]
104    fn test_gts_code_format() {
105        let error = ODataError::InvalidFilter("test".to_owned());
106        let problem = odata_error_to_problem(&error, "/user-management/v1/test", None);
107
108        // Verify the code follows GTS format
109        assert!(problem.code.starts_with("gts.hx.core.errors.err.v1~"));
110        assert!(problem.code.contains("odata"));
111    }
112}