Skip to main content

devicerail_protocol/
rpc.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor, ser::Error as _};
4use serde_json::{Map, Value};
5
6use crate::ErrorInfo;
7
8pub const MAX_SAFE_INTEGER_ID: u64 = crate::MAX_SAFE_INTEGER;
9
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
12pub enum JsonRpcVersion {
13    #[serde(rename = "2.0")]
14    V2,
15}
16
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[cfg_attr(feature = "schema", schemars(with = "RpcIdSchema"))]
19#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum RpcId {
21    String(String),
22    Number(u64),
23}
24
25/// A positive timeout in milliseconds that is safe to represent as a JSON
26/// number in every supported client language.
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
29#[serde(transparent)]
30pub struct RequestTimeoutMs(
31    #[cfg_attr(
32        feature = "schema",
33        schemars(range(min = 1_u64, max = 9_007_199_254_740_991_u64))
34    )]
35    u64,
36);
37
38impl RequestTimeoutMs {
39    pub const MIN: u64 = 1;
40    pub const MAX: u64 = crate::MAX_SAFE_INTEGER;
41
42    pub const fn new(value: u64) -> Option<Self> {
43        if value >= Self::MIN && value <= Self::MAX {
44            Some(Self(value))
45        } else {
46            None
47        }
48    }
49
50    pub const fn get(self) -> u64 {
51        self.0
52    }
53}
54
55impl<'de> Deserialize<'de> for RequestTimeoutMs {
56    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
57    where
58        D: Deserializer<'de>,
59    {
60        let value = u64::deserialize(deserializer)?;
61        Self::new(value).ok_or_else(|| {
62            serde::de::Error::custom(format!(
63                "timeout must be between {} and {} milliseconds",
64                Self::MIN,
65                Self::MAX
66            ))
67        })
68    }
69}
70
71pub(crate) fn deserialize_optional_timeout<'de, D>(
72    deserializer: D,
73) -> Result<Option<RequestTimeoutMs>, D::Error>
74where
75    D: Deserializer<'de>,
76{
77    RequestTimeoutMs::deserialize(deserializer).map(Some)
78}
79
80#[cfg(feature = "schema")]
81#[allow(dead_code)]
82#[derive(schemars::JsonSchema)]
83#[serde(untagged)]
84enum RpcIdSchema {
85    String(String),
86    Number(#[schemars(range(max = 9_007_199_254_740_991_u64))] u64),
87}
88
89impl Serialize for RpcId {
90    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
91    where
92        S: Serializer,
93    {
94        match self {
95            Self::String(value) => serializer.serialize_str(value),
96            Self::Number(value) if *value <= MAX_SAFE_INTEGER_ID => {
97                serializer.serialize_u64(*value)
98            }
99            Self::Number(_) => Err(S::Error::custom(
100                "numeric request id exceeds the JavaScript safe integer limit",
101            )),
102        }
103    }
104}
105
106impl<'de> Deserialize<'de> for RpcId {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        struct RpcIdVisitor;
112
113        impl<'de> Visitor<'de> for RpcIdVisitor {
114            type Value = RpcId;
115
116            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117                write!(
118                    formatter,
119                    "a string or a non-negative integer no larger than {MAX_SAFE_INTEGER_ID}"
120                )
121            }
122
123            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
124            where
125                E: serde::de::Error,
126            {
127                Ok(RpcId::String(value.to_owned()))
128            }
129
130            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
131            where
132                E: serde::de::Error,
133            {
134                Ok(RpcId::String(value))
135            }
136
137            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
138            where
139                E: serde::de::Error,
140            {
141                if value <= MAX_SAFE_INTEGER_ID {
142                    Ok(RpcId::Number(value))
143                } else {
144                    Err(E::custom(
145                        "numeric request id exceeds the JavaScript safe integer limit",
146                    ))
147                }
148            }
149
150            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
151            where
152                E: serde::de::Error,
153            {
154                let value = u64::try_from(value)
155                    .map_err(|_| E::custom("numeric request id must be non-negative"))?;
156                self.visit_u64(value)
157            }
158
159            fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E>
160            where
161                E: serde::de::Error,
162            {
163                Err(E::custom("numeric request id must be an integer"))
164            }
165        }
166
167        deserializer.deserialize_any(RpcIdVisitor)
168    }
169}
170
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[derive(Clone, PartialEq, Deserialize, Serialize)]
173#[serde(untagged)]
174pub enum RpcParams {
175    Object(Map<String, Value>),
176    Array(Vec<Value>),
177}
178
179impl fmt::Debug for RpcParams {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::Object(values) => formatter
183                .debug_struct("RpcParams::Object")
184                .field("field_count", &values.len())
185                .finish(),
186            Self::Array(values) => formatter
187                .debug_struct("RpcParams::Array")
188                .field("item_count", &values.len())
189                .finish(),
190        }
191    }
192}
193
194impl RpcParams {
195    pub fn is_empty(&self) -> bool {
196        match self {
197            Self::Object(values) => values.is_empty(),
198            Self::Array(values) => values.is_empty(),
199        }
200    }
201
202    pub fn into_value(self) -> Value {
203        match self {
204            Self::Object(values) => Value::Object(values),
205            Self::Array(values) => Value::Array(values),
206        }
207    }
208}
209
210fn deserialize_optional_params<'de, D>(deserializer: D) -> Result<Option<RpcParams>, D::Error>
211where
212    D: Deserializer<'de>,
213{
214    let value = Value::deserialize(deserializer)?;
215    match value {
216        Value::Object(values) => Ok(Some(RpcParams::Object(values))),
217        Value::Array(values) => Ok(Some(RpcParams::Array(values))),
218        _ => Err(serde::de::Error::custom(
219            "params must be an object or array when present",
220        )),
221    }
222}
223
224#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
225#[derive(Clone, PartialEq, Deserialize, Serialize)]
226#[serde(rename_all = "camelCase", deny_unknown_fields)]
227pub struct RpcRequest {
228    pub jsonrpc: JsonRpcVersion,
229    pub id: RpcId,
230    pub method: String,
231    #[serde(
232        default,
233        deserialize_with = "deserialize_optional_timeout",
234        skip_serializing_if = "Option::is_none"
235    )]
236    #[cfg_attr(feature = "schema", schemars(with = "RequestTimeoutMs"))]
237    pub timeout_ms: Option<RequestTimeoutMs>,
238    #[serde(
239        default,
240        deserialize_with = "deserialize_optional_params",
241        skip_serializing_if = "Option::is_none"
242    )]
243    #[cfg_attr(feature = "schema", schemars(with = "RpcParams"))]
244    pub params: Option<RpcParams>,
245}
246
247impl fmt::Debug for RpcRequest {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        formatter
250            .debug_struct("RpcRequest")
251            .field("jsonrpc", &self.jsonrpc)
252            .field("id", &self.id)
253            .field("method", &self.method)
254            .field("timeout_ms", &self.timeout_ms)
255            .field("has_params", &self.params.is_some())
256            .finish()
257    }
258}
259
260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
261#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
262#[serde(rename_all = "camelCase", deny_unknown_fields)]
263pub struct RequestCancelParams {
264    pub request_id: RpcId,
265}
266
267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
268#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
269#[serde(rename_all = "camelCase")]
270pub enum RequestCancelStatus {
271    Requested,
272    AlreadyRequested,
273    NotFound,
274}
275
276#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
277#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
278#[serde(rename_all = "camelCase", deny_unknown_fields)]
279pub struct RequestCancelResult {
280    pub request_id: RpcId,
281    pub status: RequestCancelStatus,
282}
283
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
286#[serde(rename_all = "camelCase", deny_unknown_fields)]
287pub struct RpcError {
288    #[cfg_attr(
289        feature = "schema",
290        schemars(range(min = -2_147_483_648_i64, max = 2_147_483_647_i64))
291    )]
292    pub code: i32,
293    pub message: String,
294    pub data: ErrorInfo,
295}
296
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298#[cfg_attr(feature = "schema", schemars(with = "RpcResponseSchema"))]
299#[derive(Clone, PartialEq, Serialize)]
300#[serde(untagged)]
301pub enum RpcResponse {
302    Success {
303        jsonrpc: JsonRpcVersion,
304        id: RpcId,
305        result: Value,
306    },
307    Failure {
308        jsonrpc: JsonRpcVersion,
309        id: Option<RpcId>,
310        error: RpcError,
311    },
312}
313
314impl fmt::Debug for RpcResponse {
315    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316        match self {
317            Self::Success { jsonrpc, id, .. } => formatter
318                .debug_struct("RpcResponse::Success")
319                .field("jsonrpc", jsonrpc)
320                .field("id", id)
321                .field("result", &"<redacted>")
322                .finish(),
323            Self::Failure { jsonrpc, id, error } => formatter
324                .debug_struct("RpcResponse::Failure")
325                .field("jsonrpc", jsonrpc)
326                .field("id", id)
327                .field("rpc_code", &error.code)
328                .field("error_code", &error.data.code)
329                .finish(),
330        }
331    }
332}
333
334#[cfg(feature = "schema")]
335#[allow(dead_code)]
336#[derive(schemars::JsonSchema)]
337#[serde(untagged)]
338enum RpcResponseSchema {
339    Success(RpcSuccessSchema),
340    Failure(RpcFailureSchema),
341}
342
343#[cfg(feature = "schema")]
344#[allow(dead_code)]
345#[derive(schemars::JsonSchema)]
346#[serde(rename_all = "camelCase", deny_unknown_fields)]
347struct RpcSuccessSchema {
348    jsonrpc: JsonRpcVersion,
349    id: RpcId,
350    result: Value,
351}
352
353#[cfg(feature = "schema")]
354#[allow(dead_code)]
355#[derive(schemars::JsonSchema)]
356#[serde(rename_all = "camelCase", deny_unknown_fields)]
357struct RpcFailureSchema {
358    jsonrpc: JsonRpcVersion,
359    id: NullableRpcIdSchema,
360    error: RpcError,
361}
362
363#[cfg(feature = "schema")]
364#[allow(dead_code)]
365#[derive(schemars::JsonSchema)]
366#[serde(untagged)]
367enum NullableRpcIdSchema {
368    Id(RpcId),
369    Null(()),
370}
371
372impl<'de> Deserialize<'de> for RpcResponse {
373    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
374    where
375        D: Deserializer<'de>,
376    {
377        #[derive(Deserialize)]
378        #[serde(rename_all = "camelCase", deny_unknown_fields)]
379        struct SuccessWire {
380            jsonrpc: JsonRpcVersion,
381            id: RpcId,
382            result: Value,
383        }
384
385        #[derive(Deserialize)]
386        #[serde(untagged)]
387        enum NullableRpcId {
388            Id(RpcId),
389            Null(()),
390        }
391
392        #[derive(Deserialize)]
393        #[serde(rename_all = "camelCase", deny_unknown_fields)]
394        struct FailureWire {
395            jsonrpc: JsonRpcVersion,
396            id: NullableRpcId,
397            error: RpcError,
398        }
399
400        #[derive(Deserialize)]
401        #[serde(untagged)]
402        enum ResponseWire {
403            Success(SuccessWire),
404            Failure(FailureWire),
405        }
406
407        match ResponseWire::deserialize(deserializer)? {
408            ResponseWire::Success(response) => Ok(Self::Success {
409                jsonrpc: response.jsonrpc,
410                id: response.id,
411                result: response.result,
412            }),
413            ResponseWire::Failure(response) => Ok(Self::Failure {
414                jsonrpc: response.jsonrpc,
415                id: match response.id {
416                    NullableRpcId::Id(id) => Some(id),
417                    NullableRpcId::Null(()) => None,
418                },
419                error: response.error,
420            }),
421        }
422    }
423}
424
425impl RpcResponse {
426    pub fn success(id: RpcId, result: Value) -> Self {
427        Self::Success {
428            jsonrpc: JsonRpcVersion::V2,
429            id,
430            result,
431        }
432    }
433
434    pub fn failure(id: Option<RpcId>, error: RpcError) -> Self {
435        Self::Failure {
436            jsonrpc: JsonRpcVersion::V2,
437            id,
438            error,
439        }
440    }
441
442    pub fn result(&self) -> Option<&Value> {
443        match self {
444            Self::Success { result, .. } => Some(result),
445            Self::Failure { .. } => None,
446        }
447    }
448
449    pub fn error(&self) -> Option<&RpcError> {
450        match self {
451            Self::Success { .. } => None,
452            Self::Failure { error, .. } => Some(error),
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use serde_json::{Value, json};
460
461    use super::{
462        JsonRpcVersion, MAX_SAFE_INTEGER_ID, RequestCancelParams, RequestCancelResult,
463        RequestCancelStatus, RequestTimeoutMs, RpcId, RpcParams, RpcRequest, RpcResponse,
464    };
465
466    #[test]
467    fn request_requires_json_rpc_two_and_a_safe_id() {
468        let request: RpcRequest = serde_json::from_value(json!({
469            "jsonrpc": "2.0",
470            "id": MAX_SAFE_INTEGER_ID,
471            "method": "system.hello"
472        }))
473        .expect("valid request");
474        assert_eq!(request.jsonrpc, JsonRpcVersion::V2);
475        assert_eq!(request.id, RpcId::Number(MAX_SAFE_INTEGER_ID));
476        assert!(request.params.is_none());
477
478        for invalid in [
479            json!({ "jsonrpc": "1.0", "id": 1, "method": "x" }),
480            json!({ "jsonrpc": "2.0", "id": null, "method": "x" }),
481            json!({ "jsonrpc": "2.0", "id": -1, "method": "x" }),
482            json!({ "jsonrpc": "2.0", "id": 1.5, "method": "x" }),
483            json!({ "jsonrpc": "2.0", "id": MAX_SAFE_INTEGER_ID + 1, "method": "x" }),
484            json!({ "jsonrpc": "2.0", "id": 1, "method": "x", "params": null }),
485            json!({ "jsonrpc": "2.0", "id": 1, "method": "x", "params": 42 }),
486        ] {
487            assert!(serde_json::from_value::<RpcRequest>(invalid).is_err());
488        }
489
490        assert!(serde_json::to_value(RpcId::Number(MAX_SAFE_INTEGER_ID + 1)).is_err());
491    }
492
493    #[test]
494    fn request_timeout_accepts_only_positive_safe_integers() {
495        let request: RpcRequest = serde_json::from_value(json!({
496            "jsonrpc": "2.0",
497            "id": "bounded",
498            "method": "device.observe",
499            "timeoutMs": RequestTimeoutMs::MAX
500        }))
501        .expect("maximum safe timeout");
502        assert_eq!(
503            request.timeout_ms.map(RequestTimeoutMs::get),
504            Some(RequestTimeoutMs::MAX)
505        );
506
507        for timeout in [json!(null), json!(0), json!(RequestTimeoutMs::MAX + 1)] {
508            let invalid = json!({
509                "jsonrpc": "2.0",
510                "id": "invalid-timeout",
511                "method": "device.observe",
512                "timeoutMs": timeout
513            });
514            assert!(serde_json::from_value::<RpcRequest>(invalid).is_err());
515        }
516        assert!(RequestTimeoutMs::new(0).is_none());
517        assert!(RequestTimeoutMs::new(RequestTimeoutMs::MAX + 1).is_none());
518    }
519
520    #[test]
521    fn old_request_json_round_trips_without_new_fields() {
522        let old = json!({
523            "jsonrpc": "2.0",
524            "id": "old-client",
525            "method": "device.execute",
526            "params": {
527                "id": "00000000-0000-0000-0000-000000000000",
528                "name": "tap",
529                "arguments": { "x": 10, "y": 20 }
530            }
531        });
532        let request: RpcRequest = serde_json::from_value(old.clone()).expect("old request");
533        assert!(request.timeout_ms.is_none());
534        assert_eq!(
535            serde_json::to_value(request).expect("serialize old request"),
536            old
537        );
538
539        let unknown = json!({
540            "jsonrpc": "2.0",
541            "id": "strict",
542            "method": "device.observe",
543            "deadlineMs": 100
544        });
545        assert!(serde_json::from_value::<RpcRequest>(unknown).is_err());
546    }
547
548    #[test]
549    fn cancellation_models_are_strict_and_use_camel_case_statuses() {
550        let params: RequestCancelParams = serde_json::from_value(json!({
551            "requestId": "execute-1"
552        }))
553        .expect("cancel params");
554        let result = RequestCancelResult {
555            request_id: params.request_id,
556            status: RequestCancelStatus::AlreadyRequested,
557        };
558        assert_eq!(
559            serde_json::to_value(result).expect("cancel result"),
560            json!({
561                "requestId": "execute-1",
562                "status": "alreadyRequested"
563            })
564        );
565        assert!(
566            serde_json::from_value::<RequestCancelParams>(json!({
567                "requestId": "execute-1",
568                "unknown": true
569            }))
570            .is_err()
571        );
572        assert!(
573            serde_json::from_value::<RequestCancelResult>(json!({
574                "requestId": "execute-1",
575                "status": "requested",
576                "unknown": true
577            }))
578            .is_err()
579        );
580    }
581
582    #[test]
583    fn response_shape_cannot_contain_both_result_and_error() {
584        let response = RpcResponse::success(RpcId::String("request-1".to_owned()), json!({}));
585        let value = serde_json::to_value(response).expect("serialize response");
586        assert!(value.get("result").is_some());
587        assert!(value.get("error").is_none());
588        assert_eq!(value["jsonrpc"], "2.0");
589
590        let restored: RpcResponse = serde_json::from_value(value).expect("deserialize response");
591        assert_eq!(restored.result(), Some(&Value::Object(Default::default())));
592
593        let invalid = json!({
594            "jsonrpc": "2.0",
595            "id": 1,
596            "result": {},
597            "error": {
598                "code": -32603,
599                "message": "internal",
600                "data": {
601                    "code": "internal_error",
602                    "message": "internal",
603                    "retryable": false,
604                    "details": null
605                }
606            }
607        });
608        assert!(serde_json::from_value::<RpcResponse>(invalid).is_err());
609    }
610
611    #[test]
612    fn rpc_debug_views_do_not_render_raw_params() {
613        const SENTINEL: &str = "DEVICERAIL_RPC_SECRET_SENTINEL";
614        let params = RpcParams::Object(serde_json::Map::from_iter([(
615            "arguments".to_owned(),
616            json!({ "text": SENTINEL }),
617        )]));
618        let request = RpcRequest {
619            jsonrpc: JsonRpcVersion::V2,
620            id: RpcId::Number(1),
621            method: "device.execute".to_owned(),
622            timeout_ms: None,
623            params: Some(params.clone()),
624        };
625        assert!(!format!("{params:?}").contains(SENTINEL));
626        assert!(!format!("{request:?}").contains(SENTINEL));
627        let response = RpcResponse::success(
628            RpcId::Number(1),
629            json!({ "endpoint": format!("ws://127.0.0.1/v/{SENTINEL}") }),
630        );
631        assert!(!format!("{response:?}").contains(SENTINEL));
632    }
633}