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, WitnessError,
9};
10use auths_verifier::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}
49
50impl HttpAsyncWitnessClient {
51    /// Creates a new HTTP async witness client.
52    ///
53    /// Args:
54    /// * `base_url`: The witness server base URL (e.g., `"http://localhost:3000"`).
55    /// * `quorum_size`: Minimum receipts required for this witness.
56    ///
57    /// Usage:
58    /// ```ignore
59    /// let client = HttpAsyncWitnessClient::new("http://witness:3000", 1);
60    /// ```
61    // INVARIANT: reqwest builder with these settings cannot fail
62    #[allow(clippy::expect_used)]
63    pub fn new(base_url: impl Into<String>, quorum_size: usize) -> Self {
64        let timeout = Duration::from_secs(5);
65        Self {
66            base_url: base_url.into().trim_end_matches('/').to_string(),
67            client: default_client_builder()
68                .timeout(timeout)
69                .build()
70                .expect("failed to build reqwest client"),
71            quorum_size,
72            timeout,
73        }
74    }
75
76    /// Sets a custom timeout for HTTP requests.
77    ///
78    /// Args:
79    /// * `timeout`: The request timeout duration.
80    ///
81    /// Usage:
82    /// ```ignore
83    /// let client = HttpAsyncWitnessClient::new("http://witness:3000", 1)
84    ///     .with_timeout(Duration::from_secs(30));
85    /// ```
86    // INVARIANT: reqwest builder with these settings cannot fail
87    #[allow(clippy::expect_used)]
88    pub fn with_timeout(mut self, timeout: Duration) -> Self {
89        self.timeout = timeout;
90        self.client = default_client_builder()
91            .timeout(timeout)
92            .build()
93            .expect("failed to build reqwest client");
94        self
95    }
96}
97
98#[async_trait]
99impl AsyncWitnessProvider for HttpAsyncWitnessClient {
100    async fn submit_event(
101        &self,
102        prefix: &Prefix,
103        event_json: &[u8],
104    ) -> Result<Receipt, WitnessError> {
105        let url = format!("{}/witness/{}/event", self.base_url, prefix);
106
107        let event_value: serde_json::Value = serde_json::from_slice(event_json)
108            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
109
110        let response = self
111            .client
112            .post(&url)
113            .json(&event_value)
114            .send()
115            .await
116            .map_err(|e| {
117                if e.is_timeout() {
118                    WitnessError::Timeout(self.timeout.as_millis() as u64)
119                } else {
120                    WitnessError::Network(e.to_string())
121                }
122            })?;
123
124        let status = response.status();
125
126        if status.is_success() {
127            response
128                .json::<Receipt>()
129                .await
130                .map_err(|e| WitnessError::Serialization(e.to_string()))
131        } else if status.as_u16() == 409 {
132            let error_resp: ErrorResponse = response
133                .json()
134                .await
135                .map_err(|e| WitnessError::Serialization(e.to_string()))?;
136
137            if let Some(evidence) = error_resp.duplicity {
138                Err(WitnessError::Duplicity(evidence))
139            } else {
140                Err(WitnessError::Rejected {
141                    reason: error_resp.error,
142                })
143            }
144        } else {
145            let body = response.text().await.unwrap_or_default();
146            Err(WitnessError::Rejected {
147                reason: format!("HTTP {}: {}", status, body),
148            })
149        }
150    }
151
152    async fn observe_identity_head(
153        &self,
154        prefix: &Prefix,
155    ) -> Result<Option<EventHash>, WitnessError> {
156        let url = format!("{}/witness/{}/head", self.base_url, prefix);
157
158        let response = self.client.get(&url).send().await.map_err(|e| {
159            if e.is_timeout() {
160                WitnessError::Timeout(self.timeout.as_millis() as u64)
161            } else {
162                WitnessError::Network(e.to_string())
163            }
164        })?;
165
166        if !response.status().is_success() {
167            let body = response.text().await.unwrap_or_default();
168            return Err(WitnessError::Network(format!(
169                "head query failed: {}",
170                body
171            )));
172        }
173
174        let head: HeadResponse = response
175            .json()
176            .await
177            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
178
179        Ok(head.latest_seq.map(|seq| {
180            let mut bytes = [0u8; 20];
181            bytes[12..20].copy_from_slice(&seq.to_be_bytes());
182            EventHash::from_bytes(bytes)
183        }))
184    }
185
186    async fn get_receipt(
187        &self,
188        prefix: &Prefix,
189        event_said: &Said,
190    ) -> Result<Option<Receipt>, WitnessError> {
191        let url = format!(
192            "{}/witness/{}/receipt/{}",
193            self.base_url, prefix, event_said
194        );
195
196        let response = self.client.get(&url).send().await.map_err(|e| {
197            if e.is_timeout() {
198                WitnessError::Timeout(self.timeout.as_millis() as u64)
199            } else {
200                WitnessError::Network(e.to_string())
201            }
202        })?;
203
204        if response.status().as_u16() == 404 {
205            return Ok(None);
206        }
207
208        if !response.status().is_success() {
209            let body = response.text().await.unwrap_or_default();
210            return Err(WitnessError::Network(format!(
211                "receipt query failed: {}",
212                body
213            )));
214        }
215
216        let receipt: Receipt = response
217            .json()
218            .await
219            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
220
221        Ok(Some(receipt))
222    }
223
224    fn quorum(&self) -> usize {
225        self.quorum_size
226    }
227
228    fn timeout_ms(&self) -> u64 {
229        self.timeout.as_millis() as u64
230    }
231
232    async fn is_available(&self) -> Result<bool, WitnessError> {
233        let url = format!("{}/health", self.base_url);
234
235        let response = self.client.get(&url).send().await.map_err(|e| {
236            if e.is_timeout() {
237                WitnessError::Timeout(self.timeout.as_millis() as u64)
238            } else {
239                WitnessError::Network(e.to_string())
240            }
241        })?;
242
243        if !response.status().is_success() {
244            return Ok(false);
245        }
246
247        let health: HealthResponse = response
248            .json()
249            .await
250            .map_err(|e| WitnessError::Serialization(e.to_string()))?;
251
252        Ok(health.status == "ok")
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[tokio::test]
261    async fn builder_strips_trailing_slash() {
262        let client = HttpAsyncWitnessClient::new("http://localhost:3000/", 1);
263        assert_eq!(client.base_url, "http://localhost:3000");
264    }
265
266    #[tokio::test]
267    async fn builder_preserves_clean_url() {
268        let client = HttpAsyncWitnessClient::new("http://localhost:3000", 2);
269        assert_eq!(client.base_url, "http://localhost:3000");
270        assert_eq!(client.quorum_size, 2);
271    }
272
273    #[tokio::test]
274    async fn custom_timeout() {
275        let client = HttpAsyncWitnessClient::new("http://localhost:3000", 1)
276            .with_timeout(Duration::from_secs(30));
277        assert_eq!(client.timeout_ms(), 30_000);
278    }
279
280    #[tokio::test]
281    async fn default_timeout_is_5s() {
282        let client = HttpAsyncWitnessClient::new("http://localhost:3000", 1);
283        assert_eq!(client.timeout_ms(), 5_000);
284    }
285}