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}