Skip to main content

typesec_integrations/
http.rs

1//! Small synchronous HTTP abstraction used by provider-backed policy engines.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6use serde_json::Value;
7
8/// Minimal HTTP client interface for JSON POST/GET calls.
9pub trait HttpClient: Send + Sync {
10    /// Perform a JSON GET request.
11    fn get_json(
12        &self,
13        url: &str,
14        headers: &[(&str, String)],
15    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>>;
16
17    /// Perform a JSON POST request.
18    fn post_json(
19        &self,
20        url: &str,
21        headers: &[(&str, String)],
22        body: &Value,
23    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>>;
24}
25
26/// `reqwest`-backed implementation of [`HttpClient`].
27pub struct ReqwestHttpClient {
28    client: reqwest::blocking::Client,
29}
30
31impl ReqwestHttpClient {
32    /// Create a client with a 30-second request timeout.
33    ///
34    /// Falls back to reqwest defaults if the timeout-configured builder fails.
35    pub fn new() -> Self {
36        let client = reqwest::blocking::Client::builder()
37            .timeout(std::time::Duration::from_secs(30))
38            .build()
39            .unwrap_or_else(|_| reqwest::blocking::Client::new());
40        Self { client }
41    }
42}
43
44impl Default for ReqwestHttpClient {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl HttpClient for ReqwestHttpClient {
51    fn get_json(
52        &self,
53        url: &str,
54        headers: &[(&str, String)],
55    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
56        let mut req = self.client.get(url);
57        for (name, value) in headers {
58            req = req.header(*name, value);
59        }
60        Ok(req.send()?.error_for_status()?.json()?)
61    }
62
63    fn post_json(
64        &self,
65        url: &str,
66        headers: &[(&str, String)],
67        body: &Value,
68    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
69        let mut req = self.client.post(url).json(body);
70        for (name, value) in headers {
71            req = req.header(*name, value);
72        }
73        Ok(req.send()?.error_for_status()?.json()?)
74    }
75}
76
77/// Look up a preconfigured response for `url`, shared by the test doubles.
78fn canned_response(
79    responses: &HashMap<String, Value>,
80    url: &str,
81) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
82    responses
83        .get(url)
84        .cloned()
85        .ok_or_else(|| format!("no static response for {url}").into())
86}
87
88/// Test helper that returns preconfigured responses for exact URLs.
89#[derive(Default)]
90pub struct StaticHttpClient {
91    responses: HashMap<String, Value>,
92}
93
94impl StaticHttpClient {
95    /// Create an empty static client.
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Add a response for `url`.
101    pub fn with_response(mut self, url: impl Into<String>, value: Value) -> Self {
102        self.responses.insert(url.into(), value);
103        self
104    }
105}
106
107impl HttpClient for StaticHttpClient {
108    fn get_json(
109        &self,
110        url: &str,
111        _headers: &[(&str, String)],
112    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
113        canned_response(&self.responses, url)
114    }
115
116    fn post_json(
117        &self,
118        url: &str,
119        _headers: &[(&str, String)],
120        _body: &Value,
121    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
122        canned_response(&self.responses, url)
123    }
124}
125
126/// Captured request made through [`RecordingHttpClient`].
127#[derive(Debug, Clone, PartialEq)]
128pub struct RecordedRequest {
129    /// HTTP method.
130    pub method: &'static str,
131    /// Request URL.
132    pub url: String,
133    /// Request headers.
134    pub headers: Vec<(String, String)>,
135    /// Optional JSON body.
136    pub body: Option<Value>,
137}
138
139/// Static client that also records every request it receives.
140#[derive(Clone, Default)]
141pub struct RecordingHttpClient {
142    responses: HashMap<String, Value>,
143    requests: Arc<Mutex<Vec<RecordedRequest>>>,
144}
145
146impl RecordingHttpClient {
147    /// Create an empty recording client.
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Add a response for `url`.
153    pub fn with_response(mut self, url: impl Into<String>, value: Value) -> Self {
154        self.responses.insert(url.into(), value);
155        self
156    }
157
158    /// Return all captured requests.
159    pub fn requests(&self) -> Vec<RecordedRequest> {
160        self.requests
161            .lock()
162            .expect("recording client lock poisoned")
163            .clone()
164    }
165
166    fn record(
167        &self,
168        method: &'static str,
169        url: &str,
170        headers: &[(&str, String)],
171        body: Option<&Value>,
172    ) {
173        self.requests
174            .lock()
175            .expect("recording client lock poisoned")
176            .push(RecordedRequest {
177                method,
178                url: url.to_string(),
179                headers: headers
180                    .iter()
181                    .map(|(name, value)| ((*name).to_string(), value.clone()))
182                    .collect(),
183                body: body.cloned(),
184            });
185    }
186}
187
188impl HttpClient for RecordingHttpClient {
189    fn get_json(
190        &self,
191        url: &str,
192        headers: &[(&str, String)],
193    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
194        self.record("GET", url, headers, None);
195        canned_response(&self.responses, url)
196    }
197
198    fn post_json(
199        &self,
200        url: &str,
201        headers: &[(&str, String)],
202        body: &Value,
203    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
204        self.record("POST", url, headers, Some(body));
205        canned_response(&self.responses, url)
206    }
207}