use anyhow::{Context, Result};
use url::Url;
use super::types::{
SkillsShAuditResponse, SkillsShCuratedResponse, SkillsShListResponse, SkillsShSearchResponse,
SkillsShSkillDetail,
};
const DEFAULT_BASE_URL: &str = "https://skills.sh";
#[derive(Clone)]
pub struct SkillsShClient {
base_url: Url,
client: reqwest::Client,
api_key: Option<String>,
}
impl SkillsShClient {
pub fn new(base_url: Option<String>, api_key: Option<String>) -> Result<Self> {
let base = base_url
.map(|s| Url::parse(&s))
.unwrap_or_else(|| Url::parse(DEFAULT_BASE_URL))?;
let base = base
.join("/")
.map_err(|e| anyhow::anyhow!("invalid base URL: {e}"))?;
let api_key = api_key.or_else(|| std::env::var("SKILLS_SH_TOKEN").ok());
Ok(Self {
base_url: base,
client: reqwest::Client::new(),
api_key,
})
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub fn has_api_key(&self) -> bool {
self.api_key.is_some()
}
pub async fn search(
&self,
query: &str,
limit: Option<usize>,
) -> Result<SkillsShSearchResponse> {
let mut url = self.base_url.join("/api/v1/skills/search")?;
url.query_pairs_mut()
.append_pair("q", query)
.append_pair("limit", &limit.unwrap_or(50).to_string());
let resp = self.get_response(url).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("skills.sh search failed ({status}): {body}");
}
resp.json().await.context("parse skills.sh search response")
}
pub async fn list(
&self,
view: Option<&str>,
page: Option<i64>,
per_page: Option<i64>,
) -> Result<SkillsShListResponse> {
let mut url = self.base_url.join("/api/v1/skills")?;
{
let mut qp = url.query_pairs_mut();
qp.append_pair("view", view.unwrap_or("all-time"));
if let Some(p) = page {
qp.append_pair("page", &p.to_string());
}
if let Some(pp) = per_page {
qp.append_pair("per_page", &pp.to_string());
}
}
let resp = self.get_response(url).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("skills.sh list failed ({status}): {body}");
}
resp.json().await.context("parse skills.sh list response")
}
pub async fn get_skill(&self, id: &str) -> Result<SkillsShSkillDetail> {
let url = self
.base_url
.join(&format!("/api/v1/skills/{id}"))
.context("construct skill detail URL")?;
let resp = self.get_response(url).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("skills.sh get_skill {id} failed ({status}): {body}");
}
resp.json()
.await
.context("parse skills.sh skill detail response")
}
pub async fn curated(&self) -> Result<SkillsShCuratedResponse> {
let url = self.base_url.join("/api/v1/skills/curated")?;
let resp = self.get_response(url).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("skills.sh curated failed ({status}): {body}");
}
resp.json()
.await
.context("parse skills.sh curated response")
}
pub async fn audit(&self, id: &str) -> Result<SkillsShAuditResponse> {
let url = self
.base_url
.join(&format!("/api/v1/skills/audit/{id}"))
.context("construct audit URL")?;
let resp = self.get_response(url).await?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("no audits found for skill {id}");
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("skills.sh audit {id} failed ({status}): {body}");
}
resp.json().await.context("parse skills.sh audit response")
}
async fn get_response(&self, url: Url) -> Result<reqwest::Response> {
let mut req = self.client.get(url);
if let Some(ref key) = self.api_key {
req = req.header("Authorization", format!("Bearer {key}"));
}
let resp = req.send().await?;
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_new_default() {
let client = SkillsShClient::new(None, None).unwrap();
assert_eq!(client.base_url.as_str(), "https://skills.sh/");
}
#[test]
fn test_client_new_custom_url() {
let client =
SkillsShClient::new(Some("https://staging.skills.sh".to_string()), None).unwrap();
assert_eq!(client.base_url.as_str(), "https://staging.skills.sh/");
}
#[test]
fn test_client_api_key_from_param() {
let client = SkillsShClient::new(None, Some("sk_test_123".to_string())).unwrap();
assert!(client.has_api_key());
}
}