Support private github repository (#1690)

* Refactor: Create new crate binstalk-git-repo-api

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix CI lint warnings

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix `just check`: Rm deleted features from `cargo-hack` check

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Extract  new mod error

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Ret artifact url in `has_release_artifact`

So that we can use it to download from private repositories.

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Move `test_graph_ql_error_type` to mod `error`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix running `cargo test` in `binstalk-git-repo-api``

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Remove unnecessary import in mod `error::test`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Rename mod `request`` to `release_artifacts`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Impl draft version of fetching repo info

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Move `HasReleaseArtifacts` failure variants into `GhApiError`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Use `GhRepo` in `GhRelease`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix testing

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Return `'static` future

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Make sure `'static` Future is returned

To make it easier to create generic function

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add logging to unit testing

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix unit testing

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Extract new fn `GhApiClient::do_fetch`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Rm unused `percent_encode_http_url_path`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix `cargo test` run on CI

`cargo test` run all tests in one process.

As such, `set_global_default` would fail on the second call.

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Optimize `GhApiClient::do_fetch`: Avoid unnecessary restful API call

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Rm param `auth_token` for restful API fn

which is always set to `None`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Impl new API `GhApiClient::get_repo_info`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix unit test for `GhApiClient::get_repo_info`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor testing: Parameter-ize testing

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Parallelise `test_get_repo_info`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: Create parameter-ised `test_has_release_artifact`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Parallelize `test_has_release_artifact`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Refactor: `gh_api_client::test::create_client` shall not be `async`

as there is no `.await` in it.

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Return `Url` in `GhApiClient::has_release_artifact`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Impl new API `GhApiClient::download_artifact`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Remove unused deps added to binstalk-git-repo-api

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix clippy lints

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add new API `GhApiClient::remote_client`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add `GhApiClient::has_gh_token`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add `GhRepo::try_extract_from_url`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Rename `ReleaseArtifactUrl` to `GhReleaseArtifactUrl`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add new fn `Download::with_data_verifier`

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* feature: Support private repository

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix clippy lints

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add e2e-test/private-github-repo

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix clippy lints

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix `launch_baseline_find_tasks`: Retry on rate limit

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix test failure: Retry on rate limit

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Temporarily enable debug output for e2e-test-private-github-repo

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix `get_repo_info`: Retry on rate limit

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Improve `debug!` logging

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add more debug logging

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add more debugging

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add more debug logging

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Apply suggestions from code review

* Fix compilation

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Fix cargo fmt

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>

* Add crate binstalk-git-repo-api to release-pr.yml

* Update crates/binstalk-git-repo-api/Cargo.toml

* Apply suggestions from code review

* Update crates/binstalk/Cargo.toml

---------

Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>
This commit is contained in:
Jiahao XU 2024-06-10 16:02:12 +10:00 committed by GitHub
parent 48ee0b0e3e
commit 1dbd2460a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1838 additions and 1127 deletions

View file

@ -0,0 +1,703 @@
use std::{
collections::HashMap,
future::Future,
ops::Deref,
sync::{
atomic::{AtomicBool, Ordering::Relaxed},
Arc, Mutex, RwLock,
},
time::{Duration, Instant},
};
use binstalk_downloader::{download::Download, remote};
use compact_str::{format_compact, CompactString, ToCompactString};
use tokio::sync::OnceCell;
use tracing::instrument;
use url::Url;
mod common;
mod error;
mod release_artifacts;
mod repo_info;
use common::{check_http_status_and_header, percent_decode_http_url_path};
pub use error::{GhApiContextError, GhApiError, GhGraphQLErrors};
pub use repo_info::RepoInfo;
/// default retry duration if x-ratelimit-reset is not found in response header
const DEFAULT_RETRY_DURATION: Duration = Duration::from_secs(10 * 60);
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct GhRepo {
pub owner: CompactString,
pub repo: CompactString,
}
impl GhRepo {
pub fn repo_url(&self) -> Result<Url, url::ParseError> {
Url::parse(&format_compact!(
"https://github.com/{}/{}",
self.owner,
self.repo
))
}
pub fn try_extract_from_url(url: &Url) -> Option<Self> {
if url.domain() != Some("github.com") {
return None;
}
let mut path_segments = url.path_segments()?;
Some(Self {
owner: path_segments.next()?.to_compact_string(),
repo: path_segments.next()?.to_compact_string(),
})
}
}
/// The keys required to identify a github release.
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct GhRelease {
pub repo: GhRepo,
pub tag: CompactString,
}
/// The Github Release and one of its artifact.
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct GhReleaseArtifact {
pub release: GhRelease,
pub artifact_name: CompactString,
}
impl GhReleaseArtifact {
/// Create [`GhReleaseArtifact`] from url.
pub fn try_extract_from_url(url: &remote::Url) -> Option<Self> {
if url.domain() != Some("github.com") {
return None;
}
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?;
if (path_segments.next()?, path_segments.next()?) != ("releases", "download") {
return None;
}
let tag = path_segments.next()?;
let artifact_name = path_segments.next()?;
(path_segments.next().is_none() && url.fragment().is_none() && url.query().is_none()).then(
|| Self {
release: GhRelease {
repo: GhRepo {
owner: percent_decode_http_url_path(owner),
repo: percent_decode_http_url_path(repo),
},
tag: percent_decode_http_url_path(tag),
},
artifact_name: percent_decode_http_url_path(artifact_name),
},
)
}
}
#[derive(Debug)]
struct Map<K, V>(RwLock<HashMap<K, Arc<V>>>);
impl<K, V> Default for Map<K, V> {
fn default() -> Self {
Self(Default::default())
}
}
impl<K, V> Map<K, V>
where
K: Eq + std::hash::Hash,
V: Default,
{
fn get(&self, k: K) -> Arc<V> {
let optional_value = self.0.read().unwrap().deref().get(&k).cloned();
optional_value.unwrap_or_else(|| Arc::clone(self.0.write().unwrap().entry(k).or_default()))
}
}
#[derive(Debug)]
struct Inner {
client: remote::Client,
release_artifacts: Map<GhRelease, OnceCell<Option<release_artifacts::Artifacts>>>,
retry_after: Mutex<Option<Instant>>,
auth_token: Option<CompactString>,
is_auth_token_valid: AtomicBool,
}
/// Github API client for querying whether a release artifact exitsts.
/// Can only handle github.com for now.
#[derive(Clone, Debug)]
pub struct GhApiClient(Arc<Inner>);
impl GhApiClient {
pub fn new(client: remote::Client, auth_token: Option<CompactString>) -> Self {
Self(Arc::new(Inner {
client,
release_artifacts: Default::default(),
retry_after: Default::default(),
auth_token,
is_auth_token_valid: AtomicBool::new(true),
}))
}
pub fn remote_client(&self) -> &remote::Client {
&self.0.client
}
}
impl GhApiClient {
fn check_retry_after(&self) -> Result<(), GhApiError> {
let mut guard = self.0.retry_after.lock().unwrap();
if let Some(retry_after) = *guard {
if retry_after.elapsed().is_zero() {
return Err(GhApiError::RateLimit {
retry_after: Some(retry_after - Instant::now()),
});
} else {
// Instant retry_after is already reached.
*guard = None;
}
}
Ok(())
}
fn get_auth_token(&self) -> Option<&str> {
if self.0.is_auth_token_valid.load(Relaxed) {
self.0.auth_token.as_deref()
} else {
None
}
}
pub fn has_gh_token(&self) -> bool {
self.get_auth_token().is_some()
}
async fn do_fetch<T, U, GraphQLFn, RestfulFn, GraphQLFut, RestfulFut>(
&self,
graphql_func: GraphQLFn,
restful_func: RestfulFn,
data: &T,
) -> Result<U, GhApiError>
where
GraphQLFn: Fn(&remote::Client, &T, &str) -> GraphQLFut,
RestfulFn: Fn(&remote::Client, &T) -> RestfulFut,
GraphQLFut: Future<Output = Result<U, GhApiError>> + Send + Sync + 'static,
RestfulFut: Future<Output = Result<U, GhApiError>> + Send + Sync + 'static,
{
self.check_retry_after()?;
if let Some(auth_token) = self.get_auth_token() {
match graphql_func(&self.0.client, data, auth_token).await {
Err(GhApiError::Unauthorized) => {
self.0.is_auth_token_valid.store(false, Relaxed);
}
res => return res.map_err(|err| err.context("GraphQL API")),
}
}
restful_func(&self.0.client, data)
.await
.map_err(|err| err.context("Restful API"))
}
#[instrument(level = "debug", skip(self), ret)]
pub async fn get_repo_info(&self, repo: &GhRepo) -> Result<Option<RepoInfo>, GhApiError> {
match self
.do_fetch(
repo_info::fetch_repo_info_graphql_api,
repo_info::fetch_repo_info_restful_api,
repo,
)
.await
{
Ok(repo_info) => Ok(repo_info),
Err(GhApiError::NotFound) => Ok(None),
Err(err) => Err(err),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct GhReleaseArtifactUrl(Url);
impl GhApiClient {
/// Return `Ok(Some(api_artifact_url))` if exists.
///
/// The returned future is guaranteed to be pointer size.
#[instrument(level = "debug", skip(self), ret)]
pub async fn has_release_artifact(
&self,
GhReleaseArtifact {
release,
artifact_name,
}: GhReleaseArtifact,
) -> Result<Option<GhReleaseArtifactUrl>, GhApiError> {
let once_cell = self.0.release_artifacts.get(release.clone());
let res = once_cell
.get_or_try_init(|| {
Box::pin(async {
match self
.do_fetch(
release_artifacts::fetch_release_artifacts_graphql_api,
release_artifacts::fetch_release_artifacts_restful_api,
&release,
)
.await
{
Ok(artifacts) => Ok(Some(artifacts)),
Err(GhApiError::NotFound) => Ok(None),
Err(err) => Err(err),
}
})
})
.await;
match res {
Ok(Some(artifacts)) => Ok(artifacts
.get_artifact_url(&artifact_name)
.map(GhReleaseArtifactUrl)),
Ok(None) => Ok(None),
Err(GhApiError::RateLimit { retry_after }) => {
*self.0.retry_after.lock().unwrap() =
Some(Instant::now() + retry_after.unwrap_or(DEFAULT_RETRY_DURATION));
Err(GhApiError::RateLimit { retry_after })
}
Err(err) => Err(err),
}
}
pub async fn download_artifact(
&self,
artifact_url: GhReleaseArtifactUrl,
) -> Result<Download<'static>, GhApiError> {
self.check_retry_after()?;
let Some(auth_token) = self.get_auth_token() else {
return Err(GhApiError::Unauthorized);
};
let response = self
.0
.client
.get(artifact_url.0)
.header("Accept", "application/octet-stream")
.bearer_auth(&auth_token)
.send(false)
.await?;
match check_http_status_and_header(&response) {
Err(GhApiError::Unauthorized) => {
self.0.is_auth_token_valid.store(false, Relaxed);
}
res => res?,
}
Ok(Download::from_response(response))
}
}
#[cfg(test)]
mod test {
use super::*;
use compact_str::{CompactString, ToCompactString};
use std::{env, num::NonZeroU16, time::Duration};
use tokio::time::sleep;
use tracing::subscriber::set_global_default;
use tracing_subscriber::{filter::LevelFilter, fmt::fmt};
static DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(1);
mod cargo_binstall_v0_20_1 {
use super::{CompactString, GhRelease, GhRepo};
pub(super) const RELEASE: GhRelease = GhRelease {
repo: GhRepo {
owner: CompactString::new_inline("cargo-bins"),
repo: CompactString::new_inline("cargo-binstall"),
},
tag: CompactString::new_inline("v0.20.1"),
};
pub(super) const ARTIFACTS: &[&str] = &[
"cargo-binstall-aarch64-apple-darwin.full.zip",
"cargo-binstall-aarch64-apple-darwin.zip",
"cargo-binstall-aarch64-pc-windows-msvc.full.zip",
"cargo-binstall-aarch64-pc-windows-msvc.zip",
"cargo-binstall-aarch64-unknown-linux-gnu.full.tgz",
"cargo-binstall-aarch64-unknown-linux-gnu.tgz",
"cargo-binstall-aarch64-unknown-linux-musl.full.tgz",
"cargo-binstall-aarch64-unknown-linux-musl.tgz",
"cargo-binstall-armv7-unknown-linux-gnueabihf.full.tgz",
"cargo-binstall-armv7-unknown-linux-gnueabihf.tgz",
"cargo-binstall-armv7-unknown-linux-musleabihf.full.tgz",
"cargo-binstall-armv7-unknown-linux-musleabihf.tgz",
"cargo-binstall-universal-apple-darwin.full.zip",
"cargo-binstall-universal-apple-darwin.zip",
"cargo-binstall-x86_64-apple-darwin.full.zip",
"cargo-binstall-x86_64-apple-darwin.zip",
"cargo-binstall-x86_64-pc-windows-msvc.full.zip",
"cargo-binstall-x86_64-pc-windows-msvc.zip",
"cargo-binstall-x86_64-unknown-linux-gnu.full.tgz",
"cargo-binstall-x86_64-unknown-linux-gnu.tgz",
"cargo-binstall-x86_64-unknown-linux-musl.full.tgz",
"cargo-binstall-x86_64-unknown-linux-musl.tgz",
];
}
mod cargo_audit_v_0_17_6 {
use super::*;
pub(super) const RELEASE: GhRelease = GhRelease {
repo: GhRepo {
owner: CompactString::new_inline("rustsec"),
repo: CompactString::new_inline("rustsec"),
},
tag: CompactString::new_inline("cargo-audit/v0.17.6"),
};
pub(super) const ARTIFACTS: &[&str] = &[
"cargo-audit-aarch64-unknown-linux-gnu-v0.17.6.tgz",
"cargo-audit-armv7-unknown-linux-gnueabihf-v0.17.6.tgz",
"cargo-audit-x86_64-apple-darwin-v0.17.6.tgz",
"cargo-audit-x86_64-pc-windows-msvc-v0.17.6.zip",
"cargo-audit-x86_64-unknown-linux-gnu-v0.17.6.tgz",
"cargo-audit-x86_64-unknown-linux-musl-v0.17.6.tgz",
];
#[test]
fn extract_with_escaped_characters() {
let release_artifact = try_extract_artifact_from_str(
"https://github.com/rustsec/rustsec/releases/download/cargo-audit%2Fv0.17.6/cargo-audit-aarch64-unknown-linux-gnu-v0.17.6.tgz"
).unwrap();
assert_eq!(
release_artifact,
GhReleaseArtifact {
release: RELEASE,
artifact_name: CompactString::from(
"cargo-audit-aarch64-unknown-linux-gnu-v0.17.6.tgz",
)
}
);
}
}
#[test]
fn gh_repo_extract_from_and_to_url() {
[
"https://github.com/cargo-bins/cargo-binstall",
"https://github.com/rustsec/rustsec",
]
.into_iter()
.for_each(|url| {
let url = Url::parse(&url).unwrap();
assert_eq!(
GhRepo::try_extract_from_url(&url)
.unwrap()
.repo_url()
.unwrap(),
url
);
})
}
fn try_extract_artifact_from_str(s: &str) -> Option<GhReleaseArtifact> {
GhReleaseArtifact::try_extract_from_url(&url::Url::parse(s).unwrap())
}
fn assert_extract_gh_release_artifacts_failures(urls: &[&str]) {
for url in urls {
assert_eq!(try_extract_artifact_from_str(url), None);
}
}
#[test]
fn extract_gh_release_artifacts_failure() {
use cargo_binstall_v0_20_1::*;
let GhRelease {
repo: GhRepo { owner, repo },
tag,
} = RELEASE;
assert_extract_gh_release_artifacts_failures(&[
"https://examle.com",
"https://github.com",
&format!("https://github.com/{owner}"),
&format!("https://github.com/{owner}/{repo}"),
&format!("https://github.com/{owner}/{repo}/123e"),
&format!("https://github.com/{owner}/{repo}/releases/21343"),
&format!("https://github.com/{owner}/{repo}/releases/download"),
&format!("https://github.com/{owner}/{repo}/releases/download/{tag}"),
&format!("https://github.com/{owner}/{repo}/releases/download/{tag}/a/23"),
&format!("https://github.com/{owner}/{repo}/releases/download/{tag}/a#a=12"),
&format!("https://github.com/{owner}/{repo}/releases/download/{tag}/a?page=3"),
]);
}
#[test]
fn extract_gh_release_artifacts_success() {
use cargo_binstall_v0_20_1::*;
let GhRelease {
repo: GhRepo { owner, repo },
tag,
} = RELEASE;
for artifact in ARTIFACTS {
let GhReleaseArtifact {
release,
artifact_name,
} = try_extract_artifact_from_str(&format!(
"https://github.com/{owner}/{repo}/releases/download/{tag}/{artifact}"
))
.unwrap();
assert_eq!(release, RELEASE);
assert_eq!(artifact_name, artifact);
}
}
fn init_logger() {
// Disable time, target, file, line_num, thread name/ids to make the
// output more readable
let subscriber = fmt()
.without_time()
.with_target(false)
.with_file(false)
.with_line_number(false)
.with_thread_names(false)
.with_thread_ids(false)
.with_test_writer()
.with_max_level(LevelFilter::DEBUG)
.finish();
// Setup global subscriber
let _ = set_global_default(subscriber);
}
fn create_remote_client() -> remote::Client {
remote::Client::new(
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")),
None,
NonZeroU16::new(10).unwrap(),
1.try_into().unwrap(),
[],
)
.unwrap()
}
/// Mark this as an async fn so that you won't accidentally use it in
/// sync context.
fn create_client() -> Vec<GhApiClient> {
let client = create_remote_client();
let mut gh_clients = vec![GhApiClient::new(client.clone(), None)];
if let Ok(token) = env::var("GITHUB_TOKEN") {
gh_clients.push(GhApiClient::new(client, Some(token.into())));
}
gh_clients
}
#[tokio::test]
async fn test_get_repo_info() {
const PUBLIC_REPOS: [GhRepo; 1] = [GhRepo {
owner: CompactString::new_inline("cargo-bins"),
repo: CompactString::new_inline("cargo-binstall"),
}];
const PRIVATE_REPOS: [GhRepo; 1] = [GhRepo {
owner: CompactString::new_inline("cargo-bins"),
repo: CompactString::new_inline("private-repo-for-testing"),
}];
const NON_EXISTENT_REPOS: [GhRepo; 1] = [GhRepo {
owner: CompactString::new_inline("cargo-bins"),
repo: CompactString::new_inline("ttt"),
}];
init_logger();
let mut tests: Vec<(_, _)> = Vec::new();
for client in create_client() {
for repo in PUBLIC_REPOS {
let client = client.clone();
tests.push((
Some(RepoInfo::new(repo.clone(), false)),
tokio::spawn(async move { client.get_repo_info(&repo).await }),
));
}
for repo in NON_EXISTENT_REPOS {
let client = client.clone();
tests.push((
None,
tokio::spawn(async move { client.get_repo_info(&repo).await }),
));
}
if client.has_gh_token() {
for repo in PRIVATE_REPOS {
let client = client.clone();
tests.push((
Some(RepoInfo::new(repo.clone(), true)),
tokio::spawn(async move {
loop {
match client.get_repo_info(&repo).await {
Err(GhApiError::RateLimit { retry_after }) => {
sleep(retry_after.unwrap_or(DEFAULT_RETRY_AFTER)).await
}
res => break res,
}
}
}),
));
}
}
}
for (expected, task) in tests {
assert_eq!(task.await.unwrap().unwrap(), expected);
}
}
#[tokio::test]
async fn test_has_release_artifact_and_download_artifacts() {
const RELEASES: [(GhRelease, &[&str]); 2] = [
(
cargo_binstall_v0_20_1::RELEASE,
cargo_binstall_v0_20_1::ARTIFACTS,
),
(
cargo_audit_v_0_17_6::RELEASE,
cargo_audit_v_0_17_6::ARTIFACTS,
),
];
const NON_EXISTENT_RELEASES: [GhRelease; 1] = [GhRelease {
repo: GhRepo {
owner: CompactString::new_inline("cargo-bins"),
repo: CompactString::new_inline("cargo-binstall"),
},
// We are currently at v0.20.1 and we would never release
// anything older than v0.20.1
tag: CompactString::new_inline("v0.18.2"),
}];
init_logger();
let mut tasks = Vec::new();
for client in create_client() {
for (release, artifacts) in RELEASES {
for artifact_name in artifacts {
let client = client.clone();
let release = release.clone();
tasks.push(tokio::spawn(async move {
let artifact = GhReleaseArtifact {
release,
artifact_name: artifact_name.to_compact_string(),
};
let browser_download_task = client.get_auth_token().map(|_| {
tokio::spawn(
Download::new(
client.remote_client().clone(),
Url::parse(&format!(
"https://github.com/{}/{}/releases/download/{}/{}",
artifact.release.repo.owner,
artifact.release.repo.repo,
artifact.release.tag,
artifact.artifact_name,
))
.unwrap(),
)
.into_bytes(),
)
});
let artifact_url = loop {
match client.has_release_artifact(artifact.clone()).await {
Err(GhApiError::RateLimit { retry_after }) => {
sleep(retry_after.unwrap_or(DEFAULT_RETRY_AFTER)).await
}
res => break res.unwrap().unwrap(),
}
};
if let Some(browser_download_task) = browser_download_task {
let artifact_download_data = loop {
match client.download_artifact(artifact_url.clone()).await {
Err(GhApiError::RateLimit { retry_after }) => {
sleep(retry_after.unwrap_or(DEFAULT_RETRY_AFTER)).await
}
res => break res.unwrap(),
}
}
.into_bytes()
.await
.unwrap();
let browser_download_data =
browser_download_task.await.unwrap().unwrap();
assert_eq!(artifact_download_data, browser_download_data);
}
}));
}
let client = client.clone();
tasks.push(tokio::spawn(async move {
assert_eq!(
client
.has_release_artifact(GhReleaseArtifact {
release,
artifact_name: "123z".to_compact_string(),
})
.await
.unwrap(),
None
);
}));
}
for release in NON_EXISTENT_RELEASES {
let client = client.clone();
tasks.push(tokio::spawn(async move {
assert_eq!(
client
.has_release_artifact(GhReleaseArtifact {
release,
artifact_name: "1234".to_compact_string(),
})
.await
.unwrap(),
None
);
}));
}
}
for task in tasks {
task.await.unwrap();
}
}
}

View file

@ -0,0 +1,143 @@
use std::{fmt::Debug, future::Future, sync::OnceLock, time::Duration};
use binstalk_downloader::remote::{self, Response, Url};
use compact_str::CompactString;
use percent_encoding::percent_decode_str;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::to_string as to_json_string;
use tracing::debug;
use super::{GhApiError, GhGraphQLErrors};
pub(super) fn percent_decode_http_url_path(input: &str) -> CompactString {
if input.contains('%') {
percent_decode_str(input).decode_utf8_lossy().into()
} else {
// No '%', no need to decode.
CompactString::new(input)
}
}
pub(super) fn check_http_status_and_header(response: &Response) -> Result<(), GhApiError> {
let headers = response.headers();
match response.status() {
remote::StatusCode::FORBIDDEN
if headers
.get("x-ratelimit-remaining")
.map(|val| val == "0")
.unwrap_or(false) =>
{
Err(GhApiError::RateLimit {
retry_after: headers.get("x-ratelimit-reset").and_then(|value| {
let secs = value.to_str().ok()?.parse().ok()?;
Some(Duration::from_secs(secs))
}),
})
}
remote::StatusCode::UNAUTHORIZED => Err(GhApiError::Unauthorized),
remote::StatusCode::NOT_FOUND => Err(GhApiError::NotFound),
_ => Ok(()),
}
}
fn get_api_endpoint() -> &'static Url {
static API_ENDPOINT: OnceLock<Url> = OnceLock::new();
API_ENDPOINT.get_or_init(|| {
Url::parse("https://api.github.com/").expect("Literal provided must be a valid url")
})
}
pub(super) fn issue_restful_api<T>(
client: &remote::Client,
path: &[&str],
) -> impl Future<Output = Result<T, GhApiError>> + Send + Sync + 'static
where
T: DeserializeOwned,
{
let mut url = get_api_endpoint().clone();
url.path_segments_mut()
.expect("get_api_endpoint() should return a https url")
.extend(path);
debug!("Getting restful API: {url}");
let future = client
.get(url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send(false);
async move {
let response = future.await?;
check_http_status_and_header(&response)?;
Ok(response.json().await?)
}
}
#[derive(Debug, Deserialize)]
struct GraphQLResponse<T> {
data: T,
errors: Option<GhGraphQLErrors>,
}
#[derive(Serialize)]
struct GraphQLQuery {
query: String,
}
fn get_graphql_endpoint() -> Url {
let mut graphql_endpoint = get_api_endpoint().clone();
graphql_endpoint
.path_segments_mut()
.expect("get_api_endpoint() should return a https url")
.push("graphql");
graphql_endpoint
}
pub(super) fn issue_graphql_query<T>(
client: &remote::Client,
query: String,
auth_token: &str,
) -> impl Future<Output = Result<T, GhApiError>> + Send + Sync + 'static
where
T: DeserializeOwned + Debug,
{
let res = to_json_string(&GraphQLQuery { query })
.map_err(remote::Error::from)
.map(|graphql_query| {
let graphql_endpoint = get_graphql_endpoint();
debug!("Sending graphql query to {graphql_endpoint}: '{graphql_query}'");
let request_builder = client
.post(graphql_endpoint, graphql_query)
.header("Accept", "application/vnd.github+json")
.bearer_auth(&auth_token);
request_builder.send(false)
});
async move {
let response = res?.await?;
check_http_status_and_header(&response)?;
let mut response: GraphQLResponse<T> = response.json().await?;
debug!("response = {response:?}");
if let Some(error) = response.errors.take() {
Err(error.into())
} else {
Ok(response.data)
}
}
}

View file

@ -0,0 +1,203 @@
use std::{error, fmt, io, time::Duration};
use binstalk_downloader::remote;
use compact_str::{CompactString, ToCompactString};
use serde::{de::Deserializer, Deserialize};
use thiserror::Error as ThisError;
#[derive(ThisError, Debug)]
#[error("Context: '{context}', err: '{err}'")]
pub struct GhApiContextError {
context: CompactString,
#[source]
err: GhApiError,
}
#[derive(ThisError, Debug)]
#[non_exhaustive]
pub enum GhApiError {
#[error("IO Error: {0}")]
Io(#[from] io::Error),
#[error("Remote Error: {0}")]
Remote(#[from] remote::Error),
#[error("Failed to parse url: {0}")]
InvalidUrl(#[from] url::ParseError),
/// A wrapped error providing the context the error is about.
#[error(transparent)]
Context(Box<GhApiContextError>),
#[error("Remote failed to process GraphQL query: {0}")]
GraphQLErrors(GhGraphQLErrors),
#[error("Hit rate-limit, retry after {retry_after:?}")]
RateLimit { retry_after: Option<Duration> },
#[error("Corresponding resource is not found")]
NotFound,
#[error("Does not have permission to access the API")]
Unauthorized,
}
impl GhApiError {
/// Attach context to [`GhApiError`]
pub fn context(self, context: impl fmt::Display) -> Self {
use GhApiError::*;
if matches!(self, RateLimit { .. } | NotFound | Unauthorized) {
self
} else {
Self::Context(Box::new(GhApiContextError {
context: context.to_compact_string(),
err: self,
}))
}
}
}
impl From<GhGraphQLErrors> for GhApiError {
fn from(e: GhGraphQLErrors) -> Self {
if e.is_rate_limited() {
Self::RateLimit { retry_after: None }
} else if e.is_not_found_error() {
Self::NotFound
} else {
Self::GraphQLErrors(e)
}
}
}
#[derive(Debug, Deserialize)]
pub struct GhGraphQLErrors(Box<[GraphQLError]>);
impl GhGraphQLErrors {
fn is_rate_limited(&self) -> bool {
self.0
.iter()
.any(|error| matches!(error.error_type, GraphQLErrorType::RateLimited))
}
fn is_not_found_error(&self) -> bool {
self.0
.iter()
.any(|error| matches!(&error.error_type, GraphQLErrorType::Other(error_type) if *error_type == "NOT_FOUND"))
}
}
impl error::Error for GhGraphQLErrors {}
impl fmt::Display for GhGraphQLErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let last_error_index = self.0.len() - 1;
for (i, error) in self.0.iter().enumerate() {
write!(
f,
"type: '{error_type}', msg: '{msg}'",
error_type = error.error_type,
msg = error.message,
)?;
for location in error.locations.as_deref().into_iter().flatten() {
write!(
f,
", occured on query line {line} col {col}",
line = location.line,
col = location.column
)?;
}
for (k, v) in &error.others {
write!(f, ", {k}: {v}")?;
}
if i < last_error_index {
f.write_str("\n")?;
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
struct GraphQLError {
message: CompactString,
locations: Option<Box<[GraphQLLocation]>>,
#[serde(rename = "type")]
error_type: GraphQLErrorType,
#[serde(flatten, with = "tuple_vec_map")]
others: Vec<(CompactString, serde_json::Value)>,
}
#[derive(Debug)]
pub(super) enum GraphQLErrorType {
RateLimited,
Other(CompactString),
}
impl fmt::Display for GraphQLErrorType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
GraphQLErrorType::RateLimited => "RATE_LIMITED",
GraphQLErrorType::Other(s) => s,
})
}
}
impl<'de> Deserialize<'de> for GraphQLErrorType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = CompactString::deserialize(deserializer)?;
Ok(match &*s {
"RATE_LIMITED" => GraphQLErrorType::RateLimited,
_ => GraphQLErrorType::Other(s),
})
}
}
#[derive(Debug, Deserialize)]
struct GraphQLLocation {
line: u64,
column: u64,
}
#[cfg(test)]
mod test {
use super::*;
use serde::de::value::{BorrowedStrDeserializer, Error};
macro_rules! assert_matches {
($expression:expr, $pattern:pat $(if $guard:expr)? $(,)?) => {
match $expression {
$pattern $(if $guard)? => true,
expr => {
panic!(
"assertion failed: `{expr:?}` does not match `{}`",
stringify!($pattern $(if $guard)?)
)
}
}
}
}
#[test]
fn test_graph_ql_error_type() {
let deserialize = |input: &str| {
GraphQLErrorType::deserialize(BorrowedStrDeserializer::<'_, Error>::new(input)).unwrap()
};
assert_matches!(deserialize("RATE_LIMITED"), GraphQLErrorType::RateLimited);
assert_matches!(
deserialize("rATE_LIMITED"),
GraphQLErrorType::Other(val) if val == CompactString::new("rATE_LIMITED")
);
}
}

View file

@ -0,0 +1,187 @@
use std::{
borrow::Borrow,
collections::HashSet,
fmt,
future::Future,
hash::{Hash, Hasher},
};
use binstalk_downloader::remote::{self};
use compact_str::{CompactString, ToCompactString};
use serde::Deserialize;
use url::Url;
use super::{
common::{issue_graphql_query, issue_restful_api},
GhApiError, GhRelease, GhRepo,
};
// Only include fields we do care about
#[derive(Eq, Deserialize, Debug)]
struct Artifact {
name: CompactString,
url: Url,
}
// Manually implement PartialEq and Hash to ensure it will always produce the
// same hash as a str with the same content, and that the comparison will be
// the same to coparing a string.
impl PartialEq for Artifact {
fn eq(&self, other: &Self) -> bool {
self.name.eq(&other.name)
}
}
impl Hash for Artifact {
fn hash<H>(&self, state: &mut H)
where
H: Hasher,
{
let s: &str = self.name.as_str();
s.hash(state)
}
}
// Implement Borrow so that we can use call
// `HashSet::contains::<str>`
impl Borrow<str> for Artifact {
fn borrow(&self) -> &str {
&self.name
}
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct Artifacts {
assets: HashSet<Artifact>,
}
impl Artifacts {
/// get url for downloading the artifact using GitHub API (for private repository).
pub(super) fn get_artifact_url(&self, artifact_name: &str) -> Option<Url> {
self.assets
.get(artifact_name)
.map(|artifact| artifact.url.clone())
}
}
pub(super) fn fetch_release_artifacts_restful_api(
client: &remote::Client,
GhRelease {
repo: GhRepo { owner, repo },
tag,
}: &GhRelease,
) -> impl Future<Output = Result<Artifacts, GhApiError>> + Send + Sync + 'static {
issue_restful_api(client, &["repos", owner, repo, "releases", "tags", tag])
}
#[derive(Debug, Deserialize)]
struct GraphQLData {
repository: Option<GraphQLRepo>,
}
#[derive(Debug, Deserialize)]
struct GraphQLRepo {
release: Option<GraphQLRelease>,
}
#[derive(Debug, Deserialize)]
struct GraphQLRelease {
#[serde(rename = "releaseAssets")]
assets: GraphQLReleaseAssets,
}
#[derive(Debug, Deserialize)]
struct GraphQLReleaseAssets {
nodes: Vec<Artifact>,
#[serde(rename = "pageInfo")]
page_info: GraphQLPageInfo,
}
#[derive(Debug, Deserialize)]
struct GraphQLPageInfo {
#[serde(rename = "endCursor")]
end_cursor: Option<CompactString>,
#[serde(rename = "hasNextPage")]
has_next_page: bool,
}
enum FilterCondition {
Init,
After(CompactString),
}
impl fmt::Display for FilterCondition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// GitHub imposes a limit of 100 for the value passed to param "first"
FilterCondition::Init => f.write_str("first:100"),
FilterCondition::After(end_cursor) => write!(f, r#"first:100,after:"{end_cursor}""#),
}
}
}
pub(super) fn fetch_release_artifacts_graphql_api(
client: &remote::Client,
GhRelease {
repo: GhRepo { owner, repo },
tag,
}: &GhRelease,
auth_token: &str,
) -> impl Future<Output = Result<Artifacts, GhApiError>> + Send + Sync + 'static {
let client = client.clone();
let auth_token = auth_token.to_compact_string();
let base_query_prefix = format!(
r#"
query {{
repository(owner:"{owner}",name:"{repo}") {{
release(tagName:"{tag}") {{"#
);
let base_query_suffix = r#"
nodes { name url }
pageInfo { endCursor hasNextPage }
}}}}"#
.trim();
async move {
let mut artifacts = Artifacts::default();
let mut cond = FilterCondition::Init;
let base_query_prefix = base_query_prefix.trim();
loop {
let query = format!(
r#"
{base_query_prefix}
releaseAssets({cond}) {{
{base_query_suffix}"#
);
let data: GraphQLData = issue_graphql_query(&client, query, &auth_token).await?;
let assets = data
.repository
.and_then(|repository| repository.release)
.map(|release| release.assets);
if let Some(assets) = assets {
artifacts.assets.extend(assets.nodes);
match assets.page_info {
GraphQLPageInfo {
end_cursor: Some(end_cursor),
has_next_page: true,
} => {
cond = FilterCondition::After(end_cursor);
}
_ => break Ok(artifacts),
}
} else {
break Err(GhApiError::NotFound);
}
}
}
}

View file

@ -0,0 +1,80 @@
use std::future::Future;
use compact_str::CompactString;
use serde::Deserialize;
use super::{
common::{issue_graphql_query, issue_restful_api},
remote, GhApiError, GhRepo,
};
#[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize)]
struct Owner {
login: CompactString,
}
#[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize)]
pub struct RepoInfo {
owner: Owner,
name: CompactString,
private: bool,
}
impl RepoInfo {
#[cfg(test)]
pub(crate) fn new(GhRepo { owner, repo }: GhRepo, private: bool) -> Self {
Self {
owner: Owner { login: owner },
name: repo,
private,
}
}
pub fn repo(&self) -> GhRepo {
GhRepo {
owner: self.owner.login.clone(),
repo: self.name.clone(),
}
}
pub fn is_private(&self) -> bool {
self.private
}
}
pub(super) fn fetch_repo_info_restful_api(
client: &remote::Client,
GhRepo { owner, repo }: &GhRepo,
) -> impl Future<Output = Result<Option<RepoInfo>, GhApiError>> + Send + Sync + 'static {
issue_restful_api(client, &["repos", owner, repo])
}
#[derive(Debug, Deserialize)]
struct GraphQLData {
repository: Option<RepoInfo>,
}
pub(super) fn fetch_repo_info_graphql_api(
client: &remote::Client,
GhRepo { owner, repo }: &GhRepo,
auth_token: &str,
) -> impl Future<Output = Result<Option<RepoInfo>, GhApiError>> + Send + Sync + 'static {
let query = format!(
r#"
query {{
repository(owner:"{owner}",name:"{repo}") {{
owner {{
login
}}
name
private: isPrivate
}}
}}"#
);
let future = issue_graphql_query(client, query, auth_token);
async move {
let data: GraphQLData = future.await?;
Ok(data.repository)
}
}

View file

@ -0,0 +1 @@
pub mod gh_api_client;