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}