Skip to main content

auths_infra_http/
registry_client.rs

1use auths_core::ports::network::{NetworkError, RateLimitInfo, RegistryClient, RegistryResponse};
2use std::future::Future;
3use std::time::Duration;
4
5use crate::error::map_reqwest_error;
6use crate::request::{
7    build_get_request, build_post_request, execute_request, parse_response_bytes,
8};
9use crate::ssrf::{SsrfBlocked, guard_registry_url};
10use crate::{default_client_builder, default_http_client};
11
12/// Translate a refused registry URL into a `NetworkError` for the network port.
13fn map_ssrf_error(err: SsrfBlocked) -> NetworkError {
14    NetworkError::InvalidResponse {
15        detail: err.to_string(),
16    }
17}
18
19/// HTTP-backed implementation of `RegistryClient`.
20///
21/// Fetches and pushes data to a remote registry service for identity
22/// and attestation synchronization.
23///
24/// Usage:
25/// ```ignore
26/// use auths_infra_http::HttpRegistryClient;
27///
28/// let client = HttpRegistryClient::new();
29/// let data = client.fetch_registry_data("https://registry.example.com", "identities/abc").await?;
30/// ```
31pub struct HttpRegistryClient {
32    client: reqwest::Client,
33}
34
35impl HttpRegistryClient {
36    pub fn new() -> Self {
37        Self {
38            client: default_http_client(),
39        }
40    }
41
42    /// Create an `HttpRegistryClient` with explicit connect and request timeouts.
43    ///
44    /// Args:
45    /// * `connect_timeout`: Maximum time to establish a TCP connection.
46    /// * `request_timeout`: Maximum total time for the request to complete.
47    ///
48    /// Usage:
49    /// ```ignore
50    /// let client = HttpRegistryClient::new_with_timeouts(
51    ///     Duration::from_secs(30),
52    ///     Duration::from_secs(60),
53    /// );
54    /// ```
55    // INVARIANT: reqwest builder with these settings cannot fail
56    #[allow(clippy::expect_used)]
57    pub fn new_with_timeouts(connect_timeout: Duration, request_timeout: Duration) -> Self {
58        let client = default_client_builder()
59            .connect_timeout(connect_timeout)
60            .timeout(request_timeout)
61            .build()
62            .expect("failed to build HTTP client");
63        Self { client }
64    }
65}
66
67impl Default for HttpRegistryClient {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl RegistryClient for HttpRegistryClient {
74    fn fetch_registry_data(
75        &self,
76        registry_url: &str,
77        path: &str,
78    ) -> impl Future<Output = Result<Vec<u8>, NetworkError>> + Send {
79        let guard = guard_registry_url(registry_url).map_err(map_ssrf_error);
80        let url = format!("{}/{}", registry_url.trim_end_matches('/'), path);
81        let request = build_get_request(&self.client, &url);
82
83        async move {
84            guard?;
85            let response = execute_request(request, registry_url).await?;
86            parse_response_bytes(response, path).await
87        }
88    }
89
90    fn push_registry_data(
91        &self,
92        registry_url: &str,
93        path: &str,
94        data: &[u8],
95    ) -> impl Future<Output = Result<(), NetworkError>> + Send {
96        let guard = guard_registry_url(registry_url).map_err(map_ssrf_error);
97        let url = format!("{}/{}", registry_url.trim_end_matches('/'), path);
98        let request = build_post_request(&self.client, &url, data.to_vec());
99
100        async move {
101            guard?;
102            let response = execute_request(request, registry_url).await?;
103            let _ = parse_response_bytes(response, path).await?;
104            Ok(())
105        }
106    }
107
108    fn post_json(
109        &self,
110        registry_url: &str,
111        path: &str,
112        json_body: &[u8],
113    ) -> impl Future<Output = Result<RegistryResponse, NetworkError>> + Send {
114        let guard = guard_registry_url(registry_url).map_err(map_ssrf_error);
115        let url = format!("{}/{}", registry_url.trim_end_matches('/'), path);
116        let request = self
117            .client
118            .post(&url)
119            .header("Content-Type", "application/json")
120            .body(json_body.to_vec());
121        let endpoint = registry_url.to_string();
122
123        async move {
124            guard?;
125            let response = request
126                .send()
127                .await
128                .map_err(|e| map_reqwest_error(e, &endpoint))?;
129            let status = response.status().as_u16();
130            let rate_limit = extract_rate_limit_headers(&response);
131            let body = response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
132                NetworkError::InvalidResponse {
133                    detail: e.to_string(),
134                }
135            })?;
136            Ok(RegistryResponse {
137                status,
138                body,
139                rate_limit,
140            })
141        }
142    }
143}
144
145fn extract_rate_limit_headers(response: &reqwest::Response) -> Option<RateLimitInfo> {
146    let headers = response.headers();
147    let limit = headers
148        .get("x-ratelimit-limit")
149        .and_then(|v| v.to_str().ok())
150        .and_then(|s| s.parse::<i32>().ok());
151    let remaining = headers
152        .get("x-ratelimit-remaining")
153        .and_then(|v| v.to_str().ok())
154        .and_then(|s| s.parse::<i32>().ok());
155    let reset = headers
156        .get("x-ratelimit-reset")
157        .and_then(|v| v.to_str().ok())
158        .and_then(|s| s.parse::<i64>().ok());
159    let tier = headers
160        .get("x-ratelimit-tier")
161        .and_then(|v| v.to_str().ok())
162        .map(String::from);
163
164    if limit.is_some() || remaining.is_some() || reset.is_some() || tier.is_some() {
165        Some(RateLimitInfo {
166            limit,
167            remaining,
168            reset,
169            tier,
170        })
171    } else {
172        None
173    }
174}