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}