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 super::{ common::{issue_graphql_query, issue_restful_api, percent_encode_http_url_path}, GhApiError, GhRelease, GhRepo, }; // Only include fields we do care about #[derive(Eq, Deserialize, Debug)] struct Artifact { name: CompactString, url: CompactString, } // 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(&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::` impl Borrow for Artifact { fn borrow(&self) -> &str { &self.name } } #[derive(Debug, Default, Deserialize)] pub(super) struct Artifacts { assets: HashSet, } 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 { self.assets .get(artifact_name) .map(|artifact| artifact.url.clone()) } } fn fetch_release_artifacts_restful_api( client: &remote::Client, GhRelease { repo: GhRepo { owner, repo }, tag, }: &GhRelease, auth_token: Option<&str>, ) -> impl Future> + Send + Sync + 'static { issue_restful_api( client, &[ "repos", owner, repo, "releases", "tags", tag, //&percent_encode_http_url_path(tag).to_compact_string(), ], auth_token, ) } #[derive(Deserialize)] struct GraphQLData { repository: Option, } #[derive(Deserialize)] struct GraphQLRepo { release: Option, } #[derive(Deserialize)] struct GraphQLRelease { #[serde(rename = "releaseAssets")] assets: GraphQLReleaseAssets, } #[derive(Deserialize)] struct GraphQLReleaseAssets { nodes: Vec, #[serde(rename = "pageInfo")] page_info: GraphQLPageInfo, } #[derive(Deserialize)] struct GraphQLPageInfo { #[serde(rename = "endCursor")] end_cursor: Option, #[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}""#), } } } fn fetch_release_artifacts_graphql_api( client: &remote::Client, GhRelease { repo: GhRepo { owner, repo }, tag, }: &GhRelease, auth_token: &str, ) -> impl Future> + 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); } } } } pub(super) async fn fetch_release_artifacts( client: &remote::Client, release: &GhRelease, auth_token: Option<&str>, ) -> Result { if let Some(auth_token) = auth_token { let res = fetch_release_artifacts_graphql_api(client, release, auth_token) .await .map_err(|err| err.context("GraphQL API")); match res { // Fallback to Restful API Err(GhApiError::Unauthorized) => (), res => return res, } } fetch_release_artifacts_restful_api(client, release, auth_token) .await .map_err(|err| err.context("Restful API")) }