1use base64::{engine::general_purpose, Engine as _};
2use reqwest::{Client, Method};
3use serde::{de::DeserializeOwned, Deserialize, Serialize};
4use std::collections::HashMap;
5use std::time::Duration;
6use thiserror::Error;
7
8#[derive(Error, Debug)]
9pub enum CaptureError {
10 #[error("HTTP request failed: {0}")]
11 HttpError(#[from] reqwest::Error),
12 #[error("URL parsing failed: {0}")]
13 UrlError(#[from] url::ParseError),
14 #[error("Key and Secret are required")]
15 MissingCredentials,
16 #[error("URL is required")]
17 MissingUrl,
18 #[error("Session ID is required")]
19 MissingSessionId,
20 #[error("URL should be a string")]
21 InvalidUrl,
22 #[error("JSON parsing failed: {0}")]
23 JsonError(#[from] serde_json::Error),
24 #[error("{message}")]
25 SessionsApiError {
26 status: u16,
27 body: serde_json::Value,
28 message: String,
29 },
30}
31
32pub type Result<T> = std::result::Result<T, CaptureError>;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum RequestType {
36 Image,
37 Pdf,
38 Content,
39 Metadata,
40 Animated,
41}
42
43impl RequestType {
44 fn as_str(&self) -> &'static str {
45 match self {
46 RequestType::Image => "image",
47 RequestType::Pdf => "pdf",
48 RequestType::Content => "content",
49 RequestType::Metadata => "metadata",
50 RequestType::Animated => "animated",
51 }
52 }
53}
54
55pub type RequestOptions = HashMap<String, serde_json::Value>;
56pub type SessionActionPayload = HashMap<String, serde_json::Value>;
57pub type SessionActionResponse = serde_json::Value;
58pub type SessionResponse = serde_json::Value;
59
60#[derive(Debug, Clone, Default)]
61pub struct ScreenshotOptions {
62 pub vw: Option<u32>,
64 pub vh: Option<u32>,
65 pub scale_factor: Option<f64>,
66
67 pub full: Option<bool>,
69 pub delay: Option<u32>,
70 pub wait_for: Option<String>,
71 pub wait_for_id: Option<String>,
72
73 pub dark_mode: Option<bool>,
75 pub transparent: Option<bool>,
76 pub selector: Option<String>,
77 pub selector_id: Option<String>,
78
79 pub block_cookie_banners: Option<bool>,
81 pub block_ads: Option<bool>,
82 pub bypass_bot_detection: Option<bool>,
83 pub stealth: Option<bool>,
84
85 pub image_type: Option<String>,
87 pub best_format: Option<bool>,
88 pub resize_width: Option<u32>,
89 pub resize_height: Option<u32>,
90
91 pub http_auth: Option<String>,
93 pub user_agent: Option<String>,
94 pub fresh: Option<bool>,
95
96 pub additional_options: Option<RequestOptions>,
98}
99
100#[derive(Debug, Clone, Default)]
101pub struct PdfOptions {
102 pub http_auth: Option<String>,
104 pub user_agent: Option<String>,
105
106 pub width: Option<String>,
108 pub height: Option<String>,
109 pub format: Option<String>,
110
111 pub margin_top: Option<String>,
113 pub margin_right: Option<String>,
114 pub margin_bottom: Option<String>,
115 pub margin_left: Option<String>,
116
117 pub scale: Option<f64>,
119 pub landscape: Option<bool>,
120 pub delay: Option<u32>,
121 pub stealth: Option<bool>,
122
123 pub file_name: Option<String>,
125 pub s3_acl: Option<String>,
126 pub s3_redirect: Option<bool>,
127 pub timestamp: Option<bool>,
128
129 pub additional_options: Option<RequestOptions>,
131}
132
133#[derive(Debug, Clone, Default)]
134pub struct ContentOptions {
135 pub http_auth: Option<String>,
136 pub user_agent: Option<String>,
137 pub delay: Option<u32>,
138 pub wait_for: Option<String>,
139 pub wait_for_id: Option<String>,
140 pub stealth: Option<bool>,
141
142 pub additional_options: Option<RequestOptions>,
144}
145
146#[derive(Debug, Clone, Default)]
147pub struct MetadataOptions {
148 pub stealth: Option<bool>,
149
150 pub additional_options: Option<RequestOptions>,
152}
153
154impl ScreenshotOptions {
155 pub fn to_request_options(&self) -> RequestOptions {
156 let mut options = RequestOptions::new();
157
158 if let Some(vw) = self.vw {
159 options.insert("vw".to_string(), serde_json::Value::Number(vw.into()));
160 }
161 if let Some(vh) = self.vh {
162 options.insert("vh".to_string(), serde_json::Value::Number(vh.into()));
163 }
164 if let Some(scale_factor) = self.scale_factor {
165 if let Some(num) = serde_json::Number::from_f64(scale_factor) {
166 options.insert("scaleFactor".to_string(), serde_json::Value::Number(num));
167 }
168 }
169 if let Some(full) = self.full {
170 options.insert("full".to_string(), serde_json::Value::Bool(full));
171 }
172 if let Some(delay) = self.delay {
173 options.insert("delay".to_string(), serde_json::Value::Number(delay.into()));
174 }
175 if let Some(wait_for) = &self.wait_for {
176 options.insert(
177 "waitFor".to_string(),
178 serde_json::Value::String(wait_for.clone()),
179 );
180 }
181 if let Some(wait_for_id) = &self.wait_for_id {
182 options.insert(
183 "waitForId".to_string(),
184 serde_json::Value::String(wait_for_id.clone()),
185 );
186 }
187 if let Some(dark_mode) = self.dark_mode {
188 options.insert("darkMode".to_string(), serde_json::Value::Bool(dark_mode));
189 }
190 if let Some(transparent) = self.transparent {
191 options.insert(
192 "transparent".to_string(),
193 serde_json::Value::Bool(transparent),
194 );
195 }
196 if let Some(selector) = &self.selector {
197 options.insert(
198 "selector".to_string(),
199 serde_json::Value::String(selector.clone()),
200 );
201 }
202 if let Some(selector_id) = &self.selector_id {
203 options.insert(
204 "selectorId".to_string(),
205 serde_json::Value::String(selector_id.clone()),
206 );
207 }
208 if let Some(block_cookie_banners) = self.block_cookie_banners {
209 options.insert(
210 "blockCookieBanners".to_string(),
211 serde_json::Value::Bool(block_cookie_banners),
212 );
213 }
214 if let Some(block_ads) = self.block_ads {
215 options.insert("blockAds".to_string(), serde_json::Value::Bool(block_ads));
216 }
217 if let Some(bypass_bot_detection) = self.bypass_bot_detection {
218 options.insert(
219 "bypassBotDetection".to_string(),
220 serde_json::Value::Bool(bypass_bot_detection),
221 );
222 }
223 if let Some(stealth) = self.stealth {
224 options.insert("stealth".to_string(), serde_json::Value::Bool(stealth));
225 }
226 if let Some(image_type) = &self.image_type {
227 options.insert(
228 "type".to_string(),
229 serde_json::Value::String(image_type.clone()),
230 );
231 }
232 if let Some(best_format) = self.best_format {
233 options.insert(
234 "bestFormat".to_string(),
235 serde_json::Value::Bool(best_format),
236 );
237 }
238 if let Some(resize_width) = self.resize_width {
239 options.insert(
240 "resizeWidth".to_string(),
241 serde_json::Value::Number(resize_width.into()),
242 );
243 }
244 if let Some(resize_height) = self.resize_height {
245 options.insert(
246 "resizeHeight".to_string(),
247 serde_json::Value::Number(resize_height.into()),
248 );
249 }
250 if let Some(http_auth) = &self.http_auth {
251 options.insert(
252 "httpAuth".to_string(),
253 serde_json::Value::String(http_auth.clone()),
254 );
255 }
256 if let Some(user_agent) = &self.user_agent {
257 options.insert(
258 "userAgent".to_string(),
259 serde_json::Value::String(user_agent.clone()),
260 );
261 }
262 if let Some(fresh) = self.fresh {
263 options.insert("fresh".to_string(), serde_json::Value::Bool(fresh));
264 }
265
266 if let Some(additional) = &self.additional_options {
268 for (key, value) in additional {
269 options.insert(key.clone(), value.clone());
270 }
271 }
272
273 options
274 }
275}
276
277impl PdfOptions {
278 pub fn to_request_options(&self) -> RequestOptions {
279 let mut options = RequestOptions::new();
280
281 if let Some(http_auth) = &self.http_auth {
282 options.insert(
283 "httpAuth".to_string(),
284 serde_json::Value::String(http_auth.clone()),
285 );
286 }
287 if let Some(user_agent) = &self.user_agent {
288 options.insert(
289 "userAgent".to_string(),
290 serde_json::Value::String(user_agent.clone()),
291 );
292 }
293 if let Some(width) = &self.width {
294 options.insert(
295 "width".to_string(),
296 serde_json::Value::String(width.clone()),
297 );
298 }
299 if let Some(height) = &self.height {
300 options.insert(
301 "height".to_string(),
302 serde_json::Value::String(height.clone()),
303 );
304 }
305 if let Some(format) = &self.format {
306 options.insert(
307 "format".to_string(),
308 serde_json::Value::String(format.clone()),
309 );
310 }
311 if let Some(margin_top) = &self.margin_top {
312 options.insert(
313 "marginTop".to_string(),
314 serde_json::Value::String(margin_top.clone()),
315 );
316 }
317 if let Some(margin_right) = &self.margin_right {
318 options.insert(
319 "marginRight".to_string(),
320 serde_json::Value::String(margin_right.clone()),
321 );
322 }
323 if let Some(margin_bottom) = &self.margin_bottom {
324 options.insert(
325 "marginBottom".to_string(),
326 serde_json::Value::String(margin_bottom.clone()),
327 );
328 }
329 if let Some(margin_left) = &self.margin_left {
330 options.insert(
331 "marginLeft".to_string(),
332 serde_json::Value::String(margin_left.clone()),
333 );
334 }
335 if let Some(scale) = self.scale {
336 if let Some(num) = serde_json::Number::from_f64(scale) {
337 options.insert("scale".to_string(), serde_json::Value::Number(num));
338 }
339 }
340 if let Some(landscape) = self.landscape {
341 options.insert("landscape".to_string(), serde_json::Value::Bool(landscape));
342 }
343 if let Some(delay) = self.delay {
344 options.insert("delay".to_string(), serde_json::Value::Number(delay.into()));
345 }
346 if let Some(stealth) = self.stealth {
347 options.insert("stealth".to_string(), serde_json::Value::Bool(stealth));
348 }
349 if let Some(file_name) = &self.file_name {
350 options.insert(
351 "fileName".to_string(),
352 serde_json::Value::String(file_name.clone()),
353 );
354 }
355 if let Some(s3_acl) = &self.s3_acl {
356 options.insert(
357 "s3Acl".to_string(),
358 serde_json::Value::String(s3_acl.clone()),
359 );
360 }
361 if let Some(s3_redirect) = self.s3_redirect {
362 options.insert(
363 "s3Redirect".to_string(),
364 serde_json::Value::Bool(s3_redirect),
365 );
366 }
367 if let Some(timestamp) = self.timestamp {
368 options.insert("timestamp".to_string(), serde_json::Value::Bool(timestamp));
369 }
370
371 if let Some(additional) = &self.additional_options {
373 for (key, value) in additional {
374 options.insert(key.clone(), value.clone());
375 }
376 }
377
378 options
379 }
380}
381
382impl ContentOptions {
383 pub fn to_request_options(&self) -> RequestOptions {
384 let mut options = RequestOptions::new();
385
386 if let Some(http_auth) = &self.http_auth {
387 options.insert(
388 "httpAuth".to_string(),
389 serde_json::Value::String(http_auth.clone()),
390 );
391 }
392 if let Some(user_agent) = &self.user_agent {
393 options.insert(
394 "userAgent".to_string(),
395 serde_json::Value::String(user_agent.clone()),
396 );
397 }
398 if let Some(delay) = self.delay {
399 options.insert("delay".to_string(), serde_json::Value::Number(delay.into()));
400 }
401 if let Some(wait_for) = &self.wait_for {
402 options.insert(
403 "waitFor".to_string(),
404 serde_json::Value::String(wait_for.clone()),
405 );
406 }
407 if let Some(wait_for_id) = &self.wait_for_id {
408 options.insert(
409 "waitForId".to_string(),
410 serde_json::Value::String(wait_for_id.clone()),
411 );
412 }
413 if let Some(stealth) = self.stealth {
414 options.insert("stealth".to_string(), serde_json::Value::Bool(stealth));
415 }
416
417 if let Some(additional) = &self.additional_options {
419 for (key, value) in additional {
420 options.insert(key.clone(), value.clone());
421 }
422 }
423
424 options
425 }
426}
427
428impl MetadataOptions {
429 pub fn to_request_options(&self) -> RequestOptions {
430 let mut options = RequestOptions::new();
431
432 if let Some(stealth) = self.stealth {
433 options.insert("stealth".to_string(), serde_json::Value::Bool(stealth));
434 }
435
436 if let Some(additional) = &self.additional_options {
438 for (key, value) in additional {
439 options.insert(key.clone(), value.clone());
440 }
441 }
442
443 options
444 }
445}
446
447#[derive(Debug, Clone, Default)]
448pub struct CaptureOptions {
449 pub use_edge: bool,
450 pub timeout: Option<Duration>,
451 pub client: Option<Client>,
452}
453
454impl CaptureOptions {
455 pub fn new() -> Self {
456 Self::default()
457 }
458
459 pub fn with_edge(mut self) -> Self {
460 self.use_edge = true;
461 self
462 }
463
464 pub fn with_timeout(mut self, timeout: Duration) -> Self {
465 self.timeout = Some(timeout);
466 self
467 }
468
469 pub fn with_client(mut self, client: Client) -> Self {
470 self.client = Some(client);
471 self
472 }
473}
474
475#[derive(Debug, Deserialize)]
476pub struct ContentResponse {
477 pub success: bool,
478 pub html: String,
479 #[serde(rename = "textContent")]
480 pub text_content: String,
481 pub markdown: String,
482}
483
484#[derive(Debug, Deserialize)]
485pub struct MetadataResponse {
486 pub success: bool,
487 pub metadata: HashMap<String, serde_json::Value>,
488}
489
490#[derive(Debug, Clone, Default, Serialize)]
491pub struct CreateSessionOptions {
492 #[serde(rename = "maxTtlSeconds", skip_serializing_if = "Option::is_none")]
493 pub max_ttl_seconds: Option<u32>,
494 #[serde(skip_serializing_if = "Option::is_none")]
495 pub proxy: Option<bool>,
496 #[serde(rename = "bypassBotDetection", skip_serializing_if = "Option::is_none")]
497 pub bypass_bot_detection: Option<bool>,
498 #[serde(skip_serializing_if = "Option::is_none")]
499 pub cdp: Option<bool>,
500}
501
502#[derive(Debug, Serialize)]
503struct SessionActionRequest<'a> {
504 #[serde(rename = "type")]
505 action_type: &'a str,
506 payload: &'a SessionActionPayload,
507}
508
509pub struct Capture {
510 key: String,
511 secret: String,
512 options: CaptureOptions,
513 client: Client,
514}
515
516impl Capture {
517 const API_URL: &'static str = "https://cdn.capture.page";
518 const EDGE_URL: &'static str = "https://edge.capture.page";
519
520 pub fn new(key: String, secret: String) -> Self {
521 let options = CaptureOptions::default();
522 let client = options.client.clone().unwrap_or_else(|| {
523 let mut builder = Client::builder();
524 if let Some(timeout) = options.timeout {
525 builder = builder.timeout(timeout);
526 }
527 builder.build().unwrap_or_else(|_| Client::new())
528 });
529
530 Self {
531 key,
532 secret,
533 options,
534 client,
535 }
536 }
537
538 pub fn with_options(key: String, secret: String, options: CaptureOptions) -> Self {
539 let client = options.client.clone().unwrap_or_else(|| {
540 let mut builder = Client::builder();
541 if let Some(timeout) = options.timeout {
542 builder = builder.timeout(timeout);
543 }
544 builder.build().unwrap_or_else(|_| Client::new())
545 });
546
547 Self {
548 key,
549 secret,
550 options,
551 client,
552 }
553 }
554
555 pub fn with_edge(mut self) -> Self {
556 self.options.use_edge = true;
557 self
558 }
559
560 pub fn with_timeout(mut self, timeout: Duration) -> Self {
561 self.options.timeout = Some(timeout);
562 let builder = Client::builder().timeout(timeout);
564 self.client = builder.build().unwrap_or_else(|_| Client::new());
565 self
566 }
567
568 pub fn with_client(mut self, client: Client) -> Self {
569 self.client = client;
570 self.options.client = Some(self.client.clone());
571 self
572 }
573
574 fn generate_token(&self, secret: &str, url: &str) -> String {
575 format!("{:x}", md5::compute(format!("{secret}{url}")))
576 }
577
578 fn to_query_string(&self, options: &RequestOptions) -> String {
579 let mut params = Vec::new();
580
581 for (key, value) in options {
582 let value_str = match value {
583 serde_json::Value::String(s) => s.clone(),
584 serde_json::Value::Number(n) => n.to_string(),
585 serde_json::Value::Bool(b) => b.to_string(),
586 _ => continue,
587 };
588
589 if !value_str.is_empty() {
590 params.push(format!(
591 "{}={}",
592 urlencoding::encode(key),
593 urlencoding::encode(&value_str)
594 ));
595 }
596 }
597
598 params.join("&")
599 }
600
601 fn build_url(
602 &self,
603 request_type: RequestType,
604 url: &str,
605 request_options: Option<&RequestOptions>,
606 ) -> Result<String> {
607 if self.key.is_empty() || self.secret.is_empty() {
608 return Err(CaptureError::MissingCredentials);
609 }
610
611 if url.is_empty() {
612 return Err(CaptureError::MissingUrl);
613 }
614
615 let mut options = request_options.cloned().unwrap_or_default();
616 options.insert(
617 "url".to_string(),
618 serde_json::Value::String(url.to_string()),
619 );
620
621 let query = self.to_query_string(&options);
622 let token = self.generate_token(&self.secret, &query);
623
624 let base_url = if self.options.use_edge {
625 Self::EDGE_URL
626 } else {
627 Self::API_URL
628 };
629
630 Ok(format!(
631 "{}/{}/{}/{}?{}",
632 base_url,
633 self.key,
634 token,
635 request_type.as_str(),
636 query
637 ))
638 }
639
640 pub fn build_image_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
641 self.build_url(RequestType::Image, url, options)
642 }
643
644 pub fn build_pdf_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
645 self.build_url(RequestType::Pdf, url, options)
646 }
647
648 pub fn build_content_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
649 self.build_url(RequestType::Content, url, options)
650 }
651
652 pub fn build_metadata_url(
653 &self,
654 url: &str,
655 options: Option<&RequestOptions>,
656 ) -> Result<String> {
657 self.build_url(RequestType::Metadata, url, options)
658 }
659
660 pub fn build_animated_url(
661 &self,
662 url: &str,
663 options: Option<&RequestOptions>,
664 ) -> Result<String> {
665 self.build_url(RequestType::Animated, url, options)
666 }
667
668 pub fn build_screenshot_url(
670 &self,
671 url: &str,
672 options: Option<&ScreenshotOptions>,
673 ) -> Result<String> {
674 let request_options = options.map(|o| o.to_request_options());
675 self.build_url(RequestType::Image, url, request_options.as_ref())
676 }
677
678 pub fn build_pdf_url_structured(
679 &self,
680 url: &str,
681 options: Option<&PdfOptions>,
682 ) -> Result<String> {
683 let request_options = options.map(|o| o.to_request_options());
684 self.build_url(RequestType::Pdf, url, request_options.as_ref())
685 }
686
687 pub fn build_content_url_structured(
688 &self,
689 url: &str,
690 options: Option<&ContentOptions>,
691 ) -> Result<String> {
692 let request_options = options.map(|o| o.to_request_options());
693 self.build_url(RequestType::Content, url, request_options.as_ref())
694 }
695
696 pub fn build_metadata_url_structured(
697 &self,
698 url: &str,
699 options: Option<&MetadataOptions>,
700 ) -> Result<String> {
701 let request_options = options.map(|o| o.to_request_options());
702 self.build_url(RequestType::Metadata, url, request_options.as_ref())
703 }
704
705 pub async fn fetch_image(
706 &self,
707 url: &str,
708 options: Option<&RequestOptions>,
709 ) -> Result<Vec<u8>> {
710 let capture_url = self.build_image_url(url, options)?;
711 let response = self.client.get(&capture_url).send().await?;
712 let bytes = response.bytes().await?;
713 Ok(bytes.to_vec())
714 }
715
716 pub async fn fetch_pdf(&self, url: &str, options: Option<&RequestOptions>) -> Result<Vec<u8>> {
717 let capture_url = self.build_pdf_url(url, options)?;
718 let response = self.client.get(&capture_url).send().await?;
719 let bytes = response.bytes().await?;
720 Ok(bytes.to_vec())
721 }
722
723 pub async fn fetch_content(
724 &self,
725 url: &str,
726 options: Option<&RequestOptions>,
727 ) -> Result<ContentResponse> {
728 let capture_url = self.build_content_url(url, options)?;
729 let response = self.client.get(&capture_url).send().await?;
730 let content = response.json::<ContentResponse>().await?;
731 Ok(content)
732 }
733
734 pub async fn fetch_metadata(
735 &self,
736 url: &str,
737 options: Option<&RequestOptions>,
738 ) -> Result<MetadataResponse> {
739 let capture_url = self.build_metadata_url(url, options)?;
740 let response = self.client.get(&capture_url).send().await?;
741 let metadata = response.json::<MetadataResponse>().await?;
742 Ok(metadata)
743 }
744
745 pub async fn fetch_animated(
746 &self,
747 url: &str,
748 options: Option<&RequestOptions>,
749 ) -> Result<Vec<u8>> {
750 let capture_url = self.build_animated_url(url, options)?;
751 let response = self.client.get(&capture_url).send().await?;
752 let bytes = response.bytes().await?;
753 Ok(bytes.to_vec())
754 }
755
756 pub async fn fetch_screenshot(
758 &self,
759 url: &str,
760 options: Option<&ScreenshotOptions>,
761 ) -> Result<Vec<u8>> {
762 let capture_url = self.build_screenshot_url(url, options)?;
763 let response = self.client.get(&capture_url).send().await?;
764 let bytes = response.bytes().await?;
765 Ok(bytes.to_vec())
766 }
767
768 pub async fn fetch_pdf_structured(
769 &self,
770 url: &str,
771 options: Option<&PdfOptions>,
772 ) -> Result<Vec<u8>> {
773 let capture_url = self.build_pdf_url_structured(url, options)?;
774 let response = self.client.get(&capture_url).send().await?;
775 let bytes = response.bytes().await?;
776 Ok(bytes.to_vec())
777 }
778
779 pub async fn fetch_content_structured(
780 &self,
781 url: &str,
782 options: Option<&ContentOptions>,
783 ) -> Result<ContentResponse> {
784 let capture_url = self.build_content_url_structured(url, options)?;
785 let response = self.client.get(&capture_url).send().await?;
786 let content = response.json::<ContentResponse>().await?;
787 Ok(content)
788 }
789
790 pub async fn fetch_metadata_structured(
791 &self,
792 url: &str,
793 options: Option<&MetadataOptions>,
794 ) -> Result<MetadataResponse> {
795 let capture_url = self.build_metadata_url_structured(url, options)?;
796 let response = self.client.get(&capture_url).send().await?;
797 let metadata = response.json::<MetadataResponse>().await?;
798 Ok(metadata)
799 }
800
801 pub async fn create_session(
802 &self,
803 options: Option<&CreateSessionOptions>,
804 ) -> Result<SessionResponse> {
805 let default_options;
806 let options = match options {
807 Some(options) => options,
808 None => {
809 default_options = CreateSessionOptions::default();
810 &default_options
811 }
812 };
813
814 self.sessions_request(Method::POST, "", Some(options)).await
815 }
816
817 pub async fn get_session(&self, session_id: &str) -> Result<SessionResponse> {
818 self.sessions_request::<SessionResponse, serde_json::Value>(
819 Method::GET,
820 &format!("/{}", self.escape_session_id(session_id)?),
821 None,
822 )
823 .await
824 }
825
826 pub async fn close_session(&self, session_id: &str) -> Result<SessionResponse> {
827 self.sessions_request::<SessionResponse, serde_json::Value>(
828 Method::DELETE,
829 &format!("/{}", self.escape_session_id(session_id)?),
830 None,
831 )
832 .await
833 }
834
835 pub async fn execute_action(
836 &self,
837 session_id: &str,
838 action_type: &str,
839 payload: Option<&SessionActionPayload>,
840 ) -> Result<SessionActionResponse> {
841 let default_payload;
842 let payload = match payload {
843 Some(payload) => payload,
844 None => {
845 default_payload = SessionActionPayload::new();
846 &default_payload
847 }
848 };
849 let body = SessionActionRequest {
850 action_type,
851 payload,
852 };
853
854 self.sessions_request(
855 Method::POST,
856 &format!("/{}/actions", self.escape_session_id(session_id)?),
857 Some(&body),
858 )
859 .await
860 }
861
862 fn sessions_bearer_token(&self) -> Result<String> {
863 if self.key.is_empty() || self.secret.is_empty() {
864 return Err(CaptureError::MissingCredentials);
865 }
866
867 Ok(general_purpose::STANDARD.encode(format!("{}:{}", self.key, self.secret)))
868 }
869
870 fn session_url(&self, path: &str) -> String {
871 format!("{}/v1/sessions{path}", Self::EDGE_URL)
872 }
873
874 async fn sessions_request<T, B>(
875 &self,
876 method: Method,
877 path: &str,
878 body: Option<&B>,
879 ) -> Result<T>
880 where
881 T: DeserializeOwned,
882 B: Serialize + ?Sized,
883 {
884 let mut request = self.client.request(method, self.session_url(path)).header(
885 "Authorization",
886 format!("Bearer {}", self.sessions_bearer_token()?),
887 );
888
889 if let Some(body) = body {
890 request = request.json(body);
891 }
892
893 let response = request.send().await?;
894 let status = response.status();
895 let body_text = response.text().await?;
896
897 if !status.is_success() {
898 let body = serde_json::from_str::<serde_json::Value>(&body_text)
899 .unwrap_or_else(|_| serde_json::json!({ "error": body_text }));
900 let message = body
901 .get("error")
902 .and_then(|value| value.as_str())
903 .map(ToOwned::to_owned)
904 .unwrap_or_else(|| {
905 format!(
906 "Capture Sessions API request failed with status {}",
907 status.as_u16()
908 )
909 });
910
911 return Err(CaptureError::SessionsApiError {
912 status: status.as_u16(),
913 body,
914 message,
915 });
916 }
917
918 Ok(serde_json::from_str(&body_text)?)
919 }
920
921 fn escape_session_id(&self, session_id: &str) -> Result<String> {
922 if session_id.is_empty() {
923 return Err(CaptureError::MissingSessionId);
924 }
925
926 Ok(urlencoding::encode(session_id).into_owned())
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933
934 #[test]
935 fn test_capture_new() {
936 let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
937 assert_eq!(capture.key, "test_key");
938 assert_eq!(capture.secret, "test_secret");
939 assert!(!capture.options.use_edge);
940 }
941
942 #[test]
943 fn test_capture_with_edge() {
944 let options = CaptureOptions::new().with_edge();
945 let capture =
946 Capture::with_options("test_key".to_string(), "test_secret".to_string(), options);
947 assert!(capture.options.use_edge);
948 }
949
950 #[test]
951 fn test_build_image_url() {
952 let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
953 let url = capture
954 .build_image_url("https://example.com", None)
955 .unwrap();
956 assert!(url.contains("test_key"));
957 assert!(url.contains("image"));
958 assert!(url.contains("https://cdn.capture.page"));
959 }
960
961 #[test]
962 fn test_build_image_url_with_edge() {
963 let options = CaptureOptions::new().with_edge();
964 let capture =
965 Capture::with_options("test_key".to_string(), "test_secret".to_string(), options);
966 let url = capture
967 .build_image_url("https://example.com", None)
968 .unwrap();
969 assert!(url.contains("https://edge.capture.page"));
970 }
971
972 #[test]
973 fn test_missing_credentials() {
974 let capture = Capture::new("".to_string(), "".to_string());
975 let result = capture.build_image_url("https://example.com", None);
976 assert!(matches!(result, Err(CaptureError::MissingCredentials)));
977 }
978
979 #[test]
980 fn test_missing_url() {
981 let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
982 let result = capture.build_image_url("", None);
983 assert!(matches!(result, Err(CaptureError::MissingUrl)));
984 }
985
986 #[test]
987 fn test_sessions_bearer_token() {
988 let capture = Capture::new("user_123".to_string(), "secret".to_string());
989
990 assert_eq!(
991 capture.sessions_bearer_token().unwrap(),
992 "dXNlcl8xMjM6c2VjcmV0"
993 );
994 }
995
996 #[test]
997 fn test_session_url_uses_edge_url() {
998 let capture = Capture::new("user_123".to_string(), "secret".to_string());
999
1000 assert_eq!(
1001 capture.session_url("/sess_123/actions"),
1002 "https://edge.capture.page/v1/sessions/sess_123/actions"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_session_id_escaping() {
1008 let capture = Capture::new("user_123".to_string(), "secret".to_string());
1009
1010 assert_eq!(
1011 capture.escape_session_id("sess_123/child").unwrap(),
1012 "sess_123%2Fchild"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_create_session_options_serialization() {
1018 let options = CreateSessionOptions {
1019 max_ttl_seconds: Some(300),
1020 proxy: None,
1021 bypass_bot_detection: None,
1022 cdp: Some(true),
1023 };
1024
1025 let value = serde_json::to_value(options).unwrap();
1026 assert_eq!(
1027 value,
1028 serde_json::json!({
1029 "maxTtlSeconds": 300,
1030 "cdp": true
1031 })
1032 );
1033 }
1034
1035 #[test]
1036 fn test_create_session_options_omit_empty_serialization() {
1037 let options = CreateSessionOptions::default();
1038
1039 let value = serde_json::to_value(options).unwrap();
1040 assert_eq!(value, serde_json::json!({}));
1041 }
1042}