Skip to main content

omgbase_surface/
error.rs

1//! The error envelope (`spec/surface/README.md` §4): `{ error, message,
2//! data?, retriable }`. Every failure a tool can report is one of these;
3//! `spec/mutate` §8 codes come through unchanged, an OQX or cursor failure is
4//! `filter_invalid`, and anything unexpected is `repo_not_found` with its
5//! message (§9, pinned).
6
7use std::fmt;
8
9use serde_json::{Map, Value, json};
10
11/// A surfaced failure.
12#[derive(Clone, Debug, PartialEq)]
13pub struct SurfaceError {
14    /// The code (`filter_invalid`, `doc_missing`, …).
15    pub code: String,
16    pub message: String,
17    /// The `data` member, when any.
18    pub data: Option<Value>,
19    pub retriable: bool,
20}
21
22impl SurfaceError {
23    #[must_use]
24    pub fn new(code: &str, message: impl Into<String>) -> Self {
25        Self {
26            code: code.to_owned(),
27            message: message.into(),
28            data: None,
29            retriable: false,
30        }
31    }
32
33    #[must_use]
34    pub fn with_data(code: &str, message: impl Into<String>, data: Value) -> Self {
35        Self {
36            code: code.to_owned(),
37            message: message.into(),
38            data: Some(data),
39            retriable: false,
40        }
41    }
42
43    /// The reference's `FilterInvalid`: `filter_invalid` with `{ reason, hint }`.
44    #[must_use]
45    pub fn filter_invalid(message: impl Into<String>, reason: &str) -> Self {
46        let message = message.into();
47        Self::with_data(
48            "filter_invalid",
49            message,
50            json!({ "reason": reason, "hint": "see query_syntax" }),
51        )
52    }
53
54    /// The reference's `CursorInvalid`: a cursor `surface` did not issue.
55    #[must_use]
56    pub fn cursor_invalid(surface: &str) -> Self {
57        Self::with_data(
58            "filter_invalid",
59            "invalid cursor",
60            json!({
61                "reason": format!("cursor was not issued by {surface}"),
62                "hint": "resume only with a `cursor` returned by a truncated page of the same tool",
63            }),
64        )
65    }
66
67    /// `repo_not_found` — also the catch-all (§9).
68    #[must_use]
69    pub fn other(message: impl Into<String>) -> Self {
70        Self::new("repo_not_found", message)
71    }
72
73    /// The wire envelope.
74    #[must_use]
75    pub fn to_json(&self) -> Value {
76        let mut m = Map::new();
77        m.insert("error".to_owned(), Value::String(self.code.clone()));
78        m.insert("message".to_owned(), Value::String(self.message.clone()));
79        if let Some(d) = &self.data {
80            m.insert("data".to_owned(), d.clone());
81        }
82        m.insert("retriable".to_owned(), Value::Bool(self.retriable));
83        Value::Object(m)
84    }
85}
86
87impl fmt::Display for SurfaceError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        write!(f, "{}: {}", self.code, self.message)
90    }
91}
92
93impl std::error::Error for SurfaceError {}
94
95impl From<omgbase_store::Error> for SurfaceError {
96    fn from(e: omgbase_store::Error) -> Self {
97        match e {
98            omgbase_store::Error::Mutation(m) => Self::from(m),
99            omgbase_store::Error::Search(s) => Self::new(s.code(), s.to_string()),
100            other => Self::other(other.to_string()),
101        }
102    }
103}
104
105impl From<omgbase_store::MutationError> for SurfaceError {
106    fn from(m: omgbase_store::MutationError) -> Self {
107        let retriable = m
108            .data
109            .get("retriable")
110            .and_then(Value::as_bool)
111            .unwrap_or(false);
112        Self {
113            code: m.code.as_str().to_owned(),
114            message: m.message,
115            data: Some(Value::Object(m.data)),
116            retriable,
117        }
118    }
119}
120
121impl From<omgbase_sync::Error> for SurfaceError {
122    fn from(e: omgbase_sync::Error) -> Self {
123        match e {
124            omgbase_sync::Error::Store(s) => Self::from(s),
125            omgbase_sync::Error::RepoNotFound {
126                message,
127                candidates,
128            } => Self::with_data(
129                "repo_not_found",
130                message,
131                json!({ "candidates": candidates }),
132            ),
133            other => Self::other(other.to_string()),
134        }
135    }
136}
137
138impl From<rusqlite::Error> for SurfaceError {
139    fn from(e: rusqlite::Error) -> Self {
140        Self::other(format!("sqlite: {e}"))
141    }
142}
143
144impl From<oqx::OqxError> for SurfaceError {
145    /// An OQX error is `filter_invalid` with the engine's message (§1.4).
146    fn from(e: oqx::OqxError) -> Self {
147        Self::filter_invalid(e.message, "OQX")
148    }
149}
150
151/// `Result` with this crate's error.
152pub type Result<T> = std::result::Result<T, SurfaceError>;
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn envelope_shape() {
160        let e = SurfaceError::filter_invalid("bad", "OQX");
161        let j = e.to_json();
162        assert_eq!(j["error"], "filter_invalid");
163        assert_eq!(j["message"], "bad");
164        assert_eq!(j["data"]["reason"], "OQX");
165        assert_eq!(j["retriable"], false);
166        let plain = SurfaceError::other("boom").to_json();
167        assert!(plain.get("data").is_none());
168        assert_eq!(plain["error"], "repo_not_found");
169    }
170
171    #[test]
172    fn mutation_errors_keep_code_and_data() {
173        let m = omgbase_store::MutationError::with_data(
174            omgbase_store::mutate_kernel::ErrorCode::StaleExpectation,
175            "stale",
176            json!({ "block": "b_1", "retriable": true }),
177        );
178        let e = SurfaceError::from(omgbase_store::Error::from(m));
179        assert_eq!(e.code, "stale_expectation");
180        assert!(e.retriable);
181        assert_eq!(e.data.unwrap()["block"], "b_1");
182    }
183}