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}
499
500#[derive(Debug, Serialize)]
501struct SessionActionRequest<'a> {
502 #[serde(rename = "type")]
503 action_type: &'a str,
504 payload: &'a SessionActionPayload,
505}
506
507pub struct Capture {
508 key: String,
509 secret: String,
510 options: CaptureOptions,
511 client: Client,
512}
513
514impl Capture {
515 const API_URL: &'static str = "https://cdn.capture.page";
516 const EDGE_URL: &'static str = "https://edge.capture.page";
517
518 pub fn new(key: String, secret: String) -> Self {
519 let options = CaptureOptions::default();
520 let client = options.client.clone().unwrap_or_else(|| {
521 let mut builder = Client::builder();
522 if let Some(timeout) = options.timeout {
523 builder = builder.timeout(timeout);
524 }
525 builder.build().unwrap_or_else(|_| Client::new())
526 });
527
528 Self {
529 key,
530 secret,
531 options,
532 client,
533 }
534 }
535
536 pub fn with_options(key: String, secret: String, options: CaptureOptions) -> Self {
537 let client = options.client.clone().unwrap_or_else(|| {
538 let mut builder = Client::builder();
539 if let Some(timeout) = options.timeout {
540 builder = builder.timeout(timeout);
541 }
542 builder.build().unwrap_or_else(|_| Client::new())
543 });
544
545 Self {
546 key,
547 secret,
548 options,
549 client,
550 }
551 }
552
553 pub fn with_edge(mut self) -> Self {
554 self.options.use_edge = true;
555 self
556 }
557
558 pub fn with_timeout(mut self, timeout: Duration) -> Self {
559 self.options.timeout = Some(timeout);
560 let builder = Client::builder().timeout(timeout);
562 self.client = builder.build().unwrap_or_else(|_| Client::new());
563 self
564 }
565
566 pub fn with_client(mut self, client: Client) -> Self {
567 self.client = client;
568 self.options.client = Some(self.client.clone());
569 self
570 }
571
572 fn generate_token(&self, secret: &str, url: &str) -> String {
573 format!("{:x}", md5::compute(format!("{secret}{url}")))
574 }
575
576 fn to_query_string(&self, options: &RequestOptions) -> String {
577 let mut params = Vec::new();
578
579 for (key, value) in options {
580 let value_str = match value {
581 serde_json::Value::String(s) => s.clone(),
582 serde_json::Value::Number(n) => n.to_string(),
583 serde_json::Value::Bool(b) => b.to_string(),
584 _ => continue,
585 };
586
587 if !value_str.is_empty() {
588 params.push(format!(
589 "{}={}",
590 urlencoding::encode(key),
591 urlencoding::encode(&value_str)
592 ));
593 }
594 }
595
596 params.join("&")
597 }
598
599 fn build_url(
600 &self,
601 request_type: RequestType,
602 url: &str,
603 request_options: Option<&RequestOptions>,
604 ) -> Result<String> {
605 if self.key.is_empty() || self.secret.is_empty() {
606 return Err(CaptureError::MissingCredentials);
607 }
608
609 if url.is_empty() {
610 return Err(CaptureError::MissingUrl);
611 }
612
613 let mut options = request_options.cloned().unwrap_or_default();
614 options.insert(
615 "url".to_string(),
616 serde_json::Value::String(url.to_string()),
617 );
618
619 let query = self.to_query_string(&options);
620 let token = self.generate_token(&self.secret, &query);
621
622 let base_url = if self.options.use_edge {
623 Self::EDGE_URL
624 } else {
625 Self::API_URL
626 };
627
628 Ok(format!(
629 "{}/{}/{}/{}?{}",
630 base_url,
631 self.key,
632 token,
633 request_type.as_str(),
634 query
635 ))
636 }
637
638 pub fn build_image_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
639 self.build_url(RequestType::Image, url, options)
640 }
641
642 pub fn build_pdf_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
643 self.build_url(RequestType::Pdf, url, options)
644 }
645
646 pub fn build_content_url(&self, url: &str, options: Option<&RequestOptions>) -> Result<String> {
647 self.build_url(RequestType::Content, url, options)
648 }
649
650 pub fn build_metadata_url(
651 &self,
652 url: &str,
653 options: Option<&RequestOptions>,
654 ) -> Result<String> {
655 self.build_url(RequestType::Metadata, url, options)
656 }
657
658 pub fn build_animated_url(
659 &self,
660 url: &str,
661 options: Option<&RequestOptions>,
662 ) -> Result<String> {
663 self.build_url(RequestType::Animated, url, options)
664 }
665
666 pub fn build_screenshot_url(
668 &self,
669 url: &str,
670 options: Option<&ScreenshotOptions>,
671 ) -> Result<String> {
672 let request_options = options.map(|o| o.to_request_options());
673 self.build_url(RequestType::Image, url, request_options.as_ref())
674 }
675
676 pub fn build_pdf_url_structured(
677 &self,
678 url: &str,
679 options: Option<&PdfOptions>,
680 ) -> Result<String> {
681 let request_options = options.map(|o| o.to_request_options());
682 self.build_url(RequestType::Pdf, url, request_options.as_ref())
683 }
684
685 pub fn build_content_url_structured(
686 &self,
687 url: &str,
688 options: Option<&ContentOptions>,
689 ) -> Result<String> {
690 let request_options = options.map(|o| o.to_request_options());
691 self.build_url(RequestType::Content, url, request_options.as_ref())
692 }
693
694 pub fn build_metadata_url_structured(
695 &self,
696 url: &str,
697 options: Option<&MetadataOptions>,
698 ) -> Result<String> {
699 let request_options = options.map(|o| o.to_request_options());
700 self.build_url(RequestType::Metadata, url, request_options.as_ref())
701 }
702
703 pub async fn fetch_image(
704 &self,
705 url: &str,
706 options: Option<&RequestOptions>,
707 ) -> Result<Vec<u8>> {
708 let capture_url = self.build_image_url(url, options)?;
709 let response = self.client.get(&capture_url).send().await?;
710 let bytes = response.bytes().await?;
711 Ok(bytes.to_vec())
712 }
713
714 pub async fn fetch_pdf(&self, url: &str, options: Option<&RequestOptions>) -> Result<Vec<u8>> {
715 let capture_url = self.build_pdf_url(url, options)?;
716 let response = self.client.get(&capture_url).send().await?;
717 let bytes = response.bytes().await?;
718 Ok(bytes.to_vec())
719 }
720
721 pub async fn fetch_content(
722 &self,
723 url: &str,
724 options: Option<&RequestOptions>,
725 ) -> Result<ContentResponse> {
726 let capture_url = self.build_content_url(url, options)?;
727 let response = self.client.get(&capture_url).send().await?;
728 let content = response.json::<ContentResponse>().await?;
729 Ok(content)
730 }
731
732 pub async fn fetch_metadata(
733 &self,
734 url: &str,
735 options: Option<&RequestOptions>,
736 ) -> Result<MetadataResponse> {
737 let capture_url = self.build_metadata_url(url, options)?;
738 let response = self.client.get(&capture_url).send().await?;
739 let metadata = response.json::<MetadataResponse>().await?;
740 Ok(metadata)
741 }
742
743 pub async fn fetch_animated(
744 &self,
745 url: &str,
746 options: Option<&RequestOptions>,
747 ) -> Result<Vec<u8>> {
748 let capture_url = self.build_animated_url(url, options)?;
749 let response = self.client.get(&capture_url).send().await?;
750 let bytes = response.bytes().await?;
751 Ok(bytes.to_vec())
752 }
753
754 pub async fn fetch_screenshot(
756 &self,
757 url: &str,
758 options: Option<&ScreenshotOptions>,
759 ) -> Result<Vec<u8>> {
760 let capture_url = self.build_screenshot_url(url, options)?;
761 let response = self.client.get(&capture_url).send().await?;
762 let bytes = response.bytes().await?;
763 Ok(bytes.to_vec())
764 }
765
766 pub async fn fetch_pdf_structured(
767 &self,
768 url: &str,
769 options: Option<&PdfOptions>,
770 ) -> Result<Vec<u8>> {
771 let capture_url = self.build_pdf_url_structured(url, options)?;
772 let response = self.client.get(&capture_url).send().await?;
773 let bytes = response.bytes().await?;
774 Ok(bytes.to_vec())
775 }
776
777 pub async fn fetch_content_structured(
778 &self,
779 url: &str,
780 options: Option<&ContentOptions>,
781 ) -> Result<ContentResponse> {
782 let capture_url = self.build_content_url_structured(url, options)?;
783 let response = self.client.get(&capture_url).send().await?;
784 let content = response.json::<ContentResponse>().await?;
785 Ok(content)
786 }
787
788 pub async fn fetch_metadata_structured(
789 &self,
790 url: &str,
791 options: Option<&MetadataOptions>,
792 ) -> Result<MetadataResponse> {
793 let capture_url = self.build_metadata_url_structured(url, options)?;
794 let response = self.client.get(&capture_url).send().await?;
795 let metadata = response.json::<MetadataResponse>().await?;
796 Ok(metadata)
797 }
798
799 pub async fn create_session(
800 &self,
801 options: Option<&CreateSessionOptions>,
802 ) -> Result<SessionResponse> {
803 let default_options;
804 let options = match options {
805 Some(options) => options,
806 None => {
807 default_options = CreateSessionOptions::default();
808 &default_options
809 }
810 };
811
812 self.sessions_request(Method::POST, "", Some(options)).await
813 }
814
815 pub async fn get_session(&self, session_id: &str) -> Result<SessionResponse> {
816 self.sessions_request::<SessionResponse, serde_json::Value>(
817 Method::GET,
818 &format!("/{}", self.escape_session_id(session_id)?),
819 None,
820 )
821 .await
822 }
823
824 pub async fn close_session(&self, session_id: &str) -> Result<SessionResponse> {
825 self.sessions_request::<SessionResponse, serde_json::Value>(
826 Method::DELETE,
827 &format!("/{}", self.escape_session_id(session_id)?),
828 None,
829 )
830 .await
831 }
832
833 pub async fn execute_action(
834 &self,
835 session_id: &str,
836 action_type: &str,
837 payload: Option<&SessionActionPayload>,
838 ) -> Result<SessionActionResponse> {
839 let default_payload;
840 let payload = match payload {
841 Some(payload) => payload,
842 None => {
843 default_payload = SessionActionPayload::new();
844 &default_payload
845 }
846 };
847 let body = SessionActionRequest {
848 action_type,
849 payload,
850 };
851
852 self.sessions_request(
853 Method::POST,
854 &format!("/{}/actions", self.escape_session_id(session_id)?),
855 Some(&body),
856 )
857 .await
858 }
859
860 fn sessions_bearer_token(&self) -> Result<String> {
861 if self.key.is_empty() || self.secret.is_empty() {
862 return Err(CaptureError::MissingCredentials);
863 }
864
865 Ok(general_purpose::STANDARD.encode(format!("{}:{}", self.key, self.secret)))
866 }
867
868 fn session_url(&self, path: &str) -> String {
869 format!("{}/v1/sessions{path}", Self::EDGE_URL)
870 }
871
872 async fn sessions_request<T, B>(
873 &self,
874 method: Method,
875 path: &str,
876 body: Option<&B>,
877 ) -> Result<T>
878 where
879 T: DeserializeOwned,
880 B: Serialize + ?Sized,
881 {
882 let mut request = self.client.request(method, self.session_url(path)).header(
883 "Authorization",
884 format!("Bearer {}", self.sessions_bearer_token()?),
885 );
886
887 if let Some(body) = body {
888 request = request.json(body);
889 }
890
891 let response = request.send().await?;
892 let status = response.status();
893 let body_text = response.text().await?;
894
895 if !status.is_success() {
896 let body = serde_json::from_str::<serde_json::Value>(&body_text)
897 .unwrap_or_else(|_| serde_json::json!({ "error": body_text }));
898 let message = body
899 .get("error")
900 .and_then(|value| value.as_str())
901 .map(ToOwned::to_owned)
902 .unwrap_or_else(|| {
903 format!(
904 "Capture Sessions API request failed with status {}",
905 status.as_u16()
906 )
907 });
908
909 return Err(CaptureError::SessionsApiError {
910 status: status.as_u16(),
911 body,
912 message,
913 });
914 }
915
916 Ok(serde_json::from_str(&body_text)?)
917 }
918
919 fn escape_session_id(&self, session_id: &str) -> Result<String> {
920 if session_id.is_empty() {
921 return Err(CaptureError::MissingSessionId);
922 }
923
924 Ok(urlencoding::encode(session_id).into_owned())
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 #[test]
933 fn test_capture_new() {
934 let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
935 assert_eq!(capture.key, "test_key");
936 assert_eq!(capture.secret, "test_secret");
937 assert!(!capture.options.use_edge);
938 }
939
940 #[test]
941 fn test_capture_with_edge() {
942 let options = CaptureOptions::new().with_edge();
943 let capture =
944 Capture::with_options("test_key".to_string(), "test_secret".to_string(), options);
945 assert!(capture.options.use_edge);
946 }
947
948 #[test]
949 fn test_build_image_url() {
950 let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
951 let url = capture
952 .build_image_url("https://example.com", None)
953 .unwrap();
954 assert!(url.contains("test_key"));
955 assert!(url.contains("image"));
956 assert!(url.contains("https://cdn.capture.page"));
957 }
958
959 #[test]
960 fn test_build_image_url_with_edge() {
961 let options = CaptureOptions::new().with_edge();
962 let capture =
963 Capture::with_options("test_key".to_string(), "test_secret".to_string(), options);
964 let url = capture
965 .build_image_url("https://example.com", None)
966 .unwrap();
967 assert!(url.contains("https://edge.capture.page"));
968 }
969
970 #[test]
971 fn test_missing_credentials() {
972 let capture = Capture::new("".to_string(), "".to_string());
973 let result = capture.build_image_url("https://example.com", None);
974 assert!(matches!(result, Err(CaptureError::MissingCredentials)));
975 }
976
977 #[test]
978 fn test_missing_url() {
979 let capture = Capture::new("test_key".to_string(), "test_secret".to_string());
980 let result = capture.build_image_url("", None);
981 assert!(matches!(result, Err(CaptureError::MissingUrl)));
982 }
983
984 #[test]
985 fn test_sessions_bearer_token() {
986 let capture = Capture::new("user_123".to_string(), "secret".to_string());
987
988 assert_eq!(
989 capture.sessions_bearer_token().unwrap(),
990 "dXNlcl8xMjM6c2VjcmV0"
991 );
992 }
993
994 #[test]
995 fn test_session_url_uses_edge_url() {
996 let capture = Capture::new("user_123".to_string(), "secret".to_string());
997
998 assert_eq!(
999 capture.session_url("/sess_123/actions"),
1000 "https://edge.capture.page/v1/sessions/sess_123/actions"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_session_id_escaping() {
1006 let capture = Capture::new("user_123".to_string(), "secret".to_string());
1007
1008 assert_eq!(
1009 capture.escape_session_id("sess_123/child").unwrap(),
1010 "sess_123%2Fchild"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_create_session_options_serialization() {
1016 let options = CreateSessionOptions {
1017 max_ttl_seconds: Some(300),
1018 proxy: Some(true),
1019 bypass_bot_detection: None,
1020 };
1021
1022 let value = serde_json::to_value(options).unwrap();
1023 assert_eq!(
1024 value,
1025 serde_json::json!({
1026 "maxTtlSeconds": 300,
1027 "proxy": true
1028 })
1029 );
1030 }
1031}