lean_ctx/core/providers/config_provider/
schema.rs1use std::collections::HashMap;
7
8use serde::Deserialize;
9
10#[derive(Debug, Clone, Deserialize)]
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)]
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)]
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
80#[derive(Debug, Clone, Deserialize)]
82pub struct ResponseConfig {
83 #[serde(default)]
86 pub root: Option<String>,
87 pub mapping: FieldMapping,
89}
90
91#[derive(Debug, Clone, Deserialize)]
95pub struct FieldMapping {
96 pub id: String,
97 pub title: String,
98 #[serde(default)]
99 pub body: Option<String>,
100 #[serde(default)]
101 pub state: Option<String>,
102 #[serde(default)]
103 pub author: Option<String>,
104 #[serde(default)]
105 pub url: Option<String>,
106 #[serde(default)]
108 pub labels: Option<String>,
109 #[serde(default)]
110 pub created_at: Option<String>,
111 #[serde(default)]
112 pub updated_at: Option<String>,
113}
114
115impl ProviderConfig {
116 pub fn validate(&self) -> Result<(), String> {
118 if self.id.is_empty() {
119 return Err("Provider config: 'id' must not be empty".into());
120 }
121 if self.base_url.is_empty() {
122 return Err("Provider config: 'base_url' must not be empty".into());
123 }
124 if self.resources.is_empty() {
125 return Err(format!(
126 "Provider '{}': must define at least one resource",
127 self.id
128 ));
129 }
130 for (name, res) in &self.resources {
131 if res.path.is_empty() {
132 return Err(format!(
133 "Provider '{}' resource '{}': 'path' must not be empty",
134 self.id, name
135 ));
136 }
137 let method = res.method.to_uppercase();
138 if !["GET", "POST", "PUT", "PATCH", "DELETE"].contains(&method.as_str()) {
139 return Err(format!(
140 "Provider '{}' resource '{}': unsupported method '{}'",
141 self.id, name, res.method
142 ));
143 }
144 }
145 Ok(())
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn parse_toml_bearer_config() {
155 let toml_str = r#"
156id = "linear"
157name = "Linear"
158base_url = "https://api.linear.app"
159
160[auth]
161type = "bearer"
162token_env = "LINEAR_API_KEY"
163
164[resources.issues]
165method = "GET"
166path = "/issues"
167
168[resources.issues.query_params]
169limit = "{limit}"
170state = "{state}"
171
172[resources.issues.response]
173root = "data"
174
175[resources.issues.response.mapping]
176id = "id"
177title = "title"
178body = "description"
179state = "state.name"
180author = "creator.name"
181url = "url"
182labels = "labels[].name"
183created_at = "createdAt"
184updated_at = "updatedAt"
185"#;
186 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
187 assert_eq!(cfg.id, "linear");
188 assert_eq!(cfg.name, "Linear");
189 assert!(matches!(cfg.auth, AuthConfig::Bearer { .. }));
190 assert!(cfg.resources.contains_key("issues"));
191 let issues = &cfg.resources["issues"];
192 assert_eq!(issues.path, "/issues");
193 assert_eq!(issues.response.mapping.id, "id");
194 assert_eq!(issues.response.mapping.title, "title");
195 assert_eq!(issues.response.mapping.labels, Some("labels[].name".into()));
196 assert!(cfg.validate().is_ok());
197 }
198
199 #[test]
200 fn parse_json_api_key_config() {
201 let json_str = r#"{
202 "id": "notion",
203 "name": "Notion",
204 "base_url": "https://api.notion.com/v1",
205 "auth": {
206 "type": "api_key",
207 "key_env": "NOTION_TOKEN",
208 "header_name": "Notion-Version"
209 },
210 "resources": {
211 "pages": {
212 "path": "/search",
213 "method": "POST",
214 "response": {
215 "root": "results",
216 "mapping": {
217 "id": "id",
218 "title": "properties.title.title[0].text.content"
219 }
220 }
221 }
222 }
223 }"#;
224 let cfg: ProviderConfig = serde_json::from_str(json_str).unwrap();
225 assert_eq!(cfg.id, "notion");
226 assert!(matches!(cfg.auth, AuthConfig::ApiKey { .. }));
227 assert!(cfg.validate().is_ok());
228 }
229
230 #[test]
231 fn parse_no_auth_config() {
232 let toml_str = r#"
233id = "public-api"
234name = "Public API"
235base_url = "https://api.example.com"
236
237[auth]
238type = "none"
239
240[resources.data]
241path = "/data"
242
243[resources.data.response.mapping]
244id = "uuid"
245title = "name"
246"#;
247 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
248 assert!(matches!(cfg.auth, AuthConfig::None));
249 assert!(!cfg.resources["data"].query_params.contains_key("limit"));
250 assert!(cfg.validate().is_ok());
251 }
252
253 #[test]
254 fn validate_catches_empty_id() {
255 let cfg = ProviderConfig {
256 id: String::new(),
257 name: "Test".into(),
258 base_url: "https://example.com".into(),
259 auth: AuthConfig::None,
260 cache_ttl_secs: 120,
261 resources: HashMap::new(),
262 };
263 assert!(cfg.validate().is_err());
264 }
265
266 #[test]
267 fn validate_catches_no_resources() {
268 let cfg = ProviderConfig {
269 id: "test".into(),
270 name: "Test".into(),
271 base_url: "https://example.com".into(),
272 auth: AuthConfig::None,
273 cache_ttl_secs: 120,
274 resources: HashMap::new(),
275 };
276 let err = cfg.validate().unwrap_err();
277 assert!(err.contains("at least one resource"));
278 }
279
280 #[test]
281 fn parse_basic_auth_config() {
282 let toml_str = r#"
283id = "jira-custom"
284name = "Jira (Custom)"
285base_url = "https://mycompany.atlassian.net/rest/api/3"
286
287[auth]
288type = "basic"
289username_env = "JIRA_USER"
290password_env = "JIRA_TOKEN"
291
292[resources.issues]
293path = "/search"
294[resources.issues.query_params]
295jql = "project={project} ORDER BY updated DESC"
296maxResults = "{limit}"
297[resources.issues.response]
298root = "issues"
299[resources.issues.response.mapping]
300id = "key"
301title = "fields.summary"
302body = "fields.description"
303state = "fields.status.name"
304author = "fields.reporter.displayName"
305labels = "fields.labels"
306"#;
307 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
308 assert!(matches!(cfg.auth, AuthConfig::Basic { .. }));
309 assert!(cfg.validate().is_ok());
310 }
311
312 #[test]
313 fn default_method_is_get() {
314 let toml_str = r#"
315id = "test"
316name = "Test"
317base_url = "https://example.com"
318
319[auth]
320type = "none"
321
322[resources.items]
323path = "/items"
324[resources.items.response.mapping]
325id = "id"
326title = "name"
327"#;
328 let cfg: ProviderConfig = toml::from_str(toml_str).unwrap();
329 assert_eq!(cfg.resources["items"].method, "GET");
330 }
331}