stemedb/crates/stemedb-admin/src/client.rs
jordan ad07a75d0a feat: add source content to source registry, signed assertions, feed endpoint, dashboard enhancements
- Add `content: Option<String>` to SourceRecord with rkyv schema evolution
  (LegacySourceRecord compat deserializer for backward compatibility)
- Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation
- Strip content from list responses, include in single-source GET
- Update Go SDK RegisterSourceRequest with Content field
- FCM pipeline extracts PDF text via pdftotext and passes to registration
- Dashboard impact panel fetches and displays source content with expand/collapse
- Add feed endpoint, dashboard feed panel, and signed assertion support
- Update data-structures.md, API docs, and storage docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:54:27 -07:00

157 lines
4.9 KiB
Rust

use anyhow::{Context, Result};
use tracing::{debug, instrument};
use crate::types::{
ClusterStatusResponse, HealthResponse, RangeInfoDto, RangesWrapper, ShardInfoResponse,
};
/// HTTP client for StemeDB Gateway API
pub struct AdminClient {
base_url: String,
client: reqwest::Client,
}
impl AdminClient {
/// Create a new admin client pointing to the gateway
pub fn new(base_url: String) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client");
Self { base_url, client }
}
/// Check gateway health
#[instrument(skip(self))]
pub async fn health(&self) -> Result<HealthResponse> {
let url = format!("{}/v1/health", self.base_url);
debug!("Fetching health from: {}", url);
let response = self
.client
.get(&url)
.send()
.await
.context(format!("Failed to connect to gateway at {}", self.base_url))?;
if !response.status().is_success() {
anyhow::bail!(
"Gateway returned error status: {} - {}",
response.status(),
response.text().await.unwrap_or_default()
);
}
response.json().await.context("Failed to parse health response")
}
/// Get cluster status overview
#[instrument(skip(self))]
pub async fn cluster_status(&self) -> Result<ClusterStatusResponse> {
let url = format!("{}/v1/cluster/status", self.base_url);
debug!("Fetching cluster status from: {}", url);
let response = self
.client
.get(&url)
.send()
.await
.context(format!("Failed to connect to gateway at {}", self.base_url))?;
if !response.status().is_success() {
anyhow::bail!(
"Gateway returned error status: {} - {}",
response.status(),
response.text().await.unwrap_or_default()
);
}
response.json().await.context("Failed to parse cluster status response")
}
/// Get detailed information about a specific shard
#[instrument(skip(self))]
pub async fn shard_info(&self, shard_id: u32) -> Result<RangeInfoDto> {
let url = format!("{}/v1/shards/{}", self.base_url, shard_id);
debug!("Fetching shard info from: {}", url);
let response = self
.client
.get(&url)
.send()
.await
.context(format!("Failed to connect to gateway at {}", self.base_url))?;
if !response.status().is_success() {
if response.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("Shard not found: {}", shard_id);
}
anyhow::bail!(
"Gateway returned error status: {} - {}",
response.status(),
response.text().await.unwrap_or_default()
);
}
// Gateway returns different format than /admin/ranges, so convert it
let shard_response: ShardInfoResponse =
response.json().await.context("Failed to parse shard info response")?;
Ok(shard_response.into())
}
/// Get information about all shards
#[instrument(skip(self))]
pub async fn all_ranges(&self) -> Result<Vec<RangeInfoDto>> {
let url = format!("{}/v1/admin/ranges", self.base_url);
debug!("Fetching all ranges from: {}", url);
let response = self
.client
.get(&url)
.send()
.await
.context(format!("Failed to connect to gateway at {}", self.base_url))?;
if !response.status().is_success() {
anyhow::bail!(
"Gateway returned error status: {} - {}",
response.status(),
response.text().await.unwrap_or_default()
);
}
// Gateway returns {"ranges": [...]} so we need to unwrap it
let wrapper: RangesWrapper =
response.json().await.context("Failed to parse ranges response")?;
Ok(wrapper.ranges)
}
/// Trigger anti-entropy sync (Phase 2 feature - not yet exposed in CLI)
#[allow(dead_code)]
#[instrument(skip(self))]
pub async fn force_sync(&self) -> Result<()> {
let url = format!("{}/v1/admin/sync", self.base_url);
debug!("Triggering sync at: {}", url);
let response = self
.client
.post(&url)
.send()
.await
.context(format!("Failed to connect to gateway at {}", self.base_url))?;
if !response.status().is_success() {
anyhow::bail!(
"Gateway returned error status: {} - {}",
response.status(),
response.text().await.unwrap_or_default()
);
}
Ok(())
}
}