viewpoint_core/api/context/
mod.rs

1//! API request context for making HTTP requests.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use reqwest::cookie::Jar;
7use tracing::{debug, info};
8
9use super::{APIContextOptions, APIError, APIRequestBuilder, HttpMethod};
10
11/// Context for making API requests.
12///
13/// `APIRequestContext` can be created standalone or from a browser context.
14/// When created from a browser context, cookies are shared between the two.
15///
16/// # Creating a Standalone Context
17///
18/// ```no_run
19/// use viewpoint_core::api::{APIRequestContext, APIContextOptions};
20///
21/// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
22/// let api = APIRequestContext::new(
23///     APIContextOptions::new()
24///         .base_url("https://api.example.com")
25/// ).await?;
26///
27/// // Make requests
28/// let response = api.get("/users").send().await?;
29/// # Ok(())
30/// # }
31/// ```
32///
33/// # Creating from Browser Context
34///
35/// ```
36/// # #[cfg(feature = "integration")]
37/// # tokio_test::block_on(async {
38/// use viewpoint_core::Browser;
39///
40/// let browser = Browser::launch().headless(true).launch().await.unwrap();
41/// let context = browser.new_context().await.unwrap();
42///
43/// // Get API context that shares cookies with browser
44/// let api = context.request().await.unwrap();
45///
46/// // API requests will include browser cookies
47/// let response = api.get("https://httpbin.org/get").send().await.unwrap();
48/// # });
49/// ```
50#[derive(Debug)]
51pub struct APIRequestContext {
52    /// The underlying HTTP client.
53    client: Arc<reqwest::Client>,
54    /// Cookie jar (shared with browser context if applicable).
55    cookie_jar: Arc<Jar>,
56    /// Context options.
57    options: APIContextOptions,
58    /// Whether this context has been disposed.
59    disposed: Arc<AtomicBool>,
60}
61
62impl APIRequestContext {
63    /// Create a new standalone API request context.
64    ///
65    /// # Arguments
66    ///
67    /// * `options` - Configuration options for the context
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the HTTP client cannot be created.
72    pub async fn new(options: APIContextOptions) -> Result<Self, APIError> {
73        info!("Creating standalone APIRequestContext");
74
75        let cookie_jar = Arc::new(Jar::default());
76        let client = Self::build_client(&options, Arc::clone(&cookie_jar))?;
77
78        Ok(Self {
79            client: Arc::new(client),
80            cookie_jar,
81            options,
82            disposed: Arc::new(AtomicBool::new(false)),
83        })
84    }
85
86    /// Create an API context with a shared cookie jar.
87    ///
88    /// This is used internally when creating an API context from a browser context.
89    pub(crate) async fn with_shared_cookies(
90        options: APIContextOptions,
91        cookie_jar: Arc<Jar>,
92    ) -> Result<Self, APIError> {
93        debug!("Creating APIRequestContext with shared cookie jar");
94
95        let client = Self::build_client(&options, Arc::clone(&cookie_jar))?;
96
97        Ok(Self {
98            client: Arc::new(client),
99            cookie_jar,
100            options,
101            disposed: Arc::new(AtomicBool::new(false)),
102        })
103    }
104
105    /// Build the reqwest client with the given options.
106    fn build_client(
107        options: &APIContextOptions,
108        cookie_jar: Arc<Jar>,
109    ) -> Result<reqwest::Client, APIError> {
110        let mut builder = reqwest::Client::builder().cookie_provider(cookie_jar);
111
112        // Set timeout if specified
113        if let Some(timeout) = options.timeout {
114            builder = builder.timeout(timeout);
115        }
116
117        // Set user agent if specified
118        if let Some(ref user_agent) = options.user_agent {
119            builder = builder.user_agent(user_agent);
120        }
121
122        // Handle HTTPS errors
123        if options.ignore_https_errors {
124            builder = builder.danger_accept_invalid_certs(true);
125        }
126
127        // Set up proxy if configured
128        if let Some(ref proxy_config) = options.proxy {
129            let mut proxy = reqwest::Proxy::all(&proxy_config.server)
130                .map_err(|e| APIError::BuildError(format!("Invalid proxy URL: {e}")))?;
131
132            if let (Some(username), Some(password)) =
133                (&proxy_config.username, &proxy_config.password)
134            {
135                proxy = proxy.basic_auth(username, password);
136            }
137
138            builder = builder.proxy(proxy);
139        }
140
141        // Set up HTTP credentials for basic auth
142        // Note: reqwest doesn't have built-in preemptive basic auth at client level,
143        // so we'll handle this via default headers
144
145        builder
146            .build()
147            .map_err(|e| APIError::BuildError(e.to_string()))
148    }
149
150    /// Get the default headers including any authentication.
151    fn default_headers(&self) -> Vec<(String, String)> {
152        let mut headers: Vec<(String, String)> = self
153            .options
154            .extra_http_headers
155            .iter()
156            .map(|(k, v)| (k.clone(), v.clone()))
157            .collect();
158
159        // Add basic auth header if credentials are configured
160        if let Some(ref creds) = self.options.http_credentials {
161            use base64::Engine;
162            let auth_string = format!("{}:{}", creds.username, creds.password);
163            let encoded = base64::engine::general_purpose::STANDARD.encode(auth_string);
164            headers.push(("Authorization".to_string(), format!("Basic {encoded}")));
165        }
166
167        headers
168    }
169
170    /// Create a GET request builder.
171    ///
172    /// # Example
173    ///
174    /// ```no_run
175    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
176    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
177    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
178    /// let response = api.get("https://api.example.com/users")
179    ///     .query(&[("page", "1")])
180    ///     .send()
181    ///     .await?;
182    /// # Ok(())
183    /// # }
184    /// ```
185    pub fn get(&self, url: impl Into<String>) -> APIRequestBuilder {
186        self.request(HttpMethod::Get, url)
187    }
188
189    /// Create a POST request builder.
190    ///
191    /// # Example
192    ///
193    /// ```no_run
194    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
195    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
196    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
197    /// let response = api.post("https://api.example.com/users")
198    ///     .json(&serde_json::json!({"name": "John"}))
199    ///     .send()
200    ///     .await?;
201    /// # Ok(())
202    /// # }
203    /// ```
204    pub fn post(&self, url: impl Into<String>) -> APIRequestBuilder {
205        self.request(HttpMethod::Post, url)
206    }
207
208    /// Create a PUT request builder.
209    ///
210    /// # Example
211    ///
212    /// ```no_run
213    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
214    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
215    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
216    /// let response = api.put("https://api.example.com/users/1")
217    ///     .json(&serde_json::json!({"name": "John Updated"}))
218    ///     .send()
219    ///     .await?;
220    /// # Ok(())
221    /// # }
222    /// ```
223    pub fn put(&self, url: impl Into<String>) -> APIRequestBuilder {
224        self.request(HttpMethod::Put, url)
225    }
226
227    /// Create a PATCH request builder.
228    ///
229    /// # Example
230    ///
231    /// ```no_run
232    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
233    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
234    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
235    /// let response = api.patch("https://api.example.com/users/1")
236    ///     .json(&serde_json::json!({"status": "active"}))
237    ///     .send()
238    ///     .await?;
239    /// # Ok(())
240    /// # }
241    /// ```
242    pub fn patch(&self, url: impl Into<String>) -> APIRequestBuilder {
243        self.request(HttpMethod::Patch, url)
244    }
245
246    /// Create a DELETE request builder.
247    ///
248    /// # Example
249    ///
250    /// ```no_run
251    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
252    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
253    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
254    /// let response = api.delete("https://api.example.com/users/1")
255    ///     .send()
256    ///     .await?;
257    /// # Ok(())
258    /// # }
259    /// ```
260    pub fn delete(&self, url: impl Into<String>) -> APIRequestBuilder {
261        self.request(HttpMethod::Delete, url)
262    }
263
264    /// Create a HEAD request builder.
265    ///
266    /// # Example
267    ///
268    /// ```no_run
269    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
270    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
271    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
272    /// let response = api.head("https://api.example.com/users")
273    ///     .send()
274    ///     .await?;
275    /// println!("Content-Length: {:?}", response.header("content-length"));
276    /// # Ok(())
277    /// # }
278    /// ```
279    pub fn head(&self, url: impl Into<String>) -> APIRequestBuilder {
280        self.request(HttpMethod::Head, url)
281    }
282
283    /// Create a request builder with a specific HTTP method.
284    ///
285    /// This is the underlying method used by `get()`, `post()`, etc.
286    ///
287    /// # Example
288    ///
289    /// ```no_run
290    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions, HttpMethod};
291    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
292    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
293    /// let response = api.fetch(HttpMethod::Get, "https://api.example.com/users")
294    ///     .send()
295    ///     .await?;
296    /// # Ok(())
297    /// # }
298    /// ```
299    pub fn fetch(&self, method: HttpMethod, url: impl Into<String>) -> APIRequestBuilder {
300        self.request(method, url)
301    }
302
303    /// Internal method to create a request builder.
304    fn request(&self, method: HttpMethod, url: impl Into<String>) -> APIRequestBuilder {
305        let mut builder = APIRequestBuilder::new(
306            Arc::clone(&self.client),
307            method,
308            url,
309            self.options.base_url.clone(),
310            self.default_headers(),
311        );
312
313        // Mark as disposed if the context is disposed
314        if self.disposed.load(Ordering::SeqCst) {
315            builder.set_disposed();
316        }
317
318        builder
319    }
320
321    /// Dispose of this API context, releasing resources.
322    ///
323    /// After calling this method, any new requests will fail with `APIError::Disposed`.
324    ///
325    /// # Example
326    ///
327    /// ```no_run
328    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
329    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
330    /// let api = APIRequestContext::new(APIContextOptions::new()).await?;
331    /// // ... use the API ...
332    /// api.dispose().await;
333    /// # Ok(())
334    /// # }
335    /// ```
336    pub async fn dispose(&self) {
337        info!("Disposing APIRequestContext");
338        self.disposed.store(true, Ordering::SeqCst);
339    }
340
341    /// Check if this context has been disposed.
342    pub fn is_disposed(&self) -> bool {
343        self.disposed.load(Ordering::SeqCst)
344    }
345
346    /// Get the base URL for this context.
347    pub fn base_url(&self) -> Option<&str> {
348        self.options.base_url.as_deref()
349    }
350
351    /// Get access to the cookie jar.
352    ///
353    /// This can be used to inspect or manually add cookies.
354    pub fn cookie_jar(&self) -> &Arc<Jar> {
355        &self.cookie_jar
356    }
357}
358
359impl Clone for APIRequestContext {
360    fn clone(&self) -> Self {
361        Self {
362            client: Arc::clone(&self.client),
363            cookie_jar: Arc::clone(&self.cookie_jar),
364            options: self.options.clone(),
365            disposed: Arc::clone(&self.disposed),
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests;