oxios_kernel/skill/skills_sh/
client.rs1use anyhow::{Context, Result};
12use url::Url;
13
14use super::types::{
15 SkillsShAuditResponse, SkillsShCuratedResponse, SkillsShListResponse, SkillsShSearchResponse,
16 SkillsShSkillDetail,
17};
18
19const DEFAULT_BASE_URL: &str = "https://skills.sh";
20
21#[derive(Clone)]
23pub struct SkillsShClient {
24 base_url: Url,
25 client: reqwest::Client,
26 api_key: Option<String>,
27}
28
29impl SkillsShClient {
30 pub fn new(base_url: Option<String>, api_key: Option<String>) -> Result<Self> {
36 let base = base_url
37 .map(|s| Url::parse(&s))
38 .unwrap_or_else(|| Url::parse(DEFAULT_BASE_URL))?;
39 let base = base
40 .join("/")
41 .map_err(|e| anyhow::anyhow!("invalid base URL: {e}"))?;
42
43 let api_key = api_key.or_else(|| std::env::var("SKILLS_SH_TOKEN").ok());
44
45 Ok(Self {
46 base_url: base,
47 client: reqwest::Client::new(),
48 api_key,
49 })
50 }
51
52 pub fn base_url(&self) -> &Url {
54 &self.base_url
55 }
56
57 pub fn has_api_key(&self) -> bool {
59 self.api_key.is_some()
60 }
61
62 pub async fn search(
66 &self,
67 query: &str,
68 limit: Option<usize>,
69 ) -> Result<SkillsShSearchResponse> {
70 let mut url = self.base_url.join("/api/v1/skills/search")?;
71 url.query_pairs_mut()
72 .append_pair("q", query)
73 .append_pair("limit", &limit.unwrap_or(50).to_string());
74
75 let resp = self.get_response(url).await?;
76 let status = resp.status();
77 if !status.is_success() {
78 let body = resp.text().await.unwrap_or_default();
79 anyhow::bail!("skills.sh search failed ({status}): {body}");
80 }
81
82 resp.json().await.context("parse skills.sh search response")
83 }
84
85 pub async fn list(
91 &self,
92 view: Option<&str>,
93 page: Option<i64>,
94 per_page: Option<i64>,
95 ) -> Result<SkillsShListResponse> {
96 let mut url = self.base_url.join("/api/v1/skills")?;
97 {
98 let mut qp = url.query_pairs_mut();
99 qp.append_pair("view", view.unwrap_or("all-time"));
100 if let Some(p) = page {
101 qp.append_pair("page", &p.to_string());
102 }
103 if let Some(pp) = per_page {
104 qp.append_pair("per_page", &pp.to_string());
105 }
106 }
107
108 let resp = self.get_response(url).await?;
109 let status = resp.status();
110 if !status.is_success() {
111 let body = resp.text().await.unwrap_or_default();
112 anyhow::bail!("skills.sh list failed ({status}): {body}");
113 }
114
115 resp.json().await.context("parse skills.sh list response")
116 }
117
118 pub async fn get_skill(&self, id: &str) -> Result<SkillsShSkillDetail> {
123 let url = self
125 .base_url
126 .join(&format!("/api/v1/skills/{id}"))
127 .context("construct skill detail URL")?;
128
129 let resp = self.get_response(url).await?;
130 let status = resp.status();
131 if !status.is_success() {
132 let body = resp.text().await.unwrap_or_default();
133 anyhow::bail!("skills.sh get_skill {id} failed ({status}): {body}");
134 }
135
136 resp.json()
137 .await
138 .context("parse skills.sh skill detail response")
139 }
140
141 pub async fn curated(&self) -> Result<SkillsShCuratedResponse> {
143 let url = self.base_url.join("/api/v1/skills/curated")?;
144
145 let resp = self.get_response(url).await?;
146 let status = resp.status();
147 if !status.is_success() {
148 let body = resp.text().await.unwrap_or_default();
149 anyhow::bail!("skills.sh curated failed ({status}): {body}");
150 }
151
152 resp.json()
153 .await
154 .context("parse skills.sh curated response")
155 }
156
157 pub async fn audit(&self, id: &str) -> Result<SkillsShAuditResponse> {
159 let url = self
160 .base_url
161 .join(&format!("/api/v1/skills/audit/{id}"))
162 .context("construct audit URL")?;
163
164 let resp = self.get_response(url).await?;
165 let status = resp.status();
166 if status == reqwest::StatusCode::NOT_FOUND {
167 anyhow::bail!("no audits found for skill {id}");
168 }
169 if !status.is_success() {
170 let body = resp.text().await.unwrap_or_default();
171 anyhow::bail!("skills.sh audit {id} failed ({status}): {body}");
172 }
173
174 resp.json().await.context("parse skills.sh audit response")
175 }
176
177 async fn get_response(&self, url: Url) -> Result<reqwest::Response> {
181 let mut req = self.client.get(url);
182 if let Some(ref key) = self.api_key {
183 req = req.header("Authorization", format!("Bearer {key}"));
184 }
185 let resp = req.send().await?;
186 Ok(resp)
187 }
188}
189
190#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_client_new_default() {
198 let client = SkillsShClient::new(None, None).unwrap();
199 assert_eq!(client.base_url.as_str(), "https://skills.sh/");
200 }
201
202 #[test]
203 fn test_client_new_custom_url() {
204 let client =
205 SkillsShClient::new(Some("https://staging.skills.sh".to_string()), None).unwrap();
206 assert_eq!(client.base_url.as_str(), "https://staging.skills.sh/");
207 }
208
209 #[test]
210 fn test_client_api_key_from_param() {
211 let client = SkillsShClient::new(None, Some("sk_test_123".to_string())).unwrap();
212 assert!(client.has_api_key());
213 }
214}