Skip to main content

fez/
envelope.rs

1//! The `fez/v1` JSON response envelope shared by every command's `--json` output.
2use serde::Serialize;
3use serde_json::Value;
4
5/// The envelope schema version string emitted in `apiVersion`.
6pub const API_VERSION: &str = "fez/v1";
7
8/// The machine-readable response wrapper for every command.
9#[derive(Serialize)]
10pub struct Envelope {
11    /// Schema version, always [`API_VERSION`].
12    #[serde(rename = "apiVersion")]
13    pub api_version: &'static str,
14    /// The payload kind (e.g. `ServiceList`, `Error`).
15    pub kind: String,
16    /// Host the response pertains to.
17    pub host: String,
18    /// Whether the operation succeeded.
19    pub status: Status,
20    /// Success payload, present when `status` is `ok`.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub data: Option<Value>,
23    /// Error payload, present when `status` is `error`.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub error: Option<ApiError>,
26    /// Optional machine-actionable hints (e.g. a reverse command).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub hints: Option<Value>,
29}
30
31/// Outcome of an operation.
32#[derive(Serialize, Clone, Copy)]
33#[serde(rename_all = "lowercase")]
34pub enum Status {
35    /// The operation succeeded.
36    Ok,
37    /// The operation failed; see the envelope's `error`.
38    Error,
39}
40
41/// Structured error detail carried in an error envelope.
42#[derive(Serialize)]
43pub struct ApiError {
44    /// Stable machine-readable error code.
45    pub code: String,
46    /// Human-readable error message.
47    pub message: String,
48    /// Optional extra structured detail.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub detail: Option<Value>,
51}
52
53impl Envelope {
54    /// Build a success envelope wrapping `data`.
55    pub fn ok(kind: &str, host: &str, data: Value) -> Self {
56        Envelope {
57            api_version: API_VERSION,
58            kind: kind.into(),
59            host: host.into(),
60            status: Status::Ok,
61            data: Some(data),
62            error: None,
63            hints: None,
64        }
65    }
66    /// Build an error envelope carrying `err`.
67    pub fn error(kind: &str, host: &str, err: ApiError) -> Self {
68        Envelope {
69            api_version: API_VERSION,
70            kind: kind.into(),
71            host: host.into(),
72            status: Status::Error,
73            data: None,
74            error: Some(err),
75            hints: None,
76        }
77    }
78    /// Attach machine-actionable hints (e.g. the reversibility hint, Section 8).
79    pub fn with_hints(mut self, hints: Value) -> Self {
80        self.hints = Some(hints);
81        self
82    }
83    /// Serialize the envelope to a pretty-printed JSON string.
84    ///
85    /// Returns a valid `fez/v1` error envelope on serialization failure so that
86    /// callers always receive syntactically correct JSON.
87    pub fn to_json_string(&self) -> String {
88        serde_json::to_string_pretty(self).unwrap_or_else(|_| {
89            r#"{
90  "apiVersion": "fez/v1",
91  "kind": "Error",
92  "host": "",
93  "status": "error",
94  "error": {
95    "code": "internal",
96    "message": "envelope serialization failed"
97  }
98}"#
99            .to_string()
100        })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use serde_json::json;
108
109    #[test]
110    fn ok_envelope_shape() {
111        let e = Envelope::ok("ServiceList", "localhost", json!({"units":[]}));
112        assert_eq!(
113            serde_json::to_value(&e).unwrap(),
114            json!({
115                "apiVersion":"fez/v1","kind":"ServiceList","host":"localhost",
116                "status":"ok","data":{"units":[]}
117            })
118        );
119    }
120
121    #[test]
122    fn error_envelope_shape() {
123        let e = Envelope::error(
124            "Error",
125            "h1",
126            ApiError {
127                code: "not-found".into(),
128                message: "no unit".into(),
129                detail: None,
130            },
131        );
132        assert_eq!(
133            serde_json::to_value(&e).unwrap(),
134            json!({
135                "apiVersion":"fez/v1","kind":"Error","host":"h1",
136                "status":"error","error":{"code":"not-found","message":"no unit"}
137            })
138        );
139    }
140
141    #[test]
142    fn ok_envelope_with_hints() {
143        let e = Envelope::ok(
144            "ServiceMutation",
145            "localhost",
146            json!({"unit": "nginx.service"}),
147        )
148        .with_hints(json!({"reverse": "fez services start nginx.service"}));
149        assert_eq!(
150            serde_json::to_value(&e).unwrap(),
151            json!({
152                "apiVersion":"fez/v1","kind":"ServiceMutation","host":"localhost",
153                "status":"ok","data":{"unit":"nginx.service"},
154                "hints":{"reverse":"fez services start nginx.service"}
155            })
156        );
157    }
158}