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