Skip to main content

huskarl_core/http/
mod.rs

1//! HTTP client and response abstractions.
2//!
3//! This module defines the [`HttpClient`] trait, which decouples the library
4//! from any specific HTTP implementation. Users provide their own client
5//! (e.g. backed by `reqwest`, `hyper`, or a WASM-compatible client) and the
6//! library operates against the trait, usually as `&dyn HttpClient` or
7//! `Arc<dyn HttpClient>`.
8
9mod get;
10#[cfg(feature = "metrics")]
11mod metrics_client;
12
13use std::sync::Arc;
14
15use bytes::Bytes;
16pub(crate) use get::get;
17use http::{HeaderMap, Request, StatusCode};
18#[cfg(feature = "metrics")]
19pub use metrics_client::MetricsHttpClient;
20
21use crate::{
22    error::Error,
23    platform::{MaybeSendBoxFuture, MaybeSendSync},
24};
25
26/// A fully-read HTTP response.
27///
28/// All responses this library consumes are small JSON, JWKS, or metadata
29/// documents, so [`HttpClient::execute`] reads the entire body before
30/// returning — there is no streaming interface.
31#[derive(Clone)]
32pub struct HttpResponse {
33    /// The HTTP status code of the response.
34    pub status: StatusCode,
35    /// The response headers.
36    pub headers: HeaderMap,
37    /// The full response body.
38    pub body: Bytes,
39}
40
41/// The body may contain credentials (e.g. a token response), so only its
42/// length is shown.
43impl std::fmt::Debug for HttpResponse {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("HttpResponse")
46            .field("status", &self.status)
47            .field("headers", &self.headers)
48            .field("body_len", &self.body.len())
49            .finish()
50    }
51}
52
53/// Whether a request is known to be safe to re-send.
54///
55/// Set by the call site, which knows the operation's semantics; consumed by
56/// [`HttpClient`] implementations when classifying transport failures as
57/// retryable. The distinction matters for failures where the request may
58/// have reached the server (a timeout, an interrupted response): re-sending
59/// is only safe when the operation is known not to be affected by a first
60/// attempt the server may already have processed.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Idempotency {
63    /// The request is known to be idempotent: a re-send is not affected by
64    /// whether the server processed an earlier attempt. Transports may mark
65    /// transient failures retryable even when delivery of the first attempt
66    /// is unknown.
67    Idempotent,
68    /// Not known to be idempotent. A first attempt may have consumed
69    /// one-shot state (an authorization code, a rotated refresh token), so
70    /// transports must mark a failure retryable only when the request
71    /// provably never reached the server.
72    Unknown,
73}
74
75/// Executes HTTP requests on behalf of the library, returning a fully-read response.
76///
77/// This trait is dyn-capable: implement it on your client type and the
78/// library consumes it as `&dyn HttpClient` / `Arc<dyn HttpClient>`.
79///
80/// # Implementing
81///
82/// Write the method body as `Box::pin(async move { ... })`. Read the full
83/// response body inside [`execute`](Self::execute), and classify both
84/// request and body-read failures as
85/// [`ErrorKind::Transport`](crate::error::ErrorKind::Transport). Mark a
86/// failure `retryable` only when re-sending the request is known to be
87/// safe: either the request provably never reached the server (a
88/// connection-establishment failure), or the caller declared it
89/// [`Idempotency::Idempotent`] and the failure is transient (a timeout, an
90/// interrupted response). With [`Idempotency::Unknown`], the server may
91/// have processed a first attempt that a re-send would replay, so only
92/// never-delivered failures are retryable.
93///
94/// Implementations should bound the size of the response body they buffer.
95/// Every response is read fully into memory ([`HttpResponse::body`]), and the
96/// endpoints the library calls — JWKS, token, introspection, discovery — may
97/// be attacker-influenced (for example an authorization server resolved from
98/// user input). An unbounded read lets a malicious or compromised endpoint
99/// exhaust memory with a very large or never-ending body, so enforce a size
100/// limit while reading — aborting before the whole body is buffered — and
101/// reject an oversized body as
102/// [`ErrorKind::Protocol`](crate::error::ErrorKind::Protocol) rather than a
103/// retryable transport failure: re-sending only pulls the same oversized body.
104pub trait HttpClient: MaybeSendSync {
105    /// Executes an HTTP request and returns the fully-read response.
106    ///
107    /// # Arguments
108    ///
109    /// * `request`: The `http::Request` to be executed. The body is provided as `bytes::Bytes`.
110    /// * `idempotency`: Whether the request is known to be safe to re-send,
111    ///   used to classify retryability of transport failures.
112    fn execute(
113        &self,
114        request: Request<Bytes>,
115        idempotency: Idempotency,
116    ) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>>;
117
118    /// Indicates whether this client uses mTLS for authentication.
119    ///
120    /// If true, grants should prefer to use mTLS endpoint aliases
121    /// (RFC 8705 §5) when making requests to the authorization server.
122    fn uses_mtls(&self) -> bool {
123        false
124    }
125}
126
127impl<T: HttpClient + ?Sized> HttpClient for &T {
128    fn execute(
129        &self,
130        request: Request<Bytes>,
131        idempotency: Idempotency,
132    ) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>> {
133        (**self).execute(request, idempotency)
134    }
135
136    fn uses_mtls(&self) -> bool {
137        (**self).uses_mtls()
138    }
139}
140
141impl<T: HttpClient + ?Sized> HttpClient for Box<T> {
142    fn execute(
143        &self,
144        request: Request<Bytes>,
145        idempotency: Idempotency,
146    ) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>> {
147        (**self).execute(request, idempotency)
148    }
149
150    fn uses_mtls(&self) -> bool {
151        (**self).uses_mtls()
152    }
153}
154
155impl<T: HttpClient + ?Sized> HttpClient for Arc<T> {
156    fn execute(
157        &self,
158        request: Request<Bytes>,
159        idempotency: Idempotency,
160    ) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>> {
161        (**self).execute(request, idempotency)
162    }
163
164    fn uses_mtls(&self) -> bool {
165        (**self).uses_mtls()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::error::ErrorKind;
173
174    struct FakeClient {
175        status: StatusCode,
176        body: &'static str,
177    }
178
179    impl HttpClient for FakeClient {
180        fn execute(
181            &self,
182            _request: Request<Bytes>,
183            _idempotency: Idempotency,
184        ) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>> {
185            Box::pin(async move {
186                Ok(HttpResponse {
187                    status: self.status,
188                    headers: HeaderMap::new(),
189                    body: Bytes::from_static(self.body.as_bytes()),
190                })
191            })
192        }
193    }
194
195    #[derive(Debug, serde::Deserialize)]
196    struct Doc {
197        value: u32,
198    }
199
200    fn uri() -> http::Uri {
201        http::Uri::from_static("https://example.com/doc")
202    }
203
204    #[tokio::test]
205    async fn get_deserializes_through_dyn_client() {
206        let client = FakeClient {
207            status: StatusCode::OK,
208            body: r#"{"value": 7}"#,
209        };
210        let dyn_client: &dyn HttpClient = &client;
211        let doc: Doc = get(dyn_client, uri(), HeaderMap::new())
212            .await
213            .expect("get succeeds");
214        assert_eq!(doc.value, 7);
215    }
216
217    #[tokio::test]
218    async fn get_classifies_bad_status_as_protocol() {
219        let client = FakeClient {
220            status: StatusCode::INTERNAL_SERVER_ERROR,
221            body: "",
222        };
223        let err = get::<Doc>(&client, uri(), HeaderMap::new())
224            .await
225            .expect_err("non-2xx fails");
226        assert_eq!(err.kind(), ErrorKind::Protocol);
227        assert!(!err.is_retryable());
228    }
229
230    #[tokio::test]
231    async fn erased_clients_still_implement_the_trait() {
232        fn takes_impl(_: &impl HttpClient) {}
233
234        let arc: Arc<dyn HttpClient> = Arc::new(FakeClient {
235            status: StatusCode::OK,
236            body: r#"{"value": 1}"#,
237        });
238        // An already-erased client satisfies `impl HttpClient` bounds via the
239        // blanket impls, and still dispatches correctly.
240        takes_impl(&arc);
241        let boxed: Box<dyn HttpClient> = Box::new(FakeClient {
242            status: StatusCode::OK,
243            body: r#"{"value": 2}"#,
244        });
245        takes_impl(&boxed);
246        let doc: Doc = get(&arc, uri(), HeaderMap::new()).await.expect("get");
247        assert_eq!(doc.value, 1);
248    }
249}