1use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use reqwest::Method;
7use serde::de::DeserializeOwned;
8use serde_json::Value;
9
10use crate::error::{Error, Result};
11use crate::request::{ScrapeRequest, SmartScraperRequest};
12use crate::response::{
13 Response, RunStatus, ScrapeData, SmartBrowseDispatch, SmartBrowseRun, SmartBrowseUsage,
14 SmartScraperData, WaitOptions,
15};
16use crate::transport;
17use crate::{user_agent, DEFAULT_BASE_URL};
18
19const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
20const DEFAULT_MAX_RETRIES: u32 = 2;
21const MAX_POLL_INTERVAL: Duration = Duration::from_secs(10);
22
23struct Inner {
24 http: reqwest::Client,
25 base_url: String,
26 api_key: String,
27 user_agent: String,
28 timeout: Duration,
29 max_retries: u32,
30}
31
32#[derive(Clone)]
34pub struct Client(Arc<Inner>);
35
36impl Client {
37 pub fn new(api_key: impl Into<String>) -> Result<Self> {
41 Self::builder().api_key(api_key).build()
42 }
43
44 pub fn from_env() -> Result<Self> {
49 Self::builder().build()
50 }
51
52 pub fn builder() -> ClientBuilder {
54 ClientBuilder::default()
55 }
56
57 pub async fn scrape(&self, request: ScrapeRequest) -> Result<Response<ScrapeData>> {
60 let body = to_value(&request)?;
61 self.0
62 .execute(Method::POST, "/scrape".to_string(), Some(body))
63 .await
64 }
65
66 pub async fn smartscraper(
69 &self,
70 request: SmartScraperRequest,
71 ) -> Result<Response<SmartScraperData>> {
72 let body = to_value(&request)?;
73 self.0
74 .execute(Method::POST, "/smartscraper".to_string(), Some(body))
75 .await
76 }
77
78 pub fn smartbrowse(&self) -> SmartBrowse<'_> {
80 SmartBrowse { client: self }
81 }
82}
83
84impl std::fmt::Debug for Client {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("Client")
87 .field("base_url", &self.0.base_url)
88 .field("timeout", &self.0.timeout)
89 .field("max_retries", &self.0.max_retries)
90 .finish_non_exhaustive()
91 }
92}
93
94impl Inner {
95 async fn execute<T: DeserializeOwned>(
96 &self,
97 method: Method,
98 path: String,
99 body: Option<Value>,
100 ) -> Result<Response<T>> {
101 let url = format!("{}{}", self.base_url, path);
102 let mut attempt = 0u32;
103
104 loop {
105 let mut rb = self
106 .http
107 .request(method.clone(), &url)
108 .header("X-API-Key", &self.api_key)
109 .header(reqwest::header::USER_AGENT, &self.user_agent)
110 .header(reqwest::header::ACCEPT, "application/json")
111 .timeout(self.timeout);
112 if let Some(ref b) = body {
113 rb = rb.json(b);
114 }
115
116 match rb.send().await {
117 Ok(resp) => {
118 let status = resp.status().as_u16();
119 let request_id_header = transport::header_str(resp.headers(), "x-request-id");
120 let retry_after = transport::parse_retry_after(
121 transport::header_str(resp.headers(), "retry-after").as_deref(),
122 );
123 let bytes = resp.bytes().await.map_err(Error::Transport)?;
127
128 if transport::is_retryable_status(status) && attempt < self.max_retries {
129 attempt += 1;
130 tokio::time::sleep(transport::backoff_delay(attempt, retry_after)).await;
131 continue;
132 }
133 return transport::process_response::<T>(
134 status,
135 request_id_header,
136 retry_after,
137 &bytes,
138 );
139 }
140 Err(e) => {
141 if e.is_timeout() {
142 return Err(Error::Timeout(self.timeout));
143 }
144 if transport::is_retryable_send_error(&e) && attempt < self.max_retries {
145 attempt += 1;
146 tokio::time::sleep(transport::backoff_delay(attempt, None)).await;
147 continue;
148 }
149 return Err(Error::Transport(e));
150 }
151 }
152 }
153 }
154}
155
156pub struct SmartBrowse<'a> {
158 client: &'a Client,
159}
160
161impl SmartBrowse<'_> {
162 pub async fn run(&self, recipe_id: &str) -> Result<Response<SmartBrowseDispatch>> {
165 let path = format!("/smartbrowse/recipes/{recipe_id}/run");
166 self.client.0.execute(Method::POST, path, None).await
167 }
168
169 pub async fn get_run(&self, run_id: &str) -> Result<Response<SmartBrowseRun>> {
171 let path = format!("/smartbrowse/runs/{run_id}");
172 self.client.0.execute(Method::GET, path, None).await
173 }
174
175 pub async fn usage(&self) -> Result<Response<SmartBrowseUsage>> {
177 self.client
178 .0
179 .execute(Method::GET, "/smartbrowse/usage".to_string(), None)
180 .await
181 }
182
183 pub async fn wait_for_run(
189 &self,
190 run_id: &str,
191 opts: WaitOptions,
192 ) -> Result<Response<SmartBrowseRun>> {
193 let start = Instant::now();
194 let mut interval = opts.poll_interval;
195
196 loop {
197 let resp = self.get_run(run_id).await?;
198 match resp.data.run_status {
199 RunStatus::Completed => return Ok(resp),
200 RunStatus::Failed | RunStatus::Cancelled => {
201 return Err(Error::RunFailed { run: resp.data })
202 }
203 _ => {}
204 }
205
206 if start.elapsed() >= opts.timeout {
207 return Err(Error::WaitTimeout { last: resp.data });
208 }
209 let remaining = opts.timeout.saturating_sub(start.elapsed());
210 tokio::time::sleep(interval.min(remaining)).await;
211 interval = interval.mul_f64(1.5).min(MAX_POLL_INTERVAL);
212 }
213 }
214
215 pub async fn run_and_wait(
217 &self,
218 recipe_id: &str,
219 opts: WaitOptions,
220 ) -> Result<Response<SmartBrowseRun>> {
221 let dispatch = self.run(recipe_id).await?;
222 self.wait_for_run(&dispatch.data.run_id, opts).await
223 }
224}
225
226fn to_value<T: serde::Serialize>(request: &T) -> Result<Value> {
227 serde_json::to_value(request)
228 .map_err(|e| Error::Decode(format!("failed to serialize request: {e}")))
229}
230
231#[derive(Default)]
233pub struct ClientBuilder {
234 api_key: Option<String>,
235 base_url: Option<String>,
236 timeout: Option<Duration>,
237 max_retries: Option<u32>,
238 http_client: Option<reqwest::Client>,
239}
240
241impl ClientBuilder {
242 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
244 self.api_key = Some(api_key.into());
245 self
246 }
247
248 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
251 self.base_url = Some(base_url.into());
252 self
253 }
254
255 pub fn timeout(mut self, timeout: Duration) -> Self {
257 self.timeout = Some(timeout);
258 self
259 }
260
261 pub fn max_retries(mut self, max_retries: u32) -> Self {
263 self.max_retries = Some(max_retries);
264 self
265 }
266
267 pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
269 self.http_client = Some(http_client);
270 self
271 }
272
273 pub fn build(self) -> Result<Client> {
276 let api_key = resolve_api_key(self.api_key)?;
277 let base_url = self
278 .base_url
279 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
280 .trim_end_matches('/')
281 .to_string();
282 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
283 let max_retries = self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES);
284 let http = match self.http_client {
285 Some(c) => c,
286 None => reqwest::Client::builder()
287 .build()
288 .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?,
289 };
290
291 Ok(Client(Arc::new(Inner {
292 http,
293 base_url,
294 api_key,
295 user_agent: user_agent(),
296 timeout,
297 max_retries,
298 })))
299 }
300}
301
302pub(crate) fn resolve_api_key(explicit: Option<String>) -> Result<String> {
305 let key = explicit
306 .filter(|k| !k.is_empty())
307 .or_else(|| std::env::var(crate::API_KEY_ENV).ok())
308 .filter(|k| !k.is_empty());
309 key.ok_or_else(|| {
310 Error::Config(format!(
311 "no API key: pass one to the builder or set {}",
312 crate::API_KEY_ENV
313 ))
314 })
315}