Skip to main content

parlov_probe/
http.rs

1//! `HttpProbe` — executes a single HTTP request and captures the full response surface.
2//!
3//! Uses `reqwest` for async HTTP. Timing is measured via `std::time::Instant` around
4//! the send-to-response-body path. Header forwarding is explicit: every header in
5//! `ProbeDefinition.headers` is inserted into the outgoing request.
6
7#![deny(clippy::all)]
8#![warn(clippy::pedantic)]
9
10use std::time::Instant;
11
12use bytes::Bytes;
13use http::HeaderMap;
14use parlov_core::{Error, ProbeDefinition, ResponseSurface};
15
16use crate::Probe;
17
18/// Executes HTTP requests using a shared `reqwest::Client`.
19///
20/// The client is configured with connection pooling and keep-alive enabled by default.
21/// Construct once and reuse across multiple `execute` calls to amortise connection setup.
22pub struct HttpProbe {
23    client: reqwest::Client,
24}
25
26impl HttpProbe {
27    /// Creates a new `HttpProbe` with a default `reqwest::Client`.
28    #[must_use]
29    pub fn new() -> Self {
30        Self {
31            client: reqwest::Client::new(),
32        }
33    }
34
35    /// Creates an `HttpProbe` wrapping a caller-supplied `reqwest::Client`.
36    ///
37    /// Use this when you need non-default client configuration, such as disabling redirect
38    /// following or pinning TLS roots for test purposes.
39    #[must_use]
40    pub fn with_client(client: reqwest::Client) -> Self {
41        Self { client }
42    }
43}
44
45impl Default for HttpProbe {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl Probe for HttpProbe {
52    async fn execute(&self, def: &ProbeDefinition) -> Result<ResponseSurface, Error> {
53        let method = build_method(&def.method)?;
54        let mut builder = self.client.request(method, &def.url);
55        builder = apply_headers(builder, &def.headers)?;
56
57        if let Some(body) = &def.body {
58            builder = builder.body(body.clone());
59        }
60
61        let request = builder
62            .build()
63            .map_err(|e| Error::Http(e.to_string()))?;
64
65        let start = Instant::now();
66        let response = self
67            .client
68            .execute(request)
69            .await
70            .map_err(|e| Error::Http(e.to_string()))?;
71        let status = convert_status(response.status())?;
72        let headers = convert_headers(response.headers());
73        let body_bytes: Bytes = response
74            .bytes()
75            .await
76            .map_err(|e| Error::Http(e.to_string()))?;
77        let timing_ns = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);
78
79        Ok(ResponseSurface {
80            status,
81            headers,
82            body: body_bytes,
83            timing_ns,
84        })
85    }
86}
87
88fn build_method(method: &http::Method) -> Result<reqwest::Method, Error> {
89    reqwest::Method::from_bytes(method.as_str().as_bytes())
90        .map_err(|e| Error::Http(format!("invalid HTTP method: {e}")))
91}
92
93fn apply_headers(
94    mut builder: reqwest::RequestBuilder,
95    headers: &HeaderMap,
96) -> Result<reqwest::RequestBuilder, Error> {
97    for (name, value) in headers {
98        let rname = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes())
99            .map_err(|e| Error::Http(format!("invalid header name: {e}")))?;
100        let rvalue = reqwest::header::HeaderValue::from_bytes(value.as_bytes())
101            .map_err(|e| Error::Http(format!("invalid header value: {e}")))?;
102        builder = builder.header(rname, rvalue);
103    }
104    Ok(builder)
105}
106
107fn convert_status(status: reqwest::StatusCode) -> Result<http::StatusCode, Error> {
108    http::StatusCode::from_u16(status.as_u16())
109        .map_err(|e| Error::Http(format!("unrecognised status code: {e}")))
110}
111
112fn convert_headers(headers: &reqwest::header::HeaderMap) -> HeaderMap {
113    let mut out = HeaderMap::new();
114    for (name, value) in headers {
115        if let (Ok(n), Ok(v)) = (
116            http::header::HeaderName::from_bytes(name.as_str().as_bytes()),
117            http::header::HeaderValue::from_bytes(value.as_bytes()),
118        ) {
119            out.insert(n, v);
120        }
121    }
122    out
123}