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/// Build the columnar table block shared by every list-style payload.
9///
10/// Field names are stated once in `columns`; each item becomes a positional
11/// row aligned to that column order, and `count` is the row total. This keeps
12/// `--json` list output compact and uniform across capabilities (services and
13/// packages both project through here). Callers own per-cell typing: a row
14/// value may be any [`Value`], so an integer column (e.g. `install_size`)
15/// stays an integer rather than being stringified.
16///
17/// Returns a `{"columns": [...], "rows": [[...]], "count": N}` object. Extra
18/// per-kind context (e.g. `scope`, `pattern`) is added by the caller as a
19/// sibling key, not folded in here.
20pub fn table_data(columns: &[&str], rows: Vec<Value>) -> Value {
21    let count = rows.len();
22    serde_json::json!({
23        "columns": columns,
24        "rows": rows,
25        "count": count,
26    })
27}
28
29/// The machine-readable response wrapper for every command.
30#[derive(Serialize)]
31pub struct Envelope {
32    /// Schema version, always [`API_VERSION`].
33    #[serde(rename = "apiVersion")]
34    pub api_version: &'static str,
35    /// The payload kind (e.g. `ServiceList`, `Error`).
36    pub kind: String,
37    /// Host the response pertains to.
38    pub host: String,
39    /// Whether the operation succeeded.
40    pub status: Status,
41    /// Success payload, present when `status` is `ok`.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub data: Option<Value>,
44    /// Error payload, present when `status` is `error`.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub error: Option<ApiError>,
47    /// Optional machine-actionable hints (e.g. a reverse command).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub hints: Option<Value>,
50}
51
52/// Outcome of an operation.
53#[derive(Serialize, Clone, Copy)]
54#[serde(rename_all = "lowercase")]
55pub enum Status {
56    /// The operation succeeded.
57    Ok,
58    /// The operation failed; see the envelope's `error`.
59    Error,
60}
61
62/// Structured error detail carried in an error envelope.
63#[derive(Serialize)]
64pub struct ApiError {
65    /// Stable machine-readable error code.
66    pub code: String,
67    /// Human-readable error message.
68    pub message: String,
69    /// Optional extra structured detail.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub detail: Option<Value>,
72}
73
74impl Envelope {
75    /// Build a success envelope wrapping `data`.
76    pub fn ok(kind: &str, host: &str, data: Value) -> Self {
77        Envelope {
78            api_version: API_VERSION,
79            kind: kind.into(),
80            host: host.into(),
81            status: Status::Ok,
82            data: Some(data),
83            error: None,
84            hints: None,
85        }
86    }
87    /// Build an error envelope carrying `err`.
88    pub fn error(kind: &str, host: &str, err: ApiError) -> Self {
89        Envelope {
90            api_version: API_VERSION,
91            kind: kind.into(),
92            host: host.into(),
93            status: Status::Error,
94            data: None,
95            error: Some(err),
96            hints: None,
97        }
98    }
99    /// Attach machine-actionable hints (e.g. the reversibility hint, Section 8).
100    pub fn with_hints(mut self, hints: Value) -> Self {
101        self.hints = Some(hints);
102        self
103    }
104    /// Serialize the envelope to a pretty-printed JSON string.
105    ///
106    /// Returns a valid `fez/v1` error envelope on serialization failure so that
107    /// callers always receive syntactically correct JSON.
108    pub fn to_json_string(&self) -> String {
109        serde_json::to_string(self).unwrap_or_else(|_| {
110            r#"{"apiVersion":"fez/v1","kind":"Error","host":"","status":"error","error":{"code":"internal","message":"envelope serialization failed"}}"#
111            .to_string()
112        })
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use serde_json::json;
120
121    #[test]
122    fn ok_envelope_shape() {
123        let e = Envelope::ok("Sample", "localhost", json!({"k":"v"}));
124        assert_eq!(
125            serde_json::to_value(&e).unwrap(),
126            json!({
127                "apiVersion":"fez/v1","kind":"Sample","host":"localhost",
128                "status":"ok","data":{"k":"v"}
129            })
130        );
131    }
132
133    #[test]
134    fn error_envelope_shape() {
135        let e = Envelope::error(
136            "Error",
137            "h1",
138            ApiError {
139                code: "not-found".into(),
140                message: "no unit".into(),
141                detail: None,
142            },
143        );
144        assert_eq!(
145            serde_json::to_value(&e).unwrap(),
146            json!({
147                "apiVersion":"fez/v1","kind":"Error","host":"h1",
148                "status":"error","error":{"code":"not-found","message":"no unit"}
149            })
150        );
151    }
152
153    #[test]
154    fn table_data_projects_columns_rows_count() {
155        let td = table_data(
156            &["name", "size"],
157            vec![json!(["bash", 7340032]), json!(["htop", 245760])],
158        );
159        assert_eq!(
160            td,
161            json!({
162                "columns": ["name", "size"],
163                "rows": [["bash", 7340032], ["htop", 245760]],
164                "count": 2
165            })
166        );
167        // Integer cells stay integers, not stringified.
168        assert!(td["rows"][0][1].is_i64() || td["rows"][0][1].is_u64());
169    }
170
171    #[test]
172    fn table_data_empty_has_zero_count() {
173        let td = table_data(&["name"], vec![]);
174        assert_eq!(td, json!({"columns": ["name"], "rows": [], "count": 0}));
175    }
176
177    #[test]
178    fn ok_envelope_with_hints() {
179        let e = Envelope::ok(
180            "ServiceMutation",
181            "localhost",
182            json!({"unit": "nginx.service"}),
183        )
184        .with_hints(json!({"reverse": "fez services start nginx.service"}));
185        assert_eq!(
186            serde_json::to_value(&e).unwrap(),
187            json!({
188                "apiVersion":"fez/v1","kind":"ServiceMutation","host":"localhost",
189                "status":"ok","data":{"unit":"nginx.service"},
190                "hints":{"reverse":"fez services start nginx.service"}
191            })
192        );
193    }
194}