junction_api/
error.rs

1use std::{borrow::Cow, fmt::Write as _, str::FromStr};
2
3/// An error converting a Junction API type into another type.
4///
5/// Errors should be treated as opaque, and contain a message about what went
6/// wrong and a jsonpath style path to the field that caused problems.
7#[derive(Clone, thiserror::Error)]
8pub struct Error {
9    // an error message
10    message: String,
11
12    // the reversed path to the field where the conversion error happened.
13    //
14    // the leaf of the path is built up at path[0] with the root of the
15    // struct at the end. see ErrorContext for how this gets done.
16    path: Vec<PathEntry>,
17}
18
19impl std::fmt::Display for Error {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        if !self.path.is_empty() {
22            write!(f, "{}: ", self.path())?;
23        }
24
25        f.write_str(&self.message)
26    }
27}
28
29impl std::fmt::Debug for Error {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.debug_struct("Error")
32            .field("message", &self.message)
33            .field("path", &self.path())
34            .finish()
35    }
36}
37
38impl Error {
39    pub fn path(&self) -> String {
40        path_str(None, self.path.iter().rev())
41    }
42
43    /// Create a new error with a static message.
44    pub(crate) fn new_static(message: &'static str) -> Self {
45        Self {
46            message: message.to_string(),
47            path: vec![],
48        }
49    }
50}
51
52// these are handy, but mostly used in xds/kube conversion. don't gate them
53// behind feature flags for now, just allow them to be unused.
54#[allow(unused)]
55impl Error {
56    /// Create a new error with a message.
57    pub(crate) fn new(message: String) -> Self {
58        Self {
59            message,
60            path: vec![],
61        }
62    }
63
64    /// Append a new field to this error's path.
65    pub(crate) fn with_field(mut self, field: &'static str) -> Self {
66        self.path.push(PathEntry::from(field));
67        self
68    }
69
70    /// Append a new field index to this error's path.
71    pub(crate) fn with_index(mut self, index: usize) -> Self {
72        self.path.push(PathEntry::Index(index));
73        self
74    }
75}
76
77/// Join an iterator of PathEntry together into a path string.
78///
79/// This isn't quite `entries.join('.')` because index fields exist and have to
80/// be bracketed.
81pub(crate) fn path_str<'a, I, Iter>(prefix: Option<&'static str>, path: I) -> String
82where
83    I: IntoIterator<IntoIter = Iter>,
84    Iter: Iterator<Item = &'a PathEntry> + DoubleEndedIterator,
85{
86    let path_iter = path.into_iter();
87    // this is a random guess based on the fact that we'll often be allocating
88    // something, but probably won't ever be allocating much.
89    let mut buf = String::with_capacity(16 + prefix.map_or(0, |s| s.len()));
90
91    if let Some(prefix) = prefix {
92        let _ = buf.write_fmt(format_args!("{prefix}/"));
93    }
94
95    for (i, path_entry) in path_iter.enumerate() {
96        if i > 0 && path_entry.is_field() {
97            buf.push('.');
98        }
99        let _ = write!(&mut buf, "{}", path_entry);
100    }
101
102    buf
103}
104
105/// Add field-path context to an error by appending an entry to its path. Because
106/// Context is added at the callsite this means a function can add its own fields
107/// and the path ends up in the appropriate order.
108///
109/// This trait isn't meant to be implemented, but it's not explicitly sealed
110/// because it's only `pub(crate)`. Don't implement it!
111///
112/// This trait is mostly used in xds/kube conversions, but leave it available
113/// for now. It's not much code and may be helpful for identifying errors in
114/// routes etc.
115#[allow(unused)]
116pub(crate) trait ErrorContext<T>: Sized {
117    fn with_field(self, field: &'static str) -> Result<T, Error>;
118    fn with_index(self, index: usize) -> Result<T, Error>;
119
120    /// Shorthand for `with_field(b).with_field(a)` but in a more intuitive
121    /// order.
122    fn with_fields(self, a: &'static str, b: &'static str) -> Result<T, Error> {
123        self.with_field(b).with_field(a)
124    }
125
126    /// Shorthand for `with_index(idx).with_field(name)`, but in a slightly more
127    /// inutitive order.
128    fn with_field_index(self, field: &'static str, index: usize) -> Result<T, Error> {
129        self.with_index(index).with_field(field)
130    }
131}
132
133/// A JSON-path style path entry. An entry is either a field name or an index
134/// into a sequence.
135#[derive(Debug, PartialEq, Eq, Clone)]
136pub(crate) enum PathEntry {
137    Field(Cow<'static, str>),
138    Index(usize),
139}
140
141impl PathEntry {
142    fn is_field(&self) -> bool {
143        matches!(self, PathEntry::Field(_))
144    }
145}
146
147impl std::fmt::Display for PathEntry {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        match self {
150            PathEntry::Field(field) => f.write_str(field),
151            PathEntry::Index(idx) => f.write_fmt(format_args!("[{idx}]")),
152        }
153    }
154}
155
156impl FromStr for PathEntry {
157    type Err = &'static str;
158
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        // an index is always at least 3 chars, starts with [ and ends with ]
161        if s.starts_with('[') {
162            if s.len() <= 2 || !s.ends_with(']') {
163                return Err("invalid field index: missing closing bracket");
164            }
165
166            // safety: we know the first and last chars are [] so it's safe to
167            // slice single bytes off the front and back.
168            let idx_str = &s[1..s.len() - 1];
169            let idx = idx_str
170                .parse()
171                .map_err(|_| "invalid field index: field index must be a number")?;
172
173            return Ok(PathEntry::Index(idx));
174        }
175
176        // parse anything that's not an index as a field name
177        Ok(PathEntry::from(s.to_string()))
178    }
179}
180
181impl From<String> for PathEntry {
182    fn from(value: String) -> Self {
183        PathEntry::Field(Cow::Owned(value))
184    }
185}
186
187impl From<&'static str> for PathEntry {
188    fn from(value: &'static str) -> Self {
189        PathEntry::Field(Cow::Borrowed(value))
190    }
191}
192
193impl<T> ErrorContext<T> for Result<T, Error> {
194    fn with_field(self, field: &'static str) -> Result<T, Error> {
195        match self {
196            Ok(v) => Ok(v),
197            Err(err) => Err(err.with_field(field)),
198        }
199    }
200
201    fn with_index(self, index: usize) -> Result<T, Error> {
202        match self {
203            Ok(v) => Ok(v),
204            Err(err) => Err(err.with_index(index)),
205        }
206    }
207}
208
209#[cfg(test)]
210mod test {
211    use super::*;
212
213    #[test]
214    fn test_error_message() {
215        fn baz() -> Result<(), Error> {
216            Err(Error::new_static("it broke"))
217        }
218
219        fn bar() -> Result<(), Error> {
220            baz().with_field_index("baz", 2)
221        }
222
223        fn foo() -> Result<(), Error> {
224            bar().with_field("bar")
225        }
226
227        assert_eq!(foo().unwrap_err().to_string(), "bar.baz[2]: it broke",)
228    }
229
230    #[test]
231    fn test_path_strings() {
232        let path = &[
233            PathEntry::Index(0),
234            PathEntry::from("hi"),
235            PathEntry::from("dr"),
236            PathEntry::Index(2),
237            PathEntry::from("nick"),
238        ];
239        let string = "[0].hi.dr[2].nick";
240        assert_eq!(path_str(None, path), string);
241
242        let path = &[
243            PathEntry::from("hi"),
244            PathEntry::from("dr"),
245            PathEntry::from("nick"),
246        ];
247        let string = "prefix/hi.dr.nick";
248        assert_eq!(path_str(Some("prefix"), path), string);
249    }
250}