Skip to main content

toolkit_odata/
problem_mapping.rs

1//! Mapping from `OData` errors to canonical [`CanonicalError`].
2//!
3//! Handlers and the `OData` axum extractor return `CanonicalError` directly;
4//! `IntoResponse for CanonicalError` (in `toolkit-canonical-errors`) renders the
5//! wire `Problem` and stashes the original error into the response extensions
6//! so the `canonical_error_middleware` (in `toolkit::api`) can log
7//! `diagnostic()` and fill `instance` / `trace_id`. This gear is the single
8//! source of truth for `Error → CanonicalError`; it does no logging of its own.
9
10use toolkit_canonical_errors::CanonicalError;
11
12use crate::Error;
13use crate::errors::OdataError;
14
15impl From<Error> for CanonicalError {
16    fn from(err: Error) -> Self {
17        use Error::{
18            CursorInvalidBase64, CursorInvalidDirection, CursorInvalidFields, CursorInvalidJson,
19            CursorInvalidKeys, CursorInvalidVersion, Db, FilterMismatch, InvalidCursor,
20            InvalidFilter, InvalidLimit, InvalidOrderByField, OrderMismatch, OrderWithCursor,
21            ParsingUnavailable,
22        };
23
24        match err {
25            InvalidFilter(msg) => OdataError::invalid_argument()
26                .with_field_violation(
27                    "$filter",
28                    format!("Invalid $filter: {msg}"),
29                    "INVALID_FILTER",
30                )
31                .create(),
32
33            InvalidOrderByField(field) => OdataError::invalid_argument()
34                .with_field_violation(
35                    "$orderby",
36                    format!("Unsupported $orderby field: {field}"),
37                    "INVALID_ORDERBY_FIELD",
38                )
39                .create(),
40
41            InvalidCursor => OdataError::invalid_argument()
42                .with_field_violation("cursor", "invalid cursor", "INVALID_CURSOR")
43                .create(),
44
45            CursorInvalidBase64 => OdataError::invalid_argument()
46                .with_field_violation(
47                    "cursor",
48                    "invalid cursor: invalid base64url encoding",
49                    "INVALID_CURSOR",
50                )
51                .create(),
52
53            CursorInvalidJson => OdataError::invalid_argument()
54                .with_field_violation("cursor", "invalid cursor: malformed JSON", "INVALID_CURSOR")
55                .create(),
56
57            CursorInvalidVersion => OdataError::invalid_argument()
58                .with_field_violation(
59                    "cursor",
60                    "invalid cursor: unsupported version",
61                    "INVALID_CURSOR",
62                )
63                .create(),
64
65            CursorInvalidKeys => OdataError::invalid_argument()
66                .with_field_violation(
67                    "cursor",
68                    "invalid cursor: empty or invalid keys",
69                    "INVALID_CURSOR",
70                )
71                .create(),
72
73            CursorInvalidFields => OdataError::invalid_argument()
74                .with_field_violation(
75                    "cursor",
76                    "invalid cursor: empty or invalid fields",
77                    "INVALID_CURSOR",
78                )
79                .create(),
80
81            CursorInvalidDirection => OdataError::invalid_argument()
82                .with_field_violation(
83                    "cursor",
84                    "invalid cursor: invalid sort direction",
85                    "INVALID_CURSOR",
86                )
87                .create(),
88
89            OrderMismatch => OdataError::invalid_argument()
90                .with_field_violation(
91                    "cursor",
92                    "Order mismatch between cursor and query",
93                    "ORDER_MISMATCH",
94                )
95                .create(),
96
97            FilterMismatch => OdataError::invalid_argument()
98                .with_field_violation(
99                    "cursor",
100                    "Filter mismatch between cursor and query",
101                    "FILTER_MISMATCH",
102                )
103                .create(),
104
105            InvalidLimit => OdataError::invalid_argument()
106                .with_field_violation("$top", "Invalid limit parameter", "INVALID_LIMIT")
107                .create(),
108
109            // Surface both halves of the conflict so a client filtering by
110            // `field` to render UI hints sees `$orderby` and `cursor`
111            // simultaneously. Both entries carry the same reason code so
112            // dispatch by `reason` still groups them.
113            OrderWithCursor => OdataError::invalid_argument()
114                .with_field_violation(
115                    "$orderby",
116                    "Cannot specify $orderby when cursor is present",
117                    "ORDER_WITH_CURSOR",
118                )
119                .with_field_violation(
120                    "cursor",
121                    "Cannot specify cursor when $orderby is present",
122                    "ORDER_WITH_CURSOR",
123                )
124                .create(),
125
126            // For Internal-category errors, the caller-supplied detail flows
127            // into `ctx.description` (recoverable via `CanonicalError::diagnostic()`)
128            // and is logged by `canonical_error_middleware` once the response
129            // reaches it. The wire `detail` stays opaque per DESIGN.md §3.6.
130            Db(msg) => CanonicalError::internal(format!("OData Db error: {msg}")).create(),
131
132            ParsingUnavailable(msg) => {
133                CanonicalError::internal(format!("OData parsing unavailable: {msg}")).create()
134            }
135        }
136    }
137}
138
139#[cfg(test)]
140#[cfg_attr(coverage_nightly, coverage(off))]
141mod tests {
142    use super::*;
143    use toolkit_canonical_errors::Problem;
144    use toolkit_gts::gts_id;
145
146    const ODATA_RESOURCE_TYPE: &str = gts_id!("cf.core.odata.query.v1~");
147
148    fn wire(err: Error) -> Problem {
149        Problem::from(CanonicalError::from(err))
150    }
151
152    fn field_violations(p: &Problem) -> &Vec<serde_json::Value> {
153        p.context
154            .get("field_violations")
155            .and_then(|v| v.as_array())
156            .expect("invalid_argument context must carry field_violations[]")
157    }
158
159    fn assert_violation(v: &serde_json::Value, field: &str, reason: &str) {
160        assert_eq!(
161            v.get("field").and_then(|x| x.as_str()),
162            Some(field),
163            "unexpected violation field in {v}"
164        );
165        assert_eq!(
166            v.get("reason").and_then(|x| x.as_str()),
167            Some(reason),
168            "unexpected violation reason in {v}"
169        );
170    }
171
172    fn resource_type(p: &Problem) -> &str {
173        p.context
174            .get("resource_type")
175            .and_then(|v| v.as_str())
176            .expect("InvalidArgument from the OData scope must tag resource_type")
177    }
178
179    #[test]
180    fn invalid_filter_populates_field_violation() {
181        let p = wire(Error::InvalidFilter("malformed".into()));
182        assert_eq!(p.status, 400);
183        assert!(p.problem_type.contains("invalid_argument"));
184        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
185        let violations = field_violations(&p);
186        assert_eq!(violations.len(), 1);
187        assert_violation(&violations[0], "$filter", "INVALID_FILTER");
188        let desc = violations[0]
189            .get("description")
190            .and_then(|x| x.as_str())
191            .unwrap_or_default();
192        assert!(desc.contains("malformed"), "description was {desc:?}");
193    }
194
195    #[test]
196    fn invalid_orderby_field_populates_field_violation() {
197        let p = wire(Error::InvalidOrderByField("unknown".into()));
198        assert_eq!(p.status, 400);
199        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
200        let violations = field_violations(&p);
201        assert_eq!(violations.len(), 1);
202        assert_violation(&violations[0], "$orderby", "INVALID_ORDERBY_FIELD");
203    }
204
205    #[test]
206    fn cursor_invalid_base64_populates_field_violation() {
207        let p = wire(Error::CursorInvalidBase64);
208        assert_eq!(p.status, 400);
209        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
210        let violations = field_violations(&p);
211        assert_eq!(violations.len(), 1);
212        assert_violation(&violations[0], "cursor", "INVALID_CURSOR");
213    }
214
215    #[test]
216    fn order_with_cursor_emits_two_violations() {
217        let p = wire(Error::OrderWithCursor);
218        assert_eq!(p.status, 400);
219        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
220        let violations = field_violations(&p);
221        assert_eq!(
222            violations.len(),
223            2,
224            "OrderWithCursor must surface both `$orderby` and `cursor`"
225        );
226        let fields: Vec<&str> = violations
227            .iter()
228            .filter_map(|v| v.get("field").and_then(|x| x.as_str()))
229            .collect();
230        assert!(fields.contains(&"$orderby"));
231        assert!(fields.contains(&"cursor"));
232        for v in violations {
233            assert_eq!(
234                v.get("reason").and_then(|x| x.as_str()),
235                Some("ORDER_WITH_CURSOR")
236            );
237        }
238    }
239
240    #[test]
241    fn order_mismatch_keys_to_cursor() {
242        let p = wire(Error::OrderMismatch);
243        let violations = field_violations(&p);
244        assert_eq!(violations.len(), 1);
245        assert_violation(&violations[0], "cursor", "ORDER_MISMATCH");
246    }
247
248    #[test]
249    fn filter_mismatch_keys_to_cursor() {
250        let p = wire(Error::FilterMismatch);
251        let violations = field_violations(&p);
252        assert_eq!(violations.len(), 1);
253        assert_violation(&violations[0], "cursor", "FILTER_MISMATCH");
254    }
255
256    #[test]
257    fn invalid_limit_keys_to_top() {
258        let p = wire(Error::InvalidLimit);
259        let violations = field_violations(&p);
260        assert_eq!(violations.len(), 1);
261        assert_violation(&violations[0], "$top", "INVALID_LIMIT");
262    }
263
264    #[test]
265    fn db_error_maps_to_internal_with_diagnostic() {
266        let canonical =
267            CanonicalError::from(Error::Db("connection refused: 127.0.0.1:5432".into()));
268        // Wire side: opaque internal envelope.
269        let p = Problem::from(canonical.clone());
270        assert_eq!(p.status, 500);
271        assert!(p.problem_type.contains("internal"));
272        // The wire `detail` is the canonical fixed string — never the raw msg.
273        assert!(!p.detail.contains("127.0.0.1"));
274        // Diagnostic side: descriptive cause preserved for `canonical_error_middleware`.
275        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
276        assert!(
277            diag.contains("connection refused"),
278            "diagnostic was {diag:?}"
279        );
280    }
281
282    #[test]
283    fn parsing_unavailable_maps_to_internal_with_diagnostic() {
284        let canonical = CanonicalError::from(Error::ParsingUnavailable("feature off"));
285        assert_eq!(canonical.status_code(), 500);
286        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
287        assert!(diag.contains("feature off"), "diagnostic was {diag:?}");
288    }
289}