use std::sync::Arc;
use std::time::{Duration, Instant};
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::client::resolve_api_key;
use crate::error::{Error, Result};
use crate::request::{ScrapeRequest, SmartScraperRequest};
use crate::response::{
Response, RunStatus, ScrapeData, SmartBrowseDispatch, SmartBrowseRun, SmartBrowseUsage,
SmartScraperData, WaitOptions,
};
use crate::transport;
use crate::{user_agent, DEFAULT_BASE_URL};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
const DEFAULT_MAX_RETRIES: u32 = 2;
const MAX_POLL_INTERVAL: Duration = Duration::from_secs(10);
struct Inner {
http: reqwest::blocking::Client,
base_url: String,
api_key: String,
user_agent: String,
timeout: Duration,
max_retries: u32,
}
#[derive(Clone)]
pub struct Client(Arc<Inner>);
impl Client {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
Self::builder().api_key(api_key).build()
}
pub fn from_env() -> Result<Self> {
Self::builder().build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn scrape(&self, request: ScrapeRequest) -> Result<Response<ScrapeData>> {
let body = to_value(&request)?;
self.0
.execute(Method::POST, "/scrape".to_string(), Some(body))
}
pub fn smartscraper(&self, request: SmartScraperRequest) -> Result<Response<SmartScraperData>> {
let body = to_value(&request)?;
self.0
.execute(Method::POST, "/smartscraper".to_string(), Some(body))
}
pub fn smartbrowse(&self) -> SmartBrowse<'_> {
SmartBrowse { client: self }
}
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("blocking::Client")
.field("base_url", &self.0.base_url)
.field("timeout", &self.0.timeout)
.field("max_retries", &self.0.max_retries)
.finish_non_exhaustive()
}
}
impl Inner {
fn execute<T: DeserializeOwned>(
&self,
method: Method,
path: String,
body: Option<Value>,
) -> Result<Response<T>> {
let url = format!("{}{}", self.base_url, path);
let mut attempt = 0u32;
loop {
let mut rb = self
.http
.request(method.clone(), &url)
.header("X-API-Key", &self.api_key)
.header(reqwest::header::USER_AGENT, &self.user_agent)
.header(reqwest::header::ACCEPT, "application/json")
.timeout(self.timeout);
if let Some(ref b) = body {
rb = rb.json(b);
}
match rb.send() {
Ok(resp) => {
let status = resp.status().as_u16();
let request_id_header = transport::header_str(resp.headers(), "x-request-id");
let retry_after = transport::parse_retry_after(
transport::header_str(resp.headers(), "retry-after").as_deref(),
);
let bytes = resp.bytes().map_err(Error::Transport)?;
if transport::is_retryable_status(status) && attempt < self.max_retries {
attempt += 1;
std::thread::sleep(transport::backoff_delay(attempt, retry_after));
continue;
}
return transport::process_response::<T>(
status,
request_id_header,
retry_after,
&bytes,
);
}
Err(e) => {
if e.is_timeout() {
return Err(Error::Timeout(self.timeout));
}
if transport::is_retryable_send_error(&e) && attempt < self.max_retries {
attempt += 1;
std::thread::sleep(transport::backoff_delay(attempt, None));
continue;
}
return Err(Error::Transport(e));
}
}
}
}
}
pub struct SmartBrowse<'a> {
client: &'a Client,
}
impl SmartBrowse<'_> {
pub fn run(&self, recipe_id: &str) -> Result<Response<SmartBrowseDispatch>> {
let path = format!("/smartbrowse/recipes/{recipe_id}/run");
self.client.0.execute(Method::POST, path, None)
}
pub fn get_run(&self, run_id: &str) -> Result<Response<SmartBrowseRun>> {
let path = format!("/smartbrowse/runs/{run_id}");
self.client.0.execute(Method::GET, path, None)
}
pub fn usage(&self) -> Result<Response<SmartBrowseUsage>> {
self.client
.0
.execute(Method::GET, "/smartbrowse/usage".to_string(), None)
}
pub fn wait_for_run(
&self,
run_id: &str,
opts: WaitOptions,
) -> Result<Response<SmartBrowseRun>> {
let start = Instant::now();
let mut interval = opts.poll_interval;
loop {
let resp = self.get_run(run_id)?;
match resp.data.run_status {
RunStatus::Completed => return Ok(resp),
RunStatus::Failed | RunStatus::Cancelled => {
return Err(Error::RunFailed { run: resp.data })
}
_ => {}
}
if start.elapsed() >= opts.timeout {
return Err(Error::WaitTimeout { last: resp.data });
}
let remaining = opts.timeout.saturating_sub(start.elapsed());
std::thread::sleep(interval.min(remaining));
interval = interval.mul_f64(1.5).min(MAX_POLL_INTERVAL);
}
}
pub fn run_and_wait(
&self,
recipe_id: &str,
opts: WaitOptions,
) -> Result<Response<SmartBrowseRun>> {
let dispatch = self.run(recipe_id)?;
self.wait_for_run(&dispatch.data.run_id, opts)
}
}
fn to_value<T: serde::Serialize>(request: &T) -> Result<Value> {
serde_json::to_value(request)
.map_err(|e| Error::Decode(format!("failed to serialize request: {e}")))
}
#[derive(Default)]
pub struct ClientBuilder {
api_key: Option<String>,
base_url: Option<String>,
timeout: Option<Duration>,
max_retries: Option<u32>,
http_client: Option<reqwest::blocking::Client>,
}
impl ClientBuilder {
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = Some(max_retries);
self
}
pub fn http_client(mut self, http_client: reqwest::blocking::Client) -> Self {
self.http_client = Some(http_client);
self
}
pub fn build(self) -> Result<Client> {
let api_key = resolve_api_key(self.api_key)?;
let base_url = self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
.trim_end_matches('/')
.to_string();
let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
let max_retries = self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES);
let http = match self.http_client {
Some(c) => c,
None => reqwest::blocking::Client::builder()
.build()
.map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?,
};
Ok(Client(Arc::new(Inner {
http,
base_url,
api_key,
user_agent: user_agent(),
timeout,
max_retries,
})))
}
}