stemedb/crates/stemedb-ontology/src/client.rs
jordan bbe6aedc40 feat: Aphoria security extractors + LLM evaluation architecture + ontology docs
New security extractors:
- insecure_deserialization, orm_injection, path_traversal, security_headers
- ssrf, unvalidated_redirects, weak_password, xxe
- Enhanced tls_version extractor with comprehensive cipher/protocol checks

Architecture docs:
- Scout-judge extraction pattern for LLM-based code analysis
- LLM prompt evaluation framework
- LLM eval implementation guide

Core improvements:
- stemedb-ontology README and client enhancements
- WAL journal/segment instrumentation
- Signing and ingestion refinements
- Consumer health demo script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:22:55 -07:00

422 lines
13 KiB
Rust

//! 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<String>) -> 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<String>, 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<String, ClientError> {
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<Result<String, ClientError>> {
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<SkepticResponse, ClientError> {
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<LayeredResponse, ClientError> {
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<LensQueryDto>,
) -> Result<QueryResponse, ClientError> {
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(&params.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<Vec<String>, 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<String> =
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/");
}
}