Skip to main content

dcap_qvl/
http.rs

1//! HTTP client abstraction used by [`crate::collateral`].
2//!
3//! The `HttpClient` trait keeps `reqwest` (and its types / version) out
4//! of this crate's public API surface. The default-path constructors
5//! ([`with_default_http`](crate::collateral::CollateralClient::<crate::configs::DefaultConfig>::with_default_http),
6//! [`from_env`](crate::collateral::CollateralClient::<crate::configs::DefaultConfig>::from_env))
7//! still use `reqwest` internally, but no public function signature
8//! mentions `reqwest::Client` — so a future `reqwest` major bump is an
9//! internal change, not a breaking one for downstream callers.
10//!
11//! Callers that need a custom HTTP stack (different TLS config,
12//! workspace-pinned `reqwest` major, non-`reqwest` transport, wasm host
13//! fetch, …) implement [`HttpClient`] on their own type and pass it to
14//! [`CollateralClient::new`](crate::collateral::CollateralClient::new).
15//!
16//! The trait is deliberately narrow — it covers only what
17//! [`crate::collateral`] needs: a `GET`, plus access to status, named
18//! headers, and the response body.
19
20use alloc::collections::BTreeMap;
21use alloc::string::{String, ToString};
22use alloc::vec::Vec;
23use anyhow::Result;
24
25/// Owned HTTP response.
26///
27/// Bodies are buffered into memory: the PCCS endpoints used by this crate
28/// return small payloads (≤ a few hundred KiB), so streaming is not worth
29/// the abstraction cost.
30pub struct HttpResponse {
31    /// HTTP status code (e.g. `200`).
32    pub status: u16,
33    /// Response headers. Use [`HttpResponse::header`] for
34    /// case-insensitive lookups; the field itself imposes no
35    /// case-normalization invariant on implementations.
36    pub headers: BTreeMap<String, String>,
37    /// Response body bytes.
38    pub body: Vec<u8>,
39}
40
41impl HttpResponse {
42    /// Case-insensitive header lookup. O(n) over header count — header
43    /// counts are small (typically < 20), so a linear scan is cheaper
44    /// than imposing a normalization invariant on every implementation.
45    pub fn header(&self, name: &str) -> Option<&str> {
46        self.headers
47            .iter()
48            .find(|(k, _)| k.eq_ignore_ascii_case(name))
49            .map(|(_, v)| v.as_str())
50    }
51
52    /// `true` if [`status`](Self::status) is in `200..300`.
53    pub fn is_success(&self) -> bool {
54        (200..300).contains(&self.status)
55    }
56
57    /// Decode the body as UTF-8.
58    pub fn text(&self) -> Result<&str> {
59        Ok(core::str::from_utf8(&self.body)?)
60    }
61}
62
63/// HTTP transport used by [`CollateralClient`](crate::collateral::CollateralClient).
64///
65/// Implementations only need to support `GET`; the crate buffers
66/// responses in memory (see [`HttpResponse`]).
67///
68/// The `async fn` here intentionally has no `Send` bound. Auto-traits
69/// propagate through monomorphization, so callers using a `Send` impl
70/// still get `Send` futures automatically; callers on single-threaded
71/// runtimes don't pay the `Send` bound they don't need.
72#[allow(async_fn_in_trait)]
73pub trait HttpClient {
74    /// Issue a GET request and buffer the full response.
75    async fn get(&self, url: &str) -> Result<HttpResponse>;
76}
77
78/// Opaque `reqwest`-backed [`HttpClient`] adapter.
79///
80/// The type name is `pub` only so it can sit as the default `H` on
81/// [`CollateralClient`](crate::collateral::CollateralClient); the inner
82/// `reqwest::Client` and the constructor are crate-private. Callers
83/// obtain a value only indirectly via
84/// [`with_default_http`](crate::collateral::CollateralClient::<crate::configs::DefaultConfig>::with_default_http)
85/// /
86/// [`from_env`](crate::collateral::CollateralClient::<crate::configs::DefaultConfig>::from_env)
87/// and treat it as an opaque token — `reqwest::Client` does not appear
88/// in any public signature, so a future `reqwest` major bump is an
89/// internal change.
90#[cfg(feature = "reqwest")]
91#[derive(Clone)]
92pub struct ReqwestHttp(reqwest::Client);
93
94#[cfg(feature = "reqwest")]
95impl ReqwestHttp {
96    pub(crate) fn new(client: reqwest::Client) -> Self {
97        Self(client)
98    }
99}
100
101#[cfg(feature = "reqwest")]
102impl HttpClient for ReqwestHttp {
103    async fn get(&self, url: &str) -> Result<HttpResponse> {
104        let resp = self.0.get(url).send().await?;
105        let status = resp.status().as_u16();
106        let headers = resp
107            .headers()
108            .iter()
109            .map(|(name, value)| {
110                let v = value
111                    .to_str()
112                    .map_err(|e| anyhow::anyhow!("Header {name} has non-ASCII value: {e}"))?;
113                Ok::<_, anyhow::Error>((name.as_str().to_string(), v.to_string()))
114            })
115            .collect::<Result<BTreeMap<_, _>>>()?;
116        let body = resp.bytes().await?.to_vec();
117        Ok(HttpResponse {
118            status,
119            headers,
120            body,
121        })
122    }
123}