use std::time::Duration;
use reqwest::Client;
use serde_json::Value;
use tracing::{debug, error};
use crate::types::config::CoverageConfig;
use crate::types::error::{ConfigError, HttpError, Result};
#[derive(Debug, Clone)]
pub struct CoverageClient {
base_url: String,
client: Client,
}
impl CoverageClient {
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn get_coa(&self, request: &Value) -> Result<Value> {
self.post_coverage("/generate_course_of_actions", request)
.await
}
pub async fn get_coverage_heatmap(&self, request: &Value) -> Result<Value> {
self.post_coverage("/get_coverage", request).await
}
pub async fn get_predictive_heatmap(&self, request: &Value) -> Result<Value> {
self.post_coverage("/get_predictive_heatmap", request).await
}
pub fn new(config: &CoverageConfig) -> Result<Self> {
let base_url = config.base_url.trim_end_matches('/').to_owned();
url::Url::parse(&base_url).map_err(|source| ConfigError::InvalidUrl {
field: "coverage base",
url: base_url.clone(),
source,
})?;
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|source| HttpError::ClientBuild { source })?;
debug!(base_url = base_url.as_str(), "initialized CoverageClient");
Ok(Self { base_url, client })
}
async fn post_coverage(&self, endpoint: &str, payload: &Value) -> Result<Value> {
let url = format!("{}{endpoint}", self.base_url);
let response = self
.client
.post(&url)
.json(payload)
.send()
.await
.map_err(|source| {
if source.is_connect() {
error!(
base_url = self.base_url.as_str(),
"coverage service unreachable"
);
HttpError::ServiceUnreachable {
service: "horizon-coverage",
url: self.base_url.clone(),
source,
}
} else if source.is_timeout() {
error!(
base_url = self.base_url.as_str(),
endpoint, "coverage service timed out"
);
HttpError::Timeout {
service: "horizon-coverage",
url: self.base_url.clone(),
endpoint: endpoint.to_owned(),
source,
}
} else {
error!(
base_url = self.base_url.as_str(),
endpoint,
error = %source,
"coverage HTTP request failed"
);
HttpError::RequestFailed {
service: "horizon-coverage",
endpoint: endpoint.to_owned(),
source,
}
}
})?;
let status = response.status();
if !status.is_success() {
error!(
status = status.as_u16(),
endpoint, "coverage service returned error"
);
return Err(HttpError::UnexpectedStatus {
service: "horizon-coverage",
status: status.as_u16(),
endpoint: endpoint.to_owned(),
}
.into());
}
response.json().await.map_err(|source| {
error!(endpoint, error = %source, "failed to decode coverage response");
HttpError::DecodeFailed {
endpoint: endpoint.to_owned(),
source,
}
.into()
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "unit tests use unwrap for assertions")]
mod tests {
use super::*;
#[test]
fn coverage_client_preserves_clean_url() {
let config = CoverageConfig {
base_url: "https://example.com".to_owned(),
timeout_secs: 10,
};
let client = CoverageClient::new(&config).unwrap();
assert_eq!(client.base_url(), "https://example.com");
}
#[test]
fn coverage_client_trims_trailing_slash() {
let config = CoverageConfig {
base_url: "http://localhost:3000/".to_owned(),
timeout_secs: 30,
};
let client = CoverageClient::new(&config).unwrap();
assert_eq!(client.base_url(), "http://localhost:3000");
}
#[test]
fn debug_impl_shows_base_url() {
let config = CoverageConfig {
base_url: "http://localhost:3000".to_owned(),
timeout_secs: 30,
};
let client = CoverageClient::new(&config).unwrap();
let debug = format!("{client:?}");
assert!(debug.contains("http://localhost:3000"));
}
}