viewpoint_core/api/context/
mod.rs

1//! API request context for making HTTP requests.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
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(options: &APIContextOptions, cookie_jar: Arc<Jar>) -> Result<reqwest::Client, APIError> {
107        let mut builder = reqwest::Client::builder()
108            .cookie_provider(cookie_jar);
109
110        // Set timeout if specified
111        if let Some(timeout) = options.timeout {
112            builder = builder.timeout(timeout);
113        }
114
115        // Set user agent if specified
116        if let Some(ref user_agent) = options.user_agent {
117            builder = builder.user_agent(user_agent);
118        }
119
120        // Handle HTTPS errors
121        if options.ignore_https_errors {
122            builder = builder.danger_accept_invalid_certs(true);
123        }
124
125        // Set up proxy if configured
126        if let Some(ref proxy_config) = options.proxy {
127            let mut proxy = reqwest::Proxy::all(&proxy_config.server)
128                .map_err(|e| APIError::BuildError(format!("Invalid proxy URL: {e}")))?;
129
130            if let (Some(username), Some(password)) = (&proxy_config.username, &proxy_config.password) {
131                proxy = proxy.basic_auth(username, password);
132            }
133
134            builder = builder.proxy(proxy);
135        }
136
137        // Set up HTTP credentials for basic auth
138        // Note: reqwest doesn't have built-in preemptive basic auth at client level,
139        // so we'll handle this via default headers
140
141        builder.build().map_err(|e| APIError::BuildError(e.to_string()))
142    }
143
144    /// Get the default headers including any authentication.
145    fn default_headers(&self) -> Vec<(String, String)> {
146        let mut headers: Vec<(String, String)> = self
147            .options
148            .extra_http_headers
149            .iter()
150            .map(|(k, v)| (k.clone(), v.clone()))
151            .collect();
152
153        // Add basic auth header if credentials are configured
154        if let Some(ref creds) = self.options.http_credentials {
155            use base64::Engine;
156            let auth_string = format!("{}:{}", creds.username, creds.password);
157            let encoded = base64::engine::general_purpose::STANDARD.encode(auth_string);
158            headers.push(("Authorization".to_string(), format!("Basic {encoded}")));
159        }
160
161        headers
162    }
163
164    /// Create a GET request builder.
165    ///
166    /// # Example
167    ///
168    /// ```no_run
169    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
170    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
171    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
172    /// let response = api.get("https://api.example.com/users")
173    ///     .query(&[("page", "1")])
174    ///     .send()
175    ///     .await?;
176    /// # Ok(())
177    /// # }
178    /// ```
179    pub fn get(&self, url: impl Into<String>) -> APIRequestBuilder {
180        self.request(HttpMethod::Get, url)
181    }
182
183    /// Create a POST request builder.
184    ///
185    /// # Example
186    ///
187    /// ```no_run
188    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
189    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
190    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
191    /// let response = api.post("https://api.example.com/users")
192    ///     .json(&serde_json::json!({"name": "John"}))
193    ///     .send()
194    ///     .await?;
195    /// # Ok(())
196    /// # }
197    /// ```
198    pub fn post(&self, url: impl Into<String>) -> APIRequestBuilder {
199        self.request(HttpMethod::Post, url)
200    }
201
202    /// Create a PUT request builder.
203    ///
204    /// # Example
205    ///
206    /// ```no_run
207    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
208    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
209    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
210    /// let response = api.put("https://api.example.com/users/1")
211    ///     .json(&serde_json::json!({"name": "John Updated"}))
212    ///     .send()
213    ///     .await?;
214    /// # Ok(())
215    /// # }
216    /// ```
217    pub fn put(&self, url: impl Into<String>) -> APIRequestBuilder {
218        self.request(HttpMethod::Put, url)
219    }
220
221    /// Create a PATCH request builder.
222    ///
223    /// # Example
224    ///
225    /// ```no_run
226    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
227    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
228    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
229    /// let response = api.patch("https://api.example.com/users/1")
230    ///     .json(&serde_json::json!({"status": "active"}))
231    ///     .send()
232    ///     .await?;
233    /// # Ok(())
234    /// # }
235    /// ```
236    pub fn patch(&self, url: impl Into<String>) -> APIRequestBuilder {
237        self.request(HttpMethod::Patch, url)
238    }
239
240    /// Create a DELETE request builder.
241    ///
242    /// # Example
243    ///
244    /// ```no_run
245    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
246    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
247    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
248    /// let response = api.delete("https://api.example.com/users/1")
249    ///     .send()
250    ///     .await?;
251    /// # Ok(())
252    /// # }
253    /// ```
254    pub fn delete(&self, url: impl Into<String>) -> APIRequestBuilder {
255        self.request(HttpMethod::Delete, url)
256    }
257
258    /// Create a HEAD request builder.
259    ///
260    /// # Example
261    ///
262    /// ```no_run
263    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
264    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
265    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
266    /// let response = api.head("https://api.example.com/users")
267    ///     .send()
268    ///     .await?;
269    /// println!("Content-Length: {:?}", response.header("content-length"));
270    /// # Ok(())
271    /// # }
272    /// ```
273    pub fn head(&self, url: impl Into<String>) -> APIRequestBuilder {
274        self.request(HttpMethod::Head, url)
275    }
276
277    /// Create a request builder with a specific HTTP method.
278    ///
279    /// This is the underlying method used by `get()`, `post()`, etc.
280    ///
281    /// # Example
282    ///
283    /// ```no_run
284    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions, HttpMethod};
285    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
286    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
287    /// let response = api.fetch(HttpMethod::Get, "https://api.example.com/users")
288    ///     .send()
289    ///     .await?;
290    /// # Ok(())
291    /// # }
292    /// ```
293    pub fn fetch(&self, method: HttpMethod, url: impl Into<String>) -> APIRequestBuilder {
294        self.request(method, url)
295    }
296
297    /// Internal method to create a request builder.
298    fn request(&self, method: HttpMethod, url: impl Into<String>) -> APIRequestBuilder {
299        let mut builder = APIRequestBuilder::new(
300            Arc::clone(&self.client),
301            method,
302            url,
303            self.options.base_url.clone(),
304            self.default_headers(),
305        );
306
307        // Mark as disposed if the context is disposed
308        if self.disposed.load(Ordering::SeqCst) {
309            builder.set_disposed();
310        }
311
312        builder
313    }
314
315    /// Dispose of this API context, releasing resources.
316    ///
317    /// After calling this method, any new requests will fail with `APIError::Disposed`.
318    ///
319    /// # Example
320    ///
321    /// ```no_run
322    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
323    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
324    /// let api = APIRequestContext::new(APIContextOptions::new()).await?;
325    /// // ... use the API ...
326    /// api.dispose().await;
327    /// # Ok(())
328    /// # }
329    /// ```
330    pub async fn dispose(&self) {
331        info!("Disposing APIRequestContext");
332        self.disposed.store(true, Ordering::SeqCst);
333    }
334
335    /// Check if this context has been disposed.
336    pub fn is_disposed(&self) -> bool {
337        self.disposed.load(Ordering::SeqCst)
338    }
339
340    /// Get the base URL for this context.
341    pub fn base_url(&self) -> Option<&str> {
342        self.options.base_url.as_deref()
343    }
344
345    /// Get access to the cookie jar.
346    ///
347    /// This can be used to inspect or manually add cookies.
348    pub fn cookie_jar(&self) -> &Arc<Jar> {
349        &self.cookie_jar
350    }
351}
352
353impl Clone for APIRequestContext {
354    fn clone(&self) -> Self {
355        Self {
356            client: Arc::clone(&self.client),
357            cookie_jar: Arc::clone(&self.cookie_jar),
358            options: self.options.clone(),
359            disposed: Arc::clone(&self.disposed),
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests;