auths_infra_http/
async_witness_client.rs1use 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#[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)] 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 #[serde(default)]
50 witness_did: String,
51}
52
53#[derive(Debug, Deserialize)]
54struct SaidAtSeqResponse {
55 said: Said,
56}
57
58impl HttpAsyncWitnessClient {
59 #[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 #[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 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 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}