horizon-sdk 6.9.0

Canonical Rust data access layer for the Horizon platform
Documentation
//! HTTP client wrapper for the `BentoML` horizon-coverage service.

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};

/// Async HTTP client for the horizon-coverage `BentoML` service.
///
/// Provides typed wrappers for the three coverage endpoints:
/// course-of-action generation, coverage heatmaps, and predictive heatmaps.
#[derive(Debug, Clone)]
pub struct CoverageClient {
    /// Base URL of the `BentoML` service (without trailing slash).
    base_url: String,
    /// HTTP client configured with timeout.
    client: Client,
}

impl CoverageClient {
    /// The base URL this client connects to.
    #[must_use]
    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    /// Generate course of action candidates for the provided objective.
    ///
    /// # Errors
    ///
    /// Returns `HttpError::ServiceUnreachable` / `HttpError::Timeout` /
    /// `HttpError::RequestFailed` on transport failures, or
    /// `HttpError::UnexpectedStatus` / `HttpError::DecodeFailed` on a
    /// successful-but-unusable response.
    pub async fn get_coa(&self, request: &Value) -> Result<Value> {
        self.post_coverage("/generate_course_of_actions", request)
            .await
    }

    /// Get coverage for the requested area.
    ///
    /// # Errors
    ///
    /// Returns `HttpError::ServiceUnreachable` / `HttpError::Timeout` /
    /// `HttpError::RequestFailed` on transport failures, or
    /// `HttpError::UnexpectedStatus` / `HttpError::DecodeFailed` on a
    /// successful-but-unusable response.
    pub async fn get_coverage_heatmap(&self, request: &Value) -> Result<Value> {
        self.post_coverage("/get_coverage", request).await
    }

    /// Get a predictive heatmap for the requested area.
    ///
    /// # Errors
    ///
    /// Returns `HttpError::ServiceUnreachable` / `HttpError::Timeout` /
    /// `HttpError::RequestFailed` on transport failures, or
    /// `HttpError::UnexpectedStatus` / `HttpError::DecodeFailed` on a
    /// successful-but-unusable response.
    pub async fn get_predictive_heatmap(&self, request: &Value) -> Result<Value> {
        self.post_coverage("/get_predictive_heatmap", request).await
    }

    /// Create a new coverage client from SDK configuration.
    ///
    /// # Errors
    ///
    /// Returns `ConfigError::InvalidUrl` if the base URL is malformed, or
    /// `HttpError::ClientBuild` if the HTTP client cannot be created.
    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 })
    }

    /// Send a POST request to a coverage service endpoint.
    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"));
    }
}