Skip to main content

lean_ctx/core/providers/config_provider/
http.rs

1//! HTTP client with pluggable authentication for config-based providers.
2//!
3//! Builds and executes authenticated HTTP requests using ureq based on
4//! the `AuthConfig` from the provider's TOML/JSON definition.
5
6use std::collections::HashMap;
7
8use super::schema::{AuthConfig, ResourceConfig};
9
10/// Resolved auth credentials (env vars already read).
11#[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    /// Resolve auth credentials from environment variables.
23    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    /// Whether auth credentials could be resolved (provider is usable).
75    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
93/// Interpolate `{param}` placeholders in a string with actual values.
94pub 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    // Remove unresolved placeholders (optional params not provided)
100    let re = regex::Regex::new(r"\{[a-zA-Z_][a-zA-Z0-9_]*\}").unwrap();
101    re.replace_all(&result, "").to_string()
102}
103
104/// Build the full URL with query parameters.
105fn 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
143/// Collect all headers (auth + resource-specific + Accept).
144fn 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
175/// Parse the response body into JSON, checking status first.
176fn 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
190/// Execute an HTTP request to the external API.
191///
192/// Handles GET/DELETE (no body) and POST/PUT/PATCH (with empty body) separately
193/// because ureq 3 uses different types for body vs bodyless requests.
194pub 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}", &params),
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}", &params),
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            &params,
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}