browsr_client/
lib.rs

1use browsr_types::{
2    AutomateResponse, BrowserContext, Commands, ObserveResponse, ScrapeOptions, SearchOptions,
3    SearchResponse,
4};
5use reqwest::StatusCode;
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7use serde_json::{Value, json};
8use thiserror::Error;
9use tokio::process::Command;
10
11#[derive(Debug, Clone)]
12pub enum TransportConfig {
13    Http { base_url: String },
14    Stdout { command: String },
15}
16
17#[derive(Debug, Clone)]
18pub struct BrowsrClient {
19    transport: BrowsrTransport,
20}
21
22#[derive(Debug, Clone)]
23enum BrowsrTransport {
24    Http(HttpTransport),
25    Stdout(StdoutTransport),
26}
27
28impl BrowsrClient {
29    pub fn new_http(base_url: impl Into<String>) -> Self {
30        Self {
31            transport: BrowsrTransport::Http(HttpTransport::new(base_url)),
32        }
33    }
34
35    pub fn new_stdout(command: impl Into<String>) -> Self {
36        Self {
37            transport: BrowsrTransport::Stdout(StdoutTransport::new(command)),
38        }
39    }
40
41    pub fn from_config(cfg: TransportConfig) -> Self {
42        match cfg {
43            TransportConfig::Http { base_url } => Self::new_http(base_url),
44            TransportConfig::Stdout { command } => Self::new_stdout(command),
45        }
46    }
47
48    pub async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
49        match &self.transport {
50            BrowsrTransport::Http(inner) => {
51                let response: SessionList = inner.get("/sessions").await?;
52                Ok(response.sessions)
53            }
54            BrowsrTransport::Stdout(inner) => inner.list_sessions().await,
55        }
56    }
57
58    pub async fn create_session(&self) -> Result<String, ClientError> {
59        match &self.transport {
60            BrowsrTransport::Http(inner) => {
61                let response: SessionCreated = inner.post("/sessions", &Value::Null).await?;
62                Ok(response.session_id)
63            }
64            BrowsrTransport::Stdout(inner) => inner.create_session().await,
65        }
66    }
67
68    pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
69        match &self.transport {
70            BrowsrTransport::Http(inner) => inner
71                .delete(&format!("/sessions/{}", session_id))
72                .await
73                .map(|_: Value| ()),
74            BrowsrTransport::Stdout(inner) => inner.destroy_session(session_id).await,
75        }
76    }
77
78    pub async fn execute_commands(
79        &self,
80        commands: Vec<Commands>,
81        session_id: Option<String>,
82        headless: Option<bool>,
83        context: Option<BrowserContext>,
84    ) -> Result<AutomateResponse, ClientError> {
85        let payload = CommandsPayload {
86            commands,
87            session_id,
88            headless,
89            context,
90        };
91
92        match &self.transport {
93            BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
94            BrowsrTransport::Stdout(inner) => inner.execute_commands(&payload).await,
95        }
96    }
97
98    pub async fn observe(
99        &self,
100        session_id: Option<String>,
101        headless: Option<bool>,
102        opts: ObserveOptions,
103    ) -> Result<ObserveResponse, ClientError> {
104        let payload = ObservePayload {
105            session_id,
106            headless,
107            use_image: opts.use_image,
108            full_page: opts.full_page,
109            wait_ms: opts.wait_ms,
110            include_content: opts.include_content,
111        };
112
113        match &self.transport {
114            BrowsrTransport::Http(inner) => {
115                let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
116                Ok(envelope.observation)
117            }
118            BrowsrTransport::Stdout(inner) => inner.observe(&payload).await,
119        }
120    }
121
122    pub async fn scrape(&self, options: ScrapeOptions) -> Result<Value, ClientError> {
123        match &self.transport {
124            BrowsrTransport::Http(inner) => inner.post("/scrape", &options).await,
125            BrowsrTransport::Stdout(inner) => inner.scrape(&options).await,
126        }
127    }
128
129    pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
130        match &self.transport {
131            BrowsrTransport::Http(inner) => inner.post("/search", &options).await,
132            BrowsrTransport::Stdout(inner) => inner.search(&options).await,
133        }
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138struct SessionList {
139    sessions: Vec<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143struct SessionCreated {
144    session_id: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148struct CommandsPayload {
149    commands: Vec<Commands>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    session_id: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    headless: Option<bool>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    context: Option<BrowserContext>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ObserveOptions {
160    pub use_image: Option<bool>,
161    pub full_page: Option<bool>,
162    pub wait_ms: Option<u64>,
163    pub include_content: Option<bool>,
164}
165
166impl Default for ObserveOptions {
167    fn default() -> Self {
168        Self {
169            use_image: Some(true),
170            full_page: None,
171            wait_ms: None,
172            include_content: Some(true),
173        }
174    }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178struct ObservePayload {
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub session_id: Option<String>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub headless: Option<bool>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub use_image: Option<bool>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub full_page: Option<bool>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub wait_ms: Option<u64>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub include_content: Option<bool>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194struct ObserveEnvelope {
195    pub session_id: String,
196    pub observation: ObserveResponse,
197}
198
199#[derive(Debug, Error)]
200pub enum ClientError {
201    #[error("http request failed: {0}")]
202    Http(#[from] reqwest::Error),
203    #[error("stdout transport failed: {0}")]
204    Stdout(String),
205    #[error("invalid response: {0}")]
206    InvalidResponse(String),
207    #[error("serialization error: {0}")]
208    Serialization(#[from] serde_json::Error),
209    #[error("io error: {0}")]
210    Io(#[from] std::io::Error),
211}
212
213#[derive(Debug, Clone)]
214struct HttpTransport {
215    base_url: String,
216    client: reqwest::Client,
217}
218
219impl HttpTransport {
220    fn new(base_url: impl Into<String>) -> Self {
221        Self {
222            base_url: base_url.into(),
223            client: reqwest::Client::new(),
224        }
225    }
226
227    fn url(&self, path: &str) -> String {
228        let base = self.base_url.trim_end_matches('/');
229        let suffix = path.trim_start_matches('/');
230        format!("{}/{}", base, suffix)
231    }
232
233    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
234        let resp = self.client.get(self.url(path)).send().await?;
235        Self::handle_response(resp).await
236    }
237
238    async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
239        let resp = self.client.delete(self.url(path)).send().await?;
240        Self::handle_response(resp).await
241    }
242
243    async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
244        &self,
245        path: &str,
246        body: &B,
247    ) -> Result<T, ClientError> {
248        let resp = self.client.post(self.url(path)).json(body).send().await?;
249        Self::handle_response(resp).await
250    }
251
252    async fn handle_response<T: DeserializeOwned>(
253        resp: reqwest::Response,
254    ) -> Result<T, ClientError> {
255        let status = resp.status();
256        if status == StatusCode::NO_CONTENT {
257            let empty: Value = Value::Null;
258            let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
259            return Ok(value);
260        }
261
262        let text = resp.text().await?;
263        if !status.is_success() {
264            return Err(ClientError::InvalidResponse(format!(
265                "{}: {}",
266                status, text
267            )));
268        }
269
270        serde_json::from_str(&text).map_err(ClientError::Serialization)
271    }
272}
273
274#[derive(Debug, Clone)]
275struct StdoutTransport {
276    command: String,
277}
278
279impl StdoutTransport {
280    fn new(command: impl Into<String>) -> Self {
281        Self {
282            command: command.into(),
283        }
284    }
285
286    async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
287        let envelope: SessionList = self.request("sessions", &Value::Null).await?;
288        Ok(envelope.sessions)
289    }
290
291    async fn create_session(&self) -> Result<String, ClientError> {
292        let created: SessionCreated = self.request("create_session", &Value::Null).await?;
293        Ok(created.session_id)
294    }
295
296    async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
297        let payload = json!({ "session_id": session_id });
298        let _: Value = self.request("destroy_session", &payload).await?;
299        Ok(())
300    }
301
302    async fn execute_commands(
303        &self,
304        payload: &CommandsPayload,
305    ) -> Result<AutomateResponse, ClientError> {
306        self.request("execute", payload).await
307    }
308
309    async fn observe(&self, payload: &ObservePayload) -> Result<ObserveResponse, ClientError> {
310        let envelope: ObserveEnvelope = self.request("observe", payload).await?;
311        Ok(envelope.observation)
312    }
313
314    async fn scrape(&self, options: &ScrapeOptions) -> Result<Value, ClientError> {
315        self.request("scrape", options).await
316    }
317
318    async fn search(&self, options: &SearchOptions) -> Result<SearchResponse, ClientError> {
319        self.request("search", options).await
320    }
321
322    async fn request<T: DeserializeOwned, B: Serialize>(
323        &self,
324        operation: &str,
325        payload: &B,
326    ) -> Result<T, ClientError> {
327        let mut cmd = Command::new(&self.command);
328        cmd.arg("client").arg(operation);
329
330        let payload_str = serde_json::to_string(&payload).map_err(ClientError::Serialization)?;
331        cmd.env("BROWSR_CLIENT_PAYLOAD", &payload_str);
332
333        let output = cmd.output().await?;
334        if !output.status.success() {
335            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
336            return Err(ClientError::Stdout(format!(
337                "browsr command failed ({}): {}",
338                output.status, stderr
339            )));
340        }
341
342        let stdout = String::from_utf8_lossy(&output.stdout);
343        if stdout.trim().is_empty() {
344            return Err(ClientError::InvalidResponse(
345                "empty stdout from browsr".to_string(),
346            ));
347        }
348
349        serde_json::from_str(stdout.trim()).map_err(ClientError::Serialization)
350    }
351}
352
353/// Derive a default base URL for HTTP transport from standard env vars.
354pub fn default_base_url() -> Option<String> {
355    if let Ok(url) = std::env::var("BROWSR_API_URL") {
356        return Some(url);
357    }
358    if let Ok(port) = std::env::var("BROWSR_PORT") {
359        let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
360        return Some(format!("http://{}:{}", host, port));
361    }
362    // Fallback to the common local default so we avoid the unusable stdout transport when not configured.
363    Some("http://127.0.0.1:8082".to_string())
364}
365
366pub fn default_transport() -> TransportConfig {
367    TransportConfig::Http {
368        base_url: default_base_url().unwrap_or_else(|| "http://127.0.0.1:8082".to_string()),
369    }
370}