parlov-probe 0.3.0

Probe engine trait and HTTP execution layer for parlov.
Documentation
use std::time::Instant;

use bytes::Bytes;
use http::HeaderMap;
use parlov_core::{Error, ProbeDefinition, ProbeExchange, ResponseSurface};

use crate::Probe;

/// Native-target HTTP probe. `Clone` is `O(1)` — inner `reqwest::Client` is `Arc`-backed.
#[derive(Clone)]
pub struct HttpProbe {
    client: reqwest::Client,
}

impl HttpProbe {
    /// Creates a new `HttpProbe` with a default `reqwest::Client`.
    #[must_use]
    pub fn new() -> Self {
        Self {
            client: reqwest::Client::new(),
        }
    }

    /// Creates an `HttpProbe` wrapping a caller-supplied `reqwest::Client`.
    ///
    /// Use this when you need non-default client configuration, such as disabling redirect
    /// following or pinning TLS roots for test purposes.
    #[must_use]
    pub fn with_client(client: reqwest::Client) -> Self {
        Self { client }
    }
}

impl Default for HttpProbe {
    fn default() -> Self {
        Self::new()
    }
}

impl Probe for HttpProbe {
    async fn execute(&self, def: &ProbeDefinition) -> Result<ProbeExchange, Error> {
        let method = build_method(&def.method);
        let mut builder = self.client.request(method, &def.url);
        builder = apply_headers(builder, &def.headers)?;

        if let Some(body) = &def.body {
            builder = builder.body(body.clone());
        }

        let request = builder.build().map_err(|e| Error::Http(e.to_string()))?;

        let start = Instant::now();
        let response = self
            .client
            .execute(request)
            .await
            .map_err(|e| Error::Http(e.to_string()))?;
        let status = convert_status(response.status())?;
        let headers = convert_headers(response.headers());
        let body_bytes: Bytes = response
            .bytes()
            .await
            .map_err(|e| Error::Http(e.to_string()))?;
        let timing_ns = u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX);

        Ok(ProbeExchange {
            // Clone required: ProbeExchange owns the definition alongside its response surface.
            request: def.clone(),
            response: ResponseSurface {
                status,
                headers,
                body: body_bytes,
                timing_ns,
            },
        })
    }
}

fn build_method(method: &http::Method) -> reqwest::Method {
    // SAFETY: `http::Method` only holds valid method tokens; `reqwest::Method::from_bytes`
    // accepts the same token grammar — this conversion cannot fail.
    reqwest::Method::from_bytes(method.as_str().as_bytes())
        .expect("http::Method always contains a valid token accepted by reqwest")
}

fn apply_headers(
    mut builder: reqwest::RequestBuilder,
    headers: &HeaderMap,
) -> Result<reqwest::RequestBuilder, Error> {
    for (name, value) in headers {
        let rname = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes())
            .map_err(|e| Error::Http(format!("invalid header name: {e}")))?;
        let rvalue = reqwest::header::HeaderValue::from_bytes(value.as_bytes())
            .map_err(|e| Error::Http(format!("invalid header value: {e}")))?;
        builder = builder.header(rname, rvalue);
    }
    Ok(builder)
}

fn convert_status(status: reqwest::StatusCode) -> Result<http::StatusCode, Error> {
    http::StatusCode::from_u16(status.as_u16())
        .map_err(|e| Error::Http(format!("unrecognised status code: {e}")))
}

fn convert_headers(headers: &reqwest::header::HeaderMap) -> HeaderMap {
    let mut out = HeaderMap::new();
    for (name, value) in headers {
        if let (Ok(n), Ok(v)) = (
            http::header::HeaderName::from_bytes(name.as_str().as_bytes()),
            http::header::HeaderValue::from_bytes(value.as_bytes()),
        ) {
            out.append(n, v);
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn convert_headers_preserves_multi_valued_headers() {
        let mut reqwest_headers = reqwest::header::HeaderMap::new();
        reqwest_headers.append(
            reqwest::header::SET_COOKIE,
            reqwest::header::HeaderValue::from_static("a=1; Path=/"),
        );
        reqwest_headers.append(
            reqwest::header::SET_COOKIE,
            reqwest::header::HeaderValue::from_static("b=2; Secure"),
        );

        let result = convert_headers(&reqwest_headers);
        let cookies: Vec<&str> = result
            .get_all(http::header::SET_COOKIE)
            .iter()
            .map(|v| v.to_str().expect("valid utf-8"))
            .collect();

        assert_eq!(
            cookies.len(),
            2,
            "both Set-Cookie values must survive convert_headers"
        );
        assert!(cookies.contains(&"a=1; Path=/"));
        assert!(cookies.contains(&"b=2; Secure"));
    }

    #[test]
    fn http_probe_is_clone() {
        let probe = HttpProbe::new();
        let _cloned = probe.clone();
        // If HttpProbe does not derive Clone, this will not compile.
    }

    #[test]
    fn build_method_roundtrips_all_standard_methods() {
        let methods = [
            http::Method::GET,
            http::Method::POST,
            http::Method::PUT,
            http::Method::DELETE,
            http::Method::PATCH,
            http::Method::HEAD,
            http::Method::OPTIONS,
        ];
        for method in &methods {
            let result = build_method(method);
            assert_eq!(result.as_str(), method.as_str());
        }
    }
}