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/// Test helper that returns preconfigured responses for exact URLs.
78#[derive(Default)]
79pub struct StaticHttpClient {
80    responses: HashMap<String, Value>,
81}
82
83impl StaticHttpClient {
84    /// Create an empty static client.
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Add a response for `url`.
90    pub fn with_response(mut self, url: impl Into<String>, value: Value) -> Self {
91        self.responses.insert(url.into(), value);
92        self
93    }
94}
95
96impl HttpClient for StaticHttpClient {
97    fn get_json(
98        &self,
99        url: &str,
100        _headers: &[(&str, String)],
101    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
102        self.responses
103            .get(url)
104            .cloned()
105            .ok_or_else(|| format!("no static response for {url}").into())
106    }
107
108    fn post_json(
109        &self,
110        url: &str,
111        _headers: &[(&str, String)],
112        _body: &Value,
113    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
114        self.responses
115            .get(url)
116            .cloned()
117            .ok_or_else(|| format!("no static response for {url}").into())
118    }
119}
120
121/// Captured request made through [`RecordingHttpClient`].
122#[derive(Debug, Clone, PartialEq)]
123pub struct RecordedRequest {
124    /// HTTP method.
125    pub method: &'static str,
126    /// Request URL.
127    pub url: String,
128    /// Request headers.
129    pub headers: Vec<(String, String)>,
130    /// Optional JSON body.
131    pub body: Option<Value>,
132}
133
134/// Static client that also records every request it receives.
135#[derive(Clone, Default)]
136pub struct RecordingHttpClient {
137    responses: HashMap<String, Value>,
138    requests: Arc<Mutex<Vec<RecordedRequest>>>,
139}
140
141impl RecordingHttpClient {
142    /// Create an empty recording client.
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Add a response for `url`.
148    pub fn with_response(mut self, url: impl Into<String>, value: Value) -> Self {
149        self.responses.insert(url.into(), value);
150        self
151    }
152
153    /// Return all captured requests.
154    pub fn requests(&self) -> Vec<RecordedRequest> {
155        self.requests
156            .lock()
157            .expect("recording client lock poisoned")
158            .clone()
159    }
160
161    fn record(
162        &self,
163        method: &'static str,
164        url: &str,
165        headers: &[(&str, String)],
166        body: Option<&Value>,
167    ) {
168        self.requests
169            .lock()
170            .expect("recording client lock poisoned")
171            .push(RecordedRequest {
172                method,
173                url: url.to_string(),
174                headers: headers
175                    .iter()
176                    .map(|(name, value)| ((*name).to_string(), value.clone()))
177                    .collect(),
178                body: body.cloned(),
179            });
180    }
181}
182
183impl HttpClient for RecordingHttpClient {
184    fn get_json(
185        &self,
186        url: &str,
187        headers: &[(&str, String)],
188    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
189        self.record("GET", url, headers, None);
190        self.responses
191            .get(url)
192            .cloned()
193            .ok_or_else(|| format!("no static response for {url}").into())
194    }
195
196    fn post_json(
197        &self,
198        url: &str,
199        headers: &[(&str, String)],
200        body: &Value,
201    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
202        self.record("POST", url, headers, Some(body));
203        self.responses
204            .get(url)
205            .cloned()
206            .ok_or_else(|| format!("no static response for {url}").into())
207    }
208}