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