Skip to main content

ctl_core/
model.rs

1//! The JSON envelope types. A view serializes a result as is. The envelope
2//! wraps a success or a failure for a reader that wants a status and a schema
3//! version: [`Envelope::Ok`] holds a result, and [`Envelope::Err`] holds an
4//! [`ErrorBody`].
5//!
6//! `View::emit_err` writes an `Envelope::Err` for a failure with no model.
7//! The types serialize with the `json` feature and describe themselves with
8//! `schema`.
9//!
10//! ```
11//! use ctl_core::model::{Envelope, ErrorBody, SCHEMA_VERSION};
12//!
13//! let failed = Envelope::<()>::err(ErrorBody::new("toy", "no such row"));
14//! assert!(matches!(
15//!     failed,
16//!     Envelope::Err {
17//!         schema_version: SCHEMA_VERSION,
18//!         ..
19//!     }
20//! ));
21//! ```
22
23/// The envelope shape version, written to every envelope as `schema_version`.
24/// ctl-core raises it when the envelope shape changes.
25pub const SCHEMA_VERSION: u32 = 1;
26
27/// Hosts `JsonSchema`. With `schema`, schemars expands `concat!`; this module
28/// is the allow, and only that feature needs it.
29mod data {
30    #![cfg_attr(
31        feature = "schema",
32        expect(
33            clippy::disallowed_macros,
34            reason = "schemars' JsonSchema derive expands concat!"
35        )
36    )]
37
38    /// A JSON envelope: `status` is `ok` with `data`, or `err` with `error`,
39    /// and both carry `schema_version`.
40    ///
41    /// Build one with [`Envelope::ok`] or [`Envelope::err`]. Each variant can
42    /// gain a field in a minor release, so a pattern outside ctl-core ends
43    /// with `..`.
44    #[derive(Clone, Debug, Eq, PartialEq)]
45    #[cfg_attr(feature = "json", derive(serde::Deserialize, serde::Serialize))]
46    #[cfg_attr(feature = "json", serde(tag = "status", rename_all = "snake_case"))]
47    #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48    pub enum Envelope<T> {
49        /// A success. Serializes with `"status": "ok"`.
50        #[non_exhaustive]
51        Ok {
52            /// The envelope shape version,
53            /// [`SCHEMA_VERSION`](super::SCHEMA_VERSION).
54            schema_version: u32,
55            /// The command's result.
56            data: T,
57        },
58        /// A failure. Serializes with `"status": "err"`.
59        #[non_exhaustive]
60        Err {
61            /// The envelope shape version,
62            /// [`SCHEMA_VERSION`](super::SCHEMA_VERSION).
63            schema_version: u32,
64            /// What failed.
65            error: ErrorBody,
66        },
67    }
68
69    /// The payload of a failure envelope: which binary failed, and why.
70    ///
71    /// Build one with [`ErrorBody::new`]. A field can join it in a minor
72    /// release.
73    #[derive(Clone, Debug, Eq, PartialEq)]
74    #[non_exhaustive]
75    #[cfg_attr(feature = "json", derive(serde::Deserialize, serde::Serialize))]
76    #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77    pub struct ErrorBody {
78        /// The name of the binary that failed, such as `toy`.
79        pub bin: String,
80        /// The message to show. Format an `anyhow` error with `{error:#}` to
81        /// include its whole chain.
82        pub message: String,
83    }
84}
85
86pub use data::{Envelope, ErrorBody};
87
88impl<T> Envelope<T> {
89    /// Wrap `data` in a current-version success envelope.
90    #[must_use]
91    pub fn ok(data: T) -> Self {
92        Self::Ok {
93            schema_version: SCHEMA_VERSION,
94            data,
95        }
96    }
97
98    /// Wrap `error` in a current-version failure envelope.
99    #[must_use]
100    pub fn err(error: ErrorBody) -> Self {
101        Self::Err {
102            schema_version: SCHEMA_VERSION,
103            error,
104        }
105    }
106}
107
108impl ErrorBody {
109    /// Build an error body from the failing binary's name and a message.
110    #[must_use]
111    pub fn new(bin: impl Into<String>, message: impl Into<String>) -> Self {
112        Self {
113            bin: bin.into(),
114            message: message.into(),
115        }
116    }
117}
118
119#[cfg(all(test, feature = "json"))]
120mod tests {
121    use indoc::indoc;
122
123    use super::{Envelope, ErrorBody, SCHEMA_VERSION};
124
125    #[test]
126    fn ok_envelope_roundtrip() {
127        let env = Envelope::ok("demo");
128        let json = serde_json::to_string(&env).unwrap();
129        assert!(json.contains("\"schema_version\":1"));
130        let back: Envelope<String> = serde_json::from_str(&json).unwrap();
131        assert_eq!(back, Envelope::ok("demo".to_owned()));
132        assert_eq!(SCHEMA_VERSION, 1);
133    }
134
135    #[test]
136    fn err_envelope_shape() {
137        let env = Envelope::<()>::err(ErrorBody::new("toy", "missing token"));
138        let json = serde_json::to_string_pretty(&env).unwrap();
139        let expected = indoc! {r#"
140            {
141              "status": "err",
142              "schema_version": 1,
143              "error": {
144                "bin": "toy",
145                "message": "missing token"
146              }
147            }
148        "#};
149        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
150        let expect: serde_json::Value = serde_json::from_str(expected).unwrap();
151        assert_eq!(value, expect);
152    }
153}