#![cfg_attr(test, recursion_limit = "512")]
mod api;
mod error;
mod from_response;
mod page;
pub mod auth;
pub mod etag;
pub mod models;
pub mod params;
pub mod service;
use crate::service::body::BodyStreamExt;
use chrono::{DateTime, Utc};
use http::{HeaderMap, HeaderValue, Method, Uri};
use std::convert::{Infallible, TryInto};
use std::fmt;
use std::io::Write;
use std::marker::PhantomData;
use std::str::FromStr;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use http::{header::HeaderName, StatusCode};
#[cfg(all(not(feature = "opentls"), not(feature = "rustls")))]
use hyper::client::HttpConnector;
use hyper::{body, Body, Request, Response};
use once_cell::sync::Lazy;
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
use snafu::*;
use tower::{buffer::Buffer, util::BoxService, BoxError, Layer, Service, ServiceExt};
use bytes::Bytes;
use http::header::USER_AGENT;
use http::request::Builder;
#[cfg(feature = "opentls")]
use hyper_tls::HttpsConnector;
#[cfg(feature = "rustls")]
use hyper_rustls::HttpsConnectorBuilder;
#[cfg(feature = "retry")]
use tower::retry::{Retry, RetryLayer};
#[cfg(feature = "timeout")]
use {
hyper_timeout::TimeoutConnector,
tokio::io::{AsyncRead, AsyncWrite},
};
use tower_http::{classify::ServerErrorsFailureClass, map_response_body::MapResponseBodyLayer};
#[cfg(feature = "tracing")]
use {tower_http::trace::TraceLayer, tracing::Span};
use crate::error::{
HttpSnafu, HyperSnafu, InvalidUtf8Snafu, SerdeSnafu, SerdeUrlEncodedSnafu, ServiceSnafu,
UriParseError, UriParseSnafu, UriSnafu,
};
use crate::service::middleware::base_uri::BaseUriLayer;
use crate::service::middleware::extra_headers::ExtraHeadersLayer;
#[cfg(feature = "retry")]
use crate::service::middleware::retry::RetryConfig;
use crate::api::users;
use auth::{AppAuth, Auth};
use models::{AppId, InstallationId, InstallationToken};
pub use self::{
api::{
actions, activity, apps, checks, commits, current, events, gists, gitignore, issues,
licenses, markdown, orgs, projects, pulls, ratelimit, repos, search, teams, workflows,
},
error::{Error, GitHubError},
from_response::FromResponse,
page::Page,
};
pub type Result<T, E = error::Error> = std::result::Result<T, E>;
const GITHUB_BASE_URI: &str = "https://api.github.com";
static STATIC_INSTANCE: Lazy<arc_swap::ArcSwap<Octocrab>> =
Lazy::new(|| arc_swap::ArcSwap::from_pointee(Octocrab::default()));
pub fn format_preview(preview: impl AsRef<str>) -> String {
format!("application/vnd.github.{}-preview", preview.as_ref())
}
pub fn format_media_type(media_type: impl AsRef<str>) -> String {
let media_type = media_type.as_ref();
let json_suffix = match media_type {
"raw" | "text" | "html" | "full" => "+json",
_ => "",
};
format!("application/vnd.github.v3.{media_type}{json_suffix}")
}
pub async fn map_github_error(
response: http::Response<hyper::Body>,
) -> Result<http::Response<hyper::Body>> {
if response.status().is_success() {
Ok(response)
} else {
let b: error::GitHubError = serde_json::from_slice(
body::to_bytes(response.into_body())
.await
.context(error::HyperSnafu)?
.as_ref(),
)
.context(error::SerdeSnafu)?;
Err(error::Error::GitHub {
source: b,
backtrace: Backtrace::generate(),
})
}
}
pub fn initialise(crab: Octocrab) -> Arc<Octocrab> {
STATIC_INSTANCE.swap(Arc::from(crab))
}
pub fn instance() -> Arc<Octocrab> {
STATIC_INSTANCE.load().clone()
}
pub struct OctocrabBuilder<Svc, Config, Auth, LayerReady> {
service: Svc,
auth: Auth,
config: Config,
_layer_ready: PhantomData<LayerReady>,
}
pub struct NoConfig {}
pub struct NoSvc {}
pub struct NotLayerReady {}
pub struct LayerReady {}
pub struct NoAuth {}
impl OctocrabBuilder<NoSvc, NoConfig, NoAuth, NotLayerReady> {
pub fn new_empty() -> Self {
OctocrabBuilder {
service: NoSvc {},
auth: NoAuth {},
config: NoConfig {},
_layer_ready: PhantomData,
}
}
}
impl OctocrabBuilder<NoSvc, DefaultOctocrabBuilderConfig, NoAuth, NotLayerReady> {
pub fn new() -> Self {
OctocrabBuilder::default()
}
}
impl<Config, Auth> OctocrabBuilder<NoSvc, Config, Auth, NotLayerReady> {
pub fn with_service<Svc>(self, service: Svc) -> OctocrabBuilder<Svc, Config, Auth, LayerReady> {
OctocrabBuilder {
service,
auth: self.auth,
config: self.config,
_layer_ready: PhantomData,
}
}
}
impl<Svc, Config, Auth, B> OctocrabBuilder<Svc, Config, Auth, LayerReady>
where
Svc: Service<Request<String>, Response = Response<B>> + Send + 'static,
Svc::Future: Send + 'static,
Svc::Error: Into<BoxError>,
B: http_body::Body<Data = bytes::Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
pub fn with_layer<L: Layer<Svc>>(
self,
layer: &L,
) -> OctocrabBuilder<L::Service, Config, Auth, LayerReady> {
let Self {
service: stack,
auth,
config,
..
} = self;
OctocrabBuilder {
service: layer.layer(stack),
auth,
config,
_layer_ready: PhantomData,
}
}
}
impl Default for OctocrabBuilder<NoSvc, DefaultOctocrabBuilderConfig, NoAuth, NotLayerReady> {
fn default() -> OctocrabBuilder<NoSvc, DefaultOctocrabBuilderConfig, NoAuth, NotLayerReady> {
OctocrabBuilder::new_empty().with_config(DefaultOctocrabBuilderConfig::default())
}
}
impl<Svc, Auth, LayerState> OctocrabBuilder<Svc, NoConfig, Auth, LayerState> {
fn with_config<Config>(self, config: Config) -> OctocrabBuilder<Svc, Config, Auth, LayerState> {
OctocrabBuilder {
service: self.service,
auth: self.auth,
config,
_layer_ready: PhantomData,
}
}
}
impl<Svc, B, LayerState> OctocrabBuilder<Svc, NoConfig, AuthState, LayerState>
where
Svc: Service<Request<String>, Response = Response<B>> + Send + 'static,
Svc::Future: Send + 'static,
Svc::Error: Into<BoxError>,
B: http_body::Body<Data = bytes::Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
pub fn build(self) -> Result<Octocrab, Infallible> {
Ok(Octocrab::new(self.service, self.auth))
}
}
impl<Svc, Config, LayerState> OctocrabBuilder<Svc, Config, NoAuth, LayerState> {
pub fn with_auth<Auth>(self, auth: Auth) -> OctocrabBuilder<Svc, Config, Auth, LayerState> {
OctocrabBuilder {
service: self.service,
auth,
config: self.config,
_layer_ready: PhantomData,
}
}
}
impl OctocrabBuilder<NoSvc, DefaultOctocrabBuilderConfig, NoAuth, NotLayerReady> {
#[cfg(feature = "retry")]
pub fn add_retry_config(&mut self, retry_config: RetryConfig) -> &mut Self {
self.config.retry_config = retry_config;
self
}
pub fn add_preview(mut self, preview: &'static str) -> Self {
self.config.previews.push(preview);
self
}
pub fn add_header(mut self, key: HeaderName, value: String) -> Self {
self.config.extra_headers.push((key, value));
self
}
pub fn personal_token(mut self, token: String) -> Self {
self.config.auth = Auth::PersonalToken(SecretString::new(token));
self
}
pub fn app(mut self, app_id: AppId, key: jsonwebtoken::EncodingKey) -> Self {
self.config.auth = Auth::App(AppAuth { app_id, key });
self
}
pub fn basic_auth(mut self, username: String, password: String) -> Self {
self.config.auth = Auth::Basic { username, password };
self
}
pub fn oauth(mut self, oauth: auth::OAuth) -> Self {
self.config.auth = Auth::OAuth(oauth);
self
}
pub fn user_access_token(mut self, token: String) -> Self {
self.config.auth = Auth::UserAccessToken(SecretString::new(token));
self
}
pub fn base_uri(mut self, base_uri: impl TryInto<Uri>) -> Result<Self> {
self.config.base_uri = Some(
base_uri
.try_into()
.map_err(|_| UriParseError {})
.context(UriParseSnafu)?,
);
Ok(self)
}
#[cfg(feature = "retry")]
pub fn set_connector_retry_service<S>(
&self,
connector: hyper::Client<S, String>,
) -> Retry<RetryConfig, hyper::Client<S, String>> {
let retry_layer = RetryLayer::new(self.config.retry_config.clone());
retry_layer.layer(connector)
}
#[cfg(feature = "timeout")]
pub fn set_connect_timeout_service<T>(&self, connector: T) -> TimeoutConnector<T>
where
T: Service<Uri> + Send,
T::Response: AsyncRead + AsyncWrite + Send + Unpin,
T::Future: Send + 'static,
T::Error: Into<BoxError>,
{
let mut connector = TimeoutConnector::new(connector);
connector.set_connect_timeout(self.config.connect_timeout);
connector.set_read_timeout(self.config.read_timeout);
connector.set_write_timeout(self.config.write_timeout);
connector
}
pub fn build(self) -> Result<Octocrab> {
let client: hyper::Client<_, String> = {
#[cfg(all(not(feature = "opentls"), not(feature = "rustls")))]
let mut connector = HttpConnector::new();
#[cfg(all(feature = "rustls", not(feature = "opentls")))]
let connector = {
let builder = HttpsConnectorBuilder::new();
#[cfg(feature = "rustls-webpki-tokio")]
let builder = builder.with_webpki_roots();
#[cfg(not(feature = "rustls-webpki-tokio"))]
let builder = builder.with_native_roots();
builder
.https_or_http() .enable_http1()
.build()
};
#[cfg(all(feature = "opentls", not(feature = "rustls")))]
let connector = HttpsConnector::new();
#[cfg(feature = "timeout")]
let connector = self.set_connect_timeout_service(connector);
hyper::Client::builder().build(connector)
};
#[cfg(feature = "retry")]
let client = self.set_connector_retry_service(client);
#[cfg(feature = "tracing")]
let client = TraceLayer::new_for_http()
.make_span_with(|req: &Request<String>| {
tracing::debug_span!(
"HTTP",
http.method = %req.method(),
http.url = %req.uri(),
http.status_code = tracing::field::Empty,
otel.name = req.extensions().get::<&'static str>().unwrap_or(&"HTTP"),
otel.kind = "client",
otel.status_code = tracing::field::Empty,
)
})
.on_request(|_req: &Request<String>, _span: &Span| {
tracing::debug!("requesting");
})
.on_response(
|res: &Response<hyper::Body>, _latency: Duration, span: &Span| {
let status = res.status();
span.record("http.status_code", status.as_u16());
if status.is_client_error() || status.is_server_error() {
span.record("otel.status_code", "ERROR");
}
},
)
.on_body_chunk(())
.on_eos(|_: Option<&HeaderMap>, _duration: Duration, _span: &Span| {
tracing::debug!("stream closed");
})
.on_failure(
|ec: ServerErrorsFailureClass, _latency: Duration, span: &Span| {
span.record("otel.status_code", "ERROR");
match ec {
ServerErrorsFailureClass::StatusCode(status) => {
span.record("http.status_code", status.as_u16());
tracing::error!("failed with status {}", status)
}
ServerErrorsFailureClass::Error(err) => {
tracing::error!("failed with error {}", err)
}
}
},
)
.layer(client);
let mut hmap: Vec<(HeaderName, HeaderValue)> = vec![];
hmap.push((USER_AGENT, HeaderValue::from_str("octocrab").unwrap()));
for preview in &self.config.previews {
hmap.push((
http::header::ACCEPT,
HeaderValue::from_str(crate::format_preview(preview).as_str()).unwrap(),
));
}
let auth_state = match self.config.auth {
Auth::None => AuthState::None,
Auth::Basic { username, password } => AuthState::BasicAuth { username, password },
Auth::PersonalToken(token) => {
hmap.push((
http::header::AUTHORIZATION,
format!("Bearer {}", token.expose_secret()).parse().unwrap(),
));
AuthState::None
}
Auth::UserAccessToken(token) => {
hmap.push((
http::header::AUTHORIZATION,
format!("Bearer {}", token.expose_secret()).parse().unwrap(),
));
AuthState::None
}
Auth::App(app_auth) => AuthState::App(app_auth),
Auth::OAuth(device) => {
hmap.push((
http::header::AUTHORIZATION,
format!(
"{} {}",
device.token_type,
&device.access_token.expose_secret()
)
.parse()
.unwrap(),
));
AuthState::None
}
};
for (key, value) in self.config.extra_headers.iter() {
hmap.push((
key.clone(),
HeaderValue::from_str(value.as_str())
.map_err(http::Error::from)
.context(HttpSnafu)?,
));
}
let client = ExtraHeadersLayer::new(Arc::new(hmap)).layer(client);
let client = MapResponseBodyLayer::new(|body| {
Box::new(http_body::Body::map_err(body, BoxError::from)) as Box<DynBody>
})
.layer(client);
let uri = self
.config
.base_uri
.clone()
.unwrap_or_else(|| Uri::from_str(GITHUB_BASE_URI).unwrap());
let client = BaseUriLayer::new(uri).layer(client);
Ok(Octocrab::new(client, auth_state))
}
}
pub struct DefaultOctocrabBuilderConfig {
auth: Auth,
previews: Vec<&'static str>,
extra_headers: Vec<(HeaderName, String)>,
#[cfg(feature = "timeout")]
connect_timeout: Option<Duration>,
#[cfg(feature = "timeout")]
read_timeout: Option<Duration>,
#[cfg(feature = "timeout")]
write_timeout: Option<Duration>,
base_uri: Option<Uri>,
#[cfg(feature = "retry")]
retry_config: RetryConfig,
}
impl Default for DefaultOctocrabBuilderConfig {
fn default() -> Self {
Self {
auth: Auth::None,
previews: Vec::new(),
extra_headers: Vec::new(),
#[cfg(feature = "timeout")]
connect_timeout: None,
#[cfg(feature = "timeout")]
read_timeout: None,
#[cfg(feature = "timeout")]
write_timeout: None,
base_uri: None,
#[cfg(feature = "retry")]
retry_config: RetryConfig::Simple(3),
}
}
}
impl DefaultOctocrabBuilderConfig {
pub fn new() -> Self {
Self::default()
}
}
pub type DynBody = dyn http_body::Body<Data = Bytes, Error = BoxError> + Send + Unpin;
#[derive(Debug, Clone)]
struct CachedTokenInner {
expiration: Option<DateTime<Utc>>,
secret: SecretString,
}
impl CachedTokenInner {
fn new(secret: SecretString, expiration: Option<DateTime<Utc>>) -> Self {
Self { secret, expiration }
}
fn expose_secret(&self) -> &str {
self.secret.expose_secret()
}
}
pub struct CachedToken(RwLock<Option<CachedTokenInner>>);
impl CachedToken {
fn clear(&self) {
*self.0.write().unwrap() = None;
}
fn valid_token_with_buffer(&self, buffer: chrono::Duration) -> Option<SecretString> {
let inner = self.0.read().unwrap();
if let Some(token) = inner.as_ref() {
if let Some(exp) = token.expiration {
if exp - Utc::now() > buffer {
return Some(token.secret.clone());
}
} else {
return Some(token.secret.clone());
}
}
None
}
fn valid_token(&self) -> Option<SecretString> {
self.valid_token_with_buffer(chrono::Duration::seconds(30))
}
fn set(&self, token: String, expiration: Option<DateTime<Utc>>) {
*self.0.write().unwrap() =
Some(CachedTokenInner::new(SecretString::new(token), expiration));
}
}
impl fmt::Debug for CachedToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.read().unwrap().fmt(f)
}
}
impl fmt::Display for CachedToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let option = self.0.read().unwrap();
option
.as_ref()
.map(|s| s.expose_secret().fmt(f))
.unwrap_or_else(|| write!(f, "<none>"))
}
}
impl Clone for CachedToken {
fn clone(&self) -> CachedToken {
CachedToken(RwLock::new(self.0.read().unwrap().clone()))
}
}
impl Default for CachedToken {
fn default() -> CachedToken {
CachedToken(RwLock::new(None))
}
}
#[derive(Debug, Clone)]
pub enum AuthState {
None,
BasicAuth {
username: String,
password: String,
},
App(AppAuth),
Installation {
app: AppAuth,
installation: InstallationId,
token: CachedToken,
},
}
pub type OctocrabService = Buffer<
BoxService<http::Request<String>, http::Response<hyper::Body>, BoxError>,
http::Request<String>,
>;
#[derive(Clone)]
pub struct Octocrab {
client: OctocrabService,
auth_state: AuthState,
}
impl fmt::Debug for Octocrab {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Octocrab")
.field("auth_state", &self.auth_state)
.finish()
}
}
impl Default for Octocrab {
fn default() -> Self {
OctocrabBuilder::default().build().unwrap()
}
}
impl Octocrab {
pub fn builder() -> OctocrabBuilder<NoSvc, DefaultOctocrabBuilderConfig, NoAuth, NotLayerReady>
{
OctocrabBuilder::new_empty().with_config(DefaultOctocrabBuilderConfig::default())
}
fn new<S, B>(service: S, auth_state: AuthState) -> Self
where
S: Service<Request<String>, Response = Response<B>> + Send + 'static,
S::Future: Send + 'static,
S::Error: Into<BoxError>,
B: http_body::Body<Data = bytes::Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
let service = MapResponseBodyLayer::new(|b: B| Body::wrap_stream(b.into_stream()))
.layer(service)
.map_err(|e| e.into());
let service = Buffer::new(BoxService::new(service), 1024);
Self {
client: service,
auth_state,
}
}
pub fn installation(&self, id: InstallationId) -> Octocrab {
let app_auth = if let AuthState::App(ref app_auth) = self.auth_state {
app_auth.clone()
} else {
panic!("Github App authorization is required to target an installation");
};
Octocrab {
client: self.client.clone(),
auth_state: AuthState::Installation {
app: app_auth,
installation: id,
token: CachedToken::default(),
},
}
}
pub async fn installation_and_token(
&self,
id: InstallationId,
) -> Result<(Octocrab, SecretString)> {
let crab = self.installation(id);
let token = crab.request_installation_auth_token().await?;
Ok((crab, token))
}
}
impl Octocrab {
pub fn actions(&self) -> actions::ActionsHandler {
actions::ActionsHandler::new(self)
}
pub fn current(&self) -> current::CurrentAuthHandler {
current::CurrentAuthHandler::new(self)
}
pub fn activity(&self) -> activity::ActivityHandler {
activity::ActivityHandler::new(self)
}
pub fn apps(&self) -> apps::AppsRequestHandler {
apps::AppsRequestHandler::new(self)
}
pub fn gitignore(&self) -> gitignore::GitignoreHandler {
gitignore::GitignoreHandler::new(self)
}
pub fn issues(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> issues::IssueHandler {
issues::IssueHandler::new(self, owner.into(), repo.into())
}
pub fn commits(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> commits::CommitHandler {
commits::CommitHandler::new(self, owner.into(), repo.into())
}
pub fn licenses(&self) -> licenses::LicenseHandler {
licenses::LicenseHandler::new(self)
}
pub fn markdown(&self) -> markdown::MarkdownHandler {
markdown::MarkdownHandler::new(self)
}
pub fn orgs(&self, owner: impl Into<String>) -> orgs::OrgHandler {
orgs::OrgHandler::new(self, owner.into())
}
pub fn pulls(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> pulls::PullRequestHandler {
pulls::PullRequestHandler::new(self, owner.into(), repo.into())
}
pub fn repos(&self, owner: impl Into<String>, repo: impl Into<String>) -> repos::RepoHandler {
repos::RepoHandler::new(self, owner.into(), repo.into())
}
pub fn projects(&self) -> projects::ProjectHandler {
projects::ProjectHandler::new(self)
}
pub fn search(&self) -> search::SearchHandler {
search::SearchHandler::new(self)
}
pub fn teams(&self, owner: impl Into<String>) -> teams::TeamHandler {
teams::TeamHandler::new(self, owner.into())
}
pub fn users(&self, user: impl Into<String>) -> users::UserHandler {
users::UserHandler::new(self, user.into())
}
pub fn workflows(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> workflows::WorkflowsHandler {
workflows::WorkflowsHandler::new(self, owner.into(), repo.into())
}
pub fn events(&self) -> events::EventsBuilder {
events::EventsBuilder::new(self)
}
pub fn gists(&self) -> gists::GistsHandler {
gists::GistsHandler::new(self)
}
pub fn checks(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> checks::ChecksHandler {
checks::ChecksHandler::new(self, owner.into(), repo.into())
}
pub fn ratelimit(&self) -> ratelimit::RateLimitHandler {
ratelimit::RateLimitHandler::new(self)
}
}
impl Octocrab {
pub async fn graphql<R: crate::FromResponse>(
&self,
payload: &(impl serde::Serialize + ?Sized),
) -> crate::Result<R> {
self.post("/graphql", Some(&serde_json::json!(payload)))
.await
}
}
impl Octocrab {
pub async fn post<P: Serialize + ?Sized, R: FromResponse>(
&self,
route: impl AsRef<str>,
body: Option<&P>,
) -> Result<R> {
let response = self
._post(self.parameterized_uri(route, None::<&()>)?, body)
.await?;
R::from_response(crate::map_github_error(response).await?).await
}
pub async fn _post<P: Serialize + ?Sized>(
&self,
uri: impl TryInto<http::Uri>,
body: Option<&P>,
) -> Result<http::Response<hyper::Body>> {
let uri = uri
.try_into()
.map_err(|_| UriParseError {})
.context(UriParseSnafu)?;
let request = Builder::new().method(Method::POST).uri(uri);
let request = self.build_request(request, body)?;
self.execute(request).await
}
pub async fn get<R, A, P>(&self, route: A, parameters: Option<&P>) -> Result<R>
where
A: AsRef<str>,
P: Serialize + ?Sized,
R: FromResponse,
{
self.get_with_headers(route, parameters, None).await
}
pub async fn _get(&self, uri: impl TryInto<Uri>) -> Result<http::Response<hyper::Body>> {
self._get_with_headers(uri, None).await
}
fn parameterized_uri<A, P>(&self, uri: A, parameters: Option<&P>) -> Result<Uri>
where
A: AsRef<str>,
P: Serialize + ?Sized,
{
let mut uri = uri.as_ref().to_string();
if let Some(parameters) = parameters {
if uri.contains('?') {
uri = format!("{uri}&");
} else {
uri = format!("{uri}?");
}
uri = format!(
"{}{}",
uri,
serde_urlencoded::to_string(parameters)
.context(SerdeUrlEncodedSnafu)?
.as_str()
);
}
let uri = Uri::from_str(uri.as_str()).context(UriSnafu);
uri
}
pub async fn body_to_string(&self, res: http::Response<Body>) -> Result<String> {
let body_bytes = body::to_bytes(res.into_body()).await.context(HyperSnafu)?;
String::from_utf8(body_bytes.to_vec()).context(InvalidUtf8Snafu)
}
pub async fn get_with_headers<R, A, P>(
&self,
route: A,
parameters: Option<&P>,
headers: Option<http::header::HeaderMap>,
) -> Result<R>
where
A: AsRef<str>,
P: Serialize + ?Sized,
R: FromResponse,
{
let response = self
._get_with_headers(self.parameterized_uri(route, parameters)?, headers)
.await?;
R::from_response(crate::map_github_error(response).await?).await
}
pub async fn _get_with_headers(
&self,
uri: impl TryInto<Uri>,
headers: Option<http::header::HeaderMap>,
) -> Result<http::Response<hyper::Body>> {
let uri = uri
.try_into()
.map_err(|_| UriParseError {})
.context(UriParseSnafu)?;
let mut request = Builder::new().method(Method::GET).uri(uri);
if let Some(headers) = headers {
for (key, value) in headers.iter() {
request = request.header(key, value);
}
}
let request = self.build_request(request, None::<&()>)?;
self.execute(request).await
}
pub async fn patch<R, A, B>(&self, route: A, body: Option<&B>) -> Result<R>
where
A: AsRef<str>,
B: Serialize + ?Sized,
R: FromResponse,
{
let response = self
._patch(self.parameterized_uri(route, None::<&()>)?, body)
.await?;
R::from_response(crate::map_github_error(response).await?).await
}
pub async fn _patch<B: Serialize + ?Sized>(
&self,
uri: impl TryInto<Uri>,
body: Option<&B>,
) -> Result<http::Response<hyper::Body>> {
let uri = uri
.try_into()
.map_err(|_| UriParseError {})
.context(UriParseSnafu)?;
let request = Builder::new().method(Method::PATCH).uri(uri);
let request = self.build_request(request, body)?;
self.execute(request).await
}
pub async fn put<R, A, B>(&self, route: A, body: Option<&B>) -> Result<R>
where
A: AsRef<str>,
B: Serialize + ?Sized,
R: FromResponse,
{
let response = self
._put(self.parameterized_uri(route, None::<&()>)?, body)
.await?;
R::from_response(crate::map_github_error(response).await?).await
}
pub async fn _put<B: Serialize + ?Sized>(
&self,
uri: impl TryInto<Uri>,
body: Option<&B>,
) -> Result<http::Response<hyper::Body>> {
let uri = uri
.try_into()
.map_err(|_| UriParseError {})
.context(UriParseSnafu)?;
let request = Builder::new().method(Method::PUT).uri(uri);
let request = self.build_request(request, body)?;
self.execute(request).await
}
pub fn build_request<B: Serialize + ?Sized>(
&self,
mut builder: Builder,
body: Option<&B>,
) -> Result<http::Request<String>> {
if let Some(body) = body {
builder = builder.header(http::header::CONTENT_TYPE, "application/json");
let request = builder
.body(serde_json::to_string(body).context(SerdeSnafu)?)
.context(HttpSnafu)?;
Ok(request)
} else {
Ok(builder.body(String::new()).context(HttpSnafu)?)
}
}
pub async fn delete<R, A, B>(&self, route: A, body: Option<&B>) -> Result<R>
where
A: AsRef<str>,
B: Serialize + ?Sized,
R: FromResponse,
{
let response = self
._delete(self.parameterized_uri(route, None::<&()>)?, body)
.await?;
R::from_response(crate::map_github_error(response).await?).await
}
pub async fn _delete<B: Serialize + ?Sized>(
&self,
uri: impl TryInto<Uri>,
body: Option<&B>,
) -> Result<http::Response<hyper::Body>> {
let uri = uri
.try_into()
.map_err(|_| UriParseError {})
.context(UriParseSnafu)?;
let request = self.build_request(Builder::new().method(Method::DELETE).uri(uri), body)?;
self.execute(request).await
}
async fn request_installation_auth_token(&self) -> Result<SecretString> {
let (app, installation, token) = if let AuthState::Installation {
ref app,
installation,
ref token,
} = self.auth_state
{
(app, installation, token)
} else {
panic!("Installation not configured");
};
let mut request = Builder::new();
let mut sensitive_value =
HeaderValue::from_str(format!("Bearer {}", app.generate_bearer_token()?).as_str())
.map_err(http::Error::from)
.context(HttpSnafu)?;
let uri = http::Uri::builder()
.path_and_query(format!("/app/installations/{installation}/access_tokens"))
.build()
.context(HttpSnafu)?;
sensitive_value.set_sensitive(true);
request = request
.header(hyper::header::AUTHORIZATION, sensitive_value)
.method(http::Method::POST)
.uri(uri);
let response = self
.send(request.body(String::new()).context(HttpSnafu)?)
.await?;
let _status = response.status();
let token_object =
InstallationToken::from_response(crate::map_github_error(response).await?).await?;
let expiration = token_object
.expires_at
.map(|time| {
DateTime::<Utc>::from_str(&time).map_err(|e| error::Error::Other {
source: Box::new(e),
backtrace: snafu::Backtrace::generate(),
})
})
.transpose()?;
#[cfg(feature = "tracing")]
tracing::debug!("Token expires at: {:?}", expiration);
token.set(token_object.token.clone(), expiration);
Ok(SecretString::new(token_object.token))
}
pub async fn send(&self, request: Request<String>) -> Result<http::Response<hyper::Body>> {
let mut svc = self.client.clone();
let response: Response<Body> = svc
.ready()
.await
.context(ServiceSnafu)?
.call(request)
.await
.context(ServiceSnafu)?;
Ok(response)
}
pub async fn execute(
&self,
request: http::Request<String>,
) -> Result<http::Response<hyper::Body>> {
let (mut parts, body) = request.into_parts();
let auth_header: Option<HeaderValue> = match self.auth_state {
AuthState::None => None,
AuthState::App(ref app) => Some(
HeaderValue::from_str(format!("Bearer {}", app.generate_bearer_token()?).as_str())
.map_err(http::Error::from)
.context(HttpSnafu)?,
),
AuthState::BasicAuth {
ref username,
ref password,
} => {
use base64::prelude::BASE64_STANDARD;
use base64::write::EncoderWriter;
let mut buf = b"Basic ".to_vec();
{
let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD);
write!(encoder, "{}:{}", username, password)
.expect("writing to a Vec never fails");
}
Some(HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue"))
}
AuthState::Installation { ref token, .. } => {
let token = if let Some(token) = token.valid_token() {
token
} else {
self.request_installation_auth_token().await?
};
Some(
HeaderValue::from_str(format!("Bearer {}", token.expose_secret()).as_str())
.map_err(http::Error::from)
.context(HttpSnafu)?,
)
}
};
if let Some(mut auth_header) = auth_header {
auth_header.set_sensitive(true);
parts
.headers
.insert(hyper::header::AUTHORIZATION, auth_header);
}
let request = http::Request::from_parts(parts, body);
let response = self.send(request).await?;
let status = response.status();
if StatusCode::UNAUTHORIZED == status {
if let AuthState::Installation { ref token, .. } = self.auth_state {
token.clear();
}
}
Ok(response)
}
pub async fn follow_location_to_data(
&self,
response: http::Response<hyper::Body>,
) -> crate::Result<http::Response<hyper::Body>> {
if let Some(redirect) = response.headers().get(http::header::LOCATION) {
let location = redirect.to_str().expect("Location URL not valid str");
self._get(location).await
} else {
Ok(response)
}
}
}
impl Octocrab {
pub async fn get_page<R: serde::de::DeserializeOwned>(
&self,
uri: &Option<Uri>,
) -> crate::Result<Option<Page<R>>> {
match uri {
Some(uri) => self.get(uri.to_string(), None::<&()>).await.map(Some),
None => Ok(None),
}
}
pub async fn all_pages<R: serde::de::DeserializeOwned>(
&self,
mut page: Page<R>,
) -> crate::Result<Vec<R>> {
let mut ret = page.take_items();
while let Some(mut next_page) = self.get_page(&page.next).await? {
ret.append(&mut next_page.take_items());
page = next_page;
}
Ok(ret)
}
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn parametrize_uri_valid() {
let uri = crate::instance()
.parameterized_uri("/help%20world", None::<&()>)
.unwrap();
assert_eq!(uri.path(), "/help%20world");
}
#[tokio::test]
async fn extra_headers() {
use http::header::HeaderName;
use wiremock::{matchers, Mock, MockServer, ResponseTemplate};
let response = ResponseTemplate::new(304).append_header("etag", "\"abcd\"");
let mock_server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path_regex(".*"))
.and(matchers::header("x-test1", "hello"))
.and(matchers::header("x-test2", "goodbye"))
.respond_with(response)
.expect(1)
.mount(&mock_server)
.await;
crate::OctocrabBuilder::default()
.base_uri(mock_server.uri())
.unwrap()
.add_header(HeaderName::from_static("x-test1"), "hello".to_string())
.add_header(HeaderName::from_static("x-test2"), "goodbye".to_string())
.build()
.unwrap()
.repos("XAMPPRocky", "octocrab")
.events()
.send()
.await
.unwrap();
}
use super::*;
use chrono::Duration;
#[test]
fn clear_token() {
let cache = CachedToken(RwLock::new(None));
cache.set("secret".to_string(), None);
cache.clear();
assert!(cache.valid_token().is_none(), "Token was not cleared.");
}
#[test]
fn no_token_when_expired() {
let cache = CachedToken(RwLock::new(None));
let expiration = Utc::now() + Duration::seconds(9);
cache.set("secret".to_string(), Some(expiration));
assert!(
cache
.valid_token_with_buffer(Duration::seconds(10))
.is_none(),
"Token should be considered expired due to buffer."
);
}
#[test]
fn get_valid_token_outside_buffer() {
let cache = CachedToken(RwLock::new(None));
let expiration = Utc::now() + Duration::seconds(12);
cache.set("secret".to_string(), Some(expiration));
assert!(
cache
.valid_token_with_buffer(Duration::seconds(10))
.is_some(),
"Token should still be valid outside of buffer."
);
}
#[test]
fn get_valid_token_without_expiration() {
let cache = CachedToken(RwLock::new(None));
cache.set("secret".to_string(), None);
assert!(
cache
.valid_token_with_buffer(Duration::seconds(10))
.is_some(),
"Token with no expiration should always be considered valid."
);
}
}