Skip to main content

skillfile_sources/
http.rs

1use std::process::Command;
2use std::sync::OnceLock;
3
4use skillfile_core::error::SkillfileError;
5
6// ---------------------------------------------------------------------------
7// GitHub token discovery (cached for process lifetime)
8// ---------------------------------------------------------------------------
9
10static TOKEN_CACHE: OnceLock<Option<String>> = OnceLock::new();
11
12/// Discover a GitHub token from environment or `gh` CLI. Cached after first call.
13#[must_use]
14pub fn github_token() -> Option<&'static str> {
15    TOKEN_CACHE.get_or_init(discover_github_token).as_deref()
16}
17
18fn env_token(name: &str) -> Option<String> {
19    std::env::var(name).ok().filter(|t| !t.is_empty())
20}
21
22fn discover_github_token() -> Option<String> {
23    if let Some(token) = env_token("GITHUB_TOKEN") {
24        return Some(token);
25    }
26    if let Some(token) = env_token("GH_TOKEN") {
27        return Some(token);
28    }
29    let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
30    if !output.status.success() {
31        return None;
32    }
33    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
34    if token.is_empty() {
35        None
36    } else {
37        Some(token)
38    }
39}
40
41// ---------------------------------------------------------------------------
42// HttpClient trait — abstraction over HTTP GET for testability
43// ---------------------------------------------------------------------------
44
45/// Parameters for a POST request with a Bearer token.
46pub struct BearerPost<'a> {
47    pub url: &'a str,
48    pub body: &'a str,
49    pub token: &'a str,
50}
51
52/// Contract for HTTP GET requests used by the fetcher/resolver layer.
53///
54/// Implementations are responsible for:
55/// - Setting standard headers (User-Agent, Authorization)
56/// - Connection pooling / agent reuse
57/// - Error mapping to [`SkillfileError`]
58///
59/// The trait has three methods covering the HTTP patterns in this codebase:
60/// - `get_bytes`: raw file downloads (content from `raw.githubusercontent.com`)
61/// - `get_json`: GitHub API calls that may return 4xx gracefully
62/// - `post_json`: POST with JSON body (used by some registry APIs)
63pub trait HttpClient: Send + Sync {
64    /// GET a URL and return the response body as raw bytes.
65    ///
66    /// Returns `Err(SkillfileError::Network)` on HTTP errors (including 404).
67    fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError>;
68
69    /// GET a URL with `Accept: application/vnd.github.v3+json` header.
70    ///
71    /// Returns `Ok(None)` on 4xx client errors (used for tentative lookups
72    /// like SHA resolution where a missing ref is not fatal).
73    /// Returns `Err` on network/server errors.
74    fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError>;
75
76    /// POST a JSON body to a URL and return the response body as bytes.
77    ///
78    /// Returns `Err(SkillfileError::Network)` on HTTP or network errors.
79    fn post_json(&self, url: &str, body: &str) -> Result<Vec<u8>, SkillfileError>;
80
81    /// POST with a custom `Authorization: Bearer` header (for non-GitHub APIs).
82    ///
83    /// Default: ignores the token and delegates to [`post_json`](Self::post_json).
84    /// Test mocks use this default; [`UreqClient`] overrides to send the header.
85    ///
86    /// # Note
87    /// The extra `token` parameter is required by non-GitHub registry APIs (e.g.
88    /// skillhub.club).
89    fn post_json_with_bearer(&self, req: &BearerPost<'_>) -> Result<Vec<u8>, SkillfileError> {
90        self.post_json(req.url, req.body)
91    }
92}
93
94// ---------------------------------------------------------------------------
95// UreqClient — the production implementation backed by ureq
96// ---------------------------------------------------------------------------
97
98fn read_response_text(body: &mut ureq::Body, url: &str) -> Result<String, SkillfileError> {
99    body.read_to_string()
100        .map_err(|e| SkillfileError::Network(format!("failed to read response from {url}: {e}")))
101}
102
103/// Production HTTP client backed by `ureq::Agent`.
104///
105/// Automatically attaches `User-Agent` and GitHub `Authorization` headers
106/// to every request. The GitHub token is discovered once from environment
107/// variables or the `gh` CLI and cached for the process lifetime.
108pub struct UreqClient {
109    agent: ureq::Agent,
110}
111
112impl UreqClient {
113    pub fn new() -> Self {
114        let config = ureq::config::Config::builder()
115            // Preserve Authorization header on same-host HTTPS redirects.
116            // GitHub returns 301 for renamed repos (api.github.com -> api.github.com);
117            // the default (Never) strips auth, causing 401 on the redirect target.
118            .redirect_auth_headers(ureq::config::RedirectAuthHeaders::SameHost)
119            .build();
120        Self {
121            agent: ureq::Agent::new_with_config(config),
122        }
123    }
124
125    /// Build a GET request with standard headers.
126    fn build_get(&self, url: &str) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
127        let mut req = self.agent.get(url).header("User-Agent", "skillfile/1.0");
128        if let Some(token) = github_token() {
129            req = req.header("Authorization", &format!("Bearer {token}"));
130        }
131        req
132    }
133
134    /// Build a POST request with standard headers.
135    fn build_post(&self, url: &str) -> ureq::RequestBuilder<ureq::typestate::WithBody> {
136        let mut req = self.agent.post(url).header("User-Agent", "skillfile/1.0");
137        if let Some(token) = github_token() {
138            req = req.header("Authorization", &format!("Bearer {token}"));
139        }
140        req
141    }
142}
143
144impl Default for UreqClient {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150impl HttpClient for UreqClient {
151    fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError> {
152        let mut response = self.build_get(url).call().map_err(|e| match &e {
153            ureq::Error::StatusCode(404) => SkillfileError::Network(format!(
154                "HTTP 404: {url} not found — check that the path exists in the upstream repo"
155            )),
156            ureq::Error::StatusCode(code) => {
157                SkillfileError::Network(format!("HTTP {code} fetching {url}"))
158            }
159            _ => SkillfileError::Network(format!("{e} fetching {url}")),
160        })?;
161        response.body_mut().read_to_vec().map_err(|e| {
162            SkillfileError::Network(format!("failed to read response from {url}: {e}"))
163        })
164    }
165
166    fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError> {
167        let result = self
168            .build_get(url)
169            .header("Accept", "application/vnd.github.v3+json")
170            .call();
171
172        match result {
173            Ok(mut response) => read_response_text(response.body_mut(), url).map(Some),
174            // 404/422 = ref or repo doesn't exist (tentative lookup, not fatal).
175            // 403 = rate-limited or forbidden; 401 = bad token — surface these.
176            Err(ureq::Error::StatusCode(code)) if code == 404 || code == 422 => Ok(None),
177            Err(ureq::Error::StatusCode(403)) => Err(SkillfileError::Network(format!(
178                "HTTP 403 fetching {url} — you may be rate-limited. \
179                 Set GITHUB_TOKEN or run `gh auth login` to authenticate."
180            ))),
181            Err(e) => Err(SkillfileError::Network(format!("{e} fetching {url}"))),
182        }
183    }
184
185    fn post_json(&self, url: &str, body: &str) -> Result<Vec<u8>, SkillfileError> {
186        let mut response = self
187            .build_post(url)
188            .header("Content-Type", "application/json")
189            .send(body.as_bytes())
190            .map_err(|e| match &e {
191                ureq::Error::StatusCode(code) => {
192                    SkillfileError::Network(format!("HTTP {code} posting to {url}"))
193                }
194                _ => SkillfileError::Network(format!("{e} posting to {url}")),
195            })?;
196        response.body_mut().read_to_vec().map_err(|e| {
197            SkillfileError::Network(format!("failed to read response from {url}: {e}"))
198        })
199    }
200
201    fn post_json_with_bearer(&self, req: &BearerPost<'_>) -> Result<Vec<u8>, SkillfileError> {
202        let (url, token) = (req.url, req.token);
203        let mut response = self
204            .agent
205            .post(url)
206            .header("User-Agent", "skillfile/1.0")
207            .header("Content-Type", "application/json")
208            .header("Authorization", &format!("Bearer {token}"))
209            .send(req.body.as_bytes())
210            .map_err(|e| match &e {
211                ureq::Error::StatusCode(code) => {
212                    SkillfileError::Network(format!("HTTP {code} posting to {url}"))
213                }
214                _ => SkillfileError::Network(format!("{e} posting to {url}")),
215            })?;
216        response.body_mut().read_to_vec().map_err(|e| {
217            SkillfileError::Network(format!("failed to read response from {url}: {e}"))
218        })
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn ureq_client_default_creates_successfully() {
228        let _client = UreqClient::default();
229    }
230}