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    #[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        // Rebuild client with new timeout
563        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    // Structured options methods
669    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    // Structured options fetch methods
757    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}