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    /// Bind a source to the resolved credential without making credentials
23    /// serializable. Length framing separates fields and authentication kinds.
24    pub(super) fn binding_digest(&self) -> String {
25        let fields: Vec<&str> = match self {
26            Self::Bearer(token) => vec!["bearer", token],
27            Self::ApiKeyHeader { header, value } => vec!["api-key-header", header, value],
28            Self::ApiKeyQuery { param, value } => vec!["api-key-query", param, value],
29            Self::Basic { username, password } => vec!["basic", username, password],
30            Self::CustomHeader { header, value } => vec!["custom-header", header, value],
31            Self::None => vec!["none"],
32        };
33        let mut hash = blake3::Hasher::new_derive_key("leanctx.provider.credential-binding.v1");
34        for field in fields {
35            hash.update(&(field.len() as u64).to_le_bytes());
36            hash.update(field.as_bytes());
37        }
38        hash.finalize().to_hex().to_string()
39    }
40
41    /// Resolve auth credentials from environment variables.
42    pub fn from_config(auth: &AuthConfig) -> Result<Self, String> {
43        match auth {
44            AuthConfig::Bearer { token_env } => {
45                let token = read_env(token_env)?;
46                Ok(Self::Bearer(token))
47            }
48            AuthConfig::ApiKey {
49                key_env,
50                header_name,
51                query_param,
52            } => {
53                let key = read_env(key_env)?;
54                if let Some(header) = header_name {
55                    Ok(Self::ApiKeyHeader {
56                        header: header.clone(),
57                        value: key,
58                    })
59                } else if let Some(param) = query_param {
60                    Ok(Self::ApiKeyQuery {
61                        param: param.clone(),
62                        value: key,
63                    })
64                } else {
65                    Ok(Self::ApiKeyHeader {
66                        header: "X-Api-Key".into(),
67                        value: key,
68                    })
69                }
70            }
71            AuthConfig::Basic {
72                username_env,
73                password_env,
74            } => {
75                let username = read_env(username_env)?;
76                let password = read_env(password_env)?;
77                Ok(Self::Basic { username, password })
78            }
79            AuthConfig::Header {
80                header_name,
81                value_env,
82            } => {
83                let value = read_env(value_env)?;
84                Ok(Self::CustomHeader {
85                    header: header_name.clone(),
86                    value,
87                })
88            }
89            AuthConfig::None => Ok(Self::None),
90        }
91    }
92
93    /// Whether auth credentials could be resolved (provider is usable).
94    pub fn is_available(auth: &AuthConfig) -> bool {
95        match auth {
96            AuthConfig::Bearer { token_env } => std::env::var(token_env).is_ok(),
97            AuthConfig::ApiKey { key_env, .. } => std::env::var(key_env).is_ok(),
98            AuthConfig::Basic {
99                username_env,
100                password_env,
101            } => std::env::var(username_env).is_ok() && std::env::var(password_env).is_ok(),
102            AuthConfig::Header { value_env, .. } => std::env::var(value_env).is_ok(),
103            AuthConfig::None => true,
104        }
105    }
106}
107
108fn read_env(var: &str) -> Result<String, String> {
109    std::env::var(var).map_err(|_| format!("Environment variable '{var}' not set"))
110}
111
112/// Interpolate `{param}` placeholders in a string with actual values.
113pub fn interpolate(template: &str, params: &HashMap<String, String>) -> String {
114    let mut result = template.to_string();
115    for (key, value) in params {
116        result = result.replace(&format!("{{{key}}}"), value);
117    }
118    // Remove unresolved placeholders (optional params not provided)
119    let re = regex::Regex::new(r"\{[a-zA-Z_][a-zA-Z0-9_]*\}").expect("valid placeholder regex");
120    re.replace_all(&result, "").to_string()
121}
122
123/// Build the full URL with query parameters.
124fn build_url(
125    base_url: &str,
126    resource: &ResourceConfig,
127    interp_params: &HashMap<String, String>,
128    auth: &ResolvedAuth,
129) -> String {
130    let path = interpolate(&resource.path, interp_params);
131    let base = base_url.trim_end_matches('/');
132    let mut url = format!("{base}{path}");
133
134    let mut query_parts: Vec<String> = Vec::new();
135    for (key, val_template) in &resource.query_params {
136        let val = interpolate(val_template, interp_params);
137        if !val.is_empty() {
138            query_parts.push(format!(
139                "{}={}",
140                urlencoding::encode(key),
141                urlencoding::encode(&val)
142            ));
143        }
144    }
145
146    if let ResolvedAuth::ApiKeyQuery { param, value } = auth {
147        query_parts.push(format!(
148            "{}={}",
149            urlencoding::encode(param),
150            urlencoding::encode(value)
151        ));
152    }
153
154    if !query_parts.is_empty() {
155        url.push('?');
156        url.push_str(&query_parts.join("&"));
157    }
158
159    url
160}
161
162/// Collect all headers (auth + resource-specific + Accept).
163fn collect_headers(
164    auth: &ResolvedAuth,
165    resource_headers: &HashMap<String, String>,
166) -> Vec<(String, String)> {
167    let mut headers = Vec::new();
168
169    match auth {
170        ResolvedAuth::Bearer(token) => {
171            headers.push(("Authorization".into(), format!("Bearer {token}")));
172        }
173        ResolvedAuth::ApiKeyHeader { header, value }
174        | ResolvedAuth::CustomHeader { header, value } => {
175            headers.push((header.clone(), value.clone()));
176        }
177        ResolvedAuth::Basic { username, password } => {
178            let encoded = crate::core::providers::config_provider::base64_encode(
179                format!("{username}:{password}").as_bytes(),
180            );
181            headers.push(("Authorization".into(), format!("Basic {encoded}")));
182        }
183        ResolvedAuth::ApiKeyQuery { .. } | ResolvedAuth::None => {}
184    }
185
186    for (key, value) in resource_headers {
187        headers.push((key.clone(), value.clone()));
188    }
189
190    headers.push(("Accept".into(), "application/json".into()));
191    headers
192}
193
194/// Parse the response body into JSON, checking status first.
195fn parse_response(status: u16, body: &str, url: &str) -> Result<serde_json::Value, String> {
196    if !(200..300).contains(&status) {
197        return Err(format!(
198            "API returned status {status} for {}",
199            url.split('?').next().unwrap_or(url)
200        ));
201    }
202    serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))
203}
204
205fn status_to_u16(status: ureq::http::StatusCode) -> u16 {
206    status.as_u16()
207}
208
209/// Execute an HTTP request to the external API.
210///
211/// Only the read methods of [`super::schema::READ_METHODS`] are sent: GET, and
212/// POST with an empty body (ureq 3 uses different types for body vs bodyless
213/// requests). Any other method is refused before a request exists, even if
214/// validation was bypassed.
215pub fn execute_request(
216    base_url: &str,
217    resource: &ResourceConfig,
218    auth: &ResolvedAuth,
219    interp_params: &HashMap<String, String>,
220) -> Result<serde_json::Value, String> {
221    let method = resource.method.to_uppercase();
222    if !super::schema::READ_METHODS.contains(&method.as_str()) {
223        return Err(format!(
224            "method '{}' is not a read method; connectors are read-only",
225            resource.method
226        ));
227    }
228    let url = build_url(base_url, resource, interp_params, auth);
229    let headers = collect_headers(auth, &resource.headers);
230    let agent = crate::core::providers::hardened_http::hardened_agent();
231
232    let res = if method == "POST" {
233        let mut req = agent.post(&url);
234        for (k, v) in &headers {
235            req = req.header(k, v);
236        }
237        req.send_empty()
238    } else {
239        let mut req = agent.get(&url);
240        for (k, v) in &headers {
241            req = req.header(k, v);
242        }
243        req.call()
244    }
245    .map_err(|e| format!("HTTP request failed: {e}"))?;
246    let status = status_to_u16(res.status());
247    if !(200..300).contains(&status) {
248        return Err(format!("API returned status {status}"));
249    }
250    let body = res
251        .into_body()
252        .read_to_string()
253        .map_err(|e| format!("Failed to read response body: {e}"))?;
254
255    parse_response(status, &body, &url)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn interpolate_replaces_known_params() {
264        let mut params = HashMap::new();
265        params.insert("limit".into(), "10".into());
266        params.insert("state".into(), "open".into());
267        params.insert("owner".into(), "acme".into());
268        assert_eq!(
269            interpolate("/repos/{owner}/issues?limit={limit}&state={state}", &params),
270            "/repos/acme/issues?limit=10&state=open"
271        );
272    }
273
274    #[test]
275    fn interpolate_removes_unresolved_placeholders() {
276        let params = HashMap::new();
277        assert_eq!(
278            interpolate("/items?filter={filter}", &params),
279            "/items?filter="
280        );
281    }
282
283    #[test]
284    fn build_url_with_query_params() {
285        let resource = ResourceConfig {
286            method: "GET".into(),
287            path: "/issues".into(),
288            query_params: {
289                let mut m = HashMap::new();
290                m.insert("state".into(), "{state}".into());
291                m.insert("per_page".into(), "{limit}".into());
292                m
293            },
294            headers: HashMap::new(),
295            response: super::super::schema::ResponseConfig {
296                root: None,
297                mapping: super::super::schema::FieldMapping {
298                    id: "id".into(),
299                    title: "title".into(),
300                    body: None,
301                    state: None,
302                    author: None,
303                    url: None,
304                    labels: None,
305                    created_at: None,
306                    updated_at: None,
307                },
308            },
309        };
310        let mut params = HashMap::new();
311        params.insert("state".into(), "open".into());
312        params.insert("limit".into(), "25".into());
313
314        let url = build_url(
315            "https://api.example.com",
316            &resource,
317            &params,
318            &ResolvedAuth::None,
319        );
320        assert!(url.starts_with("https://api.example.com/issues?"));
321        assert!(url.contains("state=open"));
322        assert!(url.contains("per_page=25"));
323    }
324
325    /// A write method never becomes a request, even on a config that skipped
326    /// validation: the refusal comes before any URL is built or sent (the
327    /// unroutable host would otherwise give a transport error).
328    #[test]
329    fn write_methods_are_refused_before_any_request() {
330        for method in ["PUT", "PATCH", "DELETE", "delete"] {
331            let resource = ResourceConfig {
332                method: method.into(),
333                path: "/items".into(),
334                query_params: HashMap::new(),
335                headers: HashMap::new(),
336                response: super::super::schema::ResponseConfig {
337                    root: None,
338                    mapping: super::super::schema::FieldMapping {
339                        id: "id".into(),
340                        title: "title".into(),
341                        body: None,
342                        state: None,
343                        author: None,
344                        url: None,
345                        labels: None,
346                        created_at: None,
347                        updated_at: None,
348                    },
349                },
350            };
351            let err = execute_request(
352                "https://unroutable.invalid",
353                &resource,
354                &ResolvedAuth::None,
355                &HashMap::new(),
356            )
357            .unwrap_err();
358            assert!(err.contains("connectors are read-only"), "{method}: {err}");
359        }
360    }
361
362    #[test]
363    fn build_url_with_api_key_query() {
364        let resource = ResourceConfig {
365            method: "GET".into(),
366            path: "/data".into(),
367            query_params: HashMap::new(),
368            headers: HashMap::new(),
369            response: super::super::schema::ResponseConfig {
370                root: None,
371                mapping: super::super::schema::FieldMapping {
372                    id: "id".into(),
373                    title: "name".into(),
374                    body: None,
375                    state: None,
376                    author: None,
377                    url: None,
378                    labels: None,
379                    created_at: None,
380                    updated_at: None,
381                },
382            },
383        };
384        let auth = ResolvedAuth::ApiKeyQuery {
385            param: "api_key".into(),
386            value: "secret123".into(),
387        };
388        let url = build_url("https://api.example.com", &resource, &HashMap::new(), &auth);
389        assert!(url.contains("api_key=secret123"));
390    }
391
392    #[test]
393    fn resolved_auth_none_always_available() {
394        assert!(ResolvedAuth::is_available(&AuthConfig::None));
395    }
396
397    #[test]
398    fn resolved_auth_bearer_unavailable_without_env() {
399        let auth = AuthConfig::Bearer {
400            token_env: "LEAN_CTX_TEST_NONEXISTENT_TOKEN_12345".into(),
401        };
402        assert!(!ResolvedAuth::is_available(&auth));
403    }
404}