1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
12pub struct ApiSpec {
13 pub name: String,
15 pub description: String,
17 pub spec_url: String,
19 pub base_url: String,
21 pub auth_env: Option<String>,
23}
24
25pub 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
76pub 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
159pub 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
165pub fn list_popular() -> Vec<String> {
167 load_api_registry().into_iter().map(|a| a.name).collect()
168}
169
170pub fn registry_toml_path() -> PathBuf {
173 let local = PathBuf::from(".rust-code").join("apis.toml");
175 if local.exists() {
176 return local;
177 }
178 dirs_like_home().join(".sgr-agent").join("apis.toml")
180}
181
182pub 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
194pub fn cache_path(cache_dir: &Path, name: &str) -> PathBuf {
196 cache_dir.join(format!("{}.json", name))
197}
198
199pub 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
205pub 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
213pub 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 url.ends_with(".yaml") || url.ends_with(".yml") || !text.trim_start().starts_with('{') {
234 if serde_json::from_str::<serde_json::Value>(&text).is_ok() {
236 return Ok(text);
237 }
238 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
248pub 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
263pub 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 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 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}