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>
422 lines
13 KiB
Rust
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(¶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<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/");
|
|
}
|
|
}
|