Skip to main content

protocol_droid/domain/
request_id.rs

1//! `RequestId` — a JSON-RPC request id.
2//!
3//! # Why it is the one newtype that is not `Copy`
4//!
5//! Every other newtype in this crate is `Copy` because all its fields are.
6//! `RequestId` wraps an `Arc<str>` for the string form, and duplicating a
7//! string id is not a thing anyone means to do: two "copies" of id `7` are the
8//! same request, and a function that took one by value when it only needed to
9//! read it should take a reference. The asymmetry is the signal.
10//!
11//! # Why an `Arc<str>` rather than a `String`
12//!
13//! A request id is read at least twice on every message — once to match a
14//! result back to its request, once to log or redact it — and on the JSON-RPC
15//! hot path that allocation is the expensive part. `Arc<str>` makes the
16//! long-lived case (store on the in-flight map, read when the response lands)
17//! a refcount bump instead of a copy, and it is one allocation at construction
18//! rather than one per read.
19
20use std::fmt;
21use std::sync::Arc;
22
23use crate::domain::jsonrpc::WireId;
24
25/// A JSON-RPC request id: a number or a string.
26///
27/// Deliberately *not* `Copy` — see the module docs.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum RequestId {
30    /// A numeric id, which the spec says must not contain fractional parts.
31    Number(i64),
32    /// A string id.
33    Text(Arc<str>),
34}
35
36impl RequestId {
37    /// A numeric id.
38    #[must_use]
39    pub const fn number(value: i64) -> Self {
40        Self::Number(value)
41    }
42
43    /// A string id.
44    #[must_use]
45    pub fn text(value: impl AsRef<str>) -> Self {
46        Self::Text(Arc::from(value.as_ref()))
47    }
48
49    /// This id from the wire form, where a message with no id has none.
50    ///
51    /// Lives here rather than in a transport adapter because it is a mapping
52    /// between the two representations of one value, and the stdio loop and the
53    /// HTTP router both need it. When it lived in the stdio adapter the HTTP
54    /// path had two options and both were wrong: copy it, and the two copies
55    /// drift; or reach into another adapter, and the layering breaks.
56    ///
57    /// `None` — a notification — becomes id `0`. That is not a claim the peer
58    /// made; it exists because [`RequestContext`] insists on an id to
59    /// correlate against, and a notification is given neither a progress token
60    /// nor a cancellation token, so nothing it does can reach the peer and the
61    /// value is unobservable. It is written back only if something is
62    /// genuinely broken.
63    ///
64    /// [`RequestContext`]: crate::application::server::context::RequestContext
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// # use protocol_droid::domain::RequestId;
70    /// # use protocol_droid::domain::jsonrpc::WireId;
71    /// let numeric = RequestId::from_wire(Some(&WireId::Number(7)));
72    /// let text = RequestId::from_wire(Some(&WireId::Text(std::borrow::Cow::Borrowed("a-1"))));
73    /// // A notification has no id, and gets the unobservable placeholder.
74    /// let none = RequestId::from_wire(None);
75    ///
76    /// assert_eq!(numeric, RequestId::number(7));
77    /// assert_eq!(text, RequestId::text("a-1"));
78    /// assert_eq!(none, RequestId::number(0));
79    /// ```
80    #[must_use]
81    pub fn from_wire(id: Option<&WireId<'_>>) -> Self {
82        match id {
83            Some(WireId::Number(value)) => Self::Number(*value),
84            Some(WireId::Text(text)) => Self::text(text.as_ref()),
85            None => Self::Number(0),
86        }
87    }
88
89    /// The id as it appears on the wire, as JSON.
90    ///
91    /// A string id is written with its surrounding quotes so the result can be
92    /// pasted into a JSON document without the caller having to know which arm
93    /// it got.
94    #[must_use]
95    pub fn to_json(&self) -> String {
96        match self {
97            Self::Number(value) => value.to_string(),
98            Self::Text(value) => format!("{value:?}"),
99        }
100    }
101
102    /// A short label for logs, which never gets the full id.
103    ///
104    /// Ids are redacted for the same reason tokens are: they are peer-chosen
105    /// and can carry anything. This gives correlation without disclosure.
106    #[must_use]
107    pub fn redacted(&self) -> String {
108        match self {
109            Self::Number(value) => format!("#{value}"),
110            // Characters, not bytes. A byte index of 4 lands *inside* the
111            // fourth character of any multi-byte id, and a request id is
112            // whatever UTF-8 the peer put in a JSON string — `["ab€"]` is a
113            // perfectly legal id, and slicing it by byte panics here rather
114            // than redacting. `clippy::indexing_slicing` does not catch it
115            // because the bound is not a literal.
116            Self::Text(value) => {
117                let head: String = value.chars().take(4).collect();
118                format!("#{head}…{}", value.chars().count())
119            }
120        }
121    }
122}
123
124impl fmt::Display for RequestId {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self {
127            Self::Number(value) => write!(formatter, "{value}"),
128            Self::Text(value) => formatter.write_str(value),
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    #![expect(
136        clippy::panic,
137        reason = "tests panic on the failing precondition, which is the message worth having"
138    )]
139
140    use super::RequestId;
141    use std::sync::Arc;
142
143    #[test]
144    fn numbers_and_texts_compare_by_value() {
145        assert_eq!(RequestId::number(7), RequestId::number(7));
146        assert_ne!(RequestId::number(7), RequestId::number(8));
147        assert_eq!(RequestId::text("a"), RequestId::text("a"));
148        assert_ne!(RequestId::number(7), RequestId::text("7"));
149        // Two separately-allocated strings are still equal, which is what
150        // makes the id usable as a map key.
151        assert_eq!(RequestId::text("abc"), RequestId::text(String::from("abc")));
152    }
153
154    #[test]
155    fn text_ids_are_refcounted_not_copied() {
156        let a = RequestId::text("shared");
157        let b = a.clone();
158        match (&a, &b) {
159            (RequestId::Text(left), RequestId::Text(right)) => {
160                assert!(Arc::ptr_eq(left, right), "clone copied the string");
161            }
162            _ => panic!("expected text ids"),
163        }
164    }
165
166    #[test]
167    fn to_json_quotes_only_string_ids() {
168        assert_eq!(RequestId::number(7).to_json(), "7");
169        assert_eq!(RequestId::number(-1).to_json(), "-1");
170        assert_eq!(RequestId::text("abc").to_json(), r#""abc""#);
171    }
172
173    /// Redaction must not panic on an id that is not ASCII.
174    ///
175    /// A request id is whatever UTF-8 the peer put in a JSON string, so
176    /// `["ab€"]` is a legal id — and it is five bytes, with a three-byte
177    /// character straddling byte four. Taking `&value[..len.min(4)]` landed
178    /// inside that character and panicked, which turned "log this id" into a
179    /// crash the peer chose. Every non-ASCII id of length 4 to 6 does it.
180    #[test]
181    fn redacting_a_multi_byte_id_does_not_panic() {
182        for id in ["ab€", "€€€", "工具调用", "café", "🎉ok", "a€b€"] {
183            let redacted = RequestId::text(id).redacted();
184            assert!(redacted.starts_with('#'), "{id:?} redacted to {redacted:?}");
185            assert!(redacted.contains('…'), "{id:?} redacted to {redacted:?}");
186        }
187    }
188
189    /// The length in a redaction is a character count, not a byte count.
190    ///
191    /// Stated because the obvious implementation reports bytes, and the two
192    /// disagree for every id outside ASCII — which is what makes it worth a
193    /// test rather than a comment.
194    #[test]
195    fn the_redacted_length_counts_characters() {
196        assert_eq!(RequestId::text("🎉ok").redacted(), "#🎉ok…3");
197        assert_eq!(RequestId::text("abcdef").redacted(), "#abcd…6");
198    }
199
200    /// Ids go into logs, so the redacted form has to correlate without
201    /// disclosing a peer-chosen string.
202    #[test]
203    fn redaction_correlates_without_disclosing() {
204        assert_eq!(RequestId::number(7).redacted(), "#7");
205        let redacted = RequestId::text("secret-token-value").redacted();
206        assert!(redacted.starts_with("#sec"), "{redacted}");
207        assert!(!redacted.contains("secret-token-value"), "{redacted}");
208        // A short id still works rather than panicking on a short slice.
209        assert!(RequestId::text("ab").redacted().starts_with("#ab"));
210    }
211
212    #[test]
213    fn display_is_the_bare_value() {
214        assert_eq!(RequestId::number(7).to_string(), "7");
215        assert_eq!(RequestId::text("abc").to_string(), "abc");
216    }
217
218    #[test]
219    fn ids_sort_deterministically() {
220        // Numbers before texts, so a map iteration is reproducible.
221        let mut ids = vec![
222            RequestId::text("b"),
223            RequestId::number(2),
224            RequestId::text("a"),
225            RequestId::number(1),
226        ];
227        ids.sort();
228        assert_eq!(
229            ids,
230            vec![
231                RequestId::number(1),
232                RequestId::number(2),
233                RequestId::text("a"),
234                RequestId::text("b"),
235            ]
236        );
237    }
238}