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/// ```ignore
36/// use viewpoint_core::{Browser, BrowserContext};
37///
38/// async fn example() -> Result<(), Box<dyn std::error::Error>> {
39/// let browser = Browser::launch().headless(true).await?;
40/// let context = browser.new_context().await?;
41///
42/// // Get API context that shares cookies with browser
43/// let api = context.request().await?;
44///
45/// // API requests will include browser cookies
46/// let response = api.get("https://api.example.com/user").send().await?;
47/// Ok(())
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;