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}