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}