Skip to main content

capture_rust/
lib.rs

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    // Viewport Options
63    pub vw: Option<u32>,
64    pub vh: Option<u32>,
65    pub scale_factor: Option<f64>,
66
67    // Capture Customization
68    pub full: Option<bool>,
69    pub delay: Option<u32>,
70    pub wait_for: Option<String>,
71    pub wait_for_id: Option<String>,
72
73    // Visual Modifications
74    pub dark_mode: Option<bool>,
75    pub transparent: Option<bool>,
76    pub selector: Option<String>,
77    pub selector_id: Option<String>,
78
79    // Performance/Detection
80    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    // Image Options
86    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    // Additional Options
92    pub http_auth: Option<String>,
93    pub user_agent: Option<String>,
94    pub fresh: Option<bool>,
95
96    // Generic override for any future options
97    pub additional_options: Option<RequestOptions>,
98}
99
100#[derive(Debug, Clone, Default)]
101pub struct PdfOptions {
102    // Authentication
103    pub http_auth: Option<String>,
104    pub user_agent: Option<String>,
105
106    // Page Dimensions
107    pub width: Option<String>,
108    pub height: Option<String>,
109    pub format: Option<String>,
110
111    // Margins
112    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    // Rendering Options
118    pub scale: Option<f64>,
119    pub landscape: Option<bool>,
120    pub delay: Option<u32>,
121    pub stealth: Option<bool>,
122
123    // Storage/Output
124    pub file_name: Option<String>,
125    pub s3_acl: Option<String>,
126    pub s3_redirect: Option<bool>,
127    pub timestamp: Option<bool>,
128
129    // Generic override for any future options
130    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    // Generic override for any future options
143    pub additional_options: Option<RequestOptions>,
144}
145
146#[derive(Debug, Clone, Default)]
147pub struct MetadataOptions {
148    pub stealth: Option<bool>,
149
150    // Generic override for any future options
151    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        // Merge additional options, allowing overrides
267        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        // Merge additional options, allowing overrides
372        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        // Merge additional options, allowing overrides
418        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        // Merge additional options, allowing overrides
437        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        // Rebuild client with new timeout
561        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    // Structured options methods
667    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    // Structured options fetch methods
755    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}