viewpoint_core/api/request/
mod.rs

1//! API request builder for constructing HTTP requests.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use reqwest::multipart::{Form, Part};
7use serde::Serialize;
8
9use super::{APIError, APIResponse, HttpMethod};
10
11/// Builder for constructing and sending HTTP requests.
12///
13/// This builder provides a fluent API for configuring request options
14/// like headers, body, query parameters, and timeout.
15///
16/// # Example
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(APIContextOptions::new()).await?;
23///
24/// // Simple GET request
25/// let response = api.get("https://api.example.com/users").send().await?;
26///
27/// // POST with JSON body
28/// let user = serde_json::json!({ "name": "John" });
29/// let response = api.post("https://api.example.com/users")
30///     .json(&user)
31///     .header("X-Custom", "value")
32///     .send()
33///     .await?;
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Debug)]
38pub struct APIRequestBuilder {
39    /// The HTTP client.
40    client: Arc<reqwest::Client>,
41    /// The HTTP method.
42    method: HttpMethod,
43    /// The request URL.
44    url: String,
45    /// Base URL to resolve relative URLs against.
46    base_url: Option<String>,
47    /// Request headers.
48    headers: Vec<(String, String)>,
49    /// Default headers from context.
50    default_headers: Vec<(String, String)>,
51    /// Query parameters.
52    query_params: Vec<(String, String)>,
53    /// Request body.
54    body: Option<RequestBody>,
55    /// Request timeout.
56    timeout: Option<Duration>,
57    /// Whether the context is disposed.
58    disposed: bool,
59}
60
61/// Types of request body.
62#[derive(Debug)]
63pub(crate) enum RequestBody {
64    /// JSON body (serialized).
65    Json(Vec<u8>),
66    /// Form-urlencoded body.
67    Form(Vec<(String, String)>),
68    /// Multipart form body.
69    Multipart(Vec<MultipartField>),
70    /// Raw bytes.
71    Bytes(Vec<u8>),
72    /// Text body.
73    Text(String),
74}
75
76/// A field in a multipart form.
77#[derive(Debug, Clone)]
78pub struct MultipartField {
79    /// Field name.
80    pub name: String,
81    /// Field value (for text fields).
82    pub value: Option<String>,
83    /// File content (for file fields).
84    pub file_content: Option<Vec<u8>>,
85    /// File name (for file fields).
86    pub filename: Option<String>,
87    /// Content type.
88    pub content_type: Option<String>,
89}
90
91impl MultipartField {
92    /// Create a new text field.
93    pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
94        Self {
95            name: name.into(),
96            value: Some(value.into()),
97            file_content: None,
98            filename: None,
99            content_type: None,
100        }
101    }
102
103    /// Create a new file field.
104    pub fn file(name: impl Into<String>, filename: impl Into<String>, content: Vec<u8>) -> Self {
105        Self {
106            name: name.into(),
107            value: None,
108            file_content: Some(content),
109            filename: Some(filename.into()),
110            content_type: None,
111        }
112    }
113
114    /// Set the content type for this field.
115    #[must_use]
116    pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
117        self.content_type = Some(content_type.into());
118        self
119    }
120}
121
122impl APIRequestBuilder {
123    /// Create a new request builder.
124    pub(crate) fn new(
125        client: Arc<reqwest::Client>,
126        method: HttpMethod,
127        url: impl Into<String>,
128        base_url: Option<String>,
129        default_headers: Vec<(String, String)>,
130    ) -> Self {
131        Self {
132            client,
133            method,
134            url: url.into(),
135            base_url,
136            headers: Vec::new(),
137            default_headers,
138            query_params: Vec::new(),
139            body: None,
140            timeout: None,
141            disposed: false,
142        }
143    }
144
145    /// Mark this builder as using a disposed context.
146    pub(crate) fn set_disposed(&mut self) {
147        self.disposed = true;
148    }
149
150    /// Add a header to the request.
151    ///
152    /// # Example
153    ///
154    /// ```no_run
155    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
156    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
157    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
158    /// let response = api.get("https://api.example.com/data")
159    ///     .header("Authorization", "Bearer token")
160    ///     .header("Accept", "application/json")
161    ///     .send()
162    ///     .await?;
163    /// # Ok(())
164    /// # }
165    /// ```
166    #[must_use]
167    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
168        self.headers.push((name.into(), value.into()));
169        self
170    }
171
172    /// Add multiple headers to the request.
173    #[must_use]
174    pub fn headers(mut self, headers: impl IntoIterator<Item = (String, String)>) -> Self {
175        self.headers.extend(headers);
176        self
177    }
178
179    /// Add query parameters to the request URL.
180    ///
181    /// # Example
182    ///
183    /// ```no_run
184    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
185    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
186    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
187    /// let response = api.get("https://api.example.com/search")
188    ///     .query(&[("q", "rust"), ("page", "1")])
189    ///     .send()
190    ///     .await?;
191    /// // Request URL: https://api.example.com/search?q=rust&page=1
192    /// # Ok(())
193    /// # }
194    /// ```
195    #[must_use]
196    pub fn query<K, V>(mut self, params: &[(K, V)]) -> Self
197    where
198        K: AsRef<str>,
199        V: AsRef<str>,
200    {
201        for (key, value) in params {
202            self.query_params
203                .push((key.as_ref().to_string(), value.as_ref().to_string()));
204        }
205        self
206    }
207
208    /// Add a single query parameter.
209    #[must_use]
210    pub fn query_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
211        self.query_params.push((key.into(), value.into()));
212        self
213    }
214
215    /// Set the request body as JSON.
216    ///
217    /// This will also set the `Content-Type` header to `application/json`.
218    ///
219    /// # Example
220    ///
221    /// ```no_run
222    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
223    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
224    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
225    /// let user = serde_json::json!({
226    ///     "name": "John",
227    ///     "email": "john@example.com"
228    /// });
229    ///
230    /// let response = api.post("https://api.example.com/users")
231    ///     .json(&user)
232    ///     .send()
233    ///     .await?;
234    /// # Ok(())
235    /// # }
236    /// ```
237    #[must_use]
238    pub fn json<T: Serialize>(mut self, data: &T) -> Self {
239        match serde_json::to_vec(data) {
240            Ok(bytes) => {
241                self.body = Some(RequestBody::Json(bytes));
242            }
243            Err(e) => {
244                // Store error to report later when sending
245                tracing::error!("Failed to serialize JSON: {}", e);
246            }
247        }
248        self
249    }
250
251    /// Set the request body as form-urlencoded data.
252    ///
253    /// This will also set the `Content-Type` header to `application/x-www-form-urlencoded`.
254    ///
255    /// # Example
256    ///
257    /// ```no_run
258    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
259    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
260    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
261    /// let response = api.post("https://api.example.com/login")
262    ///     .form(&[("username", "john"), ("password", "secret")])
263    ///     .send()
264    ///     .await?;
265    /// # Ok(())
266    /// # }
267    /// ```
268    #[must_use]
269    pub fn form<K, V>(mut self, data: &[(K, V)]) -> Self
270    where
271        K: AsRef<str>,
272        V: AsRef<str>,
273    {
274        let form_data: Vec<(String, String)> = data
275            .iter()
276            .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
277            .collect();
278        self.body = Some(RequestBody::Form(form_data));
279        self
280    }
281
282    /// Set the request body as multipart form data.
283    ///
284    /// This is used for file uploads.
285    ///
286    /// # Example
287    ///
288    /// ```no_run
289    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions, MultipartField};
290    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
291    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
292    /// let file_content = vec![1, 2, 3, 4]; // or std::fs::read("document.pdf")
293    ///
294    /// let response = api.post("https://api.example.com/upload")
295    ///     .multipart(vec![
296    ///         MultipartField::text("description", "My document"),
297    ///         MultipartField::file("file", "document.pdf", file_content)
298    ///             .content_type("application/pdf"),
299    ///     ])
300    ///     .send()
301    ///     .await?;
302    /// # Ok(())
303    /// # }
304    /// ```
305    #[must_use]
306    pub fn multipart(mut self, fields: Vec<MultipartField>) -> Self {
307        self.body = Some(RequestBody::Multipart(fields));
308        self
309    }
310
311    /// Set the request body as raw bytes.
312    #[must_use]
313    pub fn body(mut self, data: Vec<u8>) -> Self {
314        self.body = Some(RequestBody::Bytes(data));
315        self
316    }
317
318    /// Set the request body as text.
319    #[must_use]
320    pub fn text(mut self, data: impl Into<String>) -> Self {
321        self.body = Some(RequestBody::Text(data.into()));
322        self
323    }
324
325    /// Set the request timeout.
326    ///
327    /// This overrides the default timeout set on the API context.
328    ///
329    /// # Example
330    ///
331    /// ```no_run
332    /// # use viewpoint_core::api::{APIRequestContext, APIContextOptions};
333    /// use std::time::Duration;
334    ///
335    /// # async fn example() -> Result<(), viewpoint_core::api::APIError> {
336    /// # let api = APIRequestContext::new(APIContextOptions::new()).await?;
337    /// let response = api.get("https://slow-api.example.com/data")
338    ///     .timeout(Duration::from_secs(60))
339    ///     .send()
340    ///     .await?;
341    /// # Ok(())
342    /// # }
343    /// ```
344    #[must_use]
345    pub fn timeout(mut self, timeout: Duration) -> Self {
346        self.timeout = Some(timeout);
347        self
348    }
349
350    /// Resolve the URL, handling relative URLs with base URL.
351    fn resolve_url(&self) -> Result<String, APIError> {
352        if self.url.starts_with("http://") || self.url.starts_with("https://") {
353            Ok(self.url.clone())
354        } else if let Some(ref base) = self.base_url {
355            // Resolve relative URL against base
356            let base = base.trim_end_matches('/');
357            let path = self.url.trim_start_matches('/');
358            Ok(format!("{base}/{path}"))
359        } else {
360            Err(APIError::InvalidUrl(format!(
361                "Relative URL '{}' requires a base URL",
362                self.url
363            )))
364        }
365    }
366
367    /// Send the request and return the response.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if:
372    /// - The context has been disposed
373    /// - The URL is invalid
374    /// - The request fails
375    /// - A timeout occurs
376    pub async fn send(self) -> Result<APIResponse, APIError> {
377        if self.disposed {
378            return Err(APIError::Disposed);
379        }
380
381        let url = self.resolve_url()?;
382
383        // Build the request
384        let mut request_builder = self.client.request(self.method.to_reqwest(), &url);
385
386        // Add default headers first
387        for (name, value) in &self.default_headers {
388            request_builder = request_builder.header(name.as_str(), value.as_str());
389        }
390
391        // Add request-specific headers (override defaults)
392        for (name, value) in &self.headers {
393            request_builder = request_builder.header(name.as_str(), value.as_str());
394        }
395
396        // Add query parameters
397        if !self.query_params.is_empty() {
398            request_builder = request_builder.query(&self.query_params);
399        }
400
401        // Set timeout
402        if let Some(timeout) = self.timeout {
403            request_builder = request_builder.timeout(timeout);
404        }
405
406        // Set body
407        match self.body {
408            Some(RequestBody::Json(bytes)) => {
409                request_builder = request_builder
410                    .header("Content-Type", "application/json")
411                    .body(bytes);
412            }
413            Some(RequestBody::Form(data)) => {
414                request_builder = request_builder.form(&data);
415            }
416            Some(RequestBody::Multipart(fields)) => {
417                let mut form = Form::new();
418                for field in fields {
419                    if let Some(value) = field.value {
420                        form = form.text(field.name, value);
421                    } else if let Some(content) = field.file_content {
422                        let mut part = Part::bytes(content);
423                        if let Some(filename) = field.filename {
424                            part = part.file_name(filename);
425                        }
426                        if let Some(content_type) = field.content_type {
427                            part = part.mime_str(&content_type).map_err(|e| {
428                                APIError::BuildError(format!("Invalid content type: {e}"))
429                            })?;
430                        }
431                        form = form.part(field.name, part);
432                    }
433                }
434                request_builder = request_builder.multipart(form);
435            }
436            Some(RequestBody::Bytes(data)) => {
437                request_builder = request_builder.body(data);
438            }
439            Some(RequestBody::Text(data)) => {
440                request_builder = request_builder
441                    .header("Content-Type", "text/plain")
442                    .body(data);
443            }
444            None => {}
445        }
446
447        // Send the request
448        let response = request_builder.send().await.map_err(|e| {
449            if e.is_timeout() {
450                APIError::Timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
451            } else {
452                APIError::Http(e)
453            }
454        })?;
455
456        Ok(APIResponse::new(response))
457    }
458}
459
460// Make the builder awaitable for convenience
461impl std::future::IntoFuture for APIRequestBuilder {
462    type Output = Result<APIResponse, APIError>;
463    type IntoFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send>>;
464
465    fn into_future(self) -> Self::IntoFuture {
466        Box::pin(self.send())
467    }
468}
469
470// Unit tests moved to tests/api_request_tests.rs