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 {
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 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 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 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 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 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}