mod page;
mod util;
pub use crate::page::*;
use crate::util::*;
use indenter::indented;
use serde::{Serialize, de::DeserializeOwned};
use std::borrow::Cow;
use std::cell::Cell;
use std::fmt::{self, Write};
use std::thread::sleep;
use std::time::{Duration, Instant};
use thiserror::Error;
use ureq::{
Agent, Body,
http::{
Response,
header::{AUTHORIZATION, HeaderName, HeaderValue},
status::StatusCode,
},
};
use url::Url;
static USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
" (",
env!("CARGO_PKG_REPOSITORY"),
")",
);
static GITHUB_API_URL: &str = "https://api.github.com";
static ACCEPT_VALUE: &str = "application/vnd.github+json";
const API_VERSION_HEADER: HeaderName = HeaderName::from_static("x-github-api-version");
static API_VERSION_VALUE: &str = "2022-11-28";
const MUTATION_DELAY: Duration = Duration::from_secs(1);
#[derive(Clone, Debug)]
pub struct Client {
inner: Agent,
api_url: Url,
last_mutation: Cell<Option<Instant>>,
}
impl Client {
pub fn new(token: &str) -> Result<Client, BuildClientError> {
ClientBuilder::new().with_token(token).build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub fn agent_ref(&self) -> &Agent {
&self.inner
}
fn mkurl(&self, path: &str) -> Result<Url, RequestError> {
self.api_url
.join(path)
.map_err(|source| RequestError::Path {
source,
path: path.to_owned(),
})
}
pub fn request<T: Serialize>(
&self,
method: Method,
url: Url,
payload: Option<&T>,
) -> Result<Response<Body>, RequestError> {
if method.is_mutating()
&& let Some(lastmut) = self.last_mutation.get()
{
let delay =
MUTATION_DELAY.saturating_sub(Instant::now().saturating_duration_since(lastmut));
if !delay.is_zero() {
log::debug!("Sleeping for {delay:?} between mutating requests");
sleep(delay);
}
}
let mut retrier = Retrier::new(method, url.clone());
loop {
if method.is_mutating() {
self.last_mutation.set(Some(Instant::now()));
}
let req = match method {
Method::Get => self.inner.get(url.as_str()).force_send_body(),
Method::Post => self.inner.post(url.as_str()),
Method::Put => self.inner.put(url.as_str()),
Method::Patch => self.inner.patch(url.as_str()),
Method::Delete => self.inner.delete(url.as_str()).force_send_body(),
};
log::debug!("{method} {url}");
let resp = if let Some(p) = payload {
req.send_json(p)
} else {
req.send_empty()
};
match &resp {
Ok(r) => log::debug!("Server returned {}", r.status()),
Err(e) => log::debug!("Request failed: {e}"),
};
match retrier.handle(resp)? {
RetryDecision::Success(r) => return Ok(r),
RetryDecision::Retry(delay) => {
log::debug!("Waiting {delay:?} and then retrying request");
sleep(delay);
}
}
}
}
pub fn request_json<T: Serialize, U: DeserializeOwned>(
&self,
method: Method,
path: &str,
payload: Option<&T>,
) -> Result<U, RequestError> {
let url = self.mkurl(path)?;
let mut r = self.request::<T>(method, url.clone(), payload)?;
match r.body_mut().read_json::<U>() {
Ok(val) => Ok(val),
Err(source) => Err(RequestError::Deserialize {
method,
url,
source: Box::new(source),
}),
}
}
pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, RequestError> {
self.request_json::<(), T>(Method::Get, path, None)
}
pub fn post<T: Serialize, U: DeserializeOwned>(
&self,
path: &str,
payload: &T,
) -> Result<U, RequestError> {
self.request_json::<T, U>(Method::Post, path, Some(payload))
}
pub fn put<T: Serialize, U: DeserializeOwned>(
&self,
path: &str,
payload: &T,
) -> Result<U, RequestError> {
self.request_json::<T, U>(Method::Put, path, Some(payload))
}
pub fn patch<T: Serialize, U: DeserializeOwned>(
&self,
path: &str,
payload: &T,
) -> Result<U, RequestError> {
self.request_json::<T, U>(Method::Patch, path, Some(payload))
}
pub fn delete(&self, path: &str) -> Result<(), RequestError> {
let url = self.mkurl(path)?;
self.request::<()>(Method::Delete, url, None)?;
Ok(())
}
pub fn paginate<T: DeserializeOwned>(&self, path: &str) -> PaginationIter<'_, T> {
PaginationIter::new(self, path)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientBuilder {
token: Option<String>,
user_agent: Cow<'static, str>,
api_url: Url,
api_version: Cow<'static, str>,
accept: Cow<'static, str>,
}
impl ClientBuilder {
pub fn new() -> ClientBuilder {
let Ok(api_url) = Url::parse(GITHUB_API_URL) else {
unreachable!("GITHUB_API_URL should be a valid URL");
};
ClientBuilder {
token: None,
user_agent: Cow::from(USER_AGENT),
api_url,
api_version: Cow::from(API_VERSION_VALUE),
accept: Cow::from(ACCEPT_VALUE),
}
}
pub fn with_token(mut self, token: &str) -> Self {
self.token = Some(token.into());
self
}
pub fn with_user_agent(mut self, user_agent: &str) -> Self {
self.user_agent = Cow::from(user_agent.to_owned());
self
}
pub fn with_api_url(mut self, api_url: Url) -> Self {
self.api_url = api_url;
self
}
pub fn with_api_version(mut self, api_version: &str) -> Self {
self.api_version = Cow::from(api_version.to_owned());
self
}
pub fn with_accept_value(mut self, accept: &str) -> Self {
self.accept = Cow::from(accept.to_owned());
self
}
pub fn build(self) -> Result<Client, BuildClientError> {
let auth = if let Some(token) = self.token {
let auth = format!("Bearer {token}");
Some(HeaderValue::from_str(&auth).map_err(|source| {
BuildClientError::InvalidHeaderValue {
header: AUTHORIZATION,
source,
}
})?)
} else {
None
};
let api_version_value = HeaderValue::from_str(&self.api_version).map_err(|source| {
BuildClientError::InvalidHeaderValue {
header: API_VERSION_HEADER,
source,
}
})?;
let inner = Agent::config_builder()
.http_status_as_error(false)
.redirect_auth_headers(ureq::config::RedirectAuthHeaders::SameHost)
.user_agent(self.user_agent)
.accept(self.accept)
.https_only(true)
.middleware(
move |mut req: ureq::http::Request<ureq::SendBody<'_>>,
next: ureq::middleware::MiddlewareNext<'_>| {
if let Some(a) = auth.clone() {
req.headers_mut().insert(AUTHORIZATION, a);
}
req.headers_mut()
.insert(API_VERSION_HEADER, api_version_value.clone());
next.handle(req)
},
)
.build()
.into();
Ok(Client {
inner,
api_url: self.api_url,
last_mutation: Cell::new(None),
})
}
}
impl Default for ClientBuilder {
fn default() -> ClientBuilder {
ClientBuilder::new()
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Method {
Get,
Post,
Patch,
Put,
Delete,
}
impl Method {
pub fn is_mutating(&self) -> bool {
matches!(
self,
Method::Post | Method::Patch | Method::Put | Method::Delete
)
}
pub fn as_str(&self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
Method::Patch => "PATCH",
Method::Put => "PUT",
Method::Delete => "DELETE",
}
}
}
impl fmt::Display for Method {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl std::str::FromStr for Method {
type Err = ParseMethodError;
fn from_str(s: &str) -> Result<Method, ParseMethodError> {
match s.to_ascii_uppercase().as_str() {
"GET" => Ok(Method::Get),
"POST" => Ok(Method::Post),
"PUT" => Ok(Method::Put),
"PATCH" => Ok(Method::Patch),
"DELETE" => Ok(Method::Delete),
_ => Err(ParseMethodError),
}
}
}
impl From<Method> for ureq::http::Method {
fn from(value: Method) -> ureq::http::Method {
match value {
Method::Get => ureq::http::Method::GET,
Method::Post => ureq::http::Method::POST,
Method::Put => ureq::http::Method::PUT,
Method::Patch => ureq::http::Method::PATCH,
Method::Delete => ureq::http::Method::DELETE,
}
}
}
impl TryFrom<ureq::http::Method> for Method {
type Error = MethodConvertError;
fn try_from(value: ureq::http::Method) -> Result<Method, MethodConvertError> {
match value {
ureq::http::Method::GET => Ok(Method::Get),
ureq::http::Method::POST => Ok(Method::Post),
ureq::http::Method::PUT => Ok(Method::Put),
ureq::http::Method::PATCH => Ok(Method::Patch),
ureq::http::Method::DELETE => Ok(Method::Delete),
other => Err(MethodConvertError(other)),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Error, Hash, PartialEq)]
#[error("invalid method name")]
pub struct ParseMethodError;
#[derive(Clone, Debug, Eq, Error, PartialEq)]
#[error("method {0} is not supported by ghreq")]
pub struct MethodConvertError(
pub ureq::http::Method,
);
#[derive(Debug, Error)]
pub enum BuildClientError {
#[error("value supplied for header {header} is invalid")]
InvalidHeaderValue {
header: HeaderName,
source: ureq::http::header::InvalidHeaderValue,
},
}
#[derive(Debug, Error)]
pub enum RequestError {
#[error("failed to construct a GitHub API URL from path {path:?}")]
Path {
source: url::ParseError,
path: String,
},
#[error("failed to make {method} request to {url}")]
Send {
method: Method,
url: Url,
source: Box<ureq::Error>,
},
#[error(transparent)]
Status(StatusError),
#[error("failed to deserialize response body from {method} request to {url}")]
Deserialize {
method: Method,
url: Url,
source: Box<ureq::Error>,
},
}
impl RequestError {
pub fn body(&self) -> Option<&str> {
if let RequestError::Status(stat) = self {
stat.body()
} else {
None
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StatusError {
pub method: Method,
pub url: Url,
pub status: StatusCode,
pub body: Option<String>,
}
impl StatusError {
pub fn body(&self) -> Option<&str> {
self.body.as_deref()
}
}
impl fmt::Display for StatusError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} request to {} returned {}",
self.method, self.url, self.status
)?;
if f.alternate()
&& let Some(text) = self.body()
{
write!(indented(f).with_str(" "), "\n\n{text}\n")?;
}
Ok(())
}
}
impl std::error::Error for StatusError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mkurl_slash() {
let client = Client::new("hunter2").unwrap();
assert_eq!(
client.mkurl("/foo/bar").unwrap().as_str(),
format!("{GITHUB_API_URL}/foo/bar")
);
}
#[test]
fn mkurl_no_slash() {
let client = Client::new("hunter2").unwrap();
assert_eq!(
client.mkurl("foo/bar").unwrap().as_str(),
format!("{GITHUB_API_URL}/foo/bar")
);
}
mod method {
use super::*;
use rstest::rstest;
#[rstest]
#[case(Method::Get)]
#[case(Method::Post)]
#[case(Method::Put)]
#[case(Method::Patch)]
#[case(Method::Delete)]
fn parse_display_roundtrip(#[case] m: Method) {
assert_eq!(m.to_string().parse::<Method>().unwrap(), m);
}
#[rstest]
#[case("get", Method::Get)]
#[case("Get", Method::Get)]
#[case("gET", Method::Get)]
#[case("GeT", Method::Get)]
#[case("post", Method::Post)]
#[case("Post", Method::Post)]
#[case("pOST", Method::Post)]
#[case("put", Method::Put)]
#[case("Put", Method::Put)]
#[case("pUT", Method::Put)]
#[case("patch", Method::Patch)]
#[case("Patch", Method::Patch)]
#[case("pATCH", Method::Patch)]
#[case("delete", Method::Delete)]
#[case("Delete", Method::Delete)]
#[case("dELETE", Method::Delete)]
#[case("DeLeTe", Method::Delete)]
#[case("dElEtE", Method::Delete)]
fn parse_crazy_casing(#[case] s: &str, #[case] m: Method) {
assert_eq!(s.parse::<Method>().unwrap(), m);
}
#[rstest]
#[case("CONNECT")]
#[case("OPTIONS")]
#[case("TRACE")]
#[case("PROPFIND")]
fn parse_unsupported(#[case] s: &str) {
assert!(s.parse::<Method>().is_err());
}
#[rstest]
#[case(ureq::http::Method::CONNECT)]
#[case(ureq::http::Method::OPTIONS)]
#[case(ureq::http::Method::TRACE)]
fn try_from_unsupported(#[case] m: ureq::http::Method) {
let m2 = m.clone();
assert_eq!(Method::try_from(m), Err(MethodConvertError(m2)));
}
#[test]
fn pad() {
let m = Method::Get;
assert_eq!(format!("{m:.^10}"), "...GET....");
assert_eq!(format!("{m:.1}"), "G");
}
}
}