omni_dev/browser/
client.rs1use anyhow::{bail, Context, Result};
11
12use crate::browser::auth;
13use crate::browser::protocol::{ControlRequest, ResponseEnvelope};
14
15pub struct BridgeClient {
17 control_port: u16,
18 token: String,
19 http: reqwest::Client,
20}
21
22impl BridgeClient {
23 #[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 #[must_use]
36 pub fn endpoint(&self) -> String {
37 format!("http://127.0.0.1:{}/__bridge/request", self.control_port)
38 }
39
40 pub fn request_builder(&self, payload: &ControlRequest) -> reqwest::RequestBuilder {
44 self.http
45 .post(self.endpoint())
46 .bearer_auth(&self.token)
47 .header(auth::BRIDGE_HEADER, auth::BRIDGE_HEADER_VALUE)
48 .json(payload)
49 }
50
51 pub async fn send(&self, payload: &ControlRequest) -> Result<ResponseEnvelope> {
56 let endpoint = self.endpoint();
57 let resp = self
58 .request_builder(payload)
59 .send()
60 .await
61 .with_context(|| format!("Failed to reach bridge at {endpoint} (is it running?)"))?;
62
63 let status = resp.status();
64 let text = resp
65 .text()
66 .await
67 .context("Failed to read bridge response")?;
68 if !status.is_success() {
69 bail!("bridge returned {status}: {text}");
70 }
71 serde_json::from_str::<ResponseEnvelope>(&text)
72 .context("bridge returned an unparseable response envelope")
73 }
74}
75
76#[cfg(test)]
77#[allow(clippy::unwrap_used, clippy::expect_used)]
78mod tests {
79 use std::collections::BTreeMap;
80
81 use wiremock::matchers::{method, path};
82 use wiremock::{Mock, MockServer, ResponseTemplate};
83
84 use super::*;
85
86 fn req() -> ControlRequest {
88 ControlRequest {
89 url: "/x".to_string(),
90 method: "GET".to_string(),
91 headers: BTreeMap::new(),
92 body: None,
93 stream: false,
94 target: None,
95 allow_origin: None,
96 credentials: None,
97 encoding: None,
98 }
99 }
100
101 #[test]
102 fn endpoint_targets_loopback_control_plane() {
103 let client = BridgeClient::new(9998, "tok".to_string());
104 assert_eq!(client.endpoint(), "http://127.0.0.1:9998/__bridge/request");
105 }
106
107 async fn client_for(server: &MockServer) -> BridgeClient {
109 BridgeClient::new(server.address().port(), "tok".to_string())
110 }
111
112 #[tokio::test]
113 async fn send_returns_the_parsed_envelope_on_success() {
114 let server = MockServer::start().await;
115 let envelope = ResponseEnvelope {
116 id: 1,
117 status: 200,
118 headers: BTreeMap::new(),
119 body: "hello".to_string(),
120 encoding: None,
121 };
122 Mock::given(method("POST"))
123 .and(path("/__bridge/request"))
124 .respond_with(ResponseTemplate::new(200).set_body_json(&envelope))
125 .mount(&server)
126 .await;
127
128 let env = client_for(&server).await.send(&req()).await.unwrap();
129 assert_eq!(env.status, 200);
130 assert_eq!(env.body, "hello");
131 }
132
133 #[tokio::test]
134 async fn send_errors_on_non_success_status() {
135 let server = MockServer::start().await;
136 Mock::given(method("POST"))
137 .and(path("/__bridge/request"))
138 .respond_with(ResponseTemplate::new(503).set_body_string("no browser"))
139 .mount(&server)
140 .await;
141
142 let err = client_for(&server).await.send(&req()).await.unwrap_err();
143 assert!(err.to_string().contains("503"), "got: {err}");
144 }
145
146 #[tokio::test]
147 async fn send_errors_on_unparseable_envelope() {
148 let server = MockServer::start().await;
149 Mock::given(method("POST"))
150 .and(path("/__bridge/request"))
151 .respond_with(ResponseTemplate::new(200).set_body_string("not an envelope"))
152 .mount(&server)
153 .await;
154
155 let err = client_for(&server).await.send(&req()).await.unwrap_err();
156 assert!(err.to_string().contains("unparseable"), "got: {err}");
157 }
158
159 #[tokio::test]
160 async fn send_errors_when_bridge_unreachable() {
161 let err = BridgeClient::new(0, "tok".to_string())
163 .send(&req())
164 .await
165 .unwrap_err();
166 assert!(
167 err.to_string().contains("Failed to reach bridge"),
168 "got: {err}"
169 );
170 }
171}