Skip to main content

reliakit_json/
primitives.rs

1//! Optional integration with [`reliakit-primitives`].
2//!
3//! Available with the `primitives` feature. These helpers pull a value out of a
4//! parsed JSON document and run it through a `reliakit-primitives` validating
5//! constructor, so you get a typed, validated value instead of a raw string —
6//! and on failure the error carries the [`JsonPath`] of the offending location.
7//!
8//! ```
9//! use reliakit_json::parse_str;
10//! use reliakit_primitives::{Email, Hostname};
11//!
12//! let doc = parse_str(r#"{ "email": "ops@example.com", "host": "api.example.com" }"#).unwrap();
13//! let obj = doc.as_object().unwrap();
14//!
15//! let email: Email = obj.get_str_as("email").unwrap();
16//! let host: Hostname = obj.get_str_as("host").unwrap();
17//! assert_eq!(email.domain(), "example.com");
18//! assert_eq!(host.as_str(), "api.example.com");
19//!
20//! // A missing key, the wrong JSON type, or a value the primitive rejects all
21//! // produce an error that points at the field.
22//! let bad = parse_str(r#"{ "email": "not-an-email" }"#).unwrap();
23//! let err = bad.as_object().unwrap().get_str_as::<Email>("email").unwrap_err();
24//! assert!(err.to_string().starts_with("$.email"));
25//! ```
26//!
27//! [`reliakit-primitives`]: https://docs.rs/reliakit-primitives
28
29use alloc::string::ToString;
30use alloc::vec;
31use core::fmt;
32
33use reliakit_primitives::PrimitiveError;
34
35use crate::error::{JsonPath, JsonPathSegment};
36use crate::{JsonObject, JsonValue};
37
38/// Why extracting a typed [`reliakit-primitives`](https://docs.rs/reliakit-primitives)
39/// value from JSON failed.
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum JsonExtractErrorKind {
43    /// The requested object key was not present.
44    Missing,
45    /// The value was present but not the expected JSON shape.
46    WrongType {
47        /// The JSON kind that was expected, e.g. `"string"`.
48        expected: &'static str,
49    },
50    /// The value had the right shape but failed primitive validation.
51    Invalid(PrimitiveError),
52}
53
54/// An error from extracting a typed value out of JSON, carrying the
55/// [`JsonPath`] of the offending location.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct JsonExtractError {
58    path: JsonPath,
59    kind: JsonExtractErrorKind,
60}
61
62impl JsonExtractError {
63    /// The location of the failure, from the document root (`$`).
64    pub fn path(&self) -> &JsonPath {
65        &self.path
66    }
67
68    /// The reason extraction failed.
69    pub fn kind(&self) -> &JsonExtractErrorKind {
70        &self.kind
71    }
72}
73
74impl fmt::Display for JsonExtractError {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match &self.kind {
77            JsonExtractErrorKind::Missing => write!(f, "{}: required value is missing", self.path),
78            JsonExtractErrorKind::WrongType { expected } => {
79                write!(f, "{}: expected a JSON {expected}", self.path)
80            }
81            JsonExtractErrorKind::Invalid(err) => write!(f, "{}: {err}", self.path),
82        }
83    }
84}
85
86#[cfg(feature = "std")]
87impl std::error::Error for JsonExtractError {}
88
89/// Builds the single-segment path `$.<key>` for a field error.
90fn key_path(key: &str) -> JsonPath {
91    JsonPath::from_segments(vec![JsonPathSegment::Key(key.to_string())])
92}
93
94impl JsonObject {
95    /// Extracts member `key` as a string-backed primitive `T`, validating it
96    /// through `T`'s `TryFrom<&str>` constructor.
97    ///
98    /// Returns [`JsonExtractErrorKind::Missing`] if the key is absent,
99    /// [`JsonExtractErrorKind::WrongType`] if the member is not a JSON string,
100    /// and [`JsonExtractErrorKind::Invalid`] (wrapping the [`PrimitiveError`]) if
101    /// the string fails validation. The error carries the `$.key` path.
102    pub fn get_str_as<'a, T>(&'a self, key: &str) -> Result<T, JsonExtractError>
103    where
104        T: TryFrom<&'a str, Error = PrimitiveError>,
105    {
106        let value = self.get(key).ok_or_else(|| JsonExtractError {
107            path: key_path(key),
108            kind: JsonExtractErrorKind::Missing,
109        })?;
110        match value.as_str() {
111            None => Err(JsonExtractError {
112                path: key_path(key),
113                kind: JsonExtractErrorKind::WrongType { expected: "string" },
114            }),
115            Some(text) => T::try_from(text).map_err(|err| JsonExtractError {
116                path: key_path(key),
117                kind: JsonExtractErrorKind::Invalid(err),
118            }),
119        }
120    }
121}
122
123impl JsonValue {
124    /// Reads this value as a string-backed primitive `T`, validating it through
125    /// `T`'s `TryFrom<&str>` constructor.
126    ///
127    /// Returns [`JsonExtractErrorKind::WrongType`] if this is not a JSON string,
128    /// or [`JsonExtractErrorKind::Invalid`] if it fails validation. The error
129    /// path is the document root (`$`); use
130    /// [`JsonObject::get_str_as`] when the value lives under a key so the error
131    /// points at that field.
132    pub fn str_as<'a, T>(&'a self) -> Result<T, JsonExtractError>
133    where
134        T: TryFrom<&'a str, Error = PrimitiveError>,
135    {
136        match self.as_str() {
137            None => Err(JsonExtractError {
138                path: JsonPath::default(),
139                kind: JsonExtractErrorKind::WrongType { expected: "string" },
140            }),
141            Some(text) => T::try_from(text).map_err(|err| JsonExtractError {
142                path: JsonPath::default(),
143                kind: JsonExtractErrorKind::Invalid(err),
144            }),
145        }
146    }
147}
148
149#[cfg(all(test, feature = "primitives"))]
150mod tests {
151    use super::{JsonExtractErrorKind, PrimitiveError};
152    use crate::parse_str;
153    use reliakit_primitives::{Email, Hostname};
154
155    fn obj(input: &str) -> crate::JsonObject {
156        parse_str(input).unwrap().as_object().unwrap().clone()
157    }
158
159    #[test]
160    fn extracts_valid_string_primitive() {
161        let o = obj(r#"{ "email": "ops@example.com", "host": "api.example.com" }"#);
162        let email: Email = o.get_str_as("email").unwrap();
163        assert_eq!(email.as_str(), "ops@example.com");
164        let host: Hostname = o.get_str_as("host").unwrap();
165        assert_eq!(host.as_str(), "api.example.com");
166    }
167
168    #[test]
169    fn missing_key_reports_missing_with_path() {
170        let o = obj(r#"{ "host": "api.example.com" }"#);
171        let err = o.get_str_as::<Email>("email").unwrap_err();
172        assert_eq!(err.kind(), &JsonExtractErrorKind::Missing);
173        assert_eq!(err.path().to_string(), "$.email");
174        assert_eq!(err.to_string(), "$.email: required value is missing");
175    }
176
177    #[test]
178    fn wrong_json_type_reports_wrong_type() {
179        let o = obj(r#"{ "email": 42 }"#);
180        let err = o.get_str_as::<Email>("email").unwrap_err();
181        assert_eq!(
182            err.kind(),
183            &JsonExtractErrorKind::WrongType { expected: "string" }
184        );
185        assert_eq!(err.to_string(), "$.email: expected a JSON string");
186    }
187
188    #[test]
189    fn invalid_value_wraps_primitive_error_with_path() {
190        let o = obj(r#"{ "email": "not-an-email" }"#);
191        let err = o.get_str_as::<Email>("email").unwrap_err();
192        assert!(matches!(
193            err.kind(),
194            JsonExtractErrorKind::Invalid(PrimitiveError::Invalid { .. })
195        ));
196        assert!(err.to_string().starts_with("$.email: "));
197    }
198
199    #[test]
200    fn value_str_as_uses_root_path() {
201        let doc = parse_str(r#""ops@example.com""#).unwrap();
202        let email: Email = doc.str_as().unwrap();
203        assert_eq!(email.as_str(), "ops@example.com");
204
205        let num = parse_str("42").unwrap();
206        let err = num.str_as::<Email>().unwrap_err();
207        assert_eq!(err.path().to_string(), "$");
208        assert_eq!(
209            err.kind(),
210            &JsonExtractErrorKind::WrongType { expected: "string" }
211        );
212    }
213}