Skip to main content

ashlar_control/
protocol.rs

1//! Wire types of the control protocol: one JSON object per line each way.
2//!
3//! Request: `{"id"?: any, "key"?: string, "method": string, "params"?: any}`.
4//! Reply: `{"id"?: any, "ok": true, "result": any}` or `{"id"?: any, "ok": false, "error": string, "detail"?: any}`.
5//! See `docs/control-protocol.md`.
6
7use std::sync::mpsc::{Receiver, Sender, channel};
8
9use ashlar_command::CommandError;
10use serde_json::{Map, Value, json};
11
12/// Version of the method vocabulary, reported by `app.version`. Bumped on incompatible changes.
13pub const PROTOCOL_VERSION: u32 = 1;
14
15/// One reply. `detail` carries machine-readable context for an error (a [`CommandError`] as JSON,
16/// the step results of a failed batch).
17#[derive(Debug, Clone, PartialEq)]
18pub enum Reply {
19    Ok(Value),
20    Err { error: String, detail: Option<Value> },
21}
22
23impl Reply {
24    pub fn ok(v: Value) -> Self {
25        Reply::Ok(v)
26    }
27
28    pub fn err(e: impl std::fmt::Display) -> Self {
29        Reply::Err { error: e.to_string(), detail: None }
30    }
31
32    pub fn err_with(e: impl std::fmt::Display, detail: Value) -> Self {
33        Reply::Err { error: e.to_string(), detail: Some(detail) }
34    }
35
36    /// A command's outcome; errors keep their kind in `detail`.
37    pub fn from_command(r: Result<Value, CommandError>) -> Self {
38        match r {
39            Ok(v) => Reply::Ok(v),
40            Err(e) => Reply::Err { error: e.to_string(), detail: serde_json::to_value(&e).ok() },
41        }
42    }
43
44    pub fn is_ok(&self) -> bool {
45        matches!(self, Reply::Ok(_))
46    }
47
48    /// The wire object, with `id` when the request had one.
49    pub fn to_json(&self, id: Option<&Value>) -> Value {
50        let mut o = Map::new();
51        if let Some(id) = id {
52            o.insert("id".into(), id.clone());
53        }
54        match self {
55            Reply::Ok(v) => {
56                o.insert("ok".into(), Value::Bool(true));
57                o.insert("result".into(), v.clone());
58            }
59            Reply::Err { error, detail } => {
60                o.insert("ok".into(), Value::Bool(false));
61                o.insert("error".into(), Value::String(error.clone()));
62                if let Some(d) = detail {
63                    o.insert("detail".into(), d.clone());
64                }
65            }
66        }
67        Value::Object(o)
68    }
69
70    /// Read a wire reply. A missing or non-boolean `ok` is a protocol error (`None`).
71    pub fn from_json(v: &Value) -> Option<Self> {
72        match v.get("ok")?.as_bool()? {
73            true => Some(Reply::Ok(v.get("result").cloned().unwrap_or(Value::Null))),
74            false => {
75                Some(Reply::Err { error: v.get("error").and_then(Value::as_str).unwrap_or("error").to_string(), detail: v.get("detail").cloned() })
76            }
77        }
78    }
79
80    /// `Ok(result)` or `Err((error, detail))`.
81    pub fn into_result(self) -> Result<Value, (String, Option<Value>)> {
82        match self {
83            Reply::Ok(v) => Ok(v),
84            Reply::Err { error, detail } => Err((error, detail)),
85        }
86    }
87}
88
89/// One request on its way to the app thread. Answer it with [`Request::reply`] (or a shortcut);
90/// a request dropped unanswered replies with an error by itself, so the connection never hangs.
91/// It may be kept and answered on a later frame (the element is not on screen yet, a screenshot
92/// needs the next paint).
93#[derive(Debug)]
94pub struct Request {
95    method: String,
96    params: Value,
97    slot: Option<Sender<Reply>>,
98}
99
100impl Request {
101    /// A request and the receiver its reply arrives on. `params` that are `null` become `{}`.
102    pub fn new(method: impl Into<String>, params: Value) -> (Self, Receiver<Reply>) {
103        let (tx, rx) = channel();
104        let params = if params.is_null() { json!({}) } else { params };
105        (Self { method: method.into(), params, slot: Some(tx) }, rx)
106    }
107
108    pub fn method(&self) -> &str {
109        &self.method
110    }
111
112    pub fn params(&self) -> &Value {
113        &self.params
114    }
115
116    /// Answer. The receiver may be gone (the connection closed or timed out): that is fine.
117    pub fn reply(mut self, r: Reply) {
118        if let Some(tx) = self.slot.take() {
119            let _ = tx.send(r);
120        }
121    }
122
123    pub fn ok(self, v: Value) {
124        self.reply(Reply::Ok(v));
125    }
126
127    pub fn fail(self, e: impl std::fmt::Display) {
128        self.reply(Reply::err(e));
129    }
130
131    /// The standard answer for a method nobody handles.
132    pub fn unknown(self) {
133        let m = self.method.clone();
134        self.fail(format!("unknown method `{m}` (see app.version for the methods this app answers)"));
135    }
136}
137
138impl Drop for Request {
139    fn drop(&mut self) {
140        if let Some(tx) = self.slot.take() {
141            let _ = tx.send(Reply::err(format!("`{}` was dropped by the app without an answer", self.method)));
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn reply_round_trips() {
152        for r in [Reply::ok(json!({"a": 1})), Reply::err("nope"), Reply::err_with("bad", json!({"steps": []}))] {
153            let wire = r.to_json(Some(&json!(7)));
154            assert_eq!(wire["id"], 7);
155            assert_eq!(Reply::from_json(&wire), Some(r));
156        }
157        assert_eq!(Reply::ok(Value::Null).to_json(None), json!({"ok": true, "result": null}));
158        assert_eq!(Reply::from_json(&json!({"result": 1})), None);
159        assert_eq!(Reply::from_json(&json!({"ok": "yes"})), None);
160    }
161
162    #[test]
163    fn command_errors_keep_their_kind() {
164        let r = Reply::from_command(Err(CommandError::Unknown { id: "x".into() }));
165        let Reply::Err { error, detail } = r else { panic!() };
166        assert_eq!(error, "unknown command `x`");
167        assert_eq!(detail, Some(json!({"kind": "unknown", "id": "x"})));
168    }
169
170    #[test]
171    fn a_dropped_request_answers_itself() {
172        let (req, rx) = Request::new("ui.click", Value::Null);
173        assert_eq!(req.params(), &json!({}));
174        drop(req);
175        let r = rx.try_recv().unwrap();
176        assert!(!r.is_ok());
177        // Answered once only.
178        let (req, rx) = Request::new("x", json!({}));
179        req.ok(json!(1));
180        assert_eq!(rx.try_recv().unwrap(), Reply::ok(json!(1)));
181        assert!(rx.try_recv().is_err());
182    }
183}