lean_ctx/core/providers/config_provider/
mod.rs1pub mod discovery;
28pub mod extract;
29pub mod http;
30pub mod schema;
31
32use std::collections::HashMap;
33
34use schema::ProviderConfig;
35
36use super::ProviderResult;
37use super::provider_trait::{ContextProvider, ProviderParams};
38use http::ResolvedAuth;
39
40pub struct ConfigProvider {
42 id: &'static str,
43 display_name: &'static str,
44 actions: Vec<&'static str>,
45 config: ProviderConfig,
46}
47
48impl ConfigProvider {
49 pub fn from_config(config: ProviderConfig) -> Result<Self, String> {
54 config.validate()?;
55
56 let id: &'static str = crate::core::providers::intern(config.id.clone());
57 let display_name: &'static str = crate::core::providers::intern(config.name.clone());
58 let actions: Vec<&'static str> = config
59 .resources
60 .keys()
61 .map(|k| crate::core::providers::intern(k.clone()))
62 .collect();
63
64 Ok(Self {
65 id,
66 display_name,
67 actions,
68 config,
69 })
70 }
71
72 fn build_interp_params(params: &ProviderParams) -> HashMap<String, String> {
73 let mut map = HashMap::new();
74 if let Some(ref p) = params.project {
75 map.insert("project".into(), p.clone());
76 }
77 if let Some(ref s) = params.state {
78 map.insert("state".into(), s.clone());
79 }
80 if let Some(limit) = params.limit {
81 map.insert("limit".into(), limit.to_string());
82 }
83 if let Some(ref q) = params.query {
84 map.insert("query".into(), q.clone());
85 }
86 if let Some(ref id) = params.id {
87 map.insert("id".into(), id.clone());
88 }
89 map
90 }
91}
92
93impl ContextProvider for ConfigProvider {
94 fn id(&self) -> &'static str {
95 self.id
96 }
97
98 fn display_name(&self) -> &'static str {
99 self.display_name
100 }
101
102 fn supported_actions(&self) -> &[&str] {
103 &self.actions
104 }
105
106 fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
107 let resource = self.config.resources.get(action).ok_or_else(|| {
108 format!(
109 "Provider '{}': unknown action '{}'. Available: {:?}",
110 self.id,
111 action,
112 self.config.resources.keys().collect::<Vec<_>>()
113 )
114 })?;
115
116 let auth = ResolvedAuth::from_config(&self.config.auth)?;
117 let interp_params = Self::build_interp_params(params);
118
119 let response_json =
120 http::execute_request(&self.config.base_url, resource, &auth, &interp_params)?;
121
122 let items_json =
123 extract::extract_items_array(&response_json, resource.response.root.as_deref())?;
124
125 let limit = params.limit.unwrap_or(50);
126 let total_count = items_json.len();
127 let truncated = total_count > limit;
128
129 let items: Vec<_> = items_json
130 .iter()
131 .take(limit)
132 .filter_map(|item| extract::map_item(item, &resource.response.mapping))
133 .collect();
134
135 Ok(ProviderResult {
136 provider: self.id.to_string(),
137 resource_type: action.to_string(),
138 items,
139 total_count: Some(total_count),
140 truncated,
141 })
142 }
143
144 fn cache_ttl_secs(&self) -> u64 {
145 self.config.cache_ttl_secs
146 }
147
148 fn descriptor(&self) -> super::provider_trait::ConnectorDescriptor {
149 descriptor(
150 self.config
151 .resources
152 .values()
153 .any(|resource| !resource.method.eq_ignore_ascii_case("GET")),
154 )
155 }
156
157 fn reuse_binding(&self, action: &str, _params: &ProviderParams) -> Option<String> {
158 let resource = self.config.resources.get(action)?;
159 if !resource.method.eq_ignore_ascii_case("GET") {
160 return None;
161 }
162 let auth = ResolvedAuth::from_config(&self.config.auth).ok()?;
163 super::provenance::digest(&serde_json::json!({
164 "version": 1, "provider": self.id, "base_url": self.config.base_url,
165 "resource": resource, "credential_digest": auth.binding_digest(),
166 }))
167 }
168
169 fn reacquire_item(
170 &self,
171 action: &str,
172 params: &ProviderParams,
173 item_id: &str,
174 ) -> Result<ProviderResult, String> {
175 if self.reuse_binding(action, params).is_none() {
176 return Err("resource does not support read-only reauthorization".into());
177 }
178 let mut result = self.execute(action, params)?;
179 result.items.retain(|item| item.id == item_id);
180 Ok(result)
181 }
182
183 fn requires_auth(&self) -> bool {
184 !matches!(self.config.auth, schema::AuthConfig::None)
185 }
186
187 fn is_available(&self) -> bool {
188 ResolvedAuth::is_available(&self.config.auth)
189 }
190}
191
192pub(crate) fn base64_encode(data: &[u8]) -> String {
194 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
195 let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
196 for chunk in data.chunks(3) {
197 let b0 = chunk[0] as u32;
198 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
199 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
200 let triple = (b0 << 16) | (b1 << 8) | b2;
201 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
202 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
203 if chunk.len() > 1 {
204 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
205 } else {
206 result.push('=');
207 }
208 if chunk.len() > 2 {
209 result.push(CHARS[(triple & 0x3F) as usize] as char);
210 } else {
211 result.push('=');
212 }
213 }
214 result
215}
216
217pub(crate) fn descriptor(writes: bool) -> super::provider_trait::ConnectorDescriptor {
218 use super::provider_trait::{CapabilityLevel as L, ConnectorDescriptor as C};
219 C {
220 principal_binding: "configured auth (none, bearer, basic or header), digest-bound with base URL and resource",
221 search: L::Unsupported,
222 list: L::Supported,
223 get: L::Partial,
224 delta: L::Unsupported,
225 revalidate_acl: L::Supported,
226 revalidate_revision: L::Supported,
227 delete_event: L::Partial,
228 content_trust: C::UNTRUSTED,
229 write_surface: if writes {
230 "declared POST query resources (explicit configuration, empty body; write methods are refused); \
231 never re-checked or reused"
232 } else {
233 C::NO_WRITES
234 },
235 failure_policy: C::FAIL_CLOSED,
236 notes: "re-check re-runs the GET resource with the current credential and keeps only the stored id \
237 (an item that left the first page reads as no longer accessible); non-GET resources have no \
238 binding and are withheld from governed reuse",
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 fn sample_config() -> ProviderConfig {
247 toml::from_str(
248 r#"
249id = "test-api"
250name = "Test API"
251base_url = "https://api.example.com"
252cache_ttl_secs = 60
253
254[auth]
255type = "none"
256
257[resources.items]
258path = "/items"
259[resources.items.response]
260root = "data"
261[resources.items.response.mapping]
262id = "id"
263title = "name"
264body = "description"
265state = "status"
266"#,
267 )
268 .unwrap()
269 }
270
271 #[test]
272 fn config_provider_from_config() {
273 let provider = ConfigProvider::from_config(sample_config()).unwrap();
274 assert_eq!(provider.id(), "test-api");
275 assert_eq!(provider.display_name(), "Test API");
276 assert_eq!(provider.supported_actions(), &["items"]);
277 assert!(!provider.requires_auth());
278 assert!(provider.is_available());
279 assert_eq!(provider.cache_ttl_secs(), 60);
280 }
281
282 #[test]
283 fn config_provider_rejects_invalid() {
284 let mut cfg = sample_config();
285 cfg.id = String::new();
286 assert!(ConfigProvider::from_config(cfg).is_err());
287 }
288
289 #[test]
290 fn base64_encode_basic_auth() {
291 let encoded = base64_encode(b"user:pass");
292 assert_eq!(encoded, "dXNlcjpwYXNz");
293 }
294
295 #[test]
296 fn base64_encode_padding() {
297 assert_eq!(base64_encode(b"a"), "YQ==");
298 assert_eq!(base64_encode(b"ab"), "YWI=");
299 assert_eq!(base64_encode(b"abc"), "YWJj");
300 }
301
302 #[test]
303 fn build_interp_params_maps_all_fields() {
304 let params = ProviderParams {
305 project: Some("myproject".into()),
306 state: Some("open".into()),
307 limit: Some(10),
308 query: Some("search".into()),
309 id: Some("42".into()),
310 };
311 let map = ConfigProvider::build_interp_params(¶ms);
312 assert_eq!(map.get("project"), Some(&"myproject".into()));
313 assert_eq!(map.get("state"), Some(&"open".into()));
314 assert_eq!(map.get("limit"), Some(&"10".into()));
315 assert_eq!(map.get("query"), Some(&"search".into()));
316 assert_eq!(map.get("id"), Some(&"42".into()));
317 }
318}