Skip to main content

lean_ctx/core/providers/config_provider/
schema.rs

1//! Schema definitions for user-defined provider configs.
2//!
3//! Users drop a `.toml` or `.json` file into `~/.config/lean-ctx/providers/`
4//! (or `.lean-ctx/providers/` in a project) to register a custom data source.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10/// Top-level provider configuration.
11#[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/// Authentication strategy for the external API.
28#[derive(Debug, Clone, Default, Deserialize, Serialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum AuthConfig {
31    /// `Authorization: Bearer <token>` from an env var.
32    Bearer { token_env: String },
33    /// API key sent as a header or query parameter.
34    ApiKey {
35        key_env: String,
36        /// Header name (e.g. `X-Api-Key`). Mutually exclusive with `query_param`.
37        #[serde(default)]
38        header_name: Option<String>,
39        /// Query parameter name (e.g. `api_key`).
40        #[serde(default)]
41        query_param: Option<String>,
42    },
43    /// HTTP Basic auth from two env vars.
44    Basic {
45        username_env: String,
46        password_env: String,
47    },
48    /// Arbitrary header (e.g. `X-Custom-Token: <value>`).
49    Header {
50        header_name: String,
51        value_env: String,
52    },
53    /// No authentication required.
54    #[default]
55    None,
56}
57
58/// Configuration for a single API resource/endpoint.
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct ResourceConfig {
61    /// HTTP method: one of [`READ_METHODS`]. Defaults to `"GET"`.
62    #[serde(default = "default_method")]
63    pub method: String,
64    /// URL path appended to `base_url` (supports `{param}` interpolation).
65    pub path: String,
66    /// Query parameters (`{limit}`, `{state}` etc. are interpolated from `ProviderParams`).
67    #[serde(default)]
68    pub query_params: HashMap<String, String>,
69    /// Extra headers for this resource.
70    #[serde(default)]
71    pub headers: HashMap<String, String>,
72    /// How to extract items from the JSON response.
73    pub response: ResponseConfig,
74}
75
76fn default_method() -> String {
77    "GET".into()
78}
79
80/// The methods a custom REST resource may use. Connectors read and never
81/// write to the source, so the write verbs (PUT, PATCH, DELETE) are refused
82/// when the config is validated. POST stays for read APIs that take their
83/// query as a POST, such as Notion's `POST /v1/search`; it is sent with an
84/// empty body, has no reuse binding and is withheld from governed reuse.
85pub const READ_METHODS: [&str; 2] = ["GET", "POST"];
86
87/// Describes how to map a JSON response to `ProviderItem`s.
88#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct ResponseConfig {
90    /// Dot-notation path to the array of items (e.g. `"data.issues"`).
91    /// If `None`, the response root is treated as the array.
92    #[serde(default)]
93    pub root: Option<String>,
94    /// Maps `ProviderItem` fields to JSON paths within each array element.
95    pub mapping: FieldMapping,
96}
97
98/// Maps `ProviderItem` fields to dot-notation paths in the JSON response.
99///
100/// `id` and `title` are required; everything else is optional.
101#[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    /// Path to labels array. Each element is stringified.
114    #[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    /// Validate that the config is well-formed.
124    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    /// Connectors never write to the source: a write verb is refused at
365    /// validation, with a message that says why. Old code accepted all three.
366    #[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}