Skip to main content

auths_infra_http/
async_witness_client.rs

1use std::time::Duration;
2
3use async_trait::async_trait;
4use serde::Deserialize;
5
6use crate::default_client_builder;
7use auths_core::witness::{
8    AsyncWitnessProvider, DuplicityEvidence, EventHash, Receipt, SignedReceipt, WitnessError,
9};
10use auths_keri::{Prefix, Said};
11
12/// HTTP-based witness client implementing [`AsyncWitnessProvider`].
13///
14/// Communicates with a KERI witness server over HTTP to submit events,
15/// retrieve receipts, and check identity heads.
16///
17/// Usage:
18/// ```ignore
19/// use auths_infra_http::HttpAsyncWitnessClient;
20///
21/// let client = HttpAsyncWitnessClient::new("http://localhost:3000", 2)
22///     .with_timeout(std::time::Duration::from_secs(10));
23/// ```
24#[derive(Debug, Clone)]
25pub struct HttpAsyncWitnessClient {
26    base_url: String,
27    client: reqwest::Client,
28    quorum_size: usize,
29    timeout: Duration,
30}
31
32#[derive(Debug, Deserialize)]
33struct HeadResponse {
34    #[allow(dead_code)] // serde deserialize target — field must exist for JSON mapping
35    prefix: String,
36    latest_seq: Option<u64>,
37}
38
39#[derive(Debug, Deserialize)]
40struct ErrorResponse {
41    error: String,
42    duplicity: Option<DuplicityEvidence>,
43}
44
45#[derive(Debug, Deserialize)]
46struct HealthResponse {
47    status: String,
48    /// The witness's advertised identity (a `did:key`); present on real servers.
49    #[serde(default)]
50    witness_did: String,
51}
52
53#[derive(Debug, Deserialize)]
54struct SaidAtSeqResponse {
55    said: Said,
56}
57
58impl HttpAsyncWitnessClient {
59    /// Creates a new HTTP async witness client.
60    ///
61    /// Args:
62    /// * `base_url`: The witness server base URL (e.g., `"http://localhost:3000"`).
63    /// * `quorum_size`: Minimum receipts required for this witness.
64    ///
65    /// Usage:
66    /// ```ignore
67    /// let client = HttpAsyncWitnessClient::new("http://witness:3000", 1);
68    /// ```
69    // INVARIANT: reqwest builder with these settings cannot fail
70    #[allow(clippy::expect_used)]
71    pub fn new(base_url: impl Into<String>, quorum_size: usize) -> Self {
72        let timeout = Duration::from_secs(5);
73        Self {
74            base_url: base_url.into().trim_end_matches('/').to_string(),
75            client: default_client_builder()
76                .timeout(timeout)
77                .build()
78                .expect("failed to build reqwest client"),
79            quorum_size,
80            timeout,
81        }
82    }
83
84    /// Sets a custom timeout for HTTP requests.
85    ///
86    /// Args:
87    /// * `timeout`: The request timeout duration.
88    ///
89    /// Usage:
90    /// ```ignore
91    /// let client = HttpAsyncWitnessClient::new("http://witness:3000", 1)
92    ///     .with_timeout(Duration::from_secs(30));
93    /// ```
94    // INVARIANT: reqwest builder with these settings cannot fail
95    #[allow(clippy::expect_used)]
96    pub fn with_timeout(mut self, timeout: Duration) -> Self {
97        self.timeout = timeout;
98        self.client = default_client_builder()
99            .timeout(timeout)
100            .build()
101            .expect("failed to build reqwest client");
102        self
103    }
104
105    /// Query the SAID this witness first saw for `prefix` at sequence `seq`.
106    ///
107    /// Lets a monitor compare witnesses by CONTENT, not just HEAD number.
108    ///
109    /// Args:
110    /// * `prefix`: The KERI prefix of the identity.
111    /// * `seq`: The sequence number to query.
112    ///
113    /// Returns `Ok(None)` when the witness has not seen that sequence (a gap, not
114    /// divergence); `Ok(Some(said))` when it has.
115    ///
116    /// Usage:
117    /// ```ignore
118    /// let said = client.said_at_seq(&prefix, 0).await?;
119    /// ```
120    pub async fn said_at_seq(
121        &self,
122        prefix: &Prefix,
123        seq: u64,
124    ) -> Result<Option<Said>, WitnessError> {
125        let url = format!("{}/witness/{}/said/{}", self.base_url, prefix, seq);
126        let response = self.client.get(&url).send().await.map_err(|e| {
127            if e.is_timeout() {
128                WitnessError::Timeout(self.timeout.as_millis() as u64)
129            } else {
130                WitnessError::Network(e.to_string())
131            }
132        })?;
133
134        if response.status().as_u16() == 404 {
135            return Ok(None);
136        }
137        if !response.status().is_success() {
138            let body = response.text().await.unwrap_or_default();
139            return Err(WitnessError::Network(format!(
140                "said-at-seq query failed: {body}"
141            )));
142        }
143
144        let parsed: SaidAtSeqResponse = response
145            .json()
146            .await
147            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
148        Ok(Some(parsed.said))
149    }
150}
151
152#[async_trait]
153impl AsyncWitnessProvider for HttpAsyncWitnessClient {
154    async fn submit_event(
155        &self,
156        prefix: &Prefix,
157        event_json: &[u8],
158    ) -> Result<SignedReceipt, WitnessError> {
159        let url = format!("{}/witness/{}/event", self.base_url, prefix);
160
161        let event_value: serde_json::Value = serde_json::from_slice(event_json)
162            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
163
164        let response = self
165            .client
166            .post(&url)
167            .json(&event_value)
168            .send()
169            .await
170            .map_err(|e| {
171                if e.is_timeout() {
172                    WitnessError::Timeout(self.timeout.as_millis() as u64)
173                } else {
174                    WitnessError::Network(e.to_string())
175                }
176            })?;
177
178        let status = response.status();
179
180        if status.is_success() {
181            response
182                .json::<SignedReceipt>()
183                .await
184                .map_err(|e| WitnessError::Serialization(e.to_string()))
185        } else if status.as_u16() == 409 {
186            let error_resp: ErrorResponse = response
187                .json()
188                .await
189                .map_err(|e| WitnessError::Serialization(e.to_string()))?;
190
191            if let Some(evidence) = error_resp.duplicity {
192                Err(WitnessError::Duplicity(evidence))
193            } else {
194                Err(WitnessError::Rejected {
195                    reason: error_resp.error,
196                })
197            }
198        } else {
199            let body = response.text().await.unwrap_or_default();
200            Err(WitnessError::Rejected {
201                reason: format!("HTTP {}: {}", status, body),
202            })
203        }
204    }
205
206    async fn observe_identity_head(
207        &self,
208        prefix: &Prefix,
209    ) -> Result<Option<EventHash>, WitnessError> {
210        let url = format!("{}/witness/{}/head", self.base_url, prefix);
211
212        let response = self.client.get(&url).send().await.map_err(|e| {
213            if e.is_timeout() {
214                WitnessError::Timeout(self.timeout.as_millis() as u64)
215            } else {
216                WitnessError::Network(e.to_string())
217            }
218        })?;
219
220        if !response.status().is_success() {
221            let body = response.text().await.unwrap_or_default();
222            return Err(WitnessError::Network(format!(
223                "head query failed: {}",
224                body
225            )));
226        }
227
228        let head: HeadResponse = response
229            .json()
230            .await
231            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
232
233        Ok(head.latest_seq.map(|seq| {
234            let mut bytes = [0u8; 20];
235            bytes[12..20].copy_from_slice(&seq.to_be_bytes());
236            EventHash::from_bytes(bytes)
237        }))
238    }
239
240    async fn get_receipt(
241        &self,
242        prefix: &Prefix,
243        event_said: &Said,
244    ) -> Result<Option<Receipt>, WitnessError> {
245        let url = format!(
246            "{}/witness/{}/receipt/{}",
247            self.base_url, prefix, event_said
248        );
249
250        let response = self.client.get(&url).send().await.map_err(|e| {
251            if e.is_timeout() {
252                WitnessError::Timeout(self.timeout.as_millis() as u64)
253            } else {
254                WitnessError::Network(e.to_string())
255            }
256        })?;
257
258        if response.status().as_u16() == 404 {
259            return Ok(None);
260        }
261
262        if !response.status().is_success() {
263            let body = response.text().await.unwrap_or_default();
264            return Err(WitnessError::Network(format!(
265                "receipt query failed: {}",
266                body
267            )));
268        }
269
270        let receipt: Receipt = response
271            .json()
272            .await
273            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
274
275        Ok(Some(receipt))
276    }
277
278    fn quorum(&self) -> usize {
279        self.quorum_size
280    }
281
282    fn timeout_ms(&self) -> u64 {
283        self.timeout.as_millis() as u64
284    }
285
286    async fn is_available(&self) -> Result<bool, WitnessError> {
287        let url = format!("{}/health", self.base_url);
288
289        let response = self.client.get(&url).send().await.map_err(|e| {
290            if e.is_timeout() {
291                WitnessError::Timeout(self.timeout.as_millis() as u64)
292            } else {
293                WitnessError::Network(e.to_string())
294            }
295        })?;
296
297        if !response.status().is_success() {
298            return Ok(false);
299        }
300
301        let health: HealthResponse = response
302            .json()
303            .await
304            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
305
306        Ok(health.status == "ok")
307    }
308
309    async fn witness_aid(&self) -> Result<Prefix, WitnessError> {
310        let url = format!("{}/health", self.base_url);
311        let response = self.client.get(&url).send().await.map_err(|e| {
312            if e.is_timeout() {
313                WitnessError::Timeout(self.timeout.as_millis() as u64)
314            } else {
315                WitnessError::Network(e.to_string())
316            }
317        })?;
318        if !response.status().is_success() {
319            return Err(WitnessError::Network(format!(
320                "witness /health returned status {}",
321                response.status()
322            )));
323        }
324        let health: HealthResponse = response
325            .json()
326            .await
327            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
328        if health.witness_did.is_empty() {
329            return Err(WitnessError::Rejected {
330                reason: "witness /health did not advertise a witness_did".to_string(),
331            });
332        }
333        // The witness advertises its identity as a `did:key`; convert it to the
334        // curve-tagged CESR verkey prefix used in `b[]` and receipt verification.
335        let decoded = auths_crypto::did_key_decode(&health.witness_did).map_err(|e| {
336            WitnessError::Rejected {
337                reason: format!("witness /health did:key invalid: {e}"),
338            }
339        })?;
340        let key = auths_keri::KeriPublicKey::from_verkey_bytes(decoded.bytes(), decoded.curve())
341            .map_err(|e| WitnessError::Rejected {
342                reason: format!("witness verkey undecodable: {e}"),
343            })?;
344        let qb64 = key.to_qb64().map_err(|e| WitnessError::Rejected {
345            reason: format!("witness verkey encode failed: {e}"),
346        })?;
347        Prefix::new(qb64).map_err(|e| WitnessError::Rejected {
348            reason: format!("witness AID invalid: {e}"),
349        })
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[tokio::test]
358    async fn builder_strips_trailing_slash() {
359        let client = HttpAsyncWitnessClient::new("http://localhost:3000/", 1);
360        assert_eq!(client.base_url, "http://localhost:3000");
361    }
362
363    #[tokio::test]
364    async fn builder_preserves_clean_url() {
365        let client = HttpAsyncWitnessClient::new("http://localhost:3000", 2);
366        assert_eq!(client.base_url, "http://localhost:3000");
367        assert_eq!(client.quorum_size, 2);
368    }
369
370    #[tokio::test]
371    async fn custom_timeout() {
372        let client = HttpAsyncWitnessClient::new("http://localhost:3000", 1)
373            .with_timeout(Duration::from_secs(30));
374        assert_eq!(client.timeout_ms(), 30_000);
375    }
376
377    #[tokio::test]
378    async fn default_timeout_is_5s() {
379        let client = HttpAsyncWitnessClient::new("http://localhost:3000", 1);
380        assert_eq!(client.timeout_ms(), 5_000);
381    }
382}