upstream_ontologist/
github.rs1use crate::{HTTPJSONError, UpstreamDatum};
9use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION};
10
11const API_BASE: &str = "https://api.github.com";
13
14const RAW_BASE: &str = "https://raw.githubusercontent.com";
16
17fn auth_headers(accept: &'static str) -> HeaderMap {
20 let mut headers = HeaderMap::new();
21 headers.insert(ACCEPT, HeaderValue::from_static(accept));
22 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
23 if let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", token)) {
24 headers.insert(AUTHORIZATION, value);
25 }
26 }
27 headers
28}
29
30async fn fetch(url: &str, accept: &'static str) -> Result<reqwest::Response, HTTPJSONError> {
31 let client = crate::http::build_client()
32 .default_headers(auth_headers(accept))
33 .build()
34 .map_err(HTTPJSONError::HTTPError)?;
35
36 let response = client
37 .get(url)
38 .send()
39 .await
40 .map_err(HTTPJSONError::HTTPError)?;
41
42 if !response.status().is_success() {
43 return Err(HTTPJSONError::Error {
44 url: response.url().clone(),
45 status: response.status().as_u16(),
46 response: Box::new(response),
47 });
48 }
49
50 Ok(response)
51}
52
53pub async fn load_github_json(path: &str) -> Result<serde_json::Value, HTTPJSONError> {
59 let url = format!("{}/{}", API_BASE, path.trim_start_matches('/'));
60 let response = fetch(&url, "application/vnd.github+json").await?;
61 response.json().await.map_err(HTTPJSONError::HTTPError)
62}
63
64pub async fn download_raw_file(
70 owner: &str,
71 repo: &str,
72 reference: &str,
73 path: &str,
74) -> Result<String, HTTPJSONError> {
75 let url = format!(
76 "{}/{}/{}/{}/{}",
77 RAW_BASE,
78 owner,
79 repo,
80 reference,
81 path.trim_start_matches('/')
82 );
83 let response = fetch(&url, "text/plain").await?;
84 response.text().await.map_err(HTTPJSONError::HTTPError)
85}
86
87pub async fn download_contents(
93 owner: &str,
94 repo: &str,
95 path: &str,
96 reference: Option<&str>,
97) -> Result<Vec<u8>, crate::ProviderError> {
98 let mut url = format!(
99 "{}/repos/{}/{}/contents/{}",
100 API_BASE,
101 owner,
102 repo,
103 path.trim_start_matches('/')
104 );
105 if let Some(reference) = reference {
106 url.push_str(&format!("?ref={}", reference));
107 }
108
109 let response = fetch(&url, "application/vnd.github+json").await?;
110 let data: serde_json::Value = response.json().await.map_err(HTTPJSONError::HTTPError)?;
111
112 let encoding = data["encoding"].as_str();
113 let content = data["content"].as_str().ok_or_else(|| {
114 crate::ProviderError::ParseError("contents API response missing content".to_string())
115 })?;
116
117 match encoding {
118 Some("base64") => {
119 use base64::Engine;
120 let stripped: String = content.chars().filter(|c| !c.is_whitespace()).collect();
122 base64::engine::general_purpose::STANDARD
123 .decode(stripped.as_bytes())
124 .map_err(|e| {
125 crate::ProviderError::ParseError(format!("invalid base64 content: {}", e))
126 })
127 }
128 other => Err(crate::ProviderError::ParseError(format!(
129 "unexpected contents encoding: {:?}",
130 other
131 ))),
132 }
133}
134
135#[derive(serde::Deserialize, Debug, Clone)]
140pub struct RepoMetadata {
141 pub description: Option<String>,
143 pub homepage: Option<String>,
145 pub html_url: Option<String>,
147 pub license: Option<License>,
149 #[serde(default)]
151 pub archived: bool,
152}
153
154#[derive(serde::Deserialize, Debug, Clone)]
156pub struct License {
157 pub spdx_id: Option<String>,
160}
161
162pub async fn repo_metadata(
166 owner: &str,
167 repo: &str,
168) -> Result<Option<RepoMetadata>, crate::ProviderError> {
169 let path = format!("repos/{}/{}", owner, repo);
170 match load_github_json(&path).await {
171 Ok(value) => serde_json::from_value(value).map(Some).map_err(|e| {
172 crate::ProviderError::ParseError(format!("Failed to parse repo metadata: {}", e))
173 }),
174 Err(HTTPJSONError::Error { status: 404, .. }) => Ok(None),
175 Err(e) => Err(e.into()),
176 }
177}
178
179impl RepoMetadata {
180 pub fn to_upstream_data(&self) -> Vec<UpstreamDatum> {
182 let mut results = Vec::new();
183 if let Some(description) = self.description.as_deref() {
184 if !description.is_empty() {
185 results.push(UpstreamDatum::Summary(description.to_string()));
186 }
187 }
188 if let Some(homepage) = self.homepage.as_deref() {
189 if !homepage.is_empty() {
190 results.push(UpstreamDatum::Homepage(homepage.to_string()));
191 }
192 }
193 if let Some(html_url) = self.html_url.as_deref() {
194 results.push(UpstreamDatum::Repository(html_url.to_string()));
195 }
196 if let Some(spdx) = self.license.as_ref().and_then(|l| l.spdx_id.as_deref()) {
197 if !spdx.is_empty() && spdx != "NOASSERTION" {
198 results.push(UpstreamDatum::License(spdx.to_string()));
199 }
200 }
201 results
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn test_parse_repo_metadata() {
211 let data = r#"{
212 "description": "Serialization framework for Rust",
213 "homepage": "https://serde.rs/",
214 "html_url": "https://github.com/serde-rs/serde",
215 "license": {"spdx_id": "Apache-2.0"},
216 "archived": false
217 }"#;
218 let meta: RepoMetadata = serde_json::from_str(data).unwrap();
219 assert_eq!(
220 meta.description.as_deref(),
221 Some("Serialization framework for Rust")
222 );
223 assert_eq!(
224 meta.license.as_ref().unwrap().spdx_id.as_deref(),
225 Some("Apache-2.0")
226 );
227
228 let data = meta.to_upstream_data();
229 assert_eq!(
230 data,
231 vec![
232 UpstreamDatum::Summary("Serialization framework for Rust".to_string()),
233 UpstreamDatum::Homepage("https://serde.rs/".to_string()),
234 UpstreamDatum::Repository("https://github.com/serde-rs/serde".to_string()),
235 UpstreamDatum::License("Apache-2.0".to_string()),
236 ]
237 );
238 }
239
240 #[test]
241 fn test_noassertion_license_dropped() {
242 let data = r#"{"license": {"spdx_id": "NOASSERTION"}}"#;
243 let meta: RepoMetadata = serde_json::from_str(data).unwrap();
244 assert_eq!(meta.to_upstream_data(), vec![]);
245 }
246}