Skip to main content

modkit_http/
request.rs

1use crate::client::{BufferedService, map_buffer_error, try_acquire_buffer_slot};
2use crate::config::TransportSecurity;
3use crate::error::{HttpError, InvalidUriKind};
4use crate::response::{HttpResponse, ResponseBody};
5use bytes::Bytes;
6use http::{Request, Response};
7use http_body_util::Full;
8use serde::Serialize;
9use tower::Service;
10
11/// Body type for the request builder
12#[derive(Clone, Debug)]
13enum BodyKind {
14    /// Empty body
15    Empty,
16    /// Raw bytes body
17    Bytes(Bytes),
18    /// JSON-serialized body (stored as bytes after serialization)
19    Json(Bytes),
20    /// Form URL-encoded body (stored as bytes after serialization)
21    Form(Bytes),
22}
23
24/// HTTP request builder with fluent API
25///
26/// Created by [`HttpClient::get`], [`HttpClient::post`], etc.
27/// Supports chaining headers and body configuration before sending
28/// with [`send()`](RequestBuilder::send).
29///
30/// # URL Construction
31///
32/// This crate does **not** provide query-string composition. Build your URL
33/// externally (e.g. via `url::Url`) and pass the final string to `HttpClient`:
34///
35/// ```ignore
36/// use url::Url;
37/// use modkit_http::HttpClient;
38///
39/// let mut url = Url::parse("https://api.example.com/users")?;
40/// url.query_pairs_mut()
41///     .append_pair("page", "1")
42///     .append_pair("limit", "10");
43///
44/// let client = HttpClient::builder().build()?;
45/// let resp = client.get(url.as_str()).send().await?;
46/// ```
47///
48/// # Example
49///
50/// ```ignore
51/// use modkit_http::HttpClient;
52///
53/// let client = HttpClient::builder().build()?;
54///
55/// // Simple GET
56/// let resp = client
57///     .get("https://api.example.com/users")
58///     .send()
59///     .await?;
60///
61/// // POST with JSON body
62/// let resp = client
63///     .post("https://api.example.com/users")
64///     .header("x-request-id", "123")
65///     .json(&NewUser { name: "Alice" })?
66///     .send()
67///     .await?;
68///
69/// // POST with form body
70/// let resp = client
71///     .post("https://auth.example.com/token")
72///     .header("authorization", "Basic xyz")
73///     .form(&[("grant_type", "client_credentials")])?
74///     .send()
75///     .await?;
76/// ```
77#[must_use = "RequestBuilder does nothing until .send() is called"]
78pub struct RequestBuilder {
79    service: BufferedService,
80    max_body_size: usize,
81    method: http::Method,
82    url: String,
83    headers: Vec<(http::header::HeaderName, http::header::HeaderValue)>,
84    body: BodyKind,
85    /// Error captured during building (deferred to `send()`)
86    error: Option<HttpError>,
87    /// Transport security mode for URL scheme validation
88    transport_security: TransportSecurity,
89}
90
91impl RequestBuilder {
92    /// Create a new request builder (internal use only)
93    pub(crate) fn new(
94        service: BufferedService,
95        max_body_size: usize,
96        method: http::Method,
97        url: String,
98        transport_security: TransportSecurity,
99    ) -> Self {
100        Self {
101            service,
102            max_body_size,
103            method,
104            url,
105            headers: Vec::new(),
106            body: BodyKind::Empty,
107            error: None,
108            transport_security,
109        }
110    }
111
112    /// Add a single header to the request
113    ///
114    /// # Example
115    ///
116    /// ```ignore
117    /// let resp = client
118    ///     .get("https://api.example.com")
119    ///     .header("authorization", "Bearer token")
120    ///     .header("x-request-id", "abc123")
121    ///     .send()
122    ///     .await?;
123    /// ```
124    pub fn header(mut self, name: &str, value: &str) -> Self {
125        if self.error.is_some() {
126            return self;
127        }
128
129        match (
130            http::header::HeaderName::try_from(name),
131            http::header::HeaderValue::try_from(value),
132        ) {
133            (Ok(name), Ok(value)) => {
134                self.headers.push((name, value));
135            }
136            (Err(e), _) => {
137                self.error = Some(HttpError::InvalidHeaderName(e));
138            }
139            (_, Err(e)) => {
140                self.error = Some(HttpError::InvalidHeaderValue(e));
141            }
142        }
143        self
144    }
145
146    /// Add multiple headers to the request
147    ///
148    /// # Example
149    ///
150    /// ```ignore
151    /// let resp = client
152    ///     .get("https://api.example.com")
153    ///     .headers(vec![
154    ///         ("authorization".to_owned(), "Bearer token".to_owned()),
155    ///         ("x-request-id".to_owned(), "abc123".to_owned()),
156    ///     ])
157    ///     .send()
158    ///     .await?;
159    /// ```
160    pub fn headers(mut self, headers: Vec<(String, String)>) -> Self {
161        if self.error.is_some() {
162            return self;
163        }
164
165        for (name, value) in headers {
166            match (
167                http::header::HeaderName::try_from(name),
168                http::header::HeaderValue::try_from(value),
169            ) {
170                (Ok(name), Ok(value)) => {
171                    self.headers.push((name, value));
172                }
173                (Err(e), _) => {
174                    self.error = Some(HttpError::InvalidHeaderName(e));
175                    return self;
176                }
177                (_, Err(e)) => {
178                    self.error = Some(HttpError::InvalidHeaderValue(e));
179                    return self;
180                }
181            }
182        }
183        self
184    }
185
186    /// Set request body as JSON
187    ///
188    /// Serializes the value using `serde_json` and sets Content-Type to application/json.
189    /// unless a Content-Type header was already provided.
190    ///
191    /// # Errors
192    ///
193    /// Returns `Err(HttpError::Json)` if serialization fails.
194    ///
195    /// # Example
196    ///
197    /// ```ignore
198    /// #[derive(Serialize)]
199    /// struct CreateUser { name: String }
200    ///
201    /// let resp = client
202    ///     .post("https://api.example.com/users")
203    ///     .json(&CreateUser { name: "Alice".into() })?
204    ///     .send()
205    ///     .await?;
206    /// ```
207    pub fn json<T: Serialize>(mut self, body: &T) -> Result<Self, HttpError> {
208        if let Some(e) = self.error.take() {
209            return Err(e);
210        }
211
212        let json_bytes = serde_json::to_vec(body)?;
213        self.body = BodyKind::Json(Bytes::from(json_bytes));
214        Ok(self)
215    }
216
217    /// Set request body as form URL-encoded
218    ///
219    /// Serializes the fields and sets Content-Type to application/x-www-form-urlencoded.
220    /// unless a Content-Type header was already provided.
221    ///
222    /// # Errors
223    ///
224    /// Returns `Err(HttpError::FormEncode)` if encoding fails.
225    ///
226    /// # Example
227    ///
228    /// ```ignore
229    /// let resp = client
230    ///     .post("https://auth.example.com/token")
231    ///     .form(&[
232    ///         ("grant_type", "client_credentials"),
233    ///         ("client_id", "my-app"),
234    ///     ])?
235    ///     .send()
236    ///     .await?;
237    /// ```
238    pub fn form(mut self, fields: &[(&str, &str)]) -> Result<Self, HttpError> {
239        if let Some(e) = self.error.take() {
240            return Err(e);
241        }
242
243        let form_string = serde_urlencoded::to_string(fields)?;
244        self.body = BodyKind::Form(Bytes::from(form_string));
245        Ok(self)
246    }
247
248    /// Set request body as raw bytes
249    ///
250    /// # Example
251    ///
252    /// ```ignore
253    /// let resp = client
254    ///     .post("https://api.example.com/upload")
255    ///     .header("content-type", "application/octet-stream")
256    ///     .body_bytes(Bytes::from(file_contents))
257    ///     .send()
258    ///     .await?;
259    /// ```
260    pub fn body_bytes(mut self, body: Bytes) -> Self {
261        self.body = BodyKind::Bytes(body);
262        self
263    }
264
265    /// Set request body as a string
266    ///
267    /// # Example
268    ///
269    /// ```ignore
270    /// let resp = client
271    ///     .post("https://api.example.com/text")
272    ///     .header("content-type", "text/plain")
273    ///     .body_string("Hello, World!".into())
274    ///     .send()
275    ///     .await?;
276    /// ```
277    pub fn body_string(mut self, body: String) -> Self {
278        self.body = BodyKind::Bytes(Bytes::from(body));
279        self
280    }
281
282    /// Validate URL and scheme against transport security configuration.
283    ///
284    /// Uses proper `http::Uri` parsing instead of string prefix matching.
285    /// Returns the parsed URI on success for use in request building.
286    fn validate_url(&self) -> Result<http::Uri, HttpError> {
287        // Parse URL using http::Uri for proper validation
288        let uri: http::Uri =
289            self.url
290                .parse()
291                .map_err(|e: http::uri::InvalidUri| HttpError::InvalidUri {
292                    url: self.url.clone(),
293                    kind: InvalidUriKind::ParseError,
294                    reason: e.to_string(),
295                })?;
296
297        // Require authority (host) for absolute URLs
298        if uri.authority().is_none() {
299            return Err(HttpError::InvalidUri {
300                url: self.url.clone(),
301                kind: InvalidUriKind::MissingAuthority,
302                reason: "missing host/authority".to_owned(),
303            });
304        }
305
306        // Validate scheme
307        match uri.scheme_str() {
308            Some("https") => Ok(uri),
309            Some("http") => match self.transport_security {
310                TransportSecurity::AllowInsecureHttp => Ok(uri),
311                TransportSecurity::TlsOnly => Err(HttpError::InvalidScheme {
312                    scheme: "http".to_owned(),
313                    reason: "HTTPS required (transport security is TlsOnly)".to_owned(),
314                }),
315            },
316            Some(scheme) => Err(HttpError::InvalidScheme {
317                scheme: scheme.to_owned(),
318                reason: "only http:// and https:// schemes are supported".to_owned(),
319            }),
320            None => Err(HttpError::InvalidUri {
321                url: self.url.clone(),
322                kind: InvalidUriKind::MissingScheme,
323                reason: "missing scheme".to_owned(),
324            }),
325        }
326    }
327
328    /// Send the request and return the response
329    ///
330    /// # Errors
331    ///
332    /// Returns `HttpError` if:
333    /// - Request building failed (invalid headers, URL, etc.)
334    /// - URL scheme is invalid for the transport security mode
335    /// - Network/transport error
336    /// - Request timeout
337    /// - Concurrency limit reached (`Overloaded`)
338    ///
339    /// # Example
340    ///
341    /// ```ignore
342    /// let resp = client
343    ///     .get("https://api.example.com/data")
344    ///     .send()
345    ///     .await?;
346    ///
347    /// let data: MyData = resp.json().await?;
348    /// ```
349    pub async fn send(mut self) -> Result<HttpResponse, HttpError> {
350        // Return any deferred error
351        if let Some(e) = self.error.take() {
352            return Err(e);
353        }
354
355        // Validate URL and scheme against transport security
356        let uri = self.validate_url()?;
357
358        // Build the request using the validated URI
359        let mut builder = Request::builder().method(self.method).uri(uri);
360
361        // Add default Content-Type only if caller didn't supply one
362        let has_content_type = self
363            .headers
364            .iter()
365            .any(|(name, _)| name == http::header::CONTENT_TYPE);
366        if !has_content_type {
367            match &self.body {
368                BodyKind::Json(_) => {
369                    builder = builder.header("content-type", "application/json");
370                }
371                BodyKind::Form(_) => {
372                    builder = builder.header("content-type", "application/x-www-form-urlencoded");
373                }
374                BodyKind::Empty | BodyKind::Bytes(_) => {}
375            }
376        }
377
378        // Add user-provided headers
379        // Note: We checked has_content_type above to avoid duplicates. The http builder
380        // appends headers rather than replacing, so if user provided Content-Type,
381        // we skipped the default above and only their header is added here.
382        for (name, value) in self.headers {
383            builder = builder.header(name, value);
384        }
385
386        // Build body
387        let body_bytes = match self.body {
388            BodyKind::Empty => Bytes::new(),
389            BodyKind::Bytes(b) | BodyKind::Json(b) | BodyKind::Form(b) => b,
390        };
391
392        let request = builder.body(Full::new(body_bytes))?;
393
394        // Fail-fast if buffer is full
395        try_acquire_buffer_slot(&mut self.service).await?;
396
397        let inner: Response<ResponseBody> =
398            self.service.call(request).await.map_err(map_buffer_error)?;
399
400        Ok(HttpResponse {
401            inner,
402            max_body_size: self.max_body_size,
403        })
404    }
405}