skillfile_sources/
http.rs1use std::process::Command;
2use std::sync::OnceLock;
3
4use skillfile_core::error::SkillfileError;
5
6static TOKEN_CACHE: OnceLock<Option<String>> = OnceLock::new();
11
12#[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
41pub struct BearerPost<'a> {
47 pub url: &'a str,
48 pub body: &'a str,
49 pub token: &'a str,
50}
51
52pub trait HttpClient: Send + Sync {
64 fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError>;
68
69 fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError>;
75
76 fn post_json(&self, url: &str, body: &str) -> Result<Vec<u8>, SkillfileError>;
80
81 fn post_json_with_bearer(&self, req: &BearerPost<'_>) -> Result<Vec<u8>, SkillfileError> {
90 self.post_json(req.url, req.body)
91 }
92}
93
94fn 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
103pub struct UreqClient {
109 agent: ureq::Agent,
110}
111
112impl UreqClient {
113 pub fn new() -> Self {
114 let config = ureq::config::Config::builder()
115 .redirect_auth_headers(ureq::config::RedirectAuthHeaders::SameHost)
119 .build();
120 Self {
121 agent: ureq::Agent::new_with_config(config),
122 }
123 }
124
125 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 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 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}