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    /// Attach a tenant-plane bearer token as a **sensitive** `Authorization`
180    /// header (`Authorization: Bearer <token>`).
181    ///
182    /// The header value is marked sensitive (`HeaderValue::set_sensitive(true)`)
183    /// so header-logging / redaction layers never emit it — the same guarantee
184    /// as [`crate::security::attach_bearer_http`]. Prefer this over
185    /// `.header("authorization", ..)` for credentials, which cannot mark the
186    /// value sensitive. `token` is the raw credential; the `Bearer ` scheme is
187    /// prepended here.
188    pub fn bearer_auth(mut self, token: &str) -> Self {
189        if self.error.is_some() {
190            return self;
191        }
192        match http::header::HeaderValue::try_from(format!("Bearer {token}")) {
193            Ok(mut value) => {
194                value.set_sensitive(true);
195                self.headers.push((http::header::AUTHORIZATION, value));
196            }
197            Err(e) => {
198                self.error = Some(HttpError::InvalidHeaderValue(e));
199            }
200        }
201        self
202    }
203
204    /// Attach a platform-plane internal `token` as a **sensitive**
205    /// `X-ToolKit-Internal-Token` header.
206    ///
207    /// Carried raw (no `Bearer`) and **never** on `Authorization`, to avoid
208    /// colliding with the tenant-plane JWT (`cpt-cf-adr-two-plane-auth`). The
209    /// value is marked sensitive so logging/redaction layers never emit it —
210    /// prefer this over `.header(..)`, which cannot. Takes a
211    /// [`secrecy::SecretString`] and exposes it only here at the transport
212    /// boundary, so callers never hold a bare `&str` of the token.
213    pub fn internal_token_auth(mut self, token: &secrecy::SecretString) -> Self {
214        use secrecy::ExposeSecret as _;
215        if self.error.is_some() {
216            return self;
217        }
218        match http::header::HeaderValue::try_from(token.expose_secret()) {
219            Ok(mut value) => {
220                value.set_sensitive(true);
221                self.headers.push((
222                    http::header::HeaderName::from_static(
223                        toolkit_security::constants::INTERNAL_TOKEN_HEADER,
224                    ),
225                    value,
226                ));
227            }
228            Err(e) => {
229                self.error = Some(HttpError::InvalidHeaderValue(e));
230            }
231        }
232        self
233    }
234
235    /// Add multiple headers to the request
236    ///
237    /// # Example
238    ///
239    /// ```ignore
240    /// let resp = client
241    ///     .get("https://api.example.com")
242    ///     .headers(vec![
243    ///         ("authorization".to_owned(), "Bearer token".to_owned()),
244    ///         ("x-request-id".to_owned(), "abc123".to_owned()),
245    ///     ])
246    ///     .send()
247    ///     .await?;
248    /// ```
249    pub fn headers(mut self, headers: Vec<(String, String)>) -> Self {
250        if self.error.is_some() {
251            return self;
252        }
253
254        for (name, value) in headers {
255            match (
256                http::header::HeaderName::try_from(name),
257                http::header::HeaderValue::try_from(value),
258            ) {
259                (Ok(name), Ok(value)) => {
260                    self.headers.push((name, value));
261                }
262                (Err(e), _) => {
263                    self.error = Some(HttpError::InvalidHeaderName(e));
264                    return self;
265                }
266                (_, Err(e)) => {
267                    self.error = Some(HttpError::InvalidHeaderValue(e));
268                    return self;
269                }
270            }
271        }
272        self
273    }
274
275    /// Attach a `request_type` label for metrics.
276    ///
277    /// The label is added as a `request_type` attribute on the
278    /// `http.client.request.duration` histogram when the `otel` feature and a
279    /// metrics layer are configured. This mirrors go-appkit's
280    /// `NewContextWithRequestType` / `GetRequestTypeFromContext` pattern and lets
281    /// you break down metrics by logical operation rather than route alone.
282    ///
283    /// Setting this without a metrics layer is a safe no-op.
284    ///
285    /// # Example
286    ///
287    /// ```ignore
288    /// client
289    ///     .get("https://api.example.com/tenants/123")
290    ///     .with_request_type("tenants_resolve")
291    ///     .send()
292    ///     .await?;
293    /// ```
294    pub fn with_request_type(mut self, t: impl Into<std::borrow::Cow<'static, str>>) -> Self {
295        self.request_type = Some(RequestType::new(t));
296        self
297    }
298
299    /// Set request body as JSON
300    ///
301    /// Serializes the value using `serde_json` and sets Content-Type to application/json.
302    /// unless a Content-Type header was already provided.
303    ///
304    /// # Errors
305    ///
306    /// Returns `Err(HttpError::Json)` if serialization fails.
307    ///
308    /// # Example
309    ///
310    /// ```ignore
311    /// #[derive(Serialize)]
312    /// struct CreateUser { name: String }
313    ///
314    /// let resp = client
315    ///     .post("https://api.example.com/users")
316    ///     .json(&CreateUser { name: "Alice".into() })?
317    ///     .send()
318    ///     .await?;
319    /// ```
320    pub fn json<T: Serialize>(mut self, body: &T) -> Result<Self, HttpError> {
321        if let Some(e) = self.error.take() {
322            return Err(e);
323        }
324
325        let json_bytes = serde_json::to_vec(body)?;
326        self.body = BodyKind::Json(Bytes::from(json_bytes));
327        Ok(self)
328    }
329
330    /// Set request body as form URL-encoded
331    ///
332    /// Serializes the fields and sets Content-Type to application/x-www-form-urlencoded.
333    /// unless a Content-Type header was already provided.
334    ///
335    /// # Errors
336    ///
337    /// Returns `Err(HttpError::FormEncode)` if encoding fails.
338    ///
339    /// # Example
340    ///
341    /// ```ignore
342    /// let resp = client
343    ///     .post("https://auth.example.com/token")
344    ///     .form(&[
345    ///         ("grant_type", "client_credentials"),
346    ///         ("client_id", "my-app"),
347    ///     ])?
348    ///     .send()
349    ///     .await?;
350    /// ```
351    pub fn form(mut self, fields: &[(&str, &str)]) -> Result<Self, HttpError> {
352        if let Some(e) = self.error.take() {
353            return Err(e);
354        }
355
356        let form_string = serde_urlencoded::to_string(fields)?;
357        self.body = BodyKind::Form(Bytes::from(form_string));
358        Ok(self)
359    }
360
361    /// Set request body as raw bytes
362    ///
363    /// # Example
364    ///
365    /// ```ignore
366    /// let resp = client
367    ///     .post("https://api.example.com/upload")
368    ///     .header("content-type", "application/octet-stream")
369    ///     .body_bytes(Bytes::from(file_contents))
370    ///     .send()
371    ///     .await?;
372    /// ```
373    pub fn body_bytes(mut self, body: Bytes) -> Self {
374        self.body = BodyKind::Bytes(body);
375        self
376    }
377
378    /// Set request body as a string
379    ///
380    /// # Example
381    ///
382    /// ```ignore
383    /// let resp = client
384    ///     .post("https://api.example.com/text")
385    ///     .header("content-type", "text/plain")
386    ///     .body_string("Hello, World!".into())
387    ///     .send()
388    ///     .await?;
389    /// ```
390    pub fn body_string(mut self, body: String) -> Self {
391        self.body = BodyKind::Bytes(Bytes::from(body));
392        self
393    }
394
395    /// Validate URL and scheme against transport security configuration.
396    ///
397    /// Uses proper `http::Uri` parsing instead of string prefix matching.
398    /// Returns the parsed URI on success for use in request building.
399    fn validate_url(&self) -> Result<http::Uri, HttpError> {
400        // Parse URL using http::Uri for proper validation
401        let uri: http::Uri =
402            self.url
403                .parse()
404                .map_err(|e: http::uri::InvalidUri| HttpError::InvalidUri {
405                    url: self.url.clone(),
406                    kind: InvalidUriKind::ParseError,
407                    reason: e.to_string(),
408                })?;
409
410        // Require authority (host) for absolute URLs
411        if uri.authority().is_none() {
412            return Err(HttpError::InvalidUri {
413                url: self.url.clone(),
414                kind: InvalidUriKind::MissingAuthority,
415                reason: "missing host/authority".to_owned(),
416            });
417        }
418
419        // Validate scheme
420        match uri.scheme_str() {
421            Some("https") => Ok(uri),
422            Some("http") => match self.transport_security {
423                TransportSecurity::AllowInsecureHttp => Ok(uri),
424                TransportSecurity::TlsOnly => Err(HttpError::InvalidScheme {
425                    scheme: "http".to_owned(),
426                    reason: "HTTPS required (transport security is TlsOnly)".to_owned(),
427                }),
428            },
429            Some(scheme) => Err(HttpError::InvalidScheme {
430                scheme: scheme.to_owned(),
431                reason: "only http:// and https:// schemes are supported".to_owned(),
432            }),
433            None => Err(HttpError::InvalidUri {
434                url: self.url.clone(),
435                kind: InvalidUriKind::MissingScheme,
436                reason: "missing scheme".to_owned(),
437            }),
438        }
439    }
440
441    /// Send the request and return the response
442    ///
443    /// # Errors
444    ///
445    /// Returns `HttpError` if:
446    /// - Request building failed (invalid headers, URL, etc.)
447    /// - URL scheme is invalid for the transport security mode
448    /// - Network/transport error
449    /// - Request timeout
450    /// - Concurrency limit reached (`Overloaded`)
451    ///
452    /// # Example
453    ///
454    /// ```ignore
455    /// let resp = client
456    ///     .get("https://api.example.com/data")
457    ///     .send()
458    ///     .await?;
459    ///
460    /// let data: MyData = resp.json().await?;
461    /// ```
462    pub async fn send(mut self) -> Result<HttpResponse, HttpError> {
463        // Return any deferred error
464        if let Some(e) = self.error.take() {
465            return Err(e);
466        }
467
468        // Validate URL and scheme against transport security
469        let uri = self.validate_url()?;
470
471        // Build the request using the validated URI
472        let mut builder = Request::builder().method(self.method).uri(uri);
473
474        // Add default Content-Type only if caller didn't supply one
475        let has_content_type = self
476            .headers
477            .iter()
478            .any(|(name, _)| name == http::header::CONTENT_TYPE);
479        if !has_content_type {
480            match &self.body {
481                BodyKind::Json(_) => {
482                    builder = builder.header("content-type", "application/json");
483                }
484                BodyKind::Form(_) => {
485                    builder = builder.header("content-type", "application/x-www-form-urlencoded");
486                }
487                BodyKind::Empty | BodyKind::Bytes(_) => {}
488            }
489        }
490
491        // Add user-provided headers
492        // Note: We checked has_content_type above to avoid duplicates. The http builder
493        // appends headers rather than replacing, so if user provided Content-Type,
494        // we skipped the default above and only their header is added here.
495        for (name, value) in self.headers {
496            builder = builder.header(name, value);
497        }
498
499        // Attach request_type extension so the metrics layer can read it without
500        // accessing the request body or headers (go-appkit analogue: context value).
501        if let Some(rt) = self.request_type {
502            builder = builder.extension(rt);
503        }
504
505        // Build body
506        let body_bytes = match self.body {
507            BodyKind::Empty => Bytes::new(),
508            BodyKind::Bytes(b) | BodyKind::Json(b) | BodyKind::Form(b) => b,
509        };
510
511        let request = builder.body(Full::new(body_bytes))?;
512
513        // Fail-fast if buffer is full
514        try_acquire_buffer_slot(&mut self.service).await?;
515
516        let inner: Response<ResponseBody> =
517            self.service.call(request).await.map_err(map_buffer_error)?;
518
519        Ok(HttpResponse {
520            inner,
521            max_body_size: self.max_body_size,
522        })
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use crate::HttpClient;
529    use crate::error::HttpError;
530
531    #[tokio::test]
532    async fn bearer_auth_sets_sensitive_authorization_header() {
533        let client = HttpClient::new().expect("default toolkit-http build");
534        let builder = client
535            .get("https://example.invalid/x")
536            .bearer_auth("secret-token");
537
538        let value = builder
539            .headers
540            .iter()
541            .find(|(name, _)| *name == http::header::AUTHORIZATION)
542            .map(|(_, value)| value)
543            .expect("authorization header present");
544
545        assert_eq!(value.to_str().unwrap(), "Bearer secret-token");
546        assert!(
547            value.is_sensitive(),
548            "bearer Authorization header must be marked sensitive"
549        );
550    }
551
552    #[tokio::test]
553    async fn internal_token_auth_sets_sensitive_dedicated_header() {
554        let client = HttpClient::new().expect("default toolkit-http build");
555        let builder = client
556            .get("https://example.invalid/x")
557            .internal_token_auth(&secrecy::SecretString::from("sa.jwt.token".to_owned()));
558
559        assert!(builder.error.is_none(), "valid token must not set an error");
560
561        let header_name = http::header::HeaderName::from_static(
562            toolkit_security::constants::INTERNAL_TOKEN_HEADER,
563        );
564        let value = builder
565            .headers
566            .iter()
567            .find(|(name, _)| *name == header_name)
568            .map(|(_, value)| value)
569            .expect("internal-token header present");
570
571        assert_eq!(
572            value.to_str().unwrap(),
573            "sa.jwt.token",
574            "internal token is carried raw (no Bearer scheme)"
575        );
576        assert!(
577            value.is_sensitive(),
578            "internal-token header must be marked sensitive so logging layers never emit it"
579        );
580        // It must NEVER ride Authorization.
581        assert!(
582            !builder
583                .headers
584                .iter()
585                .any(|(name, _)| *name == http::header::AUTHORIZATION),
586            "internal token must not collide with the tenant-plane Authorization header"
587        );
588    }
589
590    #[tokio::test]
591    async fn internal_token_auth_records_error_and_does_not_add_header_for_invalid_value() {
592        let client = HttpClient::new().expect("default toolkit-http build");
593        // A newline is not a valid header value; the builder must record an
594        // error (so `.send()` fails) rather than sending the request
595        // unauthenticated.
596        let builder = client
597            .get("https://example.invalid/x")
598            .internal_token_auth(&secrecy::SecretString::from("bad\ntoken".to_owned()));
599
600        assert!(
601            matches!(builder.error, Some(HttpError::InvalidHeaderValue(_))),
602            "a malformed internal token must record InvalidHeaderValue"
603        );
604        let header_name = http::header::HeaderName::from_static(
605            toolkit_security::constants::INTERNAL_TOKEN_HEADER,
606        );
607        assert!(
608            !builder.headers.iter().any(|(name, _)| *name == header_name),
609            "no header must be added when the token is invalid"
610        );
611    }
612}