1use std::time::Duration;
9
10use hyphae_contracts::v1::{
11 CapabilitiesV1, CommitReceiptV1, DeleteRequestV1, ErrorV1, GetRequestV1, GetResponseV1,
12 HealthV1, ProofV1, PutRequestV1, QueryRequestV1, QueryResponseV1,
13};
14use reqwest::{
15 Method, StatusCode, Url,
16 header::{self, HeaderMap, HeaderValue},
17};
18use serde::{Serialize, de::DeserializeOwned};
19use thiserror::Error;
20
21const DEFAULT_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
22const DEFAULT_WITNESS_BYTES: usize = 512 * 1024 * 1024;
23const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
24
25#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ApiResponse<T> {
28 pub value: T,
30 pub request_id: String,
32}
33
34#[derive(Clone, Debug, Error, Eq, PartialEq)]
36#[error("Hyphae API returned HTTP {status} {code} (request {request_id})")]
37pub struct ApiFailure {
38 pub status: u16,
40 pub code: String,
42 pub message: String,
44 pub request_id: String,
46}
47
48#[derive(Clone, Debug, Error, Eq, PartialEq)]
50pub enum ClientConfigError {
51 #[error("invalid Hyphae base URL")]
53 InvalidBaseUrl,
54 #[error("Hyphae base URL must use http or https")]
56 UnsupportedScheme,
57 #[error("Hyphae base URL must be an origin without credentials, query, fragment, or path")]
59 NonOriginBaseUrl,
60 #[error("invalid bearer token for an HTTP authorization header")]
62 InvalidBearerToken,
63 #[error("client timeout and response limits must be nonzero")]
65 ZeroLimit,
66 #[error("failed to construct HTTP client")]
68 HttpClient,
69}
70
71#[derive(Debug, Error)]
73pub enum ClientError {
74 #[error("Hyphae HTTP transport failed: {0}")]
76 Transport(#[from] reqwest::Error),
77 #[error("Hyphae response exceeded local limit {maximum} bytes")]
79 ResponseTooLarge {
80 maximum: usize,
82 },
83 #[error("Hyphae response did not use a JSON content type")]
85 InvalidContentType,
86 #[error("Hyphae response violated the version 1 contract: {0}")]
88 InvalidJson(#[from] serde_json::Error),
89 #[error("Hyphae response has no single valid X-Request-Id header")]
91 InvalidRequestId,
92 #[error("Hyphae error envelope request ID differs from its response header")]
94 RequestIdMismatch,
95 #[error(transparent)]
97 Api(#[from] ApiFailure),
98 #[error("Hyphae returned unexpected success status {0}")]
100 UnexpectedStatus(u16),
101 #[error("proof contains a noncanonical witness reference")]
103 InvalidWitnessReference,
104 #[error("downloaded witness digest header differs from the proof")]
106 WitnessDigestMismatch,
107 #[error("downloaded witness length differs from the proof")]
109 WitnessLengthMismatch,
110}
111
112#[derive(Clone, Debug)]
114#[must_use = "a client builder has no effect until build is called"]
115pub struct ClientBuilder {
116 base_url: Url,
117 bearer_token: Option<HeaderValue>,
118 timeout: Duration,
119 response_bytes: usize,
120 witness_bytes: usize,
121}
122
123impl ClientBuilder {
124 pub fn new(base_url: &str) -> Result<Self, ClientConfigError> {
131 let mut base_url = Url::parse(base_url).map_err(|_| ClientConfigError::InvalidBaseUrl)?;
132 if !matches!(base_url.scheme(), "http" | "https") {
133 return Err(ClientConfigError::UnsupportedScheme);
134 }
135 if !base_url.username().is_empty()
136 || base_url.password().is_some()
137 || base_url.query().is_some()
138 || base_url.fragment().is_some()
139 || !matches!(base_url.path(), "" | "/")
140 {
141 return Err(ClientConfigError::NonOriginBaseUrl);
142 }
143 base_url.set_path("/");
144 Ok(Self {
145 base_url,
146 bearer_token: None,
147 timeout: DEFAULT_TIMEOUT,
148 response_bytes: DEFAULT_RESPONSE_BYTES,
149 witness_bytes: DEFAULT_WITNESS_BYTES,
150 })
151 }
152
153 pub fn bearer_token(mut self, token: &str) -> Result<Self, ClientConfigError> {
159 if token.is_empty() {
160 return Err(ClientConfigError::InvalidBearerToken);
161 }
162 let mut value = HeaderValue::from_str(&format!("Bearer {token}"))
163 .map_err(|_| ClientConfigError::InvalidBearerToken)?;
164 value.set_sensitive(true);
165 self.bearer_token = Some(value);
166 Ok(self)
167 }
168
169 pub fn timeout(mut self, timeout: Duration) -> Self {
171 self.timeout = timeout;
172 self
173 }
174
175 pub fn response_bytes(mut self, maximum: usize) -> Self {
177 self.response_bytes = maximum;
178 self
179 }
180
181 pub fn witness_bytes(mut self, maximum: usize) -> Self {
183 self.witness_bytes = maximum;
184 self
185 }
186
187 pub fn build(self) -> Result<HyphaeClient, ClientConfigError> {
193 if self.timeout.is_zero() || self.response_bytes == 0 || self.witness_bytes == 0 {
194 return Err(ClientConfigError::ZeroLimit);
195 }
196 let http = reqwest::Client::builder()
197 .timeout(self.timeout)
198 .build()
199 .map_err(|_| ClientConfigError::HttpClient)?;
200 Ok(HyphaeClient {
201 http,
202 base_url: self.base_url,
203 bearer_token: self.bearer_token,
204 response_bytes: self.response_bytes,
205 witness_bytes: self.witness_bytes,
206 })
207 }
208}
209
210#[derive(Clone, Debug)]
212pub struct HyphaeClient {
213 http: reqwest::Client,
214 base_url: Url,
215 bearer_token: Option<HeaderValue>,
216 response_bytes: usize,
217 witness_bytes: usize,
218}
219
220impl HyphaeClient {
221 pub fn builder(base_url: &str) -> Result<ClientBuilder, ClientConfigError> {
227 ClientBuilder::new(base_url)
228 }
229
230 pub async fn capabilities(&self) -> Result<ApiResponse<CapabilitiesV1>, ClientError> {
236 self.get_json("v1/capabilities", false).await
237 }
238
239 pub async fn liveness(&self) -> Result<ApiResponse<HealthV1>, ClientError> {
245 self.get_json("v1/health/live", false).await
246 }
247
248 pub async fn readiness(&self) -> Result<ApiResponse<HealthV1>, ClientError> {
254 self.get_json("v1/health/ready", false).await
255 }
256
257 pub async fn put(
263 &self,
264 request: &PutRequestV1,
265 ) -> Result<ApiResponse<CommitReceiptV1>, ClientError> {
266 self.post_json("v1/kv/put", request).await
267 }
268
269 pub async fn delete(
275 &self,
276 request: &DeleteRequestV1,
277 ) -> Result<ApiResponse<CommitReceiptV1>, ClientError> {
278 self.post_json("v1/kv/delete", request).await
279 }
280
281 pub async fn get(
287 &self,
288 request: &GetRequestV1,
289 ) -> Result<ApiResponse<GetResponseV1>, ClientError> {
290 self.post_json("v1/kv/get", request).await
291 }
292
293 pub async fn query(
299 &self,
300 request: &QueryRequestV1,
301 ) -> Result<ApiResponse<QueryResponseV1>, ClientError> {
302 self.post_json("v1/query", request).await
303 }
304
305 pub async fn download_witness(
312 &self,
313 proof: &ProofV1,
314 ) -> Result<ApiResponse<Vec<u8>>, ClientError> {
315 let expected_path = format!(
316 "/v1/witnesses/{}/{}",
317 proof.checkpoint_sequence, proof.snapshot_digest
318 );
319 if proof.witness.path != expected_path {
320 return Err(ClientError::InvalidWitnessReference);
321 }
322 if proof.witness.file_bytes > u64::try_from(self.witness_bytes).unwrap_or(u64::MAX) {
323 return Err(ClientError::ResponseTooLarge {
324 maximum: self.witness_bytes,
325 });
326 }
327 let response = self
328 .request(Method::GET, expected_path.trim_start_matches('/'), true)
329 .send()
330 .await?;
331 if !response.status().is_success() {
332 return Err(self.decode_api_failure(response).await?);
333 }
334 if response.status() != StatusCode::OK {
335 return Err(ClientError::UnexpectedStatus(response.status().as_u16()));
336 }
337 let request_id = request_id(response.headers())?;
338 let expected_digest = format!("blake3={}", proof.snapshot_digest);
339 if single_header(response.headers(), "digest") != Some(expected_digest.as_str()) {
340 return Err(ClientError::WitnessDigestMismatch);
341 }
342 let value = read_bounded(response, self.witness_bytes).await?;
343 if u64::try_from(value.len()) != Ok(proof.witness.file_bytes) {
344 return Err(ClientError::WitnessLengthMismatch);
345 }
346 Ok(ApiResponse { value, request_id })
347 }
348
349 async fn get_json<T: DeserializeOwned>(
350 &self,
351 path: &str,
352 authenticated: bool,
353 ) -> Result<ApiResponse<T>, ClientError> {
354 let response = self
355 .request(Method::GET, path, authenticated)
356 .send()
357 .await?;
358 self.decode_json(response).await
359 }
360
361 async fn post_json<RequestBody: Serialize, ResponseBody: DeserializeOwned>(
362 &self,
363 path: &str,
364 request: &RequestBody,
365 ) -> Result<ApiResponse<ResponseBody>, ClientError> {
366 let response = self
367 .request(Method::POST, path, true)
368 .json(request)
369 .send()
370 .await?;
371 self.decode_json(response).await
372 }
373
374 fn request(&self, method: Method, path: &str, authenticated: bool) -> reqwest::RequestBuilder {
375 let mut request = self.http.request(method, self.endpoint(path));
376 if authenticated && let Some(token) = &self.bearer_token {
377 request = request.header(header::AUTHORIZATION, token.clone());
378 }
379 request
380 }
381
382 fn endpoint(&self, path: &str) -> Url {
383 let mut endpoint = self.base_url.clone();
384 endpoint.set_path(&format!("/{}", path.trim_start_matches('/')));
385 endpoint
386 }
387
388 async fn decode_json<T: DeserializeOwned>(
389 &self,
390 response: reqwest::Response,
391 ) -> Result<ApiResponse<T>, ClientError> {
392 if !response.status().is_success() {
393 return Err(self.decode_api_failure(response).await?);
394 }
395 if response.status() != StatusCode::OK {
396 return Err(ClientError::UnexpectedStatus(response.status().as_u16()));
397 }
398 require_json(response.headers())?;
399 let request_id = request_id(response.headers())?;
400 let encoded = read_bounded(response, self.response_bytes).await?;
401 Ok(ApiResponse {
402 value: serde_json::from_slice(&encoded)?,
403 request_id,
404 })
405 }
406
407 async fn decode_api_failure(
408 &self,
409 response: reqwest::Response,
410 ) -> Result<ClientError, ClientError> {
411 let status = response.status().as_u16();
412 require_json(response.headers())?;
413 let header_request_id = request_id(response.headers())?;
414 let encoded = read_bounded(response, self.response_bytes).await?;
415 let envelope: ErrorV1 = serde_json::from_slice(&encoded)?;
416 if envelope.request_id != header_request_id {
417 return Err(ClientError::RequestIdMismatch);
418 }
419 Ok(ClientError::Api(ApiFailure {
420 status,
421 code: envelope.code,
422 message: envelope.message,
423 request_id: envelope.request_id,
424 }))
425 }
426}
427
428async fn read_bounded(
429 mut response: reqwest::Response,
430 maximum: usize,
431) -> Result<Vec<u8>, ClientError> {
432 if response
433 .content_length()
434 .is_some_and(|length| length > u64::try_from(maximum).unwrap_or(u64::MAX))
435 {
436 return Err(ClientError::ResponseTooLarge { maximum });
437 }
438 let mut encoded = Vec::new();
439 while let Some(chunk) = response.chunk().await? {
440 let next_length = encoded
441 .len()
442 .checked_add(chunk.len())
443 .ok_or(ClientError::ResponseTooLarge { maximum })?;
444 if next_length > maximum {
445 return Err(ClientError::ResponseTooLarge { maximum });
446 }
447 encoded.extend_from_slice(&chunk);
448 }
449 Ok(encoded)
450}
451
452fn require_json(headers: &HeaderMap) -> Result<(), ClientError> {
453 let content_type = single_header(headers, header::CONTENT_TYPE.as_str())
454 .ok_or(ClientError::InvalidContentType)?;
455 let media_type = content_type
456 .split(';')
457 .next()
458 .unwrap_or_default()
459 .trim()
460 .to_ascii_lowercase();
461 if media_type == "application/json"
462 || (media_type.starts_with("application/") && media_type.ends_with("+json"))
463 {
464 Ok(())
465 } else {
466 Err(ClientError::InvalidContentType)
467 }
468}
469
470fn request_id(headers: &HeaderMap) -> Result<String, ClientError> {
471 single_header(headers, "x-request-id")
472 .map(ToOwned::to_owned)
473 .ok_or(ClientError::InvalidRequestId)
474}
475
476fn single_header<'headers>(headers: &'headers HeaderMap, name: &str) -> Option<&'headers str> {
477 let mut values = headers.get_all(name).iter();
478 let value = values.next()?;
479 if values.next().is_some() {
480 return None;
481 }
482 value.to_str().ok()
483}
484
485#[cfg(test)]
486mod tests {
487 use std::time::Duration;
488
489 use super::{ClientBuilder, ClientConfigError};
490
491 #[test]
492 fn builder_accepts_only_bounded_root_http_origins() -> Result<(), ClientConfigError> {
493 ClientBuilder::new("http://127.0.0.1:8787")?.build()?;
494 assert!(matches!(
495 ClientBuilder::new("file:///tmp/hyphae"),
496 Err(ClientConfigError::UnsupportedScheme)
497 ));
498 assert!(matches!(
499 ClientBuilder::new("https://user@example.test/"),
500 Err(ClientConfigError::NonOriginBaseUrl)
501 ));
502 assert!(matches!(
503 ClientBuilder::new("https://example.test/prefix"),
504 Err(ClientConfigError::NonOriginBaseUrl)
505 ));
506 Ok(())
507 }
508
509 #[test]
510 fn builder_rejects_invalid_secrets_and_zero_limits() -> Result<(), ClientConfigError> {
511 assert!(matches!(
512 ClientBuilder::new("http://localhost")?.bearer_token("bad\nsecret"),
513 Err(ClientConfigError::InvalidBearerToken)
514 ));
515 assert!(matches!(
516 ClientBuilder::new("http://localhost")?
517 .timeout(Duration::ZERO)
518 .build(),
519 Err(ClientConfigError::ZeroLimit)
520 ));
521 Ok(())
522 }
523}