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
145    const ODATA_RESOURCE_TYPE: &str = "gts.cf.core.odata.query.v1~";
146
147    fn wire(err: Error) -> Problem {
148        Problem::from(CanonicalError::from(err))
149    }
150
151    fn field_violations(p: &Problem) -> &Vec<serde_json::Value> {
152        p.context
153            .get("field_violations")
154            .and_then(|v| v.as_array())
155            .expect("invalid_argument context must carry field_violations[]")
156    }
157
158    fn assert_violation(v: &serde_json::Value, field: &str, reason: &str) {
159        assert_eq!(
160            v.get("field").and_then(|x| x.as_str()),
161            Some(field),
162            "unexpected violation field in {v}"
163        );
164        assert_eq!(
165            v.get("reason").and_then(|x| x.as_str()),
166            Some(reason),
167            "unexpected violation reason in {v}"
168        );
169    }
170
171    fn resource_type(p: &Problem) -> &str {
172        p.context
173            .get("resource_type")
174            .and_then(|v| v.as_str())
175            .expect("InvalidArgument from the OData scope must tag resource_type")
176    }
177
178    #[test]
179    fn invalid_filter_populates_field_violation() {
180        let p = wire(Error::InvalidFilter("malformed".into()));
181        assert_eq!(p.status, 400);
182        assert!(p.problem_type.contains("invalid_argument"));
183        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
184        let violations = field_violations(&p);
185        assert_eq!(violations.len(), 1);
186        assert_violation(&violations[0], "$filter", "INVALID_FILTER");
187        let desc = violations[0]
188            .get("description")
189            .and_then(|x| x.as_str())
190            .unwrap_or_default();
191        assert!(desc.contains("malformed"), "description was {desc:?}");
192    }
193
194    #[test]
195    fn invalid_orderby_field_populates_field_violation() {
196        let p = wire(Error::InvalidOrderByField("unknown".into()));
197        assert_eq!(p.status, 400);
198        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
199        let violations = field_violations(&p);
200        assert_eq!(violations.len(), 1);
201        assert_violation(&violations[0], "$orderby", "INVALID_ORDERBY_FIELD");
202    }
203
204    #[test]
205    fn cursor_invalid_base64_populates_field_violation() {
206        let p = wire(Error::CursorInvalidBase64);
207        assert_eq!(p.status, 400);
208        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
209        let violations = field_violations(&p);
210        assert_eq!(violations.len(), 1);
211        assert_violation(&violations[0], "cursor", "INVALID_CURSOR");
212    }
213
214    #[test]
215    fn order_with_cursor_emits_two_violations() {
216        let p = wire(Error::OrderWithCursor);
217        assert_eq!(p.status, 400);
218        assert_eq!(resource_type(&p), ODATA_RESOURCE_TYPE);
219        let violations = field_violations(&p);
220        assert_eq!(
221            violations.len(),
222            2,
223            "OrderWithCursor must surface both `$orderby` and `cursor`"
224        );
225        let fields: Vec<&str> = violations
226            .iter()
227            .filter_map(|v| v.get("field").and_then(|x| x.as_str()))
228            .collect();
229        assert!(fields.contains(&"$orderby"));
230        assert!(fields.contains(&"cursor"));
231        for v in violations {
232            assert_eq!(
233                v.get("reason").and_then(|x| x.as_str()),
234                Some("ORDER_WITH_CURSOR")
235            );
236        }
237    }
238
239    #[test]
240    fn order_mismatch_keys_to_cursor() {
241        let p = wire(Error::OrderMismatch);
242        let violations = field_violations(&p);
243        assert_eq!(violations.len(), 1);
244        assert_violation(&violations[0], "cursor", "ORDER_MISMATCH");
245    }
246
247    #[test]
248    fn filter_mismatch_keys_to_cursor() {
249        let p = wire(Error::FilterMismatch);
250        let violations = field_violations(&p);
251        assert_eq!(violations.len(), 1);
252        assert_violation(&violations[0], "cursor", "FILTER_MISMATCH");
253    }
254
255    #[test]
256    fn invalid_limit_keys_to_top() {
257        let p = wire(Error::InvalidLimit);
258        let violations = field_violations(&p);
259        assert_eq!(violations.len(), 1);
260        assert_violation(&violations[0], "$top", "INVALID_LIMIT");
261    }
262
263    #[test]
264    fn db_error_maps_to_internal_with_diagnostic() {
265        let canonical =
266            CanonicalError::from(Error::Db("connection refused: 127.0.0.1:5432".into()));
267        // Wire side: opaque internal envelope.
268        let p = Problem::from(canonical.clone());
269        assert_eq!(p.status, 500);
270        assert!(p.problem_type.contains("internal"));
271        // The wire `detail` is the canonical fixed string — never the raw msg.
272        assert!(!p.detail.contains("127.0.0.1"));
273        // Diagnostic side: descriptive cause preserved for `canonical_error_middleware`.
274        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
275        assert!(
276            diag.contains("connection refused"),
277            "diagnostic was {diag:?}"
278        );
279    }
280
281    #[test]
282    fn parsing_unavailable_maps_to_internal_with_diagnostic() {
283        let canonical = CanonicalError::from(Error::ParsingUnavailable("feature off"));
284        assert_eq!(canonical.status_code(), 500);
285        let diag = canonical.diagnostic().expect("Internal carries diagnostic");
286        assert!(diag.contains("feature off"), "diagnostic was {diag:?}");
287    }
288}