Skip to main content

hyphae_client/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Bounded asynchronous Rust client for the public Hyphae HTTP API.
4//!
5//! This crate depends only on public wire contracts. It does not import the
6//! server, engine, storage, query, or retrieval implementations.
7
8use 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/// Successful typed API value and its response correlation identifier.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ApiResponse<T> {
28    /// Decoded public contract value.
29    pub value: T,
30    /// UUID copied from `X-Request-Id`.
31    pub request_id: String,
32}
33
34/// Stable server-declared error received from `/v1`.
35#[derive(Clone, Debug, Error, Eq, PartialEq)]
36#[error("Hyphae API returned HTTP {status} {code} (request {request_id})")]
37pub struct ApiFailure {
38    /// HTTP status code.
39    pub status: u16,
40    /// Stable machine-readable error code.
41    pub code: String,
42    /// Bounded server diagnostic.
43    pub message: String,
44    /// UUID matching the response header.
45    pub request_id: String,
46}
47
48/// Invalid client configuration detected before any network operation.
49#[derive(Clone, Debug, Error, Eq, PartialEq)]
50pub enum ClientConfigError {
51    /// Base URL is not syntactically valid.
52    #[error("invalid Hyphae base URL")]
53    InvalidBaseUrl,
54    /// Only explicit HTTP and HTTPS origins are supported.
55    #[error("Hyphae base URL must use http or https")]
56    UnsupportedScheme,
57    /// Base URL must be one origin without credentials, query, fragment, or path prefix.
58    #[error("Hyphae base URL must be an origin without credentials, query, fragment, or path")]
59    NonOriginBaseUrl,
60    /// Bearer secret cannot be represented safely in one header.
61    #[error("invalid bearer token for an HTTP authorization header")]
62    InvalidBearerToken,
63    /// Every local client bound must be positive.
64    #[error("client timeout and response limits must be nonzero")]
65    ZeroLimit,
66    /// Underlying HTTP client construction failed.
67    #[error("failed to construct HTTP client")]
68    HttpClient,
69}
70
71/// Transport, bound, envelope, or declared API failure.
72#[derive(Debug, Error)]
73pub enum ClientError {
74    /// HTTP transport failed before a complete bounded response arrived.
75    #[error("Hyphae HTTP transport failed: {0}")]
76    Transport(#[from] reqwest::Error),
77    /// Response declared or exceeded a local byte bound.
78    #[error("Hyphae response exceeded local limit {maximum} bytes")]
79    ResponseTooLarge {
80        /// Configured maximum.
81        maximum: usize,
82    },
83    /// A JSON operation returned a non-JSON media type.
84    #[error("Hyphae response did not use a JSON content type")]
85    InvalidContentType,
86    /// JSON response did not match the public versioned contract.
87    #[error("Hyphae response violated the version 1 contract: {0}")]
88    InvalidJson(#[from] serde_json::Error),
89    /// The request ID header was missing, duplicated, or malformed.
90    #[error("Hyphae response has no single valid X-Request-Id header")]
91    InvalidRequestId,
92    /// Error envelope and header correlation identifiers disagree.
93    #[error("Hyphae error envelope request ID differs from its response header")]
94    RequestIdMismatch,
95    /// Server returned a non-success response that conformed to `ErrorV1`.
96    #[error(transparent)]
97    Api(#[from] ApiFailure),
98    /// A successful operation returned an unexpected HTTP status.
99    #[error("Hyphae returned unexpected success status {0}")]
100    UnexpectedStatus(u16),
101    /// Proof witness reference was not canonical for its proof identity.
102    #[error("proof contains a noncanonical witness reference")]
103    InvalidWitnessReference,
104    /// Downloaded witness digest header differs from the proof.
105    #[error("downloaded witness digest header differs from the proof")]
106    WitnessDigestMismatch,
107    /// Downloaded witness length differs from the proof.
108    #[error("downloaded witness length differs from the proof")]
109    WitnessLengthMismatch,
110}
111
112/// Builder for a bounded public API client.
113#[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    /// Parses one root HTTP(S) origin.
125    ///
126    /// # Errors
127    ///
128    /// Rejects malformed URLs, non-HTTP schemes, credentials, query strings,
129    /// fragments, and non-root paths.
130    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    /// Configures an opaque bearer token without retaining a second copy.
154    ///
155    /// # Errors
156    ///
157    /// Rejects values that cannot be represented in one HTTP header.
158    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    /// Sets the complete request/response deadline.
170    pub fn timeout(mut self, timeout: Duration) -> Self {
171        self.timeout = timeout;
172        self
173    }
174
175    /// Sets the maximum complete JSON response bytes.
176    pub fn response_bytes(mut self, maximum: usize) -> Self {
177        self.response_bytes = maximum;
178        self
179    }
180
181    /// Sets the maximum complete snapshot witness bytes.
182    pub fn witness_bytes(mut self, maximum: usize) -> Self {
183        self.witness_bytes = maximum;
184        self
185    }
186
187    /// Constructs the reusable client.
188    ///
189    /// # Errors
190    ///
191    /// Rejects zero limits or an underlying HTTP client configuration error.
192    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/// Reusable bounded client for Hyphae HTTP API version 1.
211#[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    /// Starts a client builder for one root origin.
222    ///
223    /// # Errors
224    ///
225    /// Returns a base-URL validation error.
226    pub fn builder(base_url: &str) -> Result<ClientBuilder, ClientConfigError> {
227        ClientBuilder::new(base_url)
228    }
229
230    /// Reports server capabilities and effective limits.
231    ///
232    /// # Errors
233    ///
234    /// Returns a transport, bound, contract, correlation, or API error.
235    pub async fn capabilities(&self) -> Result<ApiResponse<CapabilitiesV1>, ClientError> {
236        self.get_json("v1/capabilities", false).await
237    }
238
239    /// Reports process liveness.
240    ///
241    /// # Errors
242    ///
243    /// Returns a transport, bound, contract, correlation, or API error.
244    pub async fn liveness(&self) -> Result<ApiResponse<HealthV1>, ClientError> {
245        self.get_json("v1/health/live", false).await
246    }
247
248    /// Reports engine readiness.
249    ///
250    /// # Errors
251    ///
252    /// Returns a transport, bound, contract, correlation, or API error.
253    pub async fn readiness(&self) -> Result<ApiResponse<HealthV1>, ClientError> {
254        self.get_json("v1/health/ready", false).await
255    }
256
257    /// Atomically stores a structured-record batch.
258    ///
259    /// # Errors
260    ///
261    /// Returns a transport, bound, contract, correlation, or API error.
262    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    /// Atomically deletes a binary-key batch.
270    ///
271    /// # Errors
272    ///
273    /// Returns a transport, bound, contract, correlation, or API error.
274    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    /// Gets proven key presence or absence.
282    ///
283    /// # Errors
284    ///
285    /// Returns a transport, bound, contract, correlation, or API error.
286    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    /// Executes a deterministic proof-bearing structured query.
294    ///
295    /// # Errors
296    ///
297    /// Returns a transport, bound, contract, correlation, or API error.
298    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    /// Downloads the exact snapshot witness referenced by a proof.
306    ///
307    /// # Errors
308    ///
309    /// Rejects a noncanonical reference, transport/bound/API failure, or a
310    /// digest header that disagrees with the proof.
311    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}