use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use crate::fetcher::FetchError;
const DEFAULT_HOST: &str = "github.com";
const USER_AGENT: &str = concat!("splashboard/", env!("CARGO_PKG_VERSION"));
fn read_env(var: &str) -> Option<String> {
std::env::var(var).ok().and_then(|raw| normalize_env(&raw))
}
fn normalize_env(raw: &str) -> Option<String> {
let v = raw.trim().trim_end_matches('/');
(!v.is_empty()).then(|| v.to_owned())
}
pub(crate) fn host() -> &'static str {
static HOST: OnceLock<String> = OnceLock::new();
HOST.get_or_init(|| {
read_env("GH_HOST")
.or_else(|| read_env("GITHUB_HOST"))
.unwrap_or_else(|| DEFAULT_HOST.into())
.to_ascii_lowercase()
})
.as_str()
}
pub(crate) fn api_base() -> &'static str {
static API_BASE: OnceLock<String> = OnceLock::new();
API_BASE.get_or_init(|| {
read_env("GH_API_URL")
.or_else(|| read_env("GITHUB_API_URL"))
.unwrap_or_else(|| format!("https://api.{}", host()))
})
}
pub(crate) fn graphql_url() -> &'static str {
static GRAPHQL_URL: OnceLock<String> = OnceLock::new();
GRAPHQL_URL.get_or_init(|| {
read_env("GH_GRAPHQL_URL")
.or_else(|| read_env("GITHUB_GRAPHQL_URL"))
.unwrap_or_else(|| format!("https://api.{}/graphql", host()))
})
}
pub(crate) fn web_base() -> &'static str {
static WEB_BASE: OnceLock<String> = OnceLock::new();
WEB_BASE.get_or_init(|| format!("https://{}", host()))
}
pub(crate) fn api_repos_prefix() -> &'static str {
static API_REPOS_PREFIX: OnceLock<String> = OnceLock::new();
API_REPOS_PREFIX.get_or_init(|| format!("{}/repos/", api_base()))
}
const ACCEPT: &str = "application/vnd.github+json";
const API_VERSION: &str = "2022-11-28";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
static AUTHENTICATED_USER_CACHE: OnceLock<Mutex<Option<String>>> = OnceLock::new();
pub fn http() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.gzip(true)
.build()
.expect("reqwest client should build with default config")
})
}
pub fn resolve_token() -> Result<String, FetchError> {
std::env::var("GH_TOKEN")
.or_else(|_| std::env::var("GITHUB_TOKEN"))
.map_err(|_| FetchError::Failed("GH_TOKEN / GITHUB_TOKEN not set".into()))
}
pub async fn resolve_authenticated_user() -> Result<String, FetchError> {
let slot = AUTHENTICATED_USER_CACHE.get_or_init(|| Mutex::new(None));
if let Some(cached) = slot.lock().ok().and_then(|g| g.clone()) {
return Ok(cached);
}
#[derive(Deserialize)]
struct Me {
login: String,
}
let me: Me = rest_get("/user").await?;
if let Ok(mut g) = slot.lock() {
*g = Some(me.login.clone());
}
Ok(me.login)
}
#[cfg(test)]
pub(crate) fn clear_authenticated_user_cache() {
if let Ok(mut guard) = AUTHENTICATED_USER_CACHE
.get_or_init(|| Mutex::new(None))
.lock()
{
*guard = None;
}
}
pub async fn rest_get<T: DeserializeOwned>(path: &str) -> Result<T, FetchError> {
let token = resolve_token()?;
let url = format!("{}{path}", api_base());
let res = http()
.get(&url)
.bearer_auth(&token)
.header("Accept", ACCEPT)
.header("X-GitHub-Api-Version", API_VERSION)
.send()
.await
.map_err(|e| FetchError::Failed(format!("github request failed: {e}")))?;
parse_json(res).await
}
pub async fn graphql<T: DeserializeOwned>(
query: &str,
variables: serde_json::Value,
) -> Result<T, FetchError> {
let token = resolve_token()?;
let body = serde_json::json!({ "query": query, "variables": variables });
let res = http()
.post(graphql_url())
.bearer_auth(&token)
.header("Accept", ACCEPT)
.header("X-GitHub-Api-Version", API_VERSION)
.json(&body)
.send()
.await
.map_err(|e| FetchError::Failed(format!("github graphql request failed: {e}")))?;
let wrapper: GqlResponse<T> = parse_json(res).await?;
if let Some(errs) = wrapper.errors.filter(|e| !e.is_empty()) {
let joined = errs
.iter()
.map(|e| e.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(FetchError::Failed(format!("github graphql: {joined}")));
}
wrapper
.data
.ok_or_else(|| FetchError::Failed("github graphql: empty data".into()))
}
async fn parse_json<T: DeserializeOwned>(res: reqwest::Response) -> Result<T, FetchError> {
let status = res.status();
let bytes = res
.bytes()
.await
.map_err(|e| FetchError::Failed(format!("github response body: {e}")))?;
if !status.is_success() {
return Err(FetchError::Failed(error_message(status, &bytes)));
}
serde_json::from_slice(&bytes)
.map_err(|e| FetchError::Failed(format!("github json parse: {e}")))
}
fn error_message(status: StatusCode, body: &[u8]) -> String {
#[derive(Deserialize)]
struct ApiError {
message: Option<String>,
}
let reported = serde_json::from_slice::<ApiError>(body)
.ok()
.and_then(|e| e.message);
match reported {
Some(m) => format!("github {status}: {m}"),
None => format!("github {status}"),
}
}
#[derive(Debug, Deserialize)]
struct GqlResponse<T> {
data: Option<T>,
#[serde(default)]
errors: Option<Vec<GqlError>>,
}
#[derive(Debug, Deserialize)]
struct GqlError {
message: String,
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use serde::Deserialize;
use super::*;
#[derive(Debug, Deserialize)]
struct TestPayload {
login: String,
}
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
restore: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
fn set(pairs: &[(&'static str, Option<&str>)]) -> Self {
let lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let restore = pairs
.iter()
.map(|(k, v)| {
let prev = std::env::var(k).ok();
match v {
Some(value) => unsafe { std::env::set_var(k, value) },
None => unsafe { std::env::remove_var(k) },
}
(*k, prev)
})
.collect();
Self {
_lock: lock,
restore,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.restore {
match v {
Some(value) => unsafe { std::env::set_var(k, value) },
None => unsafe { std::env::remove_var(k) },
}
}
}
}
#[test]
fn normalize_env_strips_trailing_slash() {
assert_eq!(
normalize_env("https://ghe.example.com/api/v3/").as_deref(),
Some("https://ghe.example.com/api/v3")
);
}
#[test]
fn normalize_env_handles_whitespace_and_empty() {
assert_eq!(
normalize_env(" github.com ").as_deref(),
Some("github.com")
);
assert_eq!(normalize_env(" ").as_deref(), None);
assert_eq!(normalize_env("").as_deref(), None);
assert_eq!(normalize_env("/").as_deref(), None);
}
#[test]
fn normalize_env_preserves_clean_values() {
assert_eq!(
normalize_env("https://ghe.example.com/api/v3").as_deref(),
Some("https://ghe.example.com/api/v3")
);
assert_eq!(normalize_env("github.com").as_deref(), Some("github.com"));
}
#[test]
fn resolve_token_prefers_gh_token() {
let _g = EnvGuard::set(&[
("GH_TOKEN", Some("from-gh")),
("GITHUB_TOKEN", Some("from-github")),
]);
assert_eq!(resolve_token().unwrap(), "from-gh");
}
#[test]
fn resolve_token_falls_back_to_github_token() {
let _g = EnvGuard::set(&[("GH_TOKEN", None), ("GITHUB_TOKEN", Some("fallback"))]);
assert_eq!(resolve_token().unwrap(), "fallback");
}
#[test]
fn resolve_token_fails_when_both_missing() {
let _g = EnvGuard::set(&[("GH_TOKEN", None), ("GITHUB_TOKEN", None)]);
assert!(resolve_token().is_err());
}
#[test]
fn http_reuses_the_same_client() {
assert!(std::ptr::eq(http(), http()));
}
#[test]
fn resolve_authenticated_user_returns_cached_login() {
let _g = EnvGuard::set(&[("GH_TOKEN", None), ("GITHUB_TOKEN", None)]);
clear_authenticated_user_cache();
*AUTHENTICATED_USER_CACHE
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap() = Some("octocat".into());
assert_eq!(run_async(resolve_authenticated_user()).unwrap(), "octocat");
clear_authenticated_user_cache();
}
#[test]
fn resolve_authenticated_user_requires_a_token_without_cache() {
let _g = EnvGuard::set(&[("GH_TOKEN", None), ("GITHUB_TOKEN", None)]);
clear_authenticated_user_cache();
let err = run_async(resolve_authenticated_user()).unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message == "GH_TOKEN / GITHUB_TOKEN not set"
));
}
#[test]
fn graphql_requires_a_token_before_sending() {
let _g = EnvGuard::set(&[("GH_TOKEN", None), ("GITHUB_TOKEN", None)]);
let err = run_async(graphql::<TestPayload>(
"query { viewer { login } }",
serde_json::json!({}),
))
.unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message == "GH_TOKEN / GITHUB_TOKEN not set"
));
}
#[test]
fn rest_get_send_error_surfaces_request_failed() {
let _g = EnvGuard::set(&[("GH_TOKEN", Some("tok\nbreak")), ("GITHUB_TOKEN", None)]);
let err = run_async(rest_get::<TestPayload>("/user")).unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message.starts_with("github request failed:")
));
}
#[test]
fn graphql_send_error_surfaces_request_failed() {
let _g = EnvGuard::set(&[("GH_TOKEN", Some("tok\nbreak")), ("GITHUB_TOKEN", None)]);
let err = run_async(graphql::<TestPayload>(
"query { viewer { login } }",
serde_json::json!({}),
))
.unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message.starts_with("github graphql request failed:")
));
}
#[test]
fn resolve_authenticated_user_propagates_rest_get_error_on_cache_miss() {
let _g = EnvGuard::set(&[("GH_TOKEN", Some("tok\nbreak")), ("GITHUB_TOKEN", None)]);
clear_authenticated_user_cache();
let err = run_async(resolve_authenticated_user()).unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message.starts_with("github request failed:")
));
}
#[test]
fn parse_json_deserializes_success_bodies() {
let payload = parse_test_payload("200 OK", r#"{"login":"octocat"}"#).unwrap();
assert_eq!(payload.login, "octocat");
}
#[test]
fn parse_json_surfaces_reported_api_messages() {
let err = parse_test_payload("404 Not Found", r#"{"message":"Not Found"}"#).unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message == "github 404 Not Found: Not Found"
));
}
#[test]
fn parse_json_falls_back_to_status_without_a_message() {
let err = parse_test_payload("500 Internal Server Error", "{}").unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message == "github 500 Internal Server Error"
));
}
#[test]
fn parse_json_surfaces_json_parse_errors() {
let err = parse_test_payload("200 OK", "not-json").unwrap_err();
assert!(matches!(
err,
FetchError::Failed(message) if message.contains("github json parse")
));
}
fn parse_test_payload(status: &str, body: &str) -> Result<TestPayload, FetchError> {
let (url, server) = serve_once(status, body);
let response = run_async(async {
let response = http().get(&url).send().await.unwrap();
parse_json::<TestPayload>(response).await
});
server.join().unwrap();
response
}
fn serve_once(status: &str, body: &str) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let status = status.to_owned();
let body = body.to_owned();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 1024];
let _ = stream.read(&mut request);
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
});
(format!("http://{addr}"), handle)
}
fn run_async<T>(future: impl Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
}