use anyhow::{bail, Context, Result};
use crate::browser::auth;
use crate::browser::protocol::{ControlRequest, ResponseEnvelope};
pub struct BridgeClient {
control_port: u16,
token: String,
http: reqwest::Client,
}
impl BridgeClient {
#[must_use]
pub fn new(control_port: u16, token: String) -> Self {
Self {
control_port,
token,
http: reqwest::Client::new(),
}
}
#[must_use]
pub fn endpoint(&self) -> String {
format!("http://127.0.0.1:{}/__bridge/request", self.control_port)
}
pub fn request_builder(&self, payload: &ControlRequest) -> reqwest::RequestBuilder {
self.http
.post(self.endpoint())
.bearer_auth(&self.token)
.header(auth::BRIDGE_HEADER, auth::BRIDGE_HEADER_VALUE)
.json(payload)
}
pub async fn send(&self, payload: &ControlRequest) -> Result<ResponseEnvelope> {
let endpoint = self.endpoint();
let resp = self
.request_builder(payload)
.send()
.await
.with_context(|| format!("Failed to reach bridge at {endpoint} (is it running?)"))?;
let status = resp.status();
let text = resp
.text()
.await
.context("Failed to read bridge response")?;
if !status.is_success() {
bail!("bridge returned {status}: {text}");
}
serde_json::from_str::<ResponseEnvelope>(&text)
.context("bridge returned an unparseable response envelope")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::collections::BTreeMap;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
fn req() -> ControlRequest {
ControlRequest {
url: "/x".to_string(),
method: "GET".to_string(),
headers: BTreeMap::new(),
body: None,
stream: false,
target: None,
allow_origin: None,
credentials: None,
}
}
#[test]
fn endpoint_targets_loopback_control_plane() {
let client = BridgeClient::new(9998, "tok".to_string());
assert_eq!(client.endpoint(), "http://127.0.0.1:9998/__bridge/request");
}
async fn client_for(server: &MockServer) -> BridgeClient {
BridgeClient::new(server.address().port(), "tok".to_string())
}
#[tokio::test]
async fn send_returns_the_parsed_envelope_on_success() {
let server = MockServer::start().await;
let envelope = ResponseEnvelope {
id: 1,
status: 200,
headers: BTreeMap::new(),
body: "hello".to_string(),
encoding: None,
};
Mock::given(method("POST"))
.and(path("/__bridge/request"))
.respond_with(ResponseTemplate::new(200).set_body_json(&envelope))
.mount(&server)
.await;
let env = client_for(&server).await.send(&req()).await.unwrap();
assert_eq!(env.status, 200);
assert_eq!(env.body, "hello");
}
#[tokio::test]
async fn send_errors_on_non_success_status() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/__bridge/request"))
.respond_with(ResponseTemplate::new(503).set_body_string("no browser"))
.mount(&server)
.await;
let err = client_for(&server).await.send(&req()).await.unwrap_err();
assert!(err.to_string().contains("503"), "got: {err}");
}
#[tokio::test]
async fn send_errors_on_unparseable_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/__bridge/request"))
.respond_with(ResponseTemplate::new(200).set_body_string("not an envelope"))
.mount(&server)
.await;
let err = client_for(&server).await.send(&req()).await.unwrap_err();
assert!(err.to_string().contains("unparseable"), "got: {err}");
}
#[tokio::test]
async fn send_errors_when_bridge_unreachable() {
let err = BridgeClient::new(0, "tok".to_string())
.send(&req())
.await
.unwrap_err();
assert!(
err.to_string().contains("Failed to reach bridge"),
"got: {err}"
);
}
}