//! HTTP client for StemeDB API. //! //! This module provides a client for submitting assertions and querying //! the StemeDB API from the ontology layer. use crate::dto::{ assertion_to_request, CreateResponse, LayeredResponse, LensQueryDto, QueryResponse, SkepticResponse, }; use stemedb_core::types::Assertion; use thiserror::Error; use tracing::{info, instrument, warn}; /// Errors that can occur when communicating with StemeDB. #[derive(Debug, Error)] pub enum ClientError { /// HTTP request failed. #[error("HTTP error: {0}")] Http(#[from] reqwest::Error), /// API returned an error response. #[error("API error ({status}): {message}")] ApiError { /// HTTP status code status: u16, /// Error message from API message: String, }, /// Failed to parse response. #[error("Failed to parse response: {0}")] ParseError(String), /// Server not available. #[error("Server not available at {url}: {message}")] ServerUnavailable { /// The URL that was attempted url: String, /// Error message message: String, }, } /// Client for StemeDB HTTP API. /// /// # Example /// /// ```ignore /// use stemedb_ontology::client::StemeClient; /// /// let client = StemeClient::new("http://localhost:18180"); /// /// // Submit an assertion /// let hash = client.assert(&assertion).await?; /// /// // Query for conflicts /// let skeptic = client.skeptic("Semaglutide:Type2Diabetes", "hba1c_change_percent").await?; /// ``` #[derive(Debug, Clone)] pub struct StemeClient { base_url: String, http_client: reqwest::Client, } impl StemeClient { /// Create a new client with the given base URL. /// /// # Arguments /// /// * `base_url` - The base URL of the StemeDB API (e.g., "http://localhost:18180") pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), http_client: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| reqwest::Client::new()), } } /// Create a client with a custom reqwest client. /// /// Useful for testing or custom configurations. pub fn with_client(base_url: impl Into, client: reqwest::Client) -> Self { Self { base_url: base_url.into(), http_client: client } } /// Submit an assertion to StemeDB. /// /// Returns the content-addressed hash on success. /// /// # Arguments /// /// * `assertion` - The assertion to submit /// /// # Errors /// /// Returns `ClientError` if the request fails or the API returns an error. #[instrument(skip(self, assertion), fields(subject = %assertion.subject, predicate = %assertion.predicate))] pub async fn assert(&self, assertion: &Assertion) -> Result { let request = assertion_to_request(assertion); let url = format!("{}/v1/assert", self.base_url); info!(url = %url, "Submitting assertion"); let response = self.http_client.post(&url).json(&request).send().await.map_err(|e| { if e.is_connect() { ClientError::ServerUnavailable { url: url.clone(), message: format!( "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api", e ), } } else { ClientError::Http(e) } })?; let status = response.status(); if !status.is_success() { let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); warn!(status = status.as_u16(), error = %text, "API error"); return Err(ClientError::ApiError { status: status.as_u16(), message: text }); } let result: CreateResponse = response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?; info!(hash = %result.hash, "Assertion created"); Ok(result.hash) } /// Submit multiple assertions in sequence. /// /// Returns a vector of (hash, error) pairs. Continues on individual failures. /// /// # Arguments /// /// * `assertions` - The assertions to submit #[instrument(skip(self, assertions), fields(count = assertions.len()))] pub async fn assert_batch(&self, assertions: &[Assertion]) -> Vec> { let mut results = Vec::with_capacity(assertions.len()); for assertion in assertions { results.push(self.assert(assertion).await); } results } /// Query the Skeptic lens for conflict analysis. /// /// # Arguments /// /// * `subject` - The subject to query /// * `predicate` - The predicate to query /// /// # Returns /// /// A `SkepticResponse` containing conflict analysis. #[instrument(skip(self))] pub async fn skeptic( &self, subject: &str, predicate: &str, ) -> Result { let url = format!( "{}/v1/skeptic?subject={}&predicate={}", self.base_url, urlencoding::encode(subject), urlencoding::encode(predicate) ); info!(url = %url, "Querying skeptic lens"); let response = self.http_client.get(&url).send().await.map_err(|e| { if e.is_connect() { ClientError::ServerUnavailable { url: url.clone(), message: format!( "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api", e ), } } else { ClientError::Http(e) } })?; let status = response.status(); if !status.is_success() { let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); warn!(status = status.as_u16(), error = %text, "Skeptic query failed"); return Err(ClientError::ApiError { status: status.as_u16(), message: text }); } let result: SkepticResponse = response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?; info!( conflict_score = result.conflict_score, claims_count = result.claims.len(), status = ?result.status, "Skeptic query completed" ); Ok(result) } /// Query the LayeredConsensus lens for per-tier analysis. /// /// # Arguments /// /// * `subject` - The subject to query /// * `predicate` - The predicate to query /// /// # Returns /// /// A `LayeredResponse` with per-tier consensus results. #[instrument(skip(self))] pub async fn layered( &self, subject: &str, predicate: &str, ) -> Result { let url = format!( "{}/v1/layered?subject={}&predicate={}", self.base_url, urlencoding::encode(subject), urlencoding::encode(predicate) ); info!(url = %url, "Querying layered consensus"); let response = self.http_client.get(&url).send().await.map_err(|e| { if e.is_connect() { ClientError::ServerUnavailable { url: url.clone(), message: format!( "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api", e ), } } else { ClientError::Http(e) } })?; let status = response.status(); if !status.is_success() { let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); warn!(status = status.as_u16(), error = %text, "Layered query failed"); return Err(ClientError::ApiError { status: status.as_u16(), message: text }); } let result: LayeredResponse = response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?; info!( overall_conflict = result.overall_conflict_score, tiers_count = result.tiers.len(), total_candidates = result.total_candidates, "Layered query completed" ); Ok(result) } /// Query assertions with optional lens. /// /// # Arguments /// /// * `subject` - Optional subject filter /// * `predicate` - Optional predicate filter /// * `lens` - Optional lens for conflict resolution /// /// # Returns /// /// A `QueryResponse` containing matching assertions. #[instrument(skip(self))] pub async fn query( &self, subject: Option<&str>, predicate: Option<&str>, lens: Option, ) -> Result { let mut url = format!("{}/v1/query?", self.base_url); let mut params = Vec::new(); if let Some(s) = subject { params.push(format!("subject={}", urlencoding::encode(s))); } if let Some(p) = predicate { params.push(format!("predicate={}", urlencoding::encode(p))); } if let Some(l) = lens { params.push(format!("lens={}", l)); } url.push_str(¶ms.join("&")); info!(url = %url, "Querying assertions"); let response = self.http_client.get(&url).send().await.map_err(|e| { if e.is_connect() { ClientError::ServerUnavailable { url: url.clone(), message: format!( "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api", e ), } } else { ClientError::Http(e) } })?; let status = response.status(); if !status.is_success() { let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); warn!(status = status.as_u16(), error = %text, "Query failed"); return Err(ClientError::ApiError { status: status.as_u16(), message: text }); } let result: QueryResponse = response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?; info!( total_count = result.total_count, has_more = result.has_more, conflict_score = ?result.conflict_score, "Query completed" ); Ok(result) } /// List distinct predicates for a subject. /// /// # Arguments /// /// * `subject` - The subject to list predicates for /// /// # Returns /// /// A list of distinct predicate strings. #[instrument(skip(self))] pub async fn list_predicates(&self, subject: &str) -> Result, ClientError> { let url = format!("{}/v1/query?subject={}", self.base_url, urlencoding::encode(subject)); info!(url = %url, "Listing predicates for subject"); let response = self.http_client.get(&url).send().await.map_err(|e| { if e.is_connect() { ClientError::ServerUnavailable { url: url.clone(), message: format!( "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api", e ), } } else { ClientError::Http(e) } })?; let status = response.status(); if !status.is_success() { let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); warn!(status = status.as_u16(), error = %text, "List predicates failed"); return Err(ClientError::ApiError { status: status.as_u16(), message: text }); } let result: QueryResponse = response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?; // Extract unique predicates from assertions let mut predicates: Vec = result.assertions.iter().map(|a| a.predicate.clone()).collect(); predicates.sort(); predicates.dedup(); info!(predicate_count = predicates.len(), "Listed predicates"); Ok(predicates) } /// Check if the StemeDB server is reachable. /// /// # Returns /// /// `true` if the server responded, `false` otherwise. pub async fn health_check(&self) -> bool { let url = format!("{}/v1/health", self.base_url); self.http_client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false) } /// Get the base URL of this client. pub fn base_url(&self) -> &str { &self.base_url } } #[cfg(test)] mod tests { use super::*; #[test] fn test_client_creation() { let client = StemeClient::new("http://localhost:18180"); assert_eq!(client.base_url(), "http://localhost:18180"); } #[test] fn test_client_with_trailing_slash() { let client = StemeClient::new("http://localhost:18180/"); // Note: This will produce double slashes in URLs, which the API should handle assert_eq!(client.base_url(), "http://localhost:18180/"); } }