protocol_droid/adapters/secondary/transport/legacy_sse/client.rs
1//! The `2024-11-05` client: a `GET` that opens a stream, and a `POST` to
2//! wherever the server says.
3//!
4//! The shape is the inverse of every other transport here, and the inversion is
5//! worth stating once. On Streamable HTTP the client owns the URL and the server
6//! answers where it was asked. Here the **server owns the URL**: the client
7//! `GET`s something, and the first thing that comes back is the path every later
8//! request must go to. A client that trusted that path would be a server
9//! choosing where the client authenticates. This one parses it through
10//! [`MessageEndpoint`], which refuses a target naming another host, and both
11//! that refusal and the missing handshake are tested rather than asserted.
12
13use core::future::Future;
14use core::pin::Pin;
15use core::str::FromStr;
16use core::sync::atomic::{AtomicI64, Ordering};
17use core::time::Duration;
18use tokio::sync::Mutex;
19
20use serde_json::Value;
21
22use crate::adapters::primary::transport::http::SseEvent;
23use crate::adapters::secondary::transport::http::FrameReader;
24use crate::application::jsonrpc::document::{
25 notification_document, reply_result, request_document,
26};
27use crate::domain::ir::JsonObject;
28use crate::domain::{MessageEndpoint, RequestId, names};
29
30use crate::application::client::{ClientTransport, TransportError};
31use crate::application::jsonrpc::document::RequestError;
32use crate::domain::ir::MethodName;
33
34use super::error::{self, LegacySseError};
35
36/// The media type that means "an open event stream".
37const EVENT_STREAM: &str = "text/event-stream";
38/// The media type this transport POSTs.
39const JSON: &str = "application/json";
40
41/// The HTTP method the connection is opened with, named in errors.
42const GET: &str = "GET";
43/// The HTTP method every client write uses.
44const POST: &str = "POST";
45
46/// Where a `2024-11-05` client connects and how patient it is.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LegacySseConfig {
49 url: String,
50 connect_timeout: Option<Duration>,
51}
52
53impl LegacySseConfig {
54 /// A configuration pointed at `url`.
55 ///
56 /// # Errors
57 ///
58 /// [`LegacySseError::Url`] if `url` is not a URL a request can be built
59 /// against. Checked here rather than at connect time, so a typo is a
60 /// construction error and not a surprise on the wire.
61 pub fn new(url: &str) -> Result<Self, LegacySseError> {
62 reqwest::Url::parse(url).map_err(|error| LegacySseError::Url {
63 got: url.to_owned(),
64 reason: error.to_string(),
65 })?;
66 Ok(Self {
67 url: url.to_owned(),
68 connect_timeout: None,
69 })
70 }
71
72 /// Bounds how long the connection may take to open.
73 ///
74 /// A **connect** timeout, not a request one, and the distinction is the
75 /// point. A request timeout would bound the whole read, and on this
76 /// transport the read *is* the conversation: a stream meant to stay open for
77 /// an hour is a correct client, not a slow one. Bounding the read would
78 /// break every session longer than the timeout and report it as a failure
79 /// naming a request that was never slow.
80 #[must_use]
81 pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
82 self.connect_timeout = Some(timeout);
83 self
84 }
85
86 /// The URL this client was pointed at.
87 #[must_use]
88 pub fn url(&self) -> &str {
89 &self.url
90 }
91}
92
93/// One frame off the stream, already classified.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum ClientFrame {
96 /// A JSON-RPC frame the server sent.
97 Message(Value),
98 /// An `endpoint` event.
99 ///
100 /// Kept in the enum rather than consumed by the handshake, because a client
101 /// that reconnects sees the handshake again — and a client with no case for
102 /// it drops the one frame that tells it where to post.
103 Endpoint(MessageEndpoint),
104}
105
106impl ClientFrame {
107 /// Classifies a frame off the wire.
108 ///
109 /// The unnamed case is the load-bearing one. The specification calls these
110 /// `message` events, and an SSE event with no `event:` field **is** a
111 /// `message` event by the standard's own default — which is what this
112 /// crate's server writes. So the question is never "is it named
113 /// `message`" but "is it named something *else*". A client filtering on
114 /// `is_named(&names::MESSAGE)` and nothing else passes every test written
115 /// against a frame explicitly named `message`, and drops every frame a
116 /// conforming server actually sends.
117 fn classify(event: &SseEvent) -> Result<Self, LegacySseError> {
118 let data = event.data().to_owned();
119 if event.is_named(&names::ENDPOINT) {
120 return MessageEndpoint::from_str(&data)
121 .map(Self::Endpoint)
122 .map_err(LegacySseError::endpoint);
123 }
124 // Everything else is a payload — including a frame explicitly named
125 // `message`, which some 2024-11-05 SDKs do send, and which is the same
126 // frame under a different spelling.
127 serde_json::from_str::<Value>(&data)
128 .map(Self::Message)
129 .map_err(|error| LegacySseError::Unreadable {
130 got: truncate(&data),
131 reason: error.to_string(),
132 })
133 }
134
135 /// What this frame was, for a message that has to name it.
136 fn describe(&self) -> String {
137 match self {
138 Self::Endpoint(endpoint) => format!("an `endpoint` event naming {endpoint}"),
139 Self::Message(value) => format!("a message frame: {value}"),
140 }
141 }
142}
143
144/// The open stream, and the machinery that turns its bytes into frames.
145///
146/// Split from [`LegacySseClient`] so the handshake can read the first event
147/// *before* the client that will hold it exists. That ordering is what lets the
148/// message endpoint be a plain field rather than an `Option` every `send` has to
149/// unwrap: there is no state in which a client is built and its post target is
150/// still unknown.
151#[derive(Debug)]
152struct Stream {
153 response: reqwest::Response,
154 reader: FrameReader,
155 ready: std::collections::VecDeque<SseEvent>,
156}
157
158impl Stream {
159 /// The next complete frame, or `None` once the peer has finished.
160 async fn next(&mut self) -> Result<Option<SseEvent>, LegacySseError> {
161 loop {
162 // Handed over before the socket is touched again. Reading first
163 // would reorder the stream past its own buffer, which only shows up
164 // when a peer is fast.
165 if let Some(event) = self.ready.pop_front() {
166 return Ok(Some(event));
167 }
168 let chunk = self
169 .response
170 .chunk()
171 .await
172 .map_err(|error| LegacySseError::Io { method: GET, error })?;
173 let Some(chunk) = chunk else {
174 return Ok(None);
175 };
176 self.ready
177 .extend(
178 self.reader
179 .push(&chunk)
180 .map_err(|source| LegacySseError::Unreadable {
181 got: String::from("a terminated SSE block"),
182 reason: source.to_string(),
183 })?,
184 );
185 }
186 }
187}
188
189/// A connected `2024-11-05` client.
190///
191/// The stream is open from the moment this exists and stays open for its life:
192/// this transport has no session header and no reconnection story, so closing
193/// the `GET` is the only way to end the conversation. That is why the send path takes
194/// `&self` but [`next`](Self::next) takes `&mut self` — a `POST` is a write on a
195/// different request and does not consume the reader.
196#[derive(Debug)]
197pub struct LegacySseClient {
198 inner: reqwest::Client,
199 config: LegacySseConfig,
200 /// Where every `POST` goes.
201 ///
202 /// Resolved, not the raw advertised target. The target arrives relative —
203 /// that is what [`MessageEndpoint`] insists on, because a relative target
204 /// has no host in it to redirect through — and a relative target is not
205 /// something a request can be sent to. Resolution is where the origin check
206 /// lives, and [`resolve`] is where it is proved.
207 post_to: reqwest::Url,
208 /// Boxed, and only because of [`Detected`](crate::adapters::secondary::transport::detect::Detected).
209 ///
210 /// The open response and its buffer are far larger than the other arm of
211 /// that enum, so an unboxed stream makes every `Detected` as big as the
212 /// legacy one. Nothing else changes: the stream is open for the client's
213 /// whole life and is never moved.
214 ///
215 /// Behind a [`Mutex`] because [`ClientTransport`] sends from `&self`: a
216 /// client that could only be read from `&mut self` could not sit behind
217 /// `Arc<dyn ClientTransport>`, which is the only shape a negotiated
218 /// [`Client`](crate::application::client::Client) can hold.
219 ///
220 /// `tokio`'s rather than `std`'s, and not for convenience: a frame is read
221 /// across an `await`, and `std::sync::MutexGuard` is `!Send`, so the whole
222 /// future would stop being `Send` — and the port requires a `Send` future.
223 /// Held across the read on purpose: a frame cannot be read from two places
224 /// at once, and a lock dropped at the first `await` would let a second call
225 /// interleave into the middle of the first one's read.
226 ///
227 /// [`Mutex`]: tokio::sync::Mutex
228 stream: Mutex<Box<Stream>>,
229
230 /// The id to put on the next request, so replies can be matched to the
231 /// request that asked for them.
232 ///
233 /// This transport carries many requests over one stream, so a reply arrives
234 /// with no request attached — matching the id is the only thing that says
235 /// which of the in-flight asks a given frame answers.
236 next_id: AtomicI64,
237}
238
239impl LegacySseClient {
240 /// Opens the stream and completes the handshake.
241 ///
242 /// The `GET` goes to the configured URL and the first event is required to
243 /// be an `endpoint`. Everything after this is driven by what the server
244 /// named, which is both the point of the transport and its only untrusted
245 /// input.
246 ///
247 /// # Errors
248 ///
249 /// [`LegacySseError::Io`] if the socket failed, [`LegacySseError::Refused`]
250 /// if the peer answered outside 2xx, [`LegacySseError::NotAStream`] if a 2xx
251 /// was not an event stream, [`LegacySseError::Unreadable`] if a frame could
252 /// not be read, [`LegacySseError::FirstEventNotEndpoint`] if the handshake
253 /// was missing, and [`LegacySseError::Endpoint`] if the handshake named a
254 /// target this client refuses to post to.
255 pub async fn connect(config: LegacySseConfig) -> Result<Self, LegacySseError> {
256 let inner = build_client(&config)?;
257 Self::connect_with(inner, config).await
258 }
259
260 /// Opens the stream on a connection pool that already exists.
261 ///
262 /// For the auto-detect step, which must hold a client before it knows which
263 /// transport the peer speaks — the probe request is the thing that tells it.
264 /// Handing the pool to whichever client wins means one TLS handshake and one
265 /// DNS resolution for the whole connection.
266 ///
267 /// # Errors
268 ///
269 /// Exactly as [`LegacySseClient::connect`].
270 pub async fn connect_with(
271 inner: reqwest::Client,
272 config: LegacySseConfig,
273 ) -> Result<Self, LegacySseError> {
274 let mut stream = open(&inner, &config.url).await?;
275
276 // The handshake, read before the client exists. A failed handshake
277 // therefore leaves nothing a caller could send with — there is no
278 // half-built client to leak out of this function.
279 let first = match stream.next().await? {
280 Some(event) => ClientFrame::classify(&event)?,
281 None => {
282 return Err(LegacySseError::FirstEventNotEndpoint {
283 got: String::from("an empty stream"),
284 });
285 }
286 };
287 let ClientFrame::Endpoint(advertised) = first else {
288 return Err(LegacySseError::FirstEventNotEndpoint {
289 got: first.describe(),
290 });
291 };
292 let post_to = resolve(&config.url, &advertised)?;
293
294 Ok(Self {
295 inner,
296 config,
297 post_to,
298 stream: Mutex::new(Box::new(stream)),
299 next_id: AtomicI64::new(1),
300 })
301 }
302
303 /// Where this client posts: the server's target, resolved against the URL
304 /// the client dialled.
305 #[must_use]
306 pub const fn message_endpoint(&self) -> &reqwest::Url {
307 &self.post_to
308 }
309
310 /// The configuration this client was built with.
311 #[must_use]
312 pub const fn config(&self) -> &LegacySseConfig {
313 &self.config
314 }
315
316 /// POSTs one JSON-RPC frame to the endpoint the server named.
317 ///
318 /// The answer does not come back here. On this transport it arrives as a
319 /// `message` event on the stream, which is why a `2024-11-05` server answers
320 /// `202` with an empty body — and why a caller treating a successful return
321 /// as "the request was handled" is right on every other transport and wrong
322 /// on this one.
323 ///
324 /// # Errors
325 ///
326 /// [`LegacySseError::Io`] if the socket failed, or
327 /// [`LegacySseError::Refused`] if the peer answered outside 2xx.
328 pub async fn send(&self, frame: &[u8]) -> Result<(), LegacySseError> {
329 let response = self
330 .inner
331 .post(self.post_to.clone())
332 .header(reqwest::header::CONTENT_TYPE, JSON)
333 .body(frame.to_vec())
334 .send()
335 .await
336 .map_err(|error| LegacySseError::Io {
337 method: POST,
338 error,
339 })?;
340 if response.status().is_success() {
341 Ok(())
342 } else {
343 Err(refusal(POST, response).await)
344 }
345 }
346
347 /// The next frame off the stream, or `None` when the peer closed it.
348 ///
349 /// # Errors
350 ///
351 /// [`LegacySseError::Io`] if the socket failed mid-read, or
352 /// [`LegacySseError::Unreadable`] if a frame was neither an `endpoint` event
353 /// nor a JSON payload.
354 pub async fn next(&self) -> Result<Option<ClientFrame>, LegacySseError> {
355 let mut stream = self.stream.lock().await;
356 stream
357 .next()
358 .await?
359 .map(|event| ClientFrame::classify(&event))
360 .transpose()
361 }
362
363 /// The id for the next request on this connection.
364 pub(crate) fn next_request_id(&self) -> RequestId {
365 RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed))
366 }
367}
368
369/// Resolves an advertised target against the URL the client dialled.
370///
371/// The advertised target is origin-relative by construction — that is what
372/// [`MessageEndpoint`] refuses to be anything else — so the join cannot leave
373/// the origin. The check is written anyway, and it earns its place: it is what
374/// verifies *this* join rather than what verifies the parser, and it is the thing
375/// that fires if someone later replaces the join with string concatenation. A
376/// test cannot drive the branch, so the property it stands for is asserted
377/// directly in this module's tests instead.
378fn resolve(base: &str, advertised: &MessageEndpoint) -> Result<reqwest::Url, LegacySseError> {
379 let base = reqwest::Url::parse(base).map_err(|error| LegacySseError::Url {
380 got: base.to_owned(),
381 reason: error.to_string(),
382 })?;
383 let resolved = base
384 .join(advertised.as_str())
385 .map_err(|error| LegacySseError::Url {
386 got: advertised.as_str().to_owned(),
387 reason: error.to_string(),
388 })?;
389 if resolved.origin() != base.origin() {
390 return Err(LegacySseError::Url {
391 got: advertised.as_str().to_owned(),
392 // `Origin` is opaque and does not render, so the check compares it
393 // and the message names the URLs. Same claim, printable.
394 reason: format!(
395 "it resolves to {resolved}, which is not on the origin this client was given ({base})"
396 ),
397 });
398 }
399 Ok(resolved)
400}
401
402/// Opens the stream and refuses anything that is not one.
403async fn open(inner: &reqwest::Client, url: &str) -> Result<Stream, LegacySseError> {
404 let response = inner
405 .get(url)
406 .header(reqwest::header::ACCEPT, EVENT_STREAM)
407 .send()
408 .await
409 .map_err(|error| LegacySseError::Io { method: GET, error })?;
410 if !response.status().is_success() {
411 return Err(refusal(GET, response).await);
412 }
413 if !is_event_stream(&response) {
414 return Err(LegacySseError::NotAStream {
415 got: response
416 .headers()
417 .get(reqwest::header::CONTENT_TYPE)
418 .and_then(|value| value.to_str().ok())
419 .unwrap_or_default()
420 .to_owned(),
421 });
422 }
423 Ok(Stream {
424 response,
425 reader: FrameReader::new(),
426 ready: std::collections::VecDeque::new(),
427 })
428}
429
430/// Whether the peer answered with an open event stream.
431fn is_event_stream(response: &reqwest::Response) -> bool {
432 response
433 .headers()
434 .get(reqwest::header::CONTENT_TYPE)
435 .and_then(|value| value.to_str().ok())
436 .is_some_and(|value| {
437 value
438 .split(';')
439 .next()
440 .is_some_and(|media| media.trim().eq_ignore_ascii_case(EVENT_STREAM))
441 })
442}
443
444/// A pool configured the way this transport needs.
445fn build_client(config: &LegacySseConfig) -> Result<reqwest::Client, LegacySseError> {
446 let mut builder = reqwest::Client::builder()
447 .user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")));
448 if let Some(limit) = config.connect_timeout {
449 builder = builder.connect_timeout(limit);
450 }
451 builder.build().map_err(|error| LegacySseError::Io {
452 method: "CLIENT",
453 error,
454 })
455}
456
457/// A peer-supplied status, rendered for an error without keeping the whole body.
458async fn refusal(method: &'static str, response: reqwest::Response) -> LegacySseError {
459 let status = response.status();
460 let content_type = response
461 .headers()
462 .get(reqwest::header::CONTENT_TYPE)
463 .and_then(|value| value.to_str().ok())
464 .map(str::to_owned);
465 let preview = response
466 .bytes()
467 .await
468 .map(|body| {
469 String::from_utf8_lossy(&body)
470 .chars()
471 .take(error::PREVIEW)
472 .collect()
473 })
474 .unwrap_or_default();
475 LegacySseError::Refused {
476 method,
477 status,
478 content_type,
479 preview,
480 }
481}
482
483/// A peer-supplied string, bounded before it goes in an error.
484fn truncate(value: &str) -> String {
485 value.chars().take(error::PREVIEW).collect()
486}
487
488#[cfg(test)]
489mod tests;
490
491impl ClientTransport for LegacySseClient {
492 /// POSTs a request frame and waits for the reply that carries its id.
493 ///
494 /// A `202` on the `POST` is not the answer: on this transport the answer
495 /// goes to the stream the `GET` opened, and it arrives there interleaved
496 /// with anything else the server chooses to send. So this writes the frame,
497 /// then reads until a frame with this request's id turns up.
498 ///
499 /// # Errors
500 ///
501 /// [`TransportError::Unavailable`] naming the refusal. The port is
502 /// protocol-neutral and must not learn that this is a legacy transport, so
503 /// the detail is rendered rather than kept.
504 fn send(
505 &self,
506 method: &str,
507 params: &JsonObject,
508 ) -> Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>> {
509 let prepared = (|| {
510 let id = self.next_request_id();
511 let name = method_name(method)?;
512 let document = request_document(&id, &name, params)
513 .map_err(unavailable)?
514 .to_string();
515 Ok::<_, TransportError>((id, document))
516 })();
517
518 Box::pin(async move {
519 let (id, document) = prepared?;
520 self.send(document.as_bytes()).await.map_err(unavailable)?;
521 self.await_reply(&id).await
522 })
523 }
524
525 /// POSTs a notification frame and returns without reading.
526 ///
527 /// No read, and that is the whole difference from
528 /// [`FrameSink::send`](crate::application::session::frame_sink::FrameSink::send). A notification
529 /// has no id, so there is nothing to match a frame against, and reading one
530 /// anyway would consume a frame that belongs to whatever asked next — which
531 /// on this transport is every reply that arrives in between.
532 ///
533 /// # Errors
534 ///
535 /// [`TransportError::Unavailable`] if the frame cannot be built or written.
536 fn notify(
537 &self,
538 method: &str,
539 params: &JsonObject,
540 ) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>> {
541 let prepared = (|| {
542 let name = method_name(method)?;
543 let document = notification_document(&name, params)
544 .map_err(unavailable)?
545 .to_string();
546 Ok::<_, TransportError>(document)
547 })();
548
549 Box::pin(async move {
550 let document = prepared?;
551 self.send(document.as_bytes()).await.map_err(unavailable)
552 })
553 }
554}
555
556impl LegacySseClient {
557 /// Reads the stream until the frame answering `id` arrives.
558 ///
559 /// Reads rather than takes the next one. The stream carries the server's
560 /// own traffic as well as answers — a `notifications/message` for a tool
561 /// call, a progress update for a request that is still running — and a
562 /// reader that took the first frame would hand the caller a notification
563 /// where it asked for a result.
564 async fn await_reply(&self, id: &RequestId) -> Result<JsonObject, TransportError> {
565 loop {
566 let Some(frame) = self.next().await.map_err(unavailable)? else {
567 return Err(TransportError::Unavailable(format!(
568 "the stream closed before answering {:?}",
569 id.redacted()
570 )));
571 };
572 match frame {
573 ClientFrame::Message(payload) => {
574 if let Some(answer) = reply_for(&payload, id) {
575 return answer.map_err(unavailable);
576 }
577 }
578 // An endpoint frame is the server re-advertising its target. It
579 // has no id, and there is nothing a client can do with a second
580 // one, so it is skipped rather than treated as a failure.
581 ClientFrame::Endpoint(_) => {}
582 }
583 }
584 }
585}
586
587/// The `result` of `payload` when it answers `id`.
588fn reply_for(payload: &Value, id: &RequestId) -> Option<Result<JsonObject, RequestError>> {
589 let members = payload.as_object()?;
590 // `to_json` is the id as JSON text — `1`, or `"a"` with its quotes — so it
591 // is parsed before it is compared. Wrapping the text in a `Value::String`
592 // matched only a server that answered a numeric id with a string, which is
593 // no server.
594 let wanted: Option<Value> = serde_json::from_str(&id.to_json()).ok();
595 if wanted.as_ref() != members.get("id") {
596 return None;
597 }
598 // `reply_result` re-parses what was just walked, which is a second pass over
599 // a small document and buys the one implementation of id matching and error
600 // reporting that the stdio transport already has tested. Matching the id
601 // here rather than inside it is what lets a notification — which has no id
602 // at all — be skipped instead of being reported as a mismatch.
603 Some(reply_result(&payload.to_string(), id))
604}
605
606/// A method name, or the port's own error.
607fn method_name(method: &str) -> Result<MethodName, TransportError> {
608 MethodName::new(method).map_err(unavailable)
609}
610
611/// This transport's failures, as the port's error.
612fn unavailable(error: impl core::fmt::Display) -> TransportError {
613 TransportError::Unavailable(error.to_string())
614}