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}