use std::time::Instant;
use bytes::Bytes;
use http::HeaderMap;
use parlov_core::{Error, ProbeDefinition, ProbeExchange, ResponseSurface};
use crate::Probe;
#[derive(Clone)]
pub struct HttpProbe {
client: reqwest::Client,
}
impl HttpProbe {
#[must_use]
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
#[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 {
request: def.clone(),
response: ResponseSurface {
status,
headers,
body: body_bytes,
timing_ns,
},
})
}
}
fn build_method(method: &http::Method) -> reqwest::Method {
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();
}
#[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());
}
}
}