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}