Skip to main content

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