Skip to main content

omni_dev/browser/
client.rs

1//! A thin client for a running bridge's HTTP control plane.
2//!
3//! Both `bridge request` and `bridge harvest ...` are clients of the same
4//! control-plane endpoint (`POST /__bridge/request`): they serialise a
5//! [`ControlRequest`], attach the bearer token and `X-Omni-Bridge` header, and
6//! read back a [`ResponseEnvelope`]. This type centralises that auth/endpoint
7//! construction so every caller exercises the same dispatch path rather than
8//! opening its own socket.
9
10use anyhow::{bail, Context, Result};
11
12use crate::browser::auth;
13use crate::browser::protocol::{ControlRequest, ResponseEnvelope};
14
15/// A client bound to one running bridge's control plane.
16pub struct BridgeClient {
17    control_port: u16,
18    token: String,
19    http: reqwest::Client,
20}
21
22impl BridgeClient {
23    /// Builds a client targeting the control plane on `control_port`, using
24    /// `token` for bearer auth.
25    #[must_use]
26    pub fn new(control_port: u16, token: String) -> Self {
27        Self {
28            control_port,
29            token,
30            http: reqwest::Client::new(),
31        }
32    }
33
34    /// The control-plane request endpoint for this client.
35    #[must_use]
36    pub fn endpoint(&self) -> String {
37        format!("http://127.0.0.1:{}/__bridge/request", self.control_port)
38    }
39
40    /// Builds the authenticated `POST /__bridge/request` request for `payload`,
41    /// without sending it. Callers that need the raw streamed response (e.g. the
42    /// `request --stream` path) drive this builder themselves.
43    ///
44    /// Carries this process's request-log `invocation_id` on the
45    /// [`auth::BRIDGE_ORIGIN_HEADER`] so daemon-hosted (or standalone) bridge HTTP
46    /// records correlate back to the originating CLI invocation (#1198). This
47    /// covers every `BridgeClient` caller — `request` and `harvest` alike.
48    pub fn request_builder(&self, payload: &ControlRequest) -> reqwest::RequestBuilder {
49        self.http
50            .post(self.endpoint())
51            .bearer_auth(&self.token)
52            .header(auth::BRIDGE_HEADER, auth::BRIDGE_HEADER_VALUE)
53            .header(
54                auth::BRIDGE_ORIGIN_HEADER,
55                crate::request_log::current_context().invocation_id,
56            )
57            .json(payload)
58    }
59
60    /// Sends a buffered request and returns the parsed response envelope.
61    ///
62    /// Errors when the bridge is unreachable, returns a non-success status, or
63    /// returns a body that is not a [`ResponseEnvelope`].
64    pub async fn send(&self, payload: &ControlRequest) -> Result<ResponseEnvelope> {
65        let endpoint = self.endpoint();
66        let resp = self
67            .request_builder(payload)
68            .send()
69            .await
70            .with_context(|| format!("Failed to reach bridge at {endpoint} (is it running?)"))?;
71
72        let status = resp.status();
73        let text = resp
74            .text()
75            .await
76            .context("Failed to read bridge response")?;
77        if !status.is_success() {
78            bail!("bridge returned {status}: {text}");
79        }
80        serde_json::from_str::<ResponseEnvelope>(&text)
81            .context("bridge returned an unparseable response envelope")
82    }
83}
84
85#[cfg(test)]
86#[allow(clippy::unwrap_used, clippy::expect_used)]
87mod tests {
88    use std::collections::BTreeMap;
89
90    use wiremock::matchers::{header_exists, method, path};
91    use wiremock::{Mock, MockServer, ResponseTemplate};
92
93    use super::*;
94
95    /// A minimal control request for driving the client.
96    fn req() -> ControlRequest {
97        ControlRequest {
98            url: "/x".to_string(),
99            method: "GET".to_string(),
100            headers: BTreeMap::new(),
101            body: None,
102            stream: false,
103            target: None,
104            allow_origin: None,
105            credentials: None,
106            encoding: None,
107        }
108    }
109
110    #[test]
111    fn endpoint_targets_loopback_control_plane() {
112        let client = BridgeClient::new(9998, "tok".to_string());
113        assert_eq!(client.endpoint(), "http://127.0.0.1:9998/__bridge/request");
114    }
115
116    /// Mounts a control plane on a mock server and returns a client pointed at it.
117    async fn client_for(server: &MockServer) -> BridgeClient {
118        BridgeClient::new(server.address().port(), "tok".to_string())
119    }
120
121    #[tokio::test]
122    async fn send_returns_the_parsed_envelope_on_success() {
123        let server = MockServer::start().await;
124        let envelope = ResponseEnvelope {
125            id: 1,
126            status: 200,
127            headers: BTreeMap::new(),
128            body: "hello".to_string(),
129            encoding: None,
130        };
131        Mock::given(method("POST"))
132            .and(path("/__bridge/request"))
133            .respond_with(ResponseTemplate::new(200).set_body_json(&envelope))
134            .mount(&server)
135            .await;
136
137        let env = client_for(&server).await.send(&req()).await.unwrap();
138        assert_eq!(env.status, 200);
139        assert_eq!(env.body, "hello");
140    }
141
142    #[tokio::test]
143    async fn send_attaches_origin_correlation_header() {
144        // Every control-plane request carries the origin id header (#1198); the
145        // mock only matches when it is present, so a successful send proves it.
146        let server = MockServer::start().await;
147        let envelope = ResponseEnvelope {
148            id: 1,
149            status: 200,
150            headers: BTreeMap::new(),
151            body: String::new(),
152            encoding: None,
153        };
154        Mock::given(method("POST"))
155            .and(path("/__bridge/request"))
156            .and(header_exists(auth::BRIDGE_ORIGIN_HEADER))
157            .respond_with(ResponseTemplate::new(200).set_body_json(&envelope))
158            .mount(&server)
159            .await;
160
161        assert!(client_for(&server).await.send(&req()).await.is_ok());
162    }
163
164    #[tokio::test]
165    async fn send_errors_on_non_success_status() {
166        let server = MockServer::start().await;
167        Mock::given(method("POST"))
168            .and(path("/__bridge/request"))
169            .respond_with(ResponseTemplate::new(503).set_body_string("no browser"))
170            .mount(&server)
171            .await;
172
173        let err = client_for(&server).await.send(&req()).await.unwrap_err();
174        assert!(err.to_string().contains("503"), "got: {err}");
175    }
176
177    #[tokio::test]
178    async fn send_errors_on_unparseable_envelope() {
179        let server = MockServer::start().await;
180        Mock::given(method("POST"))
181            .and(path("/__bridge/request"))
182            .respond_with(ResponseTemplate::new(200).set_body_string("not an envelope"))
183            .mount(&server)
184            .await;
185
186        let err = client_for(&server).await.send(&req()).await.unwrap_err();
187        assert!(err.to_string().contains("unparseable"), "got: {err}");
188    }
189
190    #[tokio::test]
191    async fn send_errors_when_bridge_unreachable() {
192        // Port 0 never has a listener, so the transport fails fast.
193        let err = BridgeClient::new(0, "tok".to_string())
194            .send(&req())
195            .await
196            .unwrap_err();
197        assert!(
198            err.to_string().contains("Failed to reach bridge"),
199            "got: {err}"
200        );
201    }
202}