Skip to main content

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}