use std::path::Path;
use std::time::Duration;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::token::{Source, Token};
pub const USER_AGENT: &str = concat!("eish/", env!("CARGO_PKG_VERSION"));
pub const DEFAULT_API_BASE: &str = "https://api.github.com";
#[derive(Debug, Clone, Deserialize)]
pub struct Release {
#[serde(default)]
pub tag_name: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub assets: Vec<Asset>,
}
impl Release {
pub fn tag(&self, fallback: &str) -> String {
self.tag_name
.as_deref()
.filter(|tag| !tag.is_empty())
.unwrap_or(fallback)
.to_string()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Asset {
pub name: String,
#[serde(default)]
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct Client {
api_base: String,
token: Option<Token>,
agent: ureq::Agent,
}
impl Default for Client {
fn default() -> Self {
Self::new()
}
}
impl Client {
pub fn new() -> Self {
let config = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.user_agent(USER_AGENT)
.build();
Self {
api_base: DEFAULT_API_BASE.to_string(),
token: None,
agent: ureq::Agent::new_with_config(config),
}
}
#[must_use]
pub fn with_api_base(mut self, api_base: impl Into<String>) -> Self {
self.api_base = api_base.into().trim_end_matches('/').to_string();
self
}
#[must_use]
pub fn with_token(mut self, token: Option<String>) -> Self {
self.token = token.and_then(Token::explicit);
self
}
fn auth_token(&self) -> Option<&Token> {
match &self.token {
Some(token) => Some(token),
None => Token::detect().filter(|token| token.may_send_to(&self.api_base)),
}
}
pub fn credential_source(&self) -> Option<Source> {
self.auth_token().map(Token::source)
}
pub fn release(&self, owner: &str, repo: &str, tag: Option<&str>) -> Result<Release> {
let repo_slug = format!("{owner}/{repo}");
let url = match tag {
None | Some("latest") => {
format!("{}/repos/{repo_slug}/releases/latest", self.api_base)
}
Some(tag) => format!("{}/repos/{repo_slug}/releases/tags/{tag}", self.api_base),
};
let mut request = self
.agent
.get(&url)
.header("Accept", "application/vnd.github+json");
let token = self.auth_token();
if let Some(token) = token {
request = request.header("Authorization", format!("Bearer {}", token.value()));
}
let mut response = request
.call()
.map_err(|source| map_request_error(&url, tag, token, source))?;
let body = response
.body_mut()
.read_to_string()
.map_err(|source| Error::Http {
url: url.clone(),
source: Box::new(source),
})?;
serde_json::from_str(&body).map_err(|source| Error::Decode { source })
}
}
pub fn release_from_json_file(path: impl AsRef<Path>) -> Result<Release> {
let path = path.as_ref();
let data = std::fs::read_to_string(path).map_err(|source| Error::ReadFile {
path: path.to_path_buf(),
source,
})?;
serde_json::from_str(&data).map_err(|source| Error::Decode { source })
}
fn map_request_error(
url: &str,
tag: Option<&str>,
token: Option<&Token>,
source: ureq::Error,
) -> Error {
let ureq::Error::StatusCode(status @ (404 | 403 | 401 | 429)) = source else {
return Error::Http {
url: url.to_string(),
source: Box::new(source),
};
};
if status == 404 && token.is_none() {
return Error::ApiStatus {
status,
url: url.to_string(),
hint: "not found; if the repository is private, \
set GITHUB_TOKEN or run `gh auth login`",
};
}
let repo = url
.split("/repos/")
.nth(1)
.map(|rest| rest.split('/').take(2).collect::<Vec<_>>().join("/"))
.unwrap_or_default();
if status == 404 {
return Error::ReleaseNotFound {
repo,
tag: tag.unwrap_or("latest").to_string(),
};
}
let hint = match (status, token.map(Token::source)) {
(401, _) => "the credentials were rejected; refresh the token or run `gh auth login`",
(_, Some(source)) => match source {
Source::Explicit => "the token was rejected or has no access to this repository",
Source::Environment => "$GITHUB_TOKEN was rejected or has no access to this repository",
Source::GhCli => {
"the token from `gh auth token` has no access to this repository; \
run `gh auth refresh`"
}
Source::GitCredential => {
"the token from git credentials has no access to this repository; \
run `gh auth login`"
}
},
(403 | 429, None) => {
"the anonymous rate limit (60 requests/hour) is exhausted and no credentials \
were found; set GITHUB_TOKEN or run `gh auth login`"
}
(_, None) => "the request was refused; set GITHUB_TOKEN or run `gh auth login`",
};
Error::ApiStatus {
status,
url: url.to_string(),
hint,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialises_a_release() {
let json = r#"{
"tag_name": "v1.2.3",
"name": "Release 1.2.3",
"assets": [
{"name": "tool-x86_64-unknown-linux-musl.tar.gz", "size": 100}
]
}"#;
let release: Release = serde_json::from_str(json).unwrap();
assert_eq!(release.tag("latest"), "v1.2.3");
assert_eq!(release.assets.len(), 1);
assert_eq!(
release.assets[0].name,
"tool-x86_64-unknown-linux-musl.tar.gz"
);
assert_eq!(release.assets[0].size, 100);
}
#[test]
fn tolerates_partial_releases() {
let release: Release = serde_json::from_str("{}").unwrap();
assert_eq!(release.tag("latest"), "latest");
assert!(release.assets.is_empty());
}
#[test]
fn maps_404_to_release_not_found() {
let error = map_request_error(
"https://api.github.com/repos/foo/bar/releases/tags/v1",
Some("v1"),
Some(&Token::new("t", Source::Explicit)),
ureq::Error::StatusCode(404),
);
assert!(matches!(error, Error::ReleaseNotFound { .. }));
assert!(error.to_string().contains("foo/bar"));
}
#[test]
fn an_unauthenticated_404_mentions_private_repositories() {
let error = map_request_error(
"https://api.github.com/repos/foo/bar/releases/latest",
None,
None,
ureq::Error::StatusCode(404),
);
assert!(matches!(error, Error::ApiStatus { status: 404, .. }));
assert!(error.to_string().contains("private"), "{error}");
}
#[test]
fn rate_limit_hint_depends_on_whether_a_token_was_used() {
let anonymous = map_request_error(
"https://api.github.com/repos/foo/bar/releases/latest",
None,
None,
ureq::Error::StatusCode(403),
);
assert!(anonymous.to_string().contains("rate limit"), "{anonymous}");
let authenticated = map_request_error(
"https://api.github.com/repos/foo/bar/releases/latest",
None,
Some(&Token::new("t", Source::Explicit)),
ureq::Error::StatusCode(403),
);
assert!(
!authenticated.to_string().contains("rate limit"),
"{authenticated}"
);
assert!(
authenticated.to_string().contains("rejected"),
"{authenticated}"
);
assert!(!authenticated.to_string().contains("--token"));
let from_gh = map_request_error(
"https://api.github.com/repos/foo/bar/releases/latest",
None,
Some(&Token::new("t", Source::GhCli)),
ureq::Error::StatusCode(403),
);
assert!(from_gh.to_string().contains("gh auth"), "{from_gh}");
}
#[test]
fn a_rejected_token_is_reported_as_such() {
let error = map_request_error(
"https://api.github.com/repos/foo/bar/releases/latest",
None,
Some(&Token::new("t", Source::Environment)),
ureq::Error::StatusCode(401),
);
assert!(error.to_string().contains("rejected"), "{error}");
}
}