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;