use std::{fmt::Debug, time::Duration};
use anyhow::anyhow;
use graphql_client::{GraphQLQuery, Response};
use reqwest::header::{self, HeaderValue};
use serde::{de::DeserializeOwned, Serialize};
use synd_o11y::{health_check::Health, opentelemetry::extension::*};
use thiserror::Error;
use tracing::{error, Span};
use url::Url;
use crate::{
auth::{Credential, Verified},
client::synd_api::payload::ExportSubscriptionPayload,
config, types,
};
use self::query::subscription::SubscriptionOutput;
mod scalar;
pub use scalar::*;
#[path = "generated/mutation.rs"]
pub mod mutation;
pub mod payload;
#[path = "generated/query.rs"]
pub mod query;
#[derive(Error, Debug)]
pub enum SubscribeFeedError {
#[error("invalid feed url: `{feed_url}` ({message})`")]
InvalidFeedUrl { feed_url: FeedUrl, message: String },
#[error("{feed_url} {message}")]
FeedUnavailable { feed_url: FeedUrl, message: String },
}
#[derive(Error, Debug)]
pub enum SyndApiError {
#[error("unauthorized")]
Unauthorized { url: Option<Url> },
#[error(transparent)]
BuildRequest(#[from] reqwest::Error),
#[error("graphql error: {errors:?}")]
Graphql { errors: Vec<graphql_client::Error> },
#[error(transparent)]
SubscribeFeed(SubscribeFeedError),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
#[derive(Clone)]
pub struct Client {
client: reqwest::Client,
credential: Option<HeaderValue>,
endpoint: Url,
}
impl Client {
const GRAPHQL: &'static str = "/graphql";
const HEALTH_CHECK: &'static str = "/health";
pub fn new(endpoint: Url, timeout: Duration) -> anyhow::Result<Self> {
let client = reqwest::ClientBuilder::new()
.user_agent(config::client::USER_AGENT)
.timeout(timeout)
.connect_timeout(Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()?;
Ok(Self {
client,
endpoint,
credential: None,
})
}
pub(crate) fn set_credential(&mut self, cred: Verified<Credential>) {
let mut token = HeaderValue::try_from(match cred.into_inner() {
Credential::Github { access_token } => format!("github {access_token}"),
Credential::Google { id_token, .. } => format!("google {id_token}"),
})
.unwrap();
token.set_sensitive(true);
self.credential = Some(token);
}
#[tracing::instrument(skip(self))]
pub async fn fetch_subscription(
&self,
after: Option<String>,
first: Option<i64>,
) -> Result<SubscriptionOutput, SyndApiError> {
let var = query::subscription::Variables { after, first };
let request = query::Subscription::build_query(var);
let response: query::subscription::ResponseData = self.request(&request).await?;
Ok(response.output)
}
#[tracing::instrument(skip(self))]
pub async fn subscribe_feed(
&self,
input: mutation::subscribe_feed::SubscribeFeedInput,
) -> Result<types::Feed, SyndApiError> {
use crate::client::synd_api::mutation::subscribe_feed::ResponseCode;
let url = input.url.clone();
let var = mutation::subscribe_feed::Variables {
subscribe_input: input,
};
let request = mutation::SubscribeFeed::build_query(var);
let response: mutation::subscribe_feed::ResponseData = self.request(&request).await?;
match response.subscribe_feed {
mutation::subscribe_feed::SubscribeFeedSubscribeFeed::SubscribeFeedSuccess(success) => {
Ok(types::Feed::from(success.feed))
}
mutation::subscribe_feed::SubscribeFeedSubscribeFeed::SubscribeFeedError(err) => {
match err.status.code {
ResponseCode::OK => unreachable!(),
ResponseCode::INVALID_FEED_URL => Err(SyndApiError::SubscribeFeed(
SubscribeFeedError::InvalidFeedUrl {
feed_url: url,
message: err.message,
},
)),
ResponseCode::FEED_UNAVAILABLE => Err(SyndApiError::SubscribeFeed(
SubscribeFeedError::FeedUnavailable {
feed_url: url,
message: err.message,
},
)),
err_code => Err(SyndApiError::Internal(anyhow::anyhow!(
"Unexpected subscribe_feed error code: {err_code:?}"
))),
}
}
}
}
#[tracing::instrument(skip(self))]
pub async fn unsubscribe_feed(&self, url: FeedUrl) -> Result<(), SyndApiError> {
let var = mutation::unsubscribe_feed::Variables {
unsubscribe_input: mutation::unsubscribe_feed::UnsubscribeFeedInput { url },
};
let request = mutation::UnsubscribeFeed::build_query(var);
let response: mutation::unsubscribe_feed::ResponseData = self.request(&request).await?;
match response.unsubscribe_feed {
mutation::unsubscribe_feed::UnsubscribeFeedUnsubscribeFeed::UnsubscribeFeedSuccess(
_,
) => Ok(()),
mutation::unsubscribe_feed::UnsubscribeFeedUnsubscribeFeed::UnsubscribeFeedError(
err,
) => Err(SyndApiError::Internal(anyhow!(
"Failed to mutate unsubscribe_feed {err:?}"
))),
}
}
#[tracing::instrument(skip(self))]
pub async fn fetch_entries(
&self,
after: Option<String>,
first: i64,
) -> Result<payload::FetchEntriesPayload, SyndApiError> {
tracing::debug!("Fetch entries...");
let var = query::entries::Variables { after, first };
let request = query::Entries::build_query(var);
let response: query::entries::ResponseData = self.request(&request).await?;
tracing::debug!("Got response");
Ok(response.output.into())
}
#[tracing::instrument(skip(self))]
pub async fn export_subscription(
&self,
after: Option<String>,
first: i64,
) -> anyhow::Result<ExportSubscriptionPayload> {
let var = query::export_subscription::Variables { after, first };
let request = query::ExportSubscription::build_query(var);
let response: query::export_subscription::ResponseData = self.request(&request).await?;
Ok(response.output.into())
}
#[tracing::instrument(skip_all, err(Display))]
async fn request<Body, ResponseData>(&self, body: &Body) -> Result<ResponseData, SyndApiError>
where
Body: Serialize + Debug + ?Sized,
ResponseData: DeserializeOwned + Debug,
{
let mut request = self
.client
.post(self.endpoint.join(Self::GRAPHQL).unwrap())
.header(
header::AUTHORIZATION,
self.credential
.as_ref()
.expect("Credential not configured. this is a BUG")
.clone(),
)
.json(body)
.build()
.map_err(SyndApiError::BuildRequest)?;
synd_o11y::opentelemetry::http::inject_with_baggage(
&Span::current().context(),
request.headers_mut(),
std::iter::once(synd_o11y::request_id_key_value()),
);
tracing::debug!(url = request.url().as_str(), "Send request");
let response: Response<ResponseData> = self
.client
.execute(request)
.await?
.error_for_status()
.map_err(|err| match err.status().map(|s| s.as_u16()) {
Some(401) => SyndApiError::Unauthorized {
url: err.url().cloned(),
},
_ => SyndApiError::Internal(anyhow::Error::from(err)),
})?
.json()
.await?;
match (response.data, response.errors) {
(_, Some(errors)) if !errors.is_empty() => Err(SyndApiError::Graphql { errors }),
(Some(data), _) => Ok(data),
_ => Err(SyndApiError::Internal(anyhow!(
"Unexpected error. response does not contain data and errors"
))),
}
}
pub async fn health(&self) -> anyhow::Result<Health> {
self.client
.get(self.endpoint.join(Self::HEALTH_CHECK).unwrap())
.send()
.await?
.error_for_status()?
.json()
.await
.map_err(anyhow::Error::from)
}
}