lean_ctx/core/providers/config_provider/
schema.rs1use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct ProviderConfig {
13 pub id: String,
14 pub name: String,
15 pub base_url: String,
16 #[serde(default)]
17 pub auth: AuthConfig,
18 #[serde(default = "default_cache_ttl")]
19 pub cache_ttl_secs: u64,
20 pub resources: HashMap<String, ResourceConfig>,
21}
22
23fn default_cache_ttl() -> u64 {
24 120
25}
26
27#[derive(Debug, Clone, Default, Deserialize, Serialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum AuthConfig {
31 Bearer { token_env: String },
33 ApiKey {
35 key_env: String,
36 #[serde(default)]
38 header_name: Option<String>,
39 #[serde(default)]
41 query_param: Option<String>,
42 },
43 Basic {
45 username_env: String,
46 password_env: String,
47 },
48 Header {
50 header_name: String,
51 value_env: String,
52 },
53 #[default]
55 None,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct ResourceConfig {
61 #[serde(default = "default_method")]
63 pub method: String,
64 pub path: String,
66 #[serde(default)]
68 pub query_params: HashMap<String, String>,
69 #[serde(default)]
71 pub headers: HashMap<String, String>,
72 pub response: ResponseConfig,
74}
75
76fn default_method() -> String {
77 "GET".into()
78}
79
80pub const READ_METHODS: [&str; 2] = ["GET", "POST"];
86
87#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct ResponseConfig {
90 #[serde(default)]
93 pub root: Option<String>,
94 pub mapping: FieldMapping,
96}
97
98#[derive(Debug, Clone, Deserialize, Serialize)]
102pub struct FieldMapping {
103 pub id: String,
104 pub title: String,
105 #[serde(default)]
106 pub body: Option<String>,
107 #[serde(default)]
108 pub state: Option<String>,
109 #[serde(default)]
110 pub author: Option<String>,
111 #[serde(default)]
112 pub url: Option<String>,
113 #[serde(default)]
115 pub labels: Option<String>,
116 #[serde(default)]
117 pub created_at: Option<String>,
118 #[serde(default)]
119 pub updated_at: Option<String>,
120}
121
122impl ProviderConfig {
123 pub fn validate(&self) -> Result<(), String> {
125 if self.id.is_empty() {
126 return Err("Provider config: 'id' must not be empty".into());
127 }
128 if self.base_url.is_empty() {
129 return Err("Provider config: 'base_url' must not be empty".into());
130 }
131 if self.resources.is_empty() {
132 return Err(format!(
133 "Provider '{}': must define at least one resource",
134 self.id
135 ));
136 }
137 for (name, res) in &self.resources {
138 if res.path.is_empty() {
139 return Err(format!(
140 "Provider '{}' resource '{}': 'path' must not be empty",
141 self.id, name
142 ));
143 }
144 let method = res.method.to_uppercase();
145 if matches!(method.as_str(), "PUT" | "PATCH" | "DELETE") {
146 return Err(format!(
147 "Provider '{}' resource '{}': method '{}' writes to the source; \
148 connectors are read-only, use GET (or POST for a search/query endpoint)",
149 self.id, name, res.method
150 ));
151 }
152 if !READ_METHODS.contains(&method.as_str()) {
153 return Err(format!(
154 "Provider '{}' resource '{}': unsupported method '{}'",
155 self.id, name, res.method
156 ));
157 }
158 }
159 Ok(())
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn parse_toml_bearer_config() {
169 let toml_str = r#"
170id = "linear"
171name = "Linear"
172base_url = "https://api.linear.app"
173
174[auth]
175type = "bearer"
176token_env = "LINEAR_API_KEY"
177
178[resources.issues]
179method = "GET"
180path = "/issues"
181
182[resources.issues.query_params]
183limit = "{limit}"
184state = "{state}"
185
186[resources.issues.response]
187root = "data"
188
189[resources.issues.response.mapping]
190id = "id"
191title = "title"
192body = "description"
193state = "state.name"
194author = "creator.name"
195url = "url"
196labels = "labels[].name"
197created_at = "createdAt"
198updated_at = "updatedAt"
199"#;
200 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
201 assert_eq!(cfg.id, "linear");
202 assert_eq!(cfg.name, "Linear");
203 assert!(matches!(cfg.auth, AuthConfig::Bearer { .. }));
204 assert!(cfg.resources.contains_key("issues"));
205 let issues = &cfg.resources["issues"];
206 assert_eq!(issues.path, "/issues");
207 assert_eq!(issues.response.mapping.id, "id");
208 assert_eq!(issues.response.mapping.title, "title");
209 assert_eq!(issues.response.mapping.labels, Some("labels[].name".into()));
210 assert!(cfg.validate().is_ok());
211 }
212
213 #[test]
214 fn parse_json_api_key_config() {
215 let json_str = r#"{
216 "id": "notion",
217 "name": "Notion",
218 "base_url": "https://api.notion.com/v1",
219 "auth": {
220 "type": "api_key",
221 "key_env": "NOTION_TOKEN",
222 "header_name": "Notion-Version"
223 },
224 "resources": {
225 "pages": {
226 "path": "/search",
227 "method": "POST",
228 "response": {
229 "root": "results",
230 "mapping": {
231 "id": "id",
232 "title": "properties.title.title[0].text.content"
233 }
234 }
235 }
236 }
237 }"#;
238 let cfg: ProviderConfig = serde_json::from_str(json_str).unwrap();
239 assert_eq!(cfg.id, "notion");
240 assert!(matches!(cfg.auth, AuthConfig::ApiKey { .. }));
241 assert!(cfg.validate().is_ok());
242 }
243
244 #[test]
245 fn parse_no_auth_config() {
246 let toml_str = r#"
247id = "public-api"
248name = "Public API"
249base_url = "https://api.example.com"
250
251[auth]
252type = "none"
253
254[resources.data]
255path = "/data"
256
257[resources.data.response.mapping]
258id = "uuid"
259title = "name"
260"#;
261 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
262 assert!(matches!(cfg.auth, AuthConfig::None));
263 assert!(!cfg.resources["data"].query_params.contains_key("limit"));
264 assert!(cfg.validate().is_ok());
265 }
266
267 #[test]
268 fn validate_catches_empty_id() {
269 let cfg = ProviderConfig {
270 id: String::new(),
271 name: "Test".into(),
272 base_url: "https://example.com".into(),
273 auth: AuthConfig::None,
274 cache_ttl_secs: 120,
275 resources: HashMap::new(),
276 };
277 assert!(cfg.validate().is_err());
278 }
279
280 #[test]
281 fn validate_catches_no_resources() {
282 let cfg = ProviderConfig {
283 id: "test".into(),
284 name: "Test".into(),
285 base_url: "https://example.com".into(),
286 auth: AuthConfig::None,
287 cache_ttl_secs: 120,
288 resources: HashMap::new(),
289 };
290 let err = cfg.validate().unwrap_err();
291 assert!(err.contains("at least one resource"));
292 }
293
294 #[test]
295 fn parse_basic_auth_config() {
296 let toml_str = r#"
297id = "jira-custom"
298name = "Jira (Custom)"
299base_url = "https://mycompany.atlassian.net/rest/api/3"
300
301[auth]
302type = "basic"
303username_env = "JIRA_USER"
304password_env = "JIRA_TOKEN"
305
306[resources.issues]
307path = "/search"
308[resources.issues.query_params]
309jql = "project={project} ORDER BY updated DESC"
310maxResults = "{limit}"
311[resources.issues.response]
312root = "issues"
313[resources.issues.response.mapping]
314id = "key"
315title = "fields.summary"
316body = "fields.description"
317state = "fields.status.name"
318author = "fields.reporter.displayName"
319labels = "fields.labels"
320"#;
321 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
322 assert!(matches!(cfg.auth, AuthConfig::Basic { .. }));
323 assert!(cfg.validate().is_ok());
324 }
325
326 #[test]
327 fn default_method_is_get() {
328 let toml_str = r#"
329id = "test"
330name = "Test"
331base_url = "https://example.com"
332
333[auth]
334type = "none"
335
336[resources.items]
337path = "/items"
338[resources.items.response.mapping]
339id = "id"
340title = "name"
341"#;
342 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
343 assert_eq!(cfg.resources["items"].method, "GET");
344 }
345
346 fn with_method(method: &str) -> ProviderConfig {
347 let toml_str = format!(
348 r#"
349id = "test"
350name = "Test"
351base_url = "https://example.com"
352
353[resources.items]
354method = "{method}"
355path = "/items"
356[resources.items.response.mapping]
357id = "id"
358title = "name"
359"#
360 );
361 toml::from_str(&toml_str).unwrap()
362 }
363
364 #[test]
367 fn write_methods_are_rejected() {
368 for method in ["PUT", "patch", "Delete"] {
369 let err = with_method(method).validate().unwrap_err();
370 assert!(err.contains("writes to the source"), "{method}: {err}");
371 assert!(err.contains("read-only"), "{method}: {err}");
372 }
373 }
374
375 #[test]
376 fn read_methods_are_accepted_and_unknown_ones_rejected() {
377 for method in ["GET", "get", "POST"] {
378 assert!(with_method(method).validate().is_ok(), "{method}");
379 }
380 for method in ["HEAD", "OPTIONS", "TRACE", ""] {
381 let err = with_method(method).validate().unwrap_err();
382 assert!(err.contains("unsupported method"), "{method}: {err}");
383 }
384 }
385}