Skip to main content

sgr_agent/openapi/
registry.rs

1//! Auto-discovery and caching of OpenAPI specs.
2//!
3//! - Hardcoded popular APIs (GitHub, Stripe, etc.) with known spec URLs
4//! - APIs.guru directory as fallback (2800+ APIs)
5//! - Local cache in `~/.sgr-agent/openapi-cache/`
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10/// A known API spec source.
11#[derive(Debug, Clone)]
12pub struct ApiSpec {
13    /// Short name: "github", "stripe"
14    pub name: String,
15    /// Human description
16    pub description: String,
17    /// URL to download the OpenAPI JSON spec
18    pub spec_url: String,
19    /// Default base URL for API calls
20    pub base_url: String,
21    /// Auth env var hint (e.g. "GITHUB_TOKEN")
22    pub auth_env: Option<String>,
23}
24
25/// Load API registry from TOML (agent-editable, no recompilation).
26/// Priority: .rust-code/apis.toml (project) > ~/.sgr-agent/apis.toml (global) > hardcoded.
27pub fn load_api_registry() -> Vec<ApiSpec> {
28    for path in &[
29        PathBuf::from(".rust-code").join("apis.toml"),
30        dirs_like_home().join(".sgr-agent").join("apis.toml"),
31    ] {
32        if let Ok(content) = std::fs::read_to_string(path) {
33            if let Ok(parsed) = toml_parse_apis(&content) {
34                return parsed;
35            }
36        }
37    }
38    popular_apis()
39}
40
41fn toml_parse_apis(content: &str) -> Result<Vec<ApiSpec>, String> {
42    let table: toml::Table = content.parse().map_err(|e| format!("TOML: {}", e))?;
43    let api_table = table
44        .get("api")
45        .and_then(|v| v.as_table())
46        .ok_or("missing [api.*]")?;
47    let mut apis = Vec::new();
48    for (name, val) in api_table {
49        let t = match val.as_table() {
50            Some(t) => t,
51            None => continue,
52        };
53        apis.push(ApiSpec {
54            name: name.clone(),
55            description: t
56                .get("description")
57                .and_then(|v| v.as_str())
58                .unwrap_or("")
59                .into(),
60            spec_url: t
61                .get("spec_url")
62                .and_then(|v| v.as_str())
63                .unwrap_or("")
64                .into(),
65            base_url: t
66                .get("base_url")
67                .and_then(|v| v.as_str())
68                .unwrap_or("")
69                .into(),
70            auth_env: t.get("auth_env").and_then(|v| v.as_str()).map(String::from),
71        });
72    }
73    Ok(apis)
74}
75
76/// Popular APIs useful for development — hardcoded fallback.
77pub fn popular_apis() -> Vec<ApiSpec> {
78    vec![
79        ApiSpec {
80            name: "github".into(),
81            description: "GitHub REST API v3 — repos, issues, PRs, actions".into(),
82            spec_url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json".into(),
83            base_url: "https://api.github.com".into(),
84            auth_env: Some("GITHUB_TOKEN".into()),
85        },
86        ApiSpec {
87            name: "stripe".into(),
88            description: "Stripe API — payments, subscriptions, customers".into(),
89            spec_url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json".into(),
90            base_url: "https://api.stripe.com".into(),
91            auth_env: Some("STRIPE_SECRET_KEY".into()),
92        },
93        ApiSpec {
94            name: "openai".into(),
95            description: "OpenAI API — chat completions, embeddings, images".into(),
96            spec_url: "https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml".into(),
97            base_url: "https://api.openai.com".into(),
98            auth_env: Some("OPENAI_API_KEY".into()),
99        },
100        ApiSpec {
101            name: "supabase-management".into(),
102            description: "Supabase Management API — projects, databases, auth".into(),
103            spec_url: "https://api.apis.guru/v2/specs/supabase.com/analytics/0.0.1/openapi.json".into(),
104            base_url: "https://api.supabase.com".into(),
105            auth_env: Some("SUPABASE_ACCESS_TOKEN".into()),
106        },
107        ApiSpec {
108            name: "posthog".into(),
109            description: "PostHog API — events, persons, feature flags".into(),
110            spec_url: "https://raw.githubusercontent.com/PostHog/posthog/master/openapi/bundled_schema.json".into(),
111            base_url: "https://eu.posthog.com".into(),
112            auth_env: Some("POSTHOG_API_KEY".into()),
113        },
114        ApiSpec {
115            name: "slack".into(),
116            description: "Slack Web API — messages, channels, users".into(),
117            spec_url: "https://api.apis.guru/v2/specs/slack.com/1.7.0/openapi.json".into(),
118            base_url: "https://slack.com/api".into(),
119            auth_env: Some("SLACK_TOKEN".into()),
120        },
121        ApiSpec {
122            name: "linear".into(),
123            description: "Linear API — issues, projects, teams".into(),
124            spec_url: "https://api.apis.guru/v2/specs/linear.app/1.0.0/openapi.json".into(),
125            base_url: "https://api.linear.app".into(),
126            auth_env: Some("LINEAR_API_KEY".into()),
127        },
128        ApiSpec {
129            name: "cloudflare".into(),
130            description: "Cloudflare API — DNS, workers, pages, R2".into(),
131            spec_url: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json".into(),
132            base_url: "https://api.cloudflare.com/client/v4".into(),
133            auth_env: Some("CLOUDFLARE_API_TOKEN".into()),
134        },
135        ApiSpec {
136            name: "vercel".into(),
137            description: "Vercel API — deployments, projects, domains".into(),
138            spec_url: "https://openapi.vercel.sh/".into(),
139            base_url: "https://api.vercel.com".into(),
140            auth_env: Some("VERCEL_TOKEN".into()),
141        },
142        ApiSpec {
143            name: "open-meteo".into(),
144            description: "Open-Meteo — free weather + air quality + geocoding APIs".into(),
145            spec_url: "https://raw.githubusercontent.com/open-meteo/open-meteo/main/openapi.yml".into(),
146            base_url: "https://api.open-meteo.com".into(),
147            auth_env: None,
148        },
149        ApiSpec {
150            name: "sentry".into(),
151            description: "Sentry API — issues, events, projects".into(),
152            spec_url: "https://api.apis.guru/v2/specs/sentry.io/0.0.1/openapi.json".into(),
153            base_url: "https://sentry.io/api/0".into(),
154            auth_env: Some("SENTRY_AUTH_TOKEN".into()),
155        },
156    ]
157}
158
159/// Find API by name — checks TOML registry first, then hardcoded.
160pub fn find_popular(name: &str) -> Option<ApiSpec> {
161    let lower = name.to_lowercase();
162    load_api_registry().into_iter().find(|a| a.name == lower)
163}
164
165/// List all available API names (from TOML registry or hardcoded).
166pub fn list_popular() -> Vec<String> {
167    load_api_registry().into_iter().map(|a| a.name).collect()
168}
169
170/// Path to the TOML registry file.
171/// Checks .rust-code/apis.toml (project) first, then ~/.sgr-agent/apis.toml (global).
172pub fn registry_toml_path() -> PathBuf {
173    // Project-local override
174    let local = PathBuf::from(".rust-code").join("apis.toml");
175    if local.exists() {
176        return local;
177    }
178    // Global
179    dirs_like_home().join(".sgr-agent").join("apis.toml")
180}
181
182/// Default cache directory: `~/.sgr-agent/openapi-cache/`
183pub fn default_cache_dir() -> PathBuf {
184    dirs_like_home().join(".sgr-agent").join("openapi-cache")
185}
186
187fn dirs_like_home() -> PathBuf {
188    std::env::var("HOME")
189        .or_else(|_| std::env::var("USERPROFILE"))
190        .map(PathBuf::from)
191        .unwrap_or_else(|_| PathBuf::from("."))
192}
193
194/// Get cached spec path for an API name.
195pub fn cache_path(cache_dir: &Path, name: &str) -> PathBuf {
196    cache_dir.join(format!("{}.json", name))
197}
198
199/// Load a cached spec from disk, if it exists.
200pub fn load_cached(cache_dir: &Path, name: &str) -> Option<String> {
201    let path = cache_path(cache_dir, name);
202    std::fs::read_to_string(path).ok()
203}
204
205/// Save a spec to cache.
206pub fn save_cache(cache_dir: &Path, name: &str, content: &str) -> Result<(), String> {
207    std::fs::create_dir_all(cache_dir).map_err(|e| format!("mkdir: {}", e))?;
208    let path = cache_path(cache_dir, name);
209    std::fs::write(&path, content).map_err(|e| format!("write: {}", e))?;
210    Ok(())
211}
212
213/// Download a spec from URL. Returns the raw JSON/YAML string.
214pub async fn download_spec(url: &str) -> Result<String, String> {
215    let client = reqwest::Client::builder()
216        .user_agent("sgr-agent/0.2")
217        .build()
218        .map_err(|e| format!("client: {}", e))?;
219
220    let resp = client
221        .get(url)
222        .send()
223        .await
224        .map_err(|e| format!("fetch: {}", e))?;
225
226    if !resp.status().is_success() {
227        return Err(format!("HTTP {}: {}", resp.status(), url));
228    }
229
230    let text = resp.text().await.map_err(|e| format!("read: {}", e))?;
231
232    // If YAML, convert to JSON
233    if url.ends_with(".yaml") || url.ends_with(".yml") || !text.trim_start().starts_with('{') {
234        // Try parsing as JSON first (some .yaml URLs return JSON)
235        if serde_json::from_str::<serde_json::Value>(&text).is_ok() {
236            return Ok(text);
237        }
238        // Parse YAML → JSON
239        let yaml_val: serde_json::Value =
240            serde_yaml::from_str(&text).map_err(|e| format!("YAML parse error: {}", e))?;
241        return serde_json::to_string(&yaml_val)
242            .map_err(|e| format!("YAML→JSON conversion error: {}", e));
243    }
244
245    Ok(text)
246}
247
248/// Load spec: try cache first, then download and cache.
249pub async fn load_or_download(
250    cache_dir: &Path,
251    name: &str,
252    spec_url: &str,
253) -> Result<String, String> {
254    if let Some(cached) = load_cached(cache_dir, name) {
255        return Ok(cached);
256    }
257
258    let content = download_spec(spec_url).await?;
259    let _ = save_cache(cache_dir, name, &content);
260    Ok(content)
261}
262
263/// Search APIs.guru directory for an API by name.
264/// Returns matching spec URLs.
265pub async fn search_apis_guru(query: &str, limit: usize) -> Result<Vec<ApiSpec>, String> {
266    let client = reqwest::Client::builder()
267        .user_agent("sgr-agent/0.2")
268        .build()
269        .map_err(|e| format!("client: {}", e))?;
270
271    let resp = client
272        .get("https://api.apis.guru/v2/list.json")
273        .send()
274        .await
275        .map_err(|e| format!("fetch: {}", e))?;
276
277    let list: HashMap<String, serde_json::Value> =
278        resp.json().await.map_err(|e| format!("parse: {}", e))?;
279
280    let query_lower = query.to_lowercase();
281    let mut results = Vec::new();
282
283    for (key, val) in &list {
284        let key_lower = key.to_lowercase();
285        if !key_lower.contains(&query_lower) {
286            continue;
287        }
288
289        // Get preferred version
290        let preferred = val.get("preferred").and_then(|v| v.as_str()).unwrap_or("");
291        let versions = match val.get("versions").and_then(|v| v.as_object()) {
292            Some(v) => v,
293            None => continue,
294        };
295        let version = versions.get(preferred).or_else(|| versions.values().next());
296        let version = match version {
297            Some(v) => v,
298            None => continue,
299        };
300
301        let spec_url = version
302            .get("swaggerUrl")
303            .and_then(|v| v.as_str())
304            .unwrap_or("");
305        let title = version
306            .get("info")
307            .and_then(|i| i.get("title"))
308            .and_then(|t| t.as_str())
309            .unwrap_or(key);
310        let description = version
311            .get("info")
312            .and_then(|i| i.get("description"))
313            .and_then(|d| d.as_str())
314            .unwrap_or("");
315
316        // Guess base URL from spec URL or key
317        let base_url = format!("https://{}", key.split(':').next().unwrap_or(key));
318
319        results.push(ApiSpec {
320            name: key.replace([':', '.'], "_"),
321            description: format!("{} — {}", title, truncate_str(description, 80)),
322            spec_url: spec_url.to_string(),
323            base_url,
324            auth_env: None,
325        });
326
327        if results.len() >= limit {
328            break;
329        }
330    }
331
332    Ok(results)
333}
334
335fn truncate_str(s: &str, max: usize) -> &str {
336    if s.len() <= max {
337        s
338    } else {
339        &s[..s
340            .char_indices()
341            .take(max)
342            .last()
343            .map(|(i, _)| i)
344            .unwrap_or(0)]
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn popular_apis_has_entries() {
354        let apis = popular_apis();
355        assert!(apis.len() >= 8);
356    }
357
358    #[test]
359    fn find_popular_case_insensitive() {
360        assert!(find_popular("GitHub").is_some());
361        assert!(find_popular("github").is_some());
362        assert!(find_popular("nonexistent").is_none());
363    }
364
365    #[test]
366    fn cache_path_format() {
367        let p = cache_path(Path::new("/tmp/cache"), "github");
368        assert_eq!(p, PathBuf::from("/tmp/cache/github.json"));
369    }
370
371    #[test]
372    fn save_and_load_cache() {
373        let dir = tempfile::tempdir().unwrap();
374        save_cache(dir.path(), "test-api", r#"{"paths":{}}"#).unwrap();
375        let loaded = load_cached(dir.path(), "test-api");
376        assert!(loaded.is_some());
377        assert_eq!(loaded.unwrap(), r#"{"paths":{}}"#);
378    }
379
380    #[test]
381    fn load_cached_missing() {
382        let dir = tempfile::tempdir().unwrap();
383        assert!(load_cached(dir.path(), "nonexistent").is_none());
384    }
385
386    #[test]
387    fn list_popular_names() {
388        let names = list_popular();
389        assert!(names.contains(&"github".to_string()));
390        assert!(names.contains(&"stripe".to_string()));
391        assert!(names.contains(&"openai".to_string()));
392    }
393
394    #[test]
395    fn popular_apis_have_required_fields() {
396        for api in popular_apis() {
397            assert!(!api.name.is_empty(), "name empty");
398            assert!(!api.spec_url.is_empty(), "{} missing spec_url", api.name);
399            assert!(!api.base_url.is_empty(), "{} missing base_url", api.name);
400            assert!(
401                api.spec_url.starts_with("https://"),
402                "{} spec_url not https",
403                api.name
404            );
405        }
406    }
407}