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 reqwest defaults.
33    pub fn new() -> Self {
34        Self {
35            client: reqwest::blocking::Client::new(),
36        }
37    }
38}
39
40impl Default for ReqwestHttpClient {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl HttpClient for ReqwestHttpClient {
47    fn get_json(
48        &self,
49        url: &str,
50        headers: &[(&str, String)],
51    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
52        let mut req = self.client.get(url);
53        for (name, value) in headers {
54            req = req.header(*name, value);
55        }
56        Ok(req.send()?.error_for_status()?.json()?)
57    }
58
59    fn post_json(
60        &self,
61        url: &str,
62        headers: &[(&str, String)],
63        body: &Value,
64    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
65        let mut req = self.client.post(url).json(body);
66        for (name, value) in headers {
67            req = req.header(*name, value);
68        }
69        Ok(req.send()?.error_for_status()?.json()?)
70    }
71}
72
73/// Test helper that returns preconfigured responses for exact URLs.
74#[derive(Default)]
75pub struct StaticHttpClient {
76    responses: HashMap<String, Value>,
77}
78
79impl StaticHttpClient {
80    /// Create an empty static client.
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Add a response for `url`.
86    pub fn with_response(mut self, url: impl Into<String>, value: Value) -> Self {
87        self.responses.insert(url.into(), value);
88        self
89    }
90}
91
92impl HttpClient for StaticHttpClient {
93    fn get_json(
94        &self,
95        url: &str,
96        _headers: &[(&str, String)],
97    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
98        self.responses
99            .get(url)
100            .cloned()
101            .ok_or_else(|| format!("no static response for {url}").into())
102    }
103
104    fn post_json(
105        &self,
106        url: &str,
107        _headers: &[(&str, String)],
108        _body: &Value,
109    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
110        self.responses
111            .get(url)
112            .cloned()
113            .ok_or_else(|| format!("no static response for {url}").into())
114    }
115}
116
117/// Captured request made through [`RecordingHttpClient`].
118#[derive(Debug, Clone, PartialEq)]
119pub struct RecordedRequest {
120    /// HTTP method.
121    pub method: &'static str,
122    /// Request URL.
123    pub url: String,
124    /// Request headers.
125    pub headers: Vec<(String, String)>,
126    /// Optional JSON body.
127    pub body: Option<Value>,
128}
129
130/// Static client that also records every request it receives.
131#[derive(Clone, Default)]
132pub struct RecordingHttpClient {
133    responses: HashMap<String, Value>,
134    requests: Arc<Mutex<Vec<RecordedRequest>>>,
135}
136
137impl RecordingHttpClient {
138    /// Create an empty recording client.
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    /// Add a response for `url`.
144    pub fn with_response(mut self, url: impl Into<String>, value: Value) -> Self {
145        self.responses.insert(url.into(), value);
146        self
147    }
148
149    /// Return all captured requests.
150    pub fn requests(&self) -> Vec<RecordedRequest> {
151        self.requests
152            .lock()
153            .expect("recording client lock poisoned")
154            .clone()
155    }
156
157    fn record(
158        &self,
159        method: &'static str,
160        url: &str,
161        headers: &[(&str, String)],
162        body: Option<&Value>,
163    ) {
164        self.requests
165            .lock()
166            .expect("recording client lock poisoned")
167            .push(RecordedRequest {
168                method,
169                url: url.to_string(),
170                headers: headers
171                    .iter()
172                    .map(|(name, value)| ((*name).to_string(), value.clone()))
173                    .collect(),
174                body: body.cloned(),
175            });
176    }
177}
178
179impl HttpClient for RecordingHttpClient {
180    fn get_json(
181        &self,
182        url: &str,
183        headers: &[(&str, String)],
184    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
185        self.record("GET", url, headers, None);
186        self.responses
187            .get(url)
188            .cloned()
189            .ok_or_else(|| format!("no static response for {url}").into())
190    }
191
192    fn post_json(
193        &self,
194        url: &str,
195        headers: &[(&str, String)],
196        body: &Value,
197    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
198        self.record("POST", url, headers, Some(body));
199        self.responses
200            .get(url)
201            .cloned()
202            .ok_or_else(|| format!("no static response for {url}").into())
203    }
204}