mirror of
https://github.com/cargo-bins/cargo-binstall.git
synced 2025-05-12 15:00:04 +00:00
feat: Add GraphQL support to GhApiClient
(#1124)
Fixed #868 - Add new fn `remote::Client::post` - Add new fn `remote::RequestBuilder::body` - Re-export `reqwest::Body` in `remote` - Add dep percent-encoding v2.2.0 to binstalk-downloader - Add dep serde-tuple-vec-map v1.0.1 to binstalk-downloader - Add GraphQL to `GhApiClient`, fallback to Restful API if token is not provided or authorization failed. - Fixed `GhReleaseArtifact::try_extract_artifact_from_str`: decode percent encoded http url path and add regression tests - Added variant `GhApiError::Context` & `GhApiContextError` - Added variant `GhApiError::GraphQLErrors` & `GhGraphQLErrors` Signed-off-by: Jiahao XU <Jiahao_XU@outlook.com>
This commit is contained in:
parent
e87e3534a8
commit
22b3419fce
8 changed files with 518 additions and 99 deletions
|
@ -1,17 +1,30 @@
|
|||
use std::{
|
||||
borrow::Borrow,
|
||||
collections::HashSet,
|
||||
error, fmt,
|
||||
hash::{Hash, Hasher},
|
||||
io,
|
||||
sync::OnceLock,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use compact_str::CompactString;
|
||||
use serde::Deserialize;
|
||||
use compact_str::{CompactString, ToCompactString};
|
||||
use reqwest::{header::HeaderMap, StatusCode};
|
||||
use serde::{de::Deserializer, Deserialize, Serialize};
|
||||
use serde_json::to_string as to_json_string;
|
||||
use thiserror::Error as ThisError;
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
|
||||
use super::{remote, GhRelease};
|
||||
use super::{percent_encode_http_url_path, remote, GhRelease};
|
||||
|
||||
#[derive(ThisError, Debug)]
|
||||
#[error("Context: '{context}', err: '{err}'")]
|
||||
pub struct GhApiContextError {
|
||||
context: CompactString,
|
||||
#[source]
|
||||
err: GhApiError,
|
||||
}
|
||||
|
||||
#[derive(ThisError, Debug)]
|
||||
#[non_exhaustive]
|
||||
|
@ -24,6 +37,23 @@ pub enum GhApiError {
|
|||
|
||||
#[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(#[from] GhGraphQLErrors),
|
||||
}
|
||||
|
||||
impl GhApiError {
|
||||
/// Attach context to [`GhApiError`]
|
||||
pub fn context(self, context: impl fmt::Display) -> Self {
|
||||
Self::Context(Box::new(GhApiContextError {
|
||||
context: context.to_compact_string(),
|
||||
err: self,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Only include fields we do care about
|
||||
|
@ -62,7 +92,7 @@ impl Borrow<str> for Artifact {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(super) struct Artifacts {
|
||||
assets: HashSet<Artifact>,
|
||||
}
|
||||
|
@ -80,15 +110,43 @@ pub(super) enum FetchReleaseRet {
|
|||
Unauthorized,
|
||||
}
|
||||
|
||||
/// Returns 404 if not found
|
||||
pub(super) async fn fetch_release_artifacts(
|
||||
fn check_for_status(status: StatusCode, headers: &HeaderMap) -> Option<FetchReleaseRet> {
|
||||
if status == remote::StatusCode::FORBIDDEN
|
||||
&& headers
|
||||
.get("x-ratelimit-remaining")
|
||||
.map(|val| val == "0")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(FetchReleaseRet::ReachedRateLimit {
|
||||
retry_after: headers.get("x-ratelimit-reset").and_then(|value| {
|
||||
let secs = value.to_str().ok()?.parse().ok()?;
|
||||
Some(Duration::from_secs(secs))
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if status == remote::StatusCode::UNAUTHORIZED {
|
||||
return Some(FetchReleaseRet::Unauthorized);
|
||||
}
|
||||
|
||||
if status == remote::StatusCode::NOT_FOUND {
|
||||
return Some(FetchReleaseRet::ReleaseNotFound);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_release_artifacts_restful_api(
|
||||
client: &remote::Client,
|
||||
GhRelease { owner, repo, tag }: &GhRelease,
|
||||
auth_token: Option<&str>,
|
||||
) -> Result<FetchReleaseRet, GhApiError> {
|
||||
let mut request_builder = client
|
||||
.get(Url::parse(&format!(
|
||||
"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
|
||||
"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}",
|
||||
owner = percent_encode_http_url_path(owner),
|
||||
repo = percent_encode_http_url_path(repo),
|
||||
tag = percent_encode_http_url_path(tag),
|
||||
))?)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28");
|
||||
|
@ -102,27 +160,291 @@ pub(super) async fn fetch_release_artifacts(
|
|||
let status = response.status();
|
||||
let headers = response.headers();
|
||||
|
||||
if status == remote::StatusCode::FORBIDDEN
|
||||
&& headers
|
||||
.get("x-ratelimit-remaining")
|
||||
.map(|val| val == "0")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(FetchReleaseRet::ReachedRateLimit {
|
||||
retry_after: headers.get("x-ratelimit-reset").and_then(|value| {
|
||||
let secs = value.to_str().ok()?.parse().ok()?;
|
||||
Some(Duration::from_secs(secs))
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if status == remote::StatusCode::UNAUTHORIZED {
|
||||
return Ok(FetchReleaseRet::Unauthorized);
|
||||
}
|
||||
|
||||
if status == remote::StatusCode::NOT_FOUND {
|
||||
return Ok(FetchReleaseRet::ReleaseNotFound);
|
||||
if let Some(ret) = check_for_status(status, headers) {
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
Ok(FetchReleaseRet::Artifacts(response.json().await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum GraphQLResponse {
|
||||
#[serde(rename = "data")]
|
||||
Data(GraphQLData),
|
||||
|
||||
#[serde(rename = "errors")]
|
||||
Errors(GhGraphQLErrors),
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQLData {
|
||||
repository: Option<GraphQLRepo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQLRepo {
|
||||
release: Option<GraphQLRelease>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQLRelease {
|
||||
#[serde(rename = "releaseAssets")]
|
||||
assets: GraphQLReleaseAssets,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQLReleaseAssets {
|
||||
nodes: Vec<Artifact>,
|
||||
#[serde(rename = "pageInfo")]
|
||||
page_info: GraphQLPageInfo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQLPageInfo {
|
||||
#[serde(rename = "endCursor")]
|
||||
end_cursor: 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}""#),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GraphQLQuery {
|
||||
query: String,
|
||||
}
|
||||
|
||||
async fn fetch_release_artifacts_graphql_api(
|
||||
client: &remote::Client,
|
||||
GhRelease { owner, repo, tag }: &GhRelease,
|
||||
auth_token: &str,
|
||||
) -> Result<FetchReleaseRet, GhApiError> {
|
||||
static GRAPHQL_ENDPOINT: OnceLock<Url> = OnceLock::new();
|
||||
|
||||
let graphql_endpoint = GRAPHQL_ENDPOINT.get_or_init(|| {
|
||||
Url::parse("https://api.github.com/graphql").expect("Literal provided must be a valid url")
|
||||
});
|
||||
|
||||
let mut artifacts = Artifacts::default();
|
||||
let mut cond = FilterCondition::Init;
|
||||
|
||||
loop {
|
||||
let query = format!(
|
||||
r#"
|
||||
query {{
|
||||
repository(owner:"{owner}",name:"{repo}") {{
|
||||
release(tagName:"{tag}") {{
|
||||
releaseAssets({cond}) {{
|
||||
nodes {{ name }}
|
||||
pageInfo {{ endCursor hasNextPage }}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
|
||||
let graphql_query = to_json_string(&GraphQLQuery { query }).map_err(remote::Error::from)?;
|
||||
|
||||
debug!("Sending graphql query to https://api.github.com/graphql: '{graphql_query}'");
|
||||
|
||||
let request_builder = client
|
||||
.post(graphql_endpoint.clone(), graphql_query)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.bearer_auth(&auth_token);
|
||||
|
||||
let response = request_builder.send(false).await?;
|
||||
|
||||
if let Some(ret) = check_for_status(response.status(), response.headers()) {
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
let response: GraphQLResponse = response.json().await?;
|
||||
|
||||
let data = match response {
|
||||
GraphQLResponse::Data(data) => data,
|
||||
GraphQLResponse::Errors(errors) if errors.is_rate_limited() => {
|
||||
return Ok(FetchReleaseRet::ReachedRateLimit { retry_after: None })
|
||||
}
|
||||
GraphQLResponse::Errors(errors) => return Err(errors.into()),
|
||||
};
|
||||
|
||||
let assets = data
|
||||
.repository
|
||||
.and_then(|repository| repository.release)
|
||||
.map(|release| release.assets);
|
||||
|
||||
if let Some(assets) = assets {
|
||||
artifacts.assets.extend(assets.nodes);
|
||||
|
||||
let page_info = assets.page_info;
|
||||
if !page_info.has_next_page {
|
||||
break Ok(FetchReleaseRet::Artifacts(artifacts));
|
||||
} else {
|
||||
cond = FilterCondition::After(page_info.end_cursor);
|
||||
}
|
||||
} else {
|
||||
break Ok(FetchReleaseRet::ReleaseNotFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_release_artifacts(
|
||||
client: &remote::Client,
|
||||
release: &GhRelease,
|
||||
auth_token: Option<&str>,
|
||||
) -> Result<FetchReleaseRet, GhApiError> {
|
||||
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
|
||||
Ok(FetchReleaseRet::Unauthorized) => (),
|
||||
res => return res,
|
||||
}
|
||||
}
|
||||
|
||||
fetch_release_artifacts_restful_api(client, release, auth_token)
|
||||
.await
|
||||
.map_err(|err| err.context("Restful API"))
|
||||
}
|
||||
|
||||
#[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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue