Skip to main content

sloop/
protocol.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6pub const PROTOCOL_VERSION: u32 = 1;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(transparent)]
10pub struct RequestId(String);
11
12impl RequestId {
13    pub fn new(value: impl Into<String>) -> Self {
14        Self(value.into())
15    }
16
17    pub fn as_str(&self) -> &str {
18        &self.0
19    }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct RequestEnvelope {
24    pub v: u32,
25    pub id: RequestId,
26    #[serde(flatten)]
27    pub request: Request,
28    pub token: Option<String>,
29}
30
31impl RequestEnvelope {
32    pub fn new(id: RequestId, request: Request, token: Option<String>) -> Self {
33        Self {
34            v: PROTOCOL_VERSION,
35            id,
36            request,
37            token,
38        }
39    }
40
41    pub fn decode(line: &str) -> Result<Self, ProtocolError> {
42        let value: Value = serde_json::from_str(line)
43            .map_err(|error| ProtocolError::invalid_request(format!("malformed JSON: {error}")))?;
44        let object = value
45            .as_object()
46            .ok_or_else(|| ProtocolError::invalid_request("request must be a JSON object"))?;
47
48        let version = object.get("v").and_then(Value::as_u64).ok_or_else(|| {
49            ProtocolError::invalid_request("request field `v` must be an integer")
50        })?;
51        if version != u64::from(PROTOCOL_VERSION) {
52            return Err(ProtocolError::new(
53                ErrorCode::UnsupportedVersion,
54                format!("unsupported protocol version {version}"),
55                json!({"supported": [PROTOCOL_VERSION], "received": version}),
56            ));
57        }
58
59        let verb = object.get("verb").and_then(Value::as_str).ok_or_else(|| {
60            ProtocolError::invalid_request("request field `verb` must be a string")
61        })?;
62        if !Request::is_known_verb(verb) {
63            return Err(ProtocolError::new(
64                ErrorCode::UnknownVerb,
65                format!("unknown verb `{verb}`"),
66                json!({"verb": verb}),
67            ));
68        }
69
70        serde_json::from_value(value).map_err(|error| {
71            ProtocolError::invalid_request(format!("invalid request envelope: {error}"))
72        })
73    }
74
75    pub fn encode(&self) -> Result<String, serde_json::Error> {
76        serde_json::to_string(self)
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(tag = "verb", content = "args", rename_all = "snake_case")]
82pub enum Request {
83    Init(EmptyArgs),
84    Daemon(EmptyArgs),
85    Post(PostArgs),
86    Run(RunArgs),
87    Retry(TicketReferenceArgs),
88    Hold(TicketReferenceArgs),
89    Ready(TicketReferenceArgs),
90    List(EmptyArgs),
91    Status(EmptyArgs),
92    Pause(EmptyArgs),
93    Resume(EmptyArgs),
94    Stop(StopArgs),
95    Cancel(RunReferenceArgs),
96    Logs(RunReferenceArgs),
97    Wait(RunReferenceArgs),
98    Reindex(EmptyArgs),
99    Brief(EmptyArgs),
100    Show(ShowArgs),
101    Note(NoteArgs),
102}
103
104impl Request {
105    pub fn verb(&self) -> &'static str {
106        match self {
107            Self::Init(_) => "init",
108            Self::Daemon(_) => "daemon",
109            Self::Post(_) => "post",
110            Self::Run(_) => "run",
111            Self::Retry(_) => "retry",
112            Self::Hold(_) => "hold",
113            Self::Ready(_) => "ready",
114            Self::List(_) => "list",
115            Self::Status(_) => "status",
116            Self::Pause(_) => "pause",
117            Self::Resume(_) => "resume",
118            Self::Stop(_) => "stop",
119            Self::Cancel(_) => "cancel",
120            Self::Logs(_) => "logs",
121            Self::Wait(_) => "wait",
122            Self::Reindex(_) => "reindex",
123            Self::Brief(_) => "brief",
124            Self::Show(_) => "show",
125            Self::Note(_) => "note",
126        }
127    }
128
129    pub fn capability(&self) -> Capability {
130        match self {
131            Self::Brief(_) | Self::Note(_) => Capability::Worker,
132            Self::Show(_) => Capability::Both,
133            _ => Capability::Operator,
134        }
135    }
136
137    fn is_known_verb(verb: &str) -> bool {
138        matches!(
139            verb,
140            "init"
141                | "daemon"
142                | "post"
143                | "run"
144                | "retry"
145                | "hold"
146                | "ready"
147                | "list"
148                | "status"
149                | "pause"
150                | "resume"
151                | "stop"
152                | "cancel"
153                | "logs"
154                | "wait"
155                | "reindex"
156                | "brief"
157                | "show"
158                | "note"
159        )
160    }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Capability {
165    Operator,
166    Worker,
167    Both,
168}
169
170#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct EmptyArgs {}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct PostArgs {
177    pub file: String,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub project: Option<String>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub flow: Option<String>,
182    pub activation: PostActivation,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(tag = "kind", rename_all = "snake_case")]
187pub enum PostActivation {
188    Auto,
189    At { time: String },
190    Manual,
191    Hold,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct RunArgs {
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub ticket: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub project: Option<String>,
201    pub activation: RunActivation,
202    pub only: Vec<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(tag = "kind", rename_all = "snake_case")]
207pub enum RunActivation {
208    Now,
209    At { local_time: String },
210    Every { interval_ms: u64 },
211    Overnight,
212}
213
214#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct StopArgs {
217    #[serde(default)]
218    pub force: bool,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct RunReferenceArgs {
224    pub run: String,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct TicketReferenceArgs {
230    pub ticket: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct ShowArgs {
236    #[serde(rename = "ref")]
237    pub reference: String,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct NoteArgs {
243    pub text: String,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum ErrorCode {
249    InvalidArguments,
250    InvalidRequest,
251    UnsupportedVersion,
252    UnknownVerb,
253    DaemonUnavailable,
254    Unauthorized,
255    NotFound,
256    Conflict,
257    CooldownActive,
258    Internal,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub struct ErrorBody {
263    pub code: ErrorCode,
264    pub message: String,
265    pub details: Value,
266}
267
268#[derive(Debug, Clone, PartialEq)]
269pub struct ProtocolError {
270    pub body: ErrorBody,
271}
272
273impl ProtocolError {
274    pub fn new(code: ErrorCode, message: impl Into<String>, details: Value) -> Self {
275        Self {
276            body: ErrorBody {
277                code,
278                message: message.into(),
279                details,
280            },
281        }
282    }
283
284    fn invalid_request(message: impl Into<String>) -> Self {
285        Self::new(ErrorCode::InvalidRequest, message, json!({}))
286    }
287}
288
289impl fmt::Display for ProtocolError {
290    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291        formatter.write_str(&self.body.message)
292    }
293}
294
295impl std::error::Error for ProtocolError {}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct ResponseEnvelope {
299    pub id: Option<RequestId>,
300    pub ok: bool,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub data: Option<Value>,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub error: Option<ErrorBody>,
305}
306
307impl ResponseEnvelope {
308    pub fn success(id: Option<RequestId>, data: Value) -> Self {
309        Self {
310            id,
311            ok: true,
312            data: Some(data),
313            error: None,
314        }
315    }
316
317    pub fn failure(id: Option<RequestId>, error: ErrorBody) -> Self {
318        Self {
319            id,
320            ok: false,
321            data: None,
322            error: Some(error),
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use serde_json::{Value, json};
330
331    use super::{
332        EmptyArgs, ErrorBody, ErrorCode, Request, RequestEnvelope, RequestId, ResponseEnvelope,
333        RunActivation, RunArgs,
334    };
335
336    #[test]
337    fn request_envelope_serializes_to_the_public_wire_shape() {
338        let envelope = RequestEnvelope::new(
339            RequestId::new("req-123"),
340            Request::Run(RunArgs {
341                ticket: Some("T1".into()),
342                project: None,
343                activation: RunActivation::Now,
344                only: Vec::new(),
345            }),
346            None,
347        );
348
349        let value: Value = serde_json::from_str(&envelope.encode().unwrap()).unwrap();
350        assert_eq!(
351            value,
352            json!({
353                "v": 1,
354                "id": "req-123",
355                "verb": "run",
356                "args": {
357                    "ticket": "T1",
358                    "activation": {"kind": "now"},
359                    "only": []
360                },
361                "token": null
362            })
363        );
364    }
365
366    #[test]
367    fn request_envelope_round_trips() {
368        let expected = RequestEnvelope::new(
369            RequestId::new("req-1"),
370            Request::Brief(EmptyArgs::default()),
371            Some("worker-token".into()),
372        );
373
374        let decoded = RequestEnvelope::decode(&expected.encode().unwrap()).unwrap();
375        assert_eq!(decoded, expected);
376    }
377
378    #[test]
379    fn malformed_json_is_an_invalid_request() {
380        let error = RequestEnvelope::decode("{").unwrap_err();
381        assert_eq!(error.body.code, ErrorCode::InvalidRequest);
382    }
383
384    #[test]
385    fn unsupported_versions_have_a_stable_error_code() {
386        let error = RequestEnvelope::decode(
387            r#"{"v":2,"id":"req-1","verb":"status","args":{},"token":null}"#,
388        )
389        .unwrap_err();
390
391        assert_eq!(error.body.code, ErrorCode::UnsupportedVersion);
392        assert_eq!(error.body.details["received"], 2);
393    }
394
395    #[test]
396    fn unknown_verbs_have_a_stable_error_code() {
397        let error = RequestEnvelope::decode(
398            r#"{"v":1,"id":"req-1","verb":"merge","args":{},"token":null}"#,
399        )
400        .unwrap_err();
401
402        assert_eq!(error.body.code, ErrorCode::UnknownVerb);
403        assert_eq!(error.body.details["verb"], "merge");
404    }
405
406    #[test]
407    fn known_verbs_reject_invalid_arguments() {
408        let error = RequestEnvelope::decode(
409            r#"{"v":1,"id":"req-1","verb":"show","args":{},"token":"token"}"#,
410        )
411        .unwrap_err();
412
413        assert_eq!(error.body.code, ErrorCode::InvalidRequest);
414    }
415
416    #[test]
417    fn response_envelopes_have_exclusive_success_and_error_payloads() {
418        let success = serde_json::to_value(ResponseEnvelope::success(
419            Some(RequestId::new("req-1")),
420            json!({"paused": false}),
421        ))
422        .unwrap();
423        assert_eq!(
424            success,
425            json!({"id": "req-1", "ok": true, "data": {"paused": false}})
426        );
427
428        let failure = serde_json::to_value(ResponseEnvelope::failure(
429            Some(RequestId::new("req-2")),
430            ErrorBody {
431                code: ErrorCode::Conflict,
432                message: "ticket is already claimed".into(),
433                details: json!({"ticket": "T1"}),
434            },
435        ))
436        .unwrap();
437        assert_eq!(
438            failure,
439            json!({
440                "id": "req-2",
441                "ok": false,
442                "error": {
443                    "code": "conflict",
444                    "message": "ticket is already claimed",
445                    "details": {"ticket": "T1"}
446                }
447            })
448        );
449    }
450}