1use 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(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 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 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
112pub 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 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
123fn 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
162fn 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
194fn 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
209pub 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}", ¶ms),
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}", ¶ms),
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 ¶ms,
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 #[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}