1use std::sync::Arc;
4use std::time::Duration;
5
6use crate::error::{Error, Result};
7use crate::fetch::{self, FetchOptions, Page};
8use crate::net::sanitize_user_agent;
9use crate::visibility::VisibilityPolicy;
10
11#[derive(Debug, Clone)]
13pub struct Client {
14 inner: Arc<ClientInner>,
15}
16
17#[derive(Debug)]
18pub(crate) struct ClientInner {
19 pub(crate) timeout: Duration,
20 pub(crate) settle: Duration,
21 pub(crate) user_agent: Option<String>,
22 pub(crate) visibility: VisibilityPolicy,
23 pub(crate) headers: http::HeaderMap,
24}
25
26impl ClientInner {
27 pub(crate) fn apply_defaults(&self, mut opts: FetchOptions) -> FetchOptions {
29 opts.timeout.get_or_insert(self.timeout);
30 opts.settle.get_or_insert(self.settle);
31 opts.visibility.get_or_insert(self.visibility);
32 if let Some(ua) = &self.user_agent {
33 opts.user_agent.get_or_insert_with(|| ua.clone());
34 }
35 if opts.headers.is_empty() && !self.headers.is_empty() {
36 opts.headers = self.headers.clone();
37 }
38 opts
39 }
40
41 pub(crate) fn options(&self, url: &str) -> FetchOptions {
42 self.apply_defaults(FetchOptions::new(url))
43 }
44}
45
46impl Default for Client {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl Client {
53 #[must_use]
55 pub fn new() -> Self {
56 Self::builder().build()
57 }
58
59 pub fn builder() -> ClientBuilder {
61 ClientBuilder::default()
62 }
63
64 pub async fn fetch(&self, url: &str) -> Result<Page> {
66 fetch::fetch(&self.inner.options(url)).await
67 }
68
69 pub async fn fetch_with(&self, opts: &FetchOptions) -> Result<Page> {
71 fetch::fetch(&self.inner.apply_defaults(opts.clone())).await
72 }
73
74 pub async fn markdown(&self, url: &str) -> Result<String> {
76 self.fetch(url).await?.markdown_with_url(url)
77 }
78
79 pub async fn text(&self, url: &str) -> Result<String> {
81 Ok(self.fetch(url).await?.inner_text)
82 }
83
84 pub async fn extract_json(&self, url: &str) -> Result<String> {
86 self.fetch(url).await?.extract_json_with_url(url)
87 }
88
89 pub async fn screenshot(&self, url: &str, opts: &ScreenshotOptions) -> Result<Vec<u8>> {
91 let fopts = self.inner.apply_defaults(FetchOptions::screenshot(url, opts.full_page));
92 let page = fetch::fetch(&fopts).await?;
93 page.screenshot_png()
94 .map(<[u8]>::to_vec)
95 .ok_or_else(|| Error::screenshot(anyhow::anyhow!("screenshot returned no data"), Some(url.to_string())))
96 }
97
98 pub async fn execute_js(&self, url: &str, expression: impl Into<String>) -> Result<String> {
100 let fopts = self.inner.apply_defaults(FetchOptions::javascript(url, expression));
101 let page = fetch::fetch(&fopts).await?;
102 page.js_result
103 .ok_or_else(|| Error::javascript(anyhow::anyhow!("execute_js returned no result"), Some(url.to_string())))
104 }
105}
106
107#[derive(Debug, Default, Clone)]
109#[non_exhaustive]
110pub struct ScreenshotOptions {
111 pub full_page: bool,
113}
114
115#[must_use = "ClientBuilder does nothing until .build() is called"]
117#[derive(Debug, Default)]
118pub struct ClientBuilder {
119 timeout: Option<Duration>,
120 settle: Option<Duration>,
121 user_agent: Option<String>,
122 visibility: Option<VisibilityPolicy>,
123 headers: http::HeaderMap,
124}
125
126impl ClientBuilder {
127 pub fn timeout(mut self, timeout: Duration) -> Self {
129 self.timeout = Some(timeout);
130 self
131 }
132
133 pub fn settle(mut self, settle: Duration) -> Self {
135 self.settle = Some(settle);
136 self
137 }
138
139 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
141 self.user_agent = Some(sanitize_user_agent(ua.into()));
142 self
143 }
144
145 pub fn visibility(mut self, policy: VisibilityPolicy) -> Self {
147 self.visibility = Some(policy);
148 self
149 }
150
151 pub fn headers(mut self, headers: http::HeaderMap) -> Self {
153 self.headers = headers;
154 self
155 }
156
157 #[must_use]
159 pub fn build(self) -> Client {
160 Client {
161 inner: Arc::new(self.build_inner()),
162 }
163 }
164
165 pub(crate) fn build_inner(self) -> ClientInner {
166 ClientInner {
167 timeout: self.timeout.unwrap_or(FetchOptions::DEFAULT_TIMEOUT),
168 settle: self.settle.unwrap_or_default(),
169 user_agent: self.user_agent,
170 visibility: self.visibility.unwrap_or_default(),
171 headers: self.headers,
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn client_default_uses_30s_timeout() {
182 assert_eq!(Client::default().inner.timeout, FetchOptions::DEFAULT_TIMEOUT);
183 }
184
185 #[test]
186 fn client_builder_sets_timeout() {
187 let client = Client::builder().timeout(Duration::from_secs(60)).build();
188 assert_eq!(client.inner.timeout, Duration::from_secs(60));
189 }
190
191 #[test]
192 fn client_builder_sets_settle() {
193 let client = Client::builder().settle(Duration::from_millis(500)).build();
194 assert_eq!(client.inner.settle, Duration::from_millis(500));
195 }
196
197 #[test]
198 fn client_builder_sanitizes_user_agent() {
199 let client = Client::builder().user_agent("Bot\r\nX-Evil: yes").build();
200 assert_eq!(client.inner.user_agent.as_deref(), Some("Bot X-Evil: yes"));
201 }
202
203 #[test]
204 fn client_options_propagates_defaults() {
205 let client = Client::builder()
206 .timeout(Duration::from_secs(60))
207 .settle(Duration::from_millis(500))
208 .user_agent("MyBot")
209 .build();
210 let opts = client.inner.options("https://example.com");
211 assert_eq!(opts.timeout, Some(Duration::from_secs(60)));
212 assert_eq!(opts.settle, Some(Duration::from_millis(500)));
213 assert_eq!(opts.user_agent.as_deref(), Some("MyBot"));
214 }
215
216 #[test]
217 fn client_apply_defaults_caller_value_wins() {
218 let client = Client::builder()
219 .timeout(Duration::from_secs(60))
220 .user_agent("ClientBot")
221 .build();
222 let user_opts = FetchOptions::new("https://example.com")
223 .timeout(Duration::from_secs(10))
224 .user_agent("UserBot");
225 let merged = client.inner.apply_defaults(user_opts);
226 assert_eq!(merged.timeout, Some(Duration::from_secs(10)));
228 assert_eq!(merged.user_agent.as_deref(), Some("UserBot"));
229 }
230
231 #[test]
232 fn client_apply_defaults_fills_unset_fields() {
233 let client = Client::builder()
234 .timeout(Duration::from_secs(60))
235 .settle(Duration::from_millis(750))
236 .user_agent("ClientBot")
237 .visibility(VisibilityPolicy::off())
238 .build();
239 let user_opts = FetchOptions::new("https://example.com");
240 let merged = client.inner.apply_defaults(user_opts);
241 assert_eq!(merged.timeout, Some(Duration::from_secs(60)));
243 assert_eq!(merged.settle, Some(Duration::from_millis(750)));
244 assert_eq!(merged.user_agent.as_deref(), Some("ClientBot"));
245 assert_eq!(merged.visibility, Some(VisibilityPolicy::off()));
246 }
247
248 #[test]
249 fn client_clone_shares_inner() {
250 let client = Client::new();
251 assert!(Arc::ptr_eq(&client.inner, &client.clone().inner));
252 }
253
254 #[test]
255 fn screenshot_options_default_is_viewport() {
256 assert!(!ScreenshotOptions::default().full_page);
257 }
258
259 #[test]
260 fn assert_send_sync() {
261 fn check<T: Send + Sync>() {}
262 check::<Client>();
263 check::<ClientBuilder>();
264 check::<ScreenshotOptions>();
265 }
266
267 #[test]
268 fn client_builder_sets_visibility() {
269 let client = Client::builder().visibility(VisibilityPolicy::off()).build();
270 assert_eq!(client.inner.visibility, VisibilityPolicy::off());
271 }
272
273 #[tokio::test]
274 async fn client_fetch_invalid_url_returns_invalid_url_error() {
275 let client = Client::new();
276 let err = client.fetch("not a url").await.unwrap_err();
277 assert!(matches!(err, Error::InvalidUrl { .. }), "got: {err:?}");
278 }
279
280 #[tokio::test]
281 async fn client_fetch_private_address_is_rejected() {
282 let client = Client::new();
283 let err = client.fetch("http://127.0.0.1/").await.unwrap_err();
284 assert!(err.is_network(), "got: {err:?}");
285 }
286}