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 }
98 }
99
100 #[test]
101 fn endpoint_targets_loopback_control_plane() {
102 let client = BridgeClient::new(9998, "tok".to_string());
103 assert_eq!(client.endpoint(), "http://127.0.0.1:9998/__bridge/request");
104 }
105
106 async fn client_for(server: &MockServer) -> BridgeClient {
108 BridgeClient::new(server.address().port(), "tok".to_string())
109 }
110
111 #[tokio::test]
112 async fn send_returns_the_parsed_envelope_on_success() {
113 let server = MockServer::start().await;
114 let envelope = ResponseEnvelope {
115 id: 1,
116 status: 200,
117 headers: BTreeMap::new(),
118 body: "hello".to_string(),
119 encoding: None,
120 };
121 Mock::given(method("POST"))
122 .and(path("/__bridge/request"))
123 .respond_with(ResponseTemplate::new(200).set_body_json(&envelope))
124 .mount(&server)
125 .await;
126
127 let env = client_for(&server).await.send(&req()).await.unwrap();
128 assert_eq!(env.status, 200);
129 assert_eq!(env.body, "hello");
130 }
131
132 #[tokio::test]
133 async fn send_errors_on_non_success_status() {
134 let server = MockServer::start().await;
135 Mock::given(method("POST"))
136 .and(path("/__bridge/request"))
137 .respond_with(ResponseTemplate::new(503).set_body_string("no browser"))
138 .mount(&server)
139 .await;
140
141 let err = client_for(&server).await.send(&req()).await.unwrap_err();
142 assert!(err.to_string().contains("503"), "got: {err}");
143 }
144
145 #[tokio::test]
146 async fn send_errors_on_unparseable_envelope() {
147 let server = MockServer::start().await;
148 Mock::given(method("POST"))
149 .and(path("/__bridge/request"))
150 .respond_with(ResponseTemplate::new(200).set_body_string("not an envelope"))
151 .mount(&server)
152 .await;
153
154 let err = client_for(&server).await.send(&req()).await.unwrap_err();
155 assert!(err.to_string().contains("unparseable"), "got: {err}");
156 }
157
158 #[tokio::test]
159 async fn send_errors_when_bridge_unreachable() {
160 let err = BridgeClient::new(0, "tok".to_string())
162 .send(&req())
163 .await
164 .unwrap_err();
165 assert!(
166 err.to_string().contains("Failed to reach bridge"),
167 "got: {err}"
168 );
169 }
170}