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}