use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use crate::default_client_builder;
use auths_core::witness::{
AsyncWitnessProvider, DuplicityEvidence, EventHash, Receipt, WitnessError,
};
use auths_verifier::keri::{Prefix, Said};
#[derive(Debug, Clone)]
pub struct HttpAsyncWitnessClient {
base_url: String,
client: reqwest::Client,
quorum_size: usize,
timeout: Duration,
}
#[derive(Debug, Deserialize)]
struct HeadResponse {
#[allow(dead_code)] prefix: String,
latest_seq: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: String,
duplicity: Option<DuplicityEvidence>,
}
#[derive(Debug, Deserialize)]
struct HealthResponse {
status: String,
}
impl HttpAsyncWitnessClient {
#[allow(clippy::expect_used)]
pub fn new(base_url: impl Into<String>, quorum_size: usize) -> Self {
let timeout = Duration::from_secs(5);
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
client: default_client_builder()
.timeout(timeout)
.build()
.expect("failed to build reqwest client"),
quorum_size,
timeout,
}
}
#[allow(clippy::expect_used)]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.client = default_client_builder()
.timeout(timeout)
.build()
.expect("failed to build reqwest client");
self
}
}
#[async_trait]
impl AsyncWitnessProvider for HttpAsyncWitnessClient {
async fn submit_event(
&self,
prefix: &Prefix,
event_json: &[u8],
) -> Result<Receipt, WitnessError> {
let url = format!("{}/witness/{}/event", self.base_url, prefix);
let event_value: serde_json::Value = serde_json::from_slice(event_json)
.map_err(|e| WitnessError::Serialization(e.to_string()))?;
let response = self
.client
.post(&url)
.json(&event_value)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
WitnessError::Timeout(self.timeout.as_millis() as u64)
} else {
WitnessError::Network(e.to_string())
}
})?;
let status = response.status();
if status.is_success() {
response
.json::<Receipt>()
.await
.map_err(|e| WitnessError::Serialization(e.to_string()))
} else if status.as_u16() == 409 {
let error_resp: ErrorResponse = response
.json()
.await
.map_err(|e| WitnessError::Serialization(e.to_string()))?;
if let Some(evidence) = error_resp.duplicity {
Err(WitnessError::Duplicity(evidence))
} else {
Err(WitnessError::Rejected {
reason: error_resp.error,
})
}
} else {
let body = response.text().await.unwrap_or_default();
Err(WitnessError::Rejected {
reason: format!("HTTP {}: {}", status, body),
})
}
}
async fn observe_identity_head(
&self,
prefix: &Prefix,
) -> Result<Option<EventHash>, WitnessError> {
let url = format!("{}/witness/{}/head", self.base_url, prefix);
let response = self.client.get(&url).send().await.map_err(|e| {
if e.is_timeout() {
WitnessError::Timeout(self.timeout.as_millis() as u64)
} else {
WitnessError::Network(e.to_string())
}
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(WitnessError::Network(format!(
"head query failed: {}",
body
)));
}
let head: HeadResponse = response
.json()
.await
.map_err(|e| WitnessError::Serialization(e.to_string()))?;
Ok(head.latest_seq.map(|seq| {
let mut bytes = [0u8; 20];
bytes[12..20].copy_from_slice(&seq.to_be_bytes());
EventHash::from_bytes(bytes)
}))
}
async fn get_receipt(
&self,
prefix: &Prefix,
event_said: &Said,
) -> Result<Option<Receipt>, WitnessError> {
let url = format!(
"{}/witness/{}/receipt/{}",
self.base_url, prefix, event_said
);
let response = self.client.get(&url).send().await.map_err(|e| {
if e.is_timeout() {
WitnessError::Timeout(self.timeout.as_millis() as u64)
} else {
WitnessError::Network(e.to_string())
}
})?;
if response.status().as_u16() == 404 {
return Ok(None);
}
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(WitnessError::Network(format!(
"receipt query failed: {}",
body
)));
}
let receipt: Receipt = response
.json()
.await
.map_err(|e| WitnessError::Serialization(e.to_string()))?;
Ok(Some(receipt))
}
fn quorum(&self) -> usize {
self.quorum_size
}
fn timeout_ms(&self) -> u64 {
self.timeout.as_millis() as u64
}
async fn is_available(&self) -> Result<bool, WitnessError> {
let url = format!("{}/health", self.base_url);
let response = self.client.get(&url).send().await.map_err(|e| {
if e.is_timeout() {
WitnessError::Timeout(self.timeout.as_millis() as u64)
} else {
WitnessError::Network(e.to_string())
}
})?;
if !response.status().is_success() {
return Ok(false);
}
let health: HealthResponse = response
.json()
.await
.map_err(|e| WitnessError::Serialization(e.to_string()))?;
Ok(health.status == "ok")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn builder_strips_trailing_slash() {
let client = HttpAsyncWitnessClient::new("http://localhost:3000/", 1);
assert_eq!(client.base_url, "http://localhost:3000");
}
#[tokio::test]
async fn builder_preserves_clean_url() {
let client = HttpAsyncWitnessClient::new("http://localhost:3000", 2);
assert_eq!(client.base_url, "http://localhost:3000");
assert_eq!(client.quorum_size, 2);
}
#[tokio::test]
async fn custom_timeout() {
let client = HttpAsyncWitnessClient::new("http://localhost:3000", 1)
.with_timeout(Duration::from_secs(30));
assert_eq!(client.timeout_ms(), 30_000);
}
#[tokio::test]
async fn default_timeout_is_5s() {
let client = HttpAsyncWitnessClient::new("http://localhost:3000", 1);
assert_eq!(client.timeout_ms(), 5_000);
}
}