lean_ctx/core/providers/config_provider/
http.rs1use std::collections::HashMap;
7
8use super::schema::{AuthConfig, ResourceConfig};
9
10#[derive(Debug, Clone)]
12pub enum ResolvedAuth {
13 Bearer(String),
14 ApiKeyHeader { header: String, value: String },
15 ApiKeyQuery { param: String, value: String },
16 Basic { username: String, password: String },
17 CustomHeader { header: String, value: String },
18 None,
19}
20
21impl ResolvedAuth {
22 pub fn from_config(auth: &AuthConfig) -> Result<Self, String> {
24 match auth {
25 AuthConfig::Bearer { token_env } => {
26 let token = read_env(token_env)?;
27 Ok(Self::Bearer(token))
28 }
29 AuthConfig::ApiKey {
30 key_env,
31 header_name,
32 query_param,
33 } => {
34 let key = read_env(key_env)?;
35 if let Some(header) = header_name {
36 Ok(Self::ApiKeyHeader {
37 header: header.clone(),
38 value: key,
39 })
40 } else if let Some(param) = query_param {
41 Ok(Self::ApiKeyQuery {
42 param: param.clone(),
43 value: key,
44 })
45 } else {
46 Ok(Self::ApiKeyHeader {
47 header: "X-Api-Key".into(),
48 value: key,
49 })
50 }
51 }
52 AuthConfig::Basic {
53 username_env,
54 password_env,
55 } => {
56 let username = read_env(username_env)?;
57 let password = read_env(password_env)?;
58 Ok(Self::Basic { username, password })
59 }
60 AuthConfig::Header {
61 header_name,
62 value_env,
63 } => {
64 let value = read_env(value_env)?;
65 Ok(Self::CustomHeader {
66 header: header_name.clone(),
67 value,
68 })
69 }
70 AuthConfig::None => Ok(Self::None),
71 }
72 }
73
74 pub fn is_available(auth: &AuthConfig) -> bool {
76 match auth {
77 AuthConfig::Bearer { token_env } => std::env::var(token_env).is_ok(),
78 AuthConfig::ApiKey { key_env, .. } => std::env::var(key_env).is_ok(),
79 AuthConfig::Basic {
80 username_env,
81 password_env,
82 } => std::env::var(username_env).is_ok() && std::env::var(password_env).is_ok(),
83 AuthConfig::Header { value_env, .. } => std::env::var(value_env).is_ok(),
84 AuthConfig::None => true,
85 }
86 }
87}
88
89fn read_env(var: &str) -> Result<String, String> {
90 std::env::var(var).map_err(|_| format!("Environment variable '{var}' not set"))
91}
92
93pub fn interpolate(template: &str, params: &HashMap<String, String>) -> String {
95 let mut result = template.to_string();
96 for (key, value) in params {
97 result = result.replace(&format!("{{{key}}}"), value);
98 }
99 let re = regex::Regex::new(r"\{[a-zA-Z_][a-zA-Z0-9_]*\}").unwrap();
101 re.replace_all(&result, "").to_string()
102}
103
104fn build_url(
106 base_url: &str,
107 resource: &ResourceConfig,
108 interp_params: &HashMap<String, String>,
109 auth: &ResolvedAuth,
110) -> String {
111 let path = interpolate(&resource.path, interp_params);
112 let base = base_url.trim_end_matches('/');
113 let mut url = format!("{base}{path}");
114
115 let mut query_parts: Vec<String> = Vec::new();
116 for (key, val_template) in &resource.query_params {
117 let val = interpolate(val_template, interp_params);
118 if !val.is_empty() {
119 query_parts.push(format!(
120 "{}={}",
121 urlencoding::encode(key),
122 urlencoding::encode(&val)
123 ));
124 }
125 }
126
127 if let ResolvedAuth::ApiKeyQuery { param, value } = auth {
128 query_parts.push(format!(
129 "{}={}",
130 urlencoding::encode(param),
131 urlencoding::encode(value)
132 ));
133 }
134
135 if !query_parts.is_empty() {
136 url.push('?');
137 url.push_str(&query_parts.join("&"));
138 }
139
140 url
141}
142
143fn collect_headers(
145 auth: &ResolvedAuth,
146 resource_headers: &HashMap<String, String>,
147) -> Vec<(String, String)> {
148 let mut headers = Vec::new();
149
150 match auth {
151 ResolvedAuth::Bearer(token) => {
152 headers.push(("Authorization".into(), format!("Bearer {token}")));
153 }
154 ResolvedAuth::ApiKeyHeader { header, value }
155 | ResolvedAuth::CustomHeader { header, value } => {
156 headers.push((header.clone(), value.clone()));
157 }
158 ResolvedAuth::Basic { username, password } => {
159 let encoded = crate::core::providers::config_provider::base64_encode(
160 format!("{username}:{password}").as_bytes(),
161 );
162 headers.push(("Authorization".into(), format!("Basic {encoded}")));
163 }
164 ResolvedAuth::ApiKeyQuery { .. } | ResolvedAuth::None => {}
165 }
166
167 for (key, value) in resource_headers {
168 headers.push((key.clone(), value.clone()));
169 }
170
171 headers.push(("Accept".into(), "application/json".into()));
172 headers
173}
174
175fn parse_response(status: u16, body: &str, url: &str) -> Result<serde_json::Value, String> {
177 if !(200..300).contains(&status) {
178 return Err(format!(
179 "API returned status {status} for {}",
180 url.split('?').next().unwrap_or(url)
181 ));
182 }
183 serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))
184}
185
186fn status_to_u16(status: ureq::http::StatusCode) -> u16 {
187 status.as_u16()
188}
189
190pub fn execute_request(
195 base_url: &str,
196 resource: &ResourceConfig,
197 auth: &ResolvedAuth,
198 interp_params: &HashMap<String, String>,
199) -> Result<serde_json::Value, String> {
200 let url = build_url(base_url, resource, interp_params, auth);
201 let headers = collect_headers(auth, &resource.headers);
202 let method = resource.method.to_uppercase();
203
204 let (status, body) = match method.as_str() {
205 "POST" | "PUT" | "PATCH" => {
206 let mut req = match method.as_str() {
207 "PUT" => ureq::put(&url),
208 "PATCH" => ureq::patch(&url),
209 _ => ureq::post(&url),
210 };
211 for (k, v) in &headers {
212 req = req.header(k, v);
213 }
214 let res = req
215 .send_empty()
216 .map_err(|e| format!("HTTP request failed: {e}"))?;
217 let st = status_to_u16(res.status());
218 let b = res
219 .into_body()
220 .read_to_string()
221 .map_err(|e| format!("Failed to read response body: {e}"))?;
222 (st, b)
223 }
224 _ => {
225 let mut req = if method == "DELETE" {
226 ureq::delete(&url)
227 } else {
228 ureq::get(&url)
229 };
230 for (k, v) in &headers {
231 req = req.header(k, v);
232 }
233 let res = req
234 .call()
235 .map_err(|e| format!("HTTP request failed: {e}"))?;
236 let st = status_to_u16(res.status());
237 let b = res
238 .into_body()
239 .read_to_string()
240 .map_err(|e| format!("Failed to read response body: {e}"))?;
241 (st, b)
242 }
243 };
244
245 parse_response(status, &body, &url)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn interpolate_replaces_known_params() {
254 let mut params = HashMap::new();
255 params.insert("limit".into(), "10".into());
256 params.insert("state".into(), "open".into());
257 params.insert("owner".into(), "acme".into());
258 assert_eq!(
259 interpolate("/repos/{owner}/issues?limit={limit}&state={state}", ¶ms),
260 "/repos/acme/issues?limit=10&state=open"
261 );
262 }
263
264 #[test]
265 fn interpolate_removes_unresolved_placeholders() {
266 let params = HashMap::new();
267 assert_eq!(
268 interpolate("/items?filter={filter}", ¶ms),
269 "/items?filter="
270 );
271 }
272
273 #[test]
274 fn build_url_with_query_params() {
275 let resource = ResourceConfig {
276 method: "GET".into(),
277 path: "/issues".into(),
278 query_params: {
279 let mut m = HashMap::new();
280 m.insert("state".into(), "{state}".into());
281 m.insert("per_page".into(), "{limit}".into());
282 m
283 },
284 headers: HashMap::new(),
285 response: super::super::schema::ResponseConfig {
286 root: None,
287 mapping: super::super::schema::FieldMapping {
288 id: "id".into(),
289 title: "title".into(),
290 body: None,
291 state: None,
292 author: None,
293 url: None,
294 labels: None,
295 created_at: None,
296 updated_at: None,
297 },
298 },
299 };
300 let mut params = HashMap::new();
301 params.insert("state".into(), "open".into());
302 params.insert("limit".into(), "25".into());
303
304 let url = build_url(
305 "https://api.example.com",
306 &resource,
307 ¶ms,
308 &ResolvedAuth::None,
309 );
310 assert!(url.starts_with("https://api.example.com/issues?"));
311 assert!(url.contains("state=open"));
312 assert!(url.contains("per_page=25"));
313 }
314
315 #[test]
316 fn build_url_with_api_key_query() {
317 let resource = ResourceConfig {
318 method: "GET".into(),
319 path: "/data".into(),
320 query_params: HashMap::new(),
321 headers: HashMap::new(),
322 response: super::super::schema::ResponseConfig {
323 root: None,
324 mapping: super::super::schema::FieldMapping {
325 id: "id".into(),
326 title: "name".into(),
327 body: None,
328 state: None,
329 author: None,
330 url: None,
331 labels: None,
332 created_at: None,
333 updated_at: None,
334 },
335 },
336 };
337 let auth = ResolvedAuth::ApiKeyQuery {
338 param: "api_key".into(),
339 value: "secret123".into(),
340 };
341 let url = build_url("https://api.example.com", &resource, &HashMap::new(), &auth);
342 assert!(url.contains("api_key=secret123"));
343 }
344
345 #[test]
346 fn resolved_auth_none_always_available() {
347 assert!(ResolvedAuth::is_available(&AuthConfig::None));
348 }
349
350 #[test]
351 fn resolved_auth_bearer_unavailable_without_env() {
352 let auth = AuthConfig::Bearer {
353 token_env: "LEAN_CTX_TEST_NONEXISTENT_TOKEN_12345".into(),
354 };
355 assert!(!ResolvedAuth::is_available(&auth));
356 }
357}