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