hubcaps_ex/
lib.rs

1//! Hubcaps provides a set of building blocks for interacting with the GitHub API
2//!
3//! # Examples
4//!
5//!  Typical use will require instantiation of a GitHub client. Which requires
6//! a user agent string and set of `hubcaps::Credentials`.
7//!
8//! ```no_run
9//! use hubcaps::{Credentials, Github};
10//!
11//! let github = Github::new(
12//!   String::from("user-agent-name"),
13//!   Credentials::Token(
14//!     String::from("personal-access-token")
15//!   ),
16//! );
17//! ```
18//!
19//! GitHub enterprise customers will want to create a client with the
20//! [Github#host](struct.Github.html#method.host) method
21//!
22//! Access to various services are provided via methods on instances of the `Github` type.
23//!
24//! The convention for executing operations typically looks like
25//! `github.repo(.., ..).service().operation(OperationOptions)` where operation may be `create`,
26//! `delete`, etc.
27//!
28//! Services and their types are packaged under their own module namespace.
29//! A service interface will provide access to operations and operations may access options types
30//! that define the various parameter options available for the operation. Most operation option
31//! types expose `builder()` methods for a builder oriented style of constructing options.
32//!
33//! ## Entity listings
34//!
35//! Many of GitHub's APIs return a collection of entities with a common interface for supporting pagination
36//! Hubcaps supports two types of interfaces for working with listings. `list(...)` interfaces return the first
37//! ( often enough ) list of entities. Alternatively for listings that require > 30 items you may wish to
38//! use the `iter(..)` variant which returns a `futures::Stream` over all entities in a paginated set.
39//!
40//! # Errors
41//!
42//! Operations typically result in a `hubcaps::Future` with an error type pinned to
43//! [hubcaps::Error](errors/struct.Error.html).
44//!
45//! ## Rate Limiting
46//!
47//! A special note should be taken when accounting for GitHub's
48//! [API Rate Limiting](https://developer.github.com/v3/rate_limit/)
49//! A special case
50//! [hubcaps::ErrorKind::RateLimit](errors/enum.ErrorKind.html#variant.RateLimit)
51//! will be returned from api operations when the rate limit
52//! associated with credentials has been exhausted. This type will include a reset
53//! Duration to wait before making future requests.
54//!
55//! This crate uses the `log` crate's debug log interface to log x-rate-limit
56//! headers received from Github.
57//! If you are attempting to test your access patterns against
58//! GitHub's rate limits, enable debug looking and look for "x-rate-limit"
59//! log patterns sourced from this crate
60//!
61//! # Features
62//!
63//! ## httpcache
64//!
65//! Github supports conditional HTTP requests using etags to checksum responses
66//! Experimental support for utilizing this to cache responses locally with the
67//! `httpcache` feature flag
68//!
69//! To enable this, add the following to your `Cargo.toml` file
70//!
71//! ```toml
72//! [dependencies.hubcaps]
73//!  version = "..."
74//!  default-features = false
75//!  features = ["default-tls","httpcache"]
76//! ```
77//!
78//! Then use the `Github::custom` constructor to provide a cache implementation. See
79//! the conditional_requests example in this crates github repository for an example usage
80//!
81#![allow(missing_docs)] // todo: make this a deny eventually
82
83use std::fmt;
84use std::pin::Pin;
85use std::str::FromStr;
86use std::sync::{Arc, Mutex};
87use std::time;
88use std::time::{Duration, SystemTime, UNIX_EPOCH};
89
90use futures::{future, prelude::*, stream, Future as StdFuture, Stream as StdStream};
91#[cfg(feature = "httpcache")]
92use http::header::IF_NONE_MATCH;
93use http::header::{HeaderMap, HeaderValue};
94use http::header::{ACCEPT, AUTHORIZATION, ETAG, LINK, USER_AGENT};
95use http::{Method, StatusCode};
96#[cfg(feature = "httpcache")]
97use hyperx::header::LinkValue;
98use hyperx::header::{qitem, Link, RelationType};
99use jsonwebtoken as jwt;
100use log::{debug, error, trace};
101use mime::Mime;
102use reqwest::Url;
103use reqwest::{Body, Client};
104use serde::de::DeserializeOwned;
105use serde::Serialize;
106
107#[doc(hidden)] // public for doc testing and integration testing only
108#[cfg(feature = "httpcache")]
109pub mod http_cache;
110#[macro_use]
111mod macros; // expose json! macro to child modules
112pub mod activity;
113pub mod app;
114pub mod branches;
115pub mod checks;
116pub mod collaborators;
117pub mod comments;
118pub mod content;
119pub mod deployments;
120pub mod errors;
121pub mod gists;
122pub mod git;
123pub mod hooks;
124pub mod issues;
125pub mod keys;
126pub mod labels;
127pub mod membership;
128pub mod notifications;
129pub mod organizations;
130pub mod pull_commits;
131pub mod pulls;
132pub mod rate_limit;
133pub mod releases;
134pub mod repo_commits;
135pub mod repositories;
136pub mod review_comments;
137pub mod review_requests;
138pub mod search;
139pub mod stars;
140pub mod statuses;
141pub mod teams;
142pub mod traffic;
143pub mod users;
144pub mod watching;
145pub mod milestone;
146
147pub use crate::errors::{Error, Result};
148#[cfg(feature = "httpcache")]
149pub use crate::http_cache::{BoxedHttpCache, HttpCache};
150
151use crate::activity::Activity;
152use crate::app::App;
153use crate::gists::{Gists, UserGists};
154use crate::organizations::{Organization, Organizations, UserOrganizations};
155use crate::rate_limit::RateLimit;
156use crate::repositories::{OrganizationRepositories, Repositories, Repository, UserRepositories};
157use crate::search::Search;
158use crate::users::Users;
159
160const DEFAULT_HOST: &str = "https://api.github.com";
161// We use 9 minutes for the life to give some buffer for clock drift between
162// our clock and GitHub's. The absolute max is 10 minutes.
163const MAX_JWT_TOKEN_LIFE: time::Duration = time::Duration::from_secs(60 * 9);
164// 8 minutes so we refresh sooner than it actually expires
165const JWT_TOKEN_REFRESH_PERIOD: time::Duration = time::Duration::from_secs(60 * 8);
166
167/// A type alias for `Futures` that may return `hubcaps::Errors`
168pub type Future<T> = Pin<Box<dyn StdFuture<Output = Result<T>> + Send>>;
169
170/// A type alias for `Streams` that may result in `hubcaps::Errors`
171pub type Stream<T> = Pin<Box<dyn StdStream<Item = Result<T>> + Send>>;
172
173const X_GITHUB_REQUEST_ID: &str = "x-github-request-id";
174const X_RATELIMIT_LIMIT: &str = "x-ratelimit-limit";
175const X_RATELIMIT_REMAINING: &str = "x-ratelimit-remaining";
176const X_RATELIMIT_RESET: &str = "x-ratelimit-reset";
177
178pub(crate) mod utils {
179    pub use percent_encoding::percent_encode;
180    use percent_encoding::{AsciiSet, CONTROLS};
181
182    /// https://url.spec.whatwg.org/#fragment-percent-encode-set
183    const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
184
185    /// https://url.spec.whatwg.org/#path-percent-encode-set
186    pub const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
187
188    pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');
189}
190
191/// GitHub defined Media types
192/// See [this doc](https://developer.github.com/v3/media/) for more for more information
193#[derive(Clone, Copy)]
194pub enum MediaType {
195    /// Return json (the default)
196    Json,
197    /// Return json in preview form
198    Preview(&'static str),
199}
200
201impl Default for MediaType {
202    fn default() -> MediaType {
203        MediaType::Json
204    }
205}
206
207impl From<MediaType> for Mime {
208    fn from(media: MediaType) -> Mime {
209        match media {
210            MediaType::Json => "application/vnd.github.v3+json".parse().unwrap(),
211            MediaType::Preview(codename) => {
212                format!("application/vnd.github.{}-preview+json", codename)
213                    .parse()
214                    .unwrap_or_else(|_| {
215                        panic!("could not parse media type for preview {}", codename)
216                    })
217            }
218        }
219    }
220}
221
222/// Controls what sort of authentication is required for this request
223#[derive(Clone, Copy, Debug, PartialEq)]
224pub enum AuthenticationConstraint {
225    /// No constraint
226    Unconstrained,
227    /// Must be JWT
228    JWT,
229}
230
231/// enum representation of Github list sorting options
232#[derive(Clone, Copy, Debug, PartialEq)]
233pub enum SortDirection {
234    /// Sort in ascending order (the default)
235    Asc,
236    /// Sort in descending order
237    Desc,
238}
239
240impl fmt::Display for SortDirection {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match *self {
243            SortDirection::Asc => "asc",
244            SortDirection::Desc => "desc",
245        }
246        .fmt(f)
247    }
248}
249
250impl Default for SortDirection {
251    fn default() -> SortDirection {
252        SortDirection::Asc
253    }
254}
255
256/// Various forms of authentication credentials supported by GitHub
257#[derive(PartialEq, Clone)]
258pub enum Credentials {
259    /// Oauth token string
260    /// https://developer.github.com/v3/#oauth2-token-sent-in-a-header
261    Token(String),
262    /// Oauth client id and secret
263    /// https://developer.github.com/v3/#oauth2-keysecret
264    Client(String, String),
265    /// JWT token exchange, to be performed transparently in the
266    /// background. app-id, DER key-file.
267    /// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/
268    JWT(JWTCredentials),
269    /// JWT-based App Installation Token
270    /// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/
271    InstallationToken(InstallationTokenGenerator),
272}
273
274impl fmt::Debug for Credentials {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Credentials::Token(value) => f
278                .debug_tuple("Credentials::Token")
279                .field(&"*".repeat(value.len()))
280                .finish(),
281            Credentials::Client(id, secret) => f
282                .debug_tuple("Credentials::Client")
283                .field(&id)
284                .field(&"*".repeat(secret.len()))
285                .finish(),
286            Credentials::JWT(jwt) => f
287                .debug_struct("Credentials::JWT")
288                .field("app_id", &jwt.app_id)
289                .field("private_key", &"vec![***]")
290                .finish(),
291            Credentials::InstallationToken(generator) => f
292                .debug_struct("Credentials::InstallationToken")
293                .field("installation_id", &generator.installation_id)
294                .field("jwt_credential", &"***")
295                .finish(),
296        }
297    }
298}
299
300/// JSON Web Token authentication mechanism
301///
302/// The GitHub client methods are all &self, but the dynamically
303/// generated JWT token changes regularly. The token is also a bit
304/// expensive to regenerate, so we do want to have a mutable cache.
305///
306/// We use a token inside a Mutex so we can have interior mutability
307/// even though JWTCredentials is not mutable.
308#[derive(Clone)]
309pub struct JWTCredentials {
310    pub app_id: u64,
311    /// DER RSA key. Generate with
312    /// `openssl rsa -in private_rsa_key.pem -outform DER -out private_rsa_key.der`
313    pub private_key: Vec<u8>,
314    cache: Arc<Mutex<ExpiringJWTCredential>>,
315}
316
317impl JWTCredentials {
318    pub fn new(app_id: u64, private_key: Vec<u8>) -> Result<JWTCredentials> {
319        let creds = ExpiringJWTCredential::calculate(app_id, &private_key)?;
320
321        Ok(JWTCredentials {
322            app_id,
323            private_key,
324            cache: Arc::new(Mutex::new(creds)),
325        })
326    }
327
328    fn is_stale(&self) -> bool {
329        self.cache.lock().unwrap().is_stale()
330    }
331
332    /// Fetch a valid JWT token, regenerating it if necessary
333    pub fn token(&self) -> String {
334        let mut expiring = self.cache.lock().unwrap();
335        if expiring.is_stale() {
336            *expiring = ExpiringJWTCredential::calculate(self.app_id, &self.private_key)
337                .expect("JWT private key worked before, it should work now...");
338        }
339
340        expiring.token.clone()
341    }
342}
343
344impl PartialEq for JWTCredentials {
345    fn eq(&self, other: &JWTCredentials) -> bool {
346        self.app_id == other.app_id && self.private_key == other.private_key
347    }
348}
349
350#[derive(Debug)]
351struct ExpiringJWTCredential {
352    token: String,
353    created_at: time::Instant,
354}
355
356#[derive(Serialize)]
357struct JWTCredentialClaim {
358    iat: u64,
359    exp: u64,
360    iss: u64,
361}
362
363impl ExpiringJWTCredential {
364    fn calculate(app_id: u64, private_key: &[u8]) -> Result<ExpiringJWTCredential> {
365        // SystemTime can go backwards, Instant can't, so always use
366        // Instant for ensuring regular cycling.
367        let created_at = time::Instant::now();
368        let now = time::SystemTime::now()
369            .duration_since(time::UNIX_EPOCH)
370            .unwrap();
371        let expires = now + MAX_JWT_TOKEN_LIFE;
372
373        let payload = JWTCredentialClaim {
374            iat: now.as_secs(),
375            exp: expires.as_secs(),
376            iss: app_id,
377        };
378        let header = jwt::Header::new(jwt::Algorithm::RS256);
379        let jwt = jwt::encode(
380            &header,
381            &payload,
382            &jsonwebtoken::EncodingKey::from_rsa_der(private_key),
383        )?;
384
385        Ok(ExpiringJWTCredential {
386            created_at,
387            token: jwt,
388        })
389    }
390
391    fn is_stale(&self) -> bool {
392        self.created_at.elapsed() >= JWT_TOKEN_REFRESH_PERIOD
393    }
394}
395
396/// A caching token "generator" which contains JWT credentials.
397///
398/// The authentication mechanism in the GitHub client library
399/// determines if the token is stale, and if so, uses the contained
400/// JWT credentials to fetch a new installation token.
401///
402/// The Mutex<Option> access key is for interior mutability.
403#[derive(Debug, Clone)]
404pub struct InstallationTokenGenerator {
405    pub installation_id: u64,
406    pub jwt_credential: Box<Credentials>,
407    access_key: Arc<Mutex<Option<String>>>,
408}
409
410impl InstallationTokenGenerator {
411    pub fn new(installation_id: u64, creds: JWTCredentials) -> InstallationTokenGenerator {
412        InstallationTokenGenerator {
413            installation_id,
414            jwt_credential: Box::new(Credentials::JWT(creds)),
415            access_key: Arc::new(Mutex::new(None)),
416        }
417    }
418
419    fn token(&self) -> Option<String> {
420        if let Credentials::JWT(ref creds) = *self.jwt_credential {
421            if creds.is_stale() {
422                return None;
423            }
424        }
425        self.access_key.lock().unwrap().clone()
426    }
427
428    fn jwt(&self) -> &Credentials {
429        &*self.jwt_credential
430    }
431}
432
433impl PartialEq for InstallationTokenGenerator {
434    fn eq(&self, other: &InstallationTokenGenerator) -> bool {
435        self.installation_id == other.installation_id && self.jwt_credential == other.jwt_credential
436    }
437}
438
439/// Entry point interface for interacting with GitHub API
440#[derive(Clone, Debug)]
441pub struct Github {
442    host: String,
443    agent: String,
444    client: Client,
445    credentials: Option<Credentials>,
446    #[cfg(feature = "httpcache")]
447    http_cache: BoxedHttpCache,
448}
449
450impl Github {
451    pub fn new<A, C>(agent: A, credentials: C) -> Result<Self>
452    where
453        A: Into<String>,
454        C: Into<Option<Credentials>>,
455    {
456        Self::host(DEFAULT_HOST, agent, credentials)
457    }
458
459    pub fn host<H, A, C>(host: H, agent: A, credentials: C) -> Result<Self>
460    where
461        H: Into<String>,
462        A: Into<String>,
463        C: Into<Option<Credentials>>,
464    {
465        let http = Client::builder().build()?;
466        #[cfg(feature = "httpcache")]
467        {
468            Ok(Self::custom(
469                host,
470                agent,
471                credentials,
472                http,
473                HttpCache::noop(),
474            ))
475        }
476        #[cfg(not(feature = "httpcache"))]
477        {
478            Ok(Self::custom(host, agent, credentials, http))
479        }
480    }
481
482    #[cfg(feature = "httpcache")]
483    pub fn custom<H, A, CR>(
484        host: H,
485        agent: A,
486        credentials: CR,
487        http: Client,
488        http_cache: BoxedHttpCache,
489    ) -> Self
490    where
491        H: Into<String>,
492        A: Into<String>,
493        CR: Into<Option<Credentials>>,
494    {
495        Self {
496            host: host.into(),
497            agent: agent.into(),
498            client: http,
499            credentials: credentials.into(),
500            http_cache,
501        }
502    }
503
504    #[cfg(not(feature = "httpcache"))]
505    pub fn custom<H, A, CR>(host: H, agent: A, credentials: CR, http: Client) -> Self
506    where
507        H: Into<String>,
508        A: Into<String>,
509        CR: Into<Option<Credentials>>,
510    {
511        Self {
512            host: host.into(),
513            agent: agent.into(),
514            client: http,
515            credentials: credentials.into(),
516        }
517    }
518
519    pub fn set_credentials<CR>(&mut self, credentials: CR)
520    where
521        CR: Into<Option<Credentials>>,
522    {
523        self.credentials = credentials.into();
524    }
525
526    pub fn rate_limit(&self) -> RateLimit {
527        RateLimit::new(self.clone())
528    }
529
530    /// Return a reference to user activity
531    pub fn activity(&self) -> Activity {
532        Activity::new(self.clone())
533    }
534
535    /// Return a reference to a Github repository
536    pub fn repo<O, R>(&self, owner: O, repo: R) -> Repository
537    where
538        O: Into<String>,
539        R: Into<String>,
540    {
541        Repository::new(self.clone(), owner, repo)
542    }
543
544    /// Return a reference to the collection of repositories owned by and
545    /// associated with an owner
546    pub fn user_repos<S>(&self, owner: S) -> UserRepositories
547    where
548        S: Into<String>,
549    {
550        UserRepositories::new(self.clone(), owner)
551    }
552
553    /// Return a reference to the collection of repositories owned by the user
554    /// associated with the current authentication credentials
555    pub fn repos(&self) -> Repositories {
556        Repositories::new(self.clone())
557    }
558
559    pub fn org<O>(&self, org: O) -> Organization
560    where
561        O: Into<String>,
562    {
563        Organization::new(self.clone(), org)
564    }
565
566    /// Return a reference to the collection of organizations that the user
567    /// associated with the current authentication credentials is in
568    pub fn orgs(&self) -> Organizations {
569        Organizations::new(self.clone())
570    }
571
572    /// Return a reference to an interface that provides access
573    /// to user information.
574    pub fn users(&self) -> Users {
575        Users::new(self.clone())
576    }
577
578    /// Return a reference to the collection of organizations a user
579    /// is publicly associated with
580    pub fn user_orgs<U>(&self, user: U) -> UserOrganizations
581    where
582        U: Into<String>,
583    {
584        UserOrganizations::new(self.clone(), user)
585    }
586
587    /// Return a reference to an interface that provides access to a user's gists
588    pub fn user_gists<O>(&self, owner: O) -> UserGists
589    where
590        O: Into<String>,
591    {
592        UserGists::new(self.clone(), owner)
593    }
594
595    /// Return a reference to an interface that provides access to the
596    /// gists belonging to the owner of the token used to configure this client
597    pub fn gists(&self) -> Gists {
598        Gists::new(self.clone())
599    }
600
601    /// Return a reference to an interface that provides access to search operations
602    pub fn search(&self) -> Search {
603        Search::new(self.clone())
604    }
605
606    /// Return a reference to the collection of repositories owned by and
607    /// associated with an organization
608    pub fn org_repos<O>(&self, org: O) -> OrganizationRepositories
609    where
610        O: Into<String>,
611    {
612        OrganizationRepositories::new(self.clone(), org)
613    }
614
615    /// Return a reference to GitHub Apps
616    pub fn app(&self) -> App {
617        App::new(self.clone())
618    }
619
620    fn credentials(&self, authentication: AuthenticationConstraint) -> Option<&Credentials> {
621        match (authentication, self.credentials.as_ref()) {
622            (AuthenticationConstraint::Unconstrained, creds) => creds,
623            (AuthenticationConstraint::JWT, creds @ Some(&Credentials::JWT(_))) => creds,
624            (
625                AuthenticationConstraint::JWT,
626                Some(&Credentials::InstallationToken(ref apptoken)),
627            ) => Some(apptoken.jwt()),
628            (AuthenticationConstraint::JWT, creds) => {
629                error!(
630                    "Request needs JWT authentication but only {:?} available",
631                    creds
632                );
633                None
634            }
635        }
636    }
637
638    fn url_and_auth(
639        &self,
640        uri: &str,
641        authentication: AuthenticationConstraint,
642    ) -> Future<(Url, Option<String>)> {
643        let parsed_url = uri.parse::<Url>();
644
645        match self.credentials(authentication) {
646            Some(&Credentials::Client(ref id, ref secret)) => Box::pin(future::ready(
647                parsed_url
648                    .map(|mut u| {
649                        u.query_pairs_mut()
650                            .append_pair("client_id", id)
651                            .append_pair("client_secret", secret);
652                        (u, None)
653                    })
654                    .map_err(Error::from),
655            )),
656            Some(&Credentials::Token(ref token)) => {
657                let auth = format!("token {}", token);
658                Box::pin(future::ready(
659                    parsed_url.map(|u| (u, Some(auth))).map_err(Error::from),
660                ))
661            }
662            Some(&Credentials::JWT(ref jwt)) => {
663                let auth = format!("Bearer {}", jwt.token());
664                Box::pin(future::ready(
665                    parsed_url.map(|u| (u, Some(auth))).map_err(Error::from),
666                ))
667            }
668            Some(&Credentials::InstallationToken(ref apptoken)) => {
669                if let Some(token) = apptoken.token() {
670                    let auth = format!("token {}", token);
671                    Box::pin(future::ready(
672                        parsed_url.map(|u| (u, Some(auth))).map_err(Error::from),
673                    ))
674                } else {
675                    debug!("App token is stale, refreshing");
676                    let token_ref = apptoken.access_key.clone();
677                    Box::pin(
678                        self.app()
679                            .make_access_token(apptoken.installation_id)
680                            .and_then(move |token| {
681                                let auth = format!("token {}", &token.token);
682                                *token_ref.lock().unwrap() = Some(token.token);
683                                future::ready(
684                                    parsed_url.map(|u| (u, Some(auth))).map_err(Error::from),
685                                )
686                            }),
687                    )
688                }
689            }
690            None => Box::pin(future::ready(
691                parsed_url.map(|u| (u, None)).map_err(Error::from),
692            )),
693        }
694    }
695
696    fn request<Out>(
697        &self,
698        method: Method,
699        uri: &str,
700        body: Option<Vec<u8>>,
701        media_type: MediaType,
702        authentication: AuthenticationConstraint,
703    ) -> Future<(Option<Link>, Out)>
704    where
705        Out: DeserializeOwned + 'static + Send,
706    {
707        let url_and_auth = self.url_and_auth(uri, authentication);
708
709        let instance = self.clone();
710        #[cfg(feature = "httpcache")]
711        let uri2 = uri.to_string();
712        let response = url_and_auth
713            .map_err(Error::from)
714            .and_then(move |(url, auth)| {
715                #[cfg(not(feature = "httpcache"))]
716                let mut req = instance.client.request(method, url);
717
718                #[cfg(feature = "httpcache")]
719                let mut req = {
720                    let mut req = instance.client.request(method.clone(), url);
721                    if method == Method::GET {
722                        if let Ok(etag) = instance.http_cache.lookup_etag(&uri2) {
723                            req = req.header(IF_NONE_MATCH, etag);
724                        }
725                    }
726                    req
727                };
728
729                req = req.header(USER_AGENT, &*instance.agent);
730                req = req.header(
731                    ACCEPT,
732                    &*format!("{}", qitem::<Mime>(From::from(media_type))),
733                );
734
735                if let Some(auth_str) = auth {
736                    req = req.header(AUTHORIZATION, &*auth_str);
737                }
738
739                trace!("Body: {:?}", &body);
740                if let Some(body) = body {
741                    req = req.body(Body::from(body));
742                }
743                debug!("Request: {:?}", &req);
744                req.send().map_err(Error::from)
745            });
746
747        #[cfg(feature = "httpcache")]
748        let instance2 = self.clone();
749
750        #[cfg(feature = "httpcache")]
751        let uri3 = uri.to_string();
752        Box::pin(response.and_then(move |response| {
753            #[cfg(not(feature = "httpcache"))]
754            let (remaining, reset) = get_header_values(response.headers());
755            #[cfg(feature = "httpcache")]
756            let (remaining, reset, etag) = get_header_values(response.headers());
757
758            let status = response.status();
759            let link = response
760                .headers()
761                .get(LINK)
762                .and_then(|l| l.to_str().ok())
763                .and_then(|l| l.parse().ok());
764
765            Box::pin(
766                response
767                    .bytes()
768                    .map_err(Error::from)
769                    .and_then(move |response_body| async move {
770                        if status.is_success() {
771                            debug!(
772                                "response payload {}",
773                                String::from_utf8_lossy(&response_body)
774                            );
775                            #[cfg(feature = "httpcache")]
776                            {
777                                if let Some(etag) = etag {
778                                    let next_link = link.as_ref().and_then(|l| next_link(&l));
779                                    if let Err(e) = instance2.http_cache.cache_response(
780                                        &uri3,
781                                        &response_body,
782                                        &etag,
783                                        &next_link,
784                                    ) {
785                                        // failing to cache isn't fatal, so just log & swallow the error
786                                        debug!("Failed to cache body & etag: {}", e);
787                                    }
788                                }
789                            }
790                            let parsed_response = if status == StatusCode::NO_CONTENT { serde_json::from_str("null") } else { serde_json::from_slice::<Out>(&response_body) };
791                            parsed_response
792                                .map(|out| (link, out))
793                                .map_err(Error::Codec)
794                        } else if status == StatusCode::NOT_MODIFIED {
795                            // only supported case is when client provides if-none-match
796                            // header when cargo builds with --cfg feature="httpcache"
797                            #[cfg(feature = "httpcache")]
798                            {
799                                instance2
800                                    .http_cache
801                                    .lookup_body(&uri3)
802                                    .map_err(Error::from)
803                                    .and_then(|body| {
804                                        serde_json::from_str::<Out>(&body)
805                                            .map_err(Error::from)
806                                            .and_then(|out| {
807                                                let link = match link {
808                                                    Some(link) => Ok(Some(link)),
809                                                    None => instance2
810                                                        .http_cache
811                                                        .lookup_next_link(&uri3)
812                                                        .map(|next_link| next_link.map(|next| {
813                                                            let next = LinkValue::new(next).push_rel(RelationType::Next);
814                                                            Link::new(vec![next])
815                                                        }))
816                                                };
817                                                link.map(|link| (link, out))
818                                            })
819                                    })
820                            }
821                            #[cfg(not(feature = "httpcache"))]
822                            {
823                                unreachable!("this should not be reachable without the httpcache feature enabled")
824                            }
825                        } else {
826                            let error = match (remaining, reset) {
827                                (Some(remaining), Some(reset)) if remaining == 0 => {
828                                    let now = SystemTime::now()
829                                        .duration_since(UNIX_EPOCH)
830                                        .unwrap()
831                                        .as_secs();
832                                    Error::RateLimit {
833                                        reset: Duration::from_secs(u64::from(reset) - now),
834                                    }
835                                }
836                                _ => Error::Fault {
837                                    code: status,
838                                    error: serde_json::from_slice(&response_body)?,
839                                },
840                            };
841                            Err(error)
842                        }
843                    }),
844            )
845        }))
846    }
847
848    fn request_entity<D>(
849        &self,
850        method: Method,
851        uri: &str,
852        body: Option<Vec<u8>>,
853        media_type: MediaType,
854        authentication: AuthenticationConstraint,
855    ) -> Future<D>
856    where
857        D: DeserializeOwned + 'static + Send,
858    {
859        Box::pin(
860            self.request(method, uri, body, media_type, authentication)
861                .map_ok(|(_, entity)| entity),
862        )
863    }
864
865    fn get<D>(&self, uri: &str) -> Future<D>
866    where
867        D: DeserializeOwned + 'static + Send,
868    {
869        self.get_media(uri, MediaType::Json)
870    }
871
872    fn get_media<D>(&self, uri: &str, media: MediaType) -> Future<D>
873    where
874        D: DeserializeOwned + 'static + Send,
875    {
876        self.request_entity(
877            Method::GET,
878            &(self.host.clone() + uri),
879            None,
880            media,
881            AuthenticationConstraint::Unconstrained,
882        )
883    }
884
885    fn get_stream<D>(&self, uri: &str) -> Stream<D>
886    where
887        D: DeserializeOwned + 'static + Send,
888    {
889        unfold(self.clone(), self.get_pages(uri), |x| x)
890    }
891
892    fn get_pages<D>(&self, uri: &str) -> Future<(Option<Link>, D)>
893    where
894        D: DeserializeOwned + 'static + Send,
895    {
896        self.request(
897            Method::GET,
898            &(self.host.clone() + uri),
899            None,
900            MediaType::Json,
901            AuthenticationConstraint::Unconstrained,
902        )
903    }
904
905    fn get_pages_url<D>(&self, url: &Url) -> Future<(Option<Link>, D)>
906    where
907        D: DeserializeOwned + 'static + Send,
908    {
909        self.request(
910            Method::GET,
911            url.as_str(),
912            None,
913            MediaType::Json,
914            AuthenticationConstraint::Unconstrained,
915        )
916    }
917
918    fn delete(&self, uri: &str) -> Future<()> {
919        Box::pin(
920            self.request_entity::<()>(
921                Method::DELETE,
922                &(self.host.clone() + uri),
923                None,
924                MediaType::Json,
925                AuthenticationConstraint::Unconstrained,
926            )
927            .or_else(|err| async move {
928                match err {
929                    Error::Codec(_) => Ok(()),
930                    otherwise => Err(otherwise),
931                }
932            }),
933        )
934    }
935
936    fn delete_message(&self, uri: &str, message: Vec<u8>) -> Future<()> {
937        Box::pin(
938            self.request_entity::<()>(
939                Method::DELETE,
940                &(self.host.clone() + uri),
941                Some(message),
942                MediaType::Json,
943                AuthenticationConstraint::Unconstrained,
944            )
945            .or_else(|err| async move {
946                match err {
947                    Error::Codec(_) => Ok(()),
948                    otherwise => Err(otherwise),
949                }
950            }),
951        )
952    }
953
954    fn post<D>(&self, uri: &str, message: Vec<u8>) -> Future<D>
955    where
956        D: DeserializeOwned + 'static + Send,
957    {
958        self.post_media(
959            uri,
960            message,
961            MediaType::Json,
962            AuthenticationConstraint::Unconstrained,
963        )
964    }
965
966    fn post_media<D>(
967        &self,
968        uri: &str,
969        message: Vec<u8>,
970        media: MediaType,
971        authentication: AuthenticationConstraint,
972    ) -> Future<D>
973    where
974        D: DeserializeOwned + 'static + Send,
975    {
976        self.request_entity(
977            Method::POST,
978            &(self.host.clone() + uri),
979            Some(message),
980            media,
981            authentication,
982        )
983    }
984
985    fn patch_no_response(&self, uri: &str, message: Vec<u8>) -> Future<()> {
986        Box::pin(self.patch(uri, message).or_else(|err| async move {
987            match err {
988                Error::Codec(_) => Ok(()),
989                err => Err(err),
990            }
991        }))
992    }
993
994    fn patch_media<D>(&self, uri: &str, message: Vec<u8>, media: MediaType) -> Future<D>
995    where
996        D: DeserializeOwned + 'static + Send,
997    {
998        self.request_entity(
999            Method::PATCH,
1000            &(self.host.clone() + uri),
1001            Some(message),
1002            media,
1003            AuthenticationConstraint::Unconstrained,
1004        )
1005    }
1006
1007    fn patch<D>(&self, uri: &str, message: Vec<u8>) -> Future<D>
1008    where
1009        D: DeserializeOwned + 'static + Send,
1010    {
1011        self.patch_media(uri, message, MediaType::Json)
1012    }
1013
1014    fn put_no_response(&self, uri: &str, message: Vec<u8>) -> Future<()> {
1015        Box::pin(self.put(uri, message).or_else(|err| async move {
1016            match err {
1017                Error::Codec(_) => Ok(()),
1018                err => Err(err),
1019            }
1020        }))
1021    }
1022
1023    fn put<D>(&self, uri: &str, message: Vec<u8>) -> Future<D>
1024    where
1025        D: DeserializeOwned + 'static + Send,
1026    {
1027        self.put_media(uri, message, MediaType::Json)
1028    }
1029
1030    fn put_media<D>(&self, uri: &str, message: Vec<u8>, media: MediaType) -> Future<D>
1031    where
1032        D: DeserializeOwned + 'static + Send,
1033    {
1034        self.request_entity(
1035            Method::PUT,
1036            &(self.host.clone() + uri),
1037            Some(message),
1038            media,
1039            AuthenticationConstraint::Unconstrained,
1040        )
1041    }
1042}
1043
1044#[cfg(not(feature = "httpcache"))]
1045type HeaderValues = (Option<u32>, Option<u32>);
1046#[cfg(feature = "httpcache")]
1047type HeaderValues = (Option<u32>, Option<u32>, Option<Vec<u8>>);
1048
1049fn get_header_values(headers: &HeaderMap<HeaderValue>) -> HeaderValues {
1050    if let Some(value) = headers.get(X_GITHUB_REQUEST_ID) {
1051        debug!("x-github-request-id: {:?}", value)
1052    }
1053    if let Some(value) = headers.get(X_RATELIMIT_LIMIT) {
1054        debug!("x-rate-limit-limit: {:?}", value)
1055    }
1056    let remaining = headers
1057        .get(X_RATELIMIT_REMAINING)
1058        .and_then(|val| val.to_str().ok())
1059        .and_then(|val| val.parse::<u32>().ok());
1060    let reset = headers
1061        .get(X_RATELIMIT_RESET)
1062        .and_then(|val| val.to_str().ok())
1063        .and_then(|val| val.parse::<u32>().ok());
1064    if let Some(value) = remaining {
1065        debug!("x-rate-limit-remaining: {}", value)
1066    }
1067    if let Some(value) = reset {
1068        debug!("x-rate-limit-reset: {}", value)
1069    }
1070    let etag = headers.get(ETAG);
1071    if let Some(value) = etag {
1072        debug!("etag: {:?}", value)
1073    }
1074
1075    #[cfg(feature = "httpcache")]
1076    {
1077        let etag = etag.map(|etag| etag.as_bytes().to_vec());
1078        (remaining, reset, etag)
1079    }
1080    #[cfg(not(feature = "httpcache"))]
1081    (remaining, reset)
1082}
1083
1084fn next_link(l: &Link) -> Option<String> {
1085    l.values().iter().find_map(|value| {
1086        value.rel().and_then(|rels| {
1087            if rels.iter().any(|rel| rel == &RelationType::Next) {
1088                Some(value.link().into())
1089            } else {
1090                None
1091            }
1092        })
1093    })
1094}
1095
1096/// "unfold" paginated results of a list of github entities
1097fn unfold<D, I>(
1098    github: Github,
1099    first: Future<(Option<Link>, D)>,
1100    into_items: fn(D) -> Vec<I>,
1101) -> Stream<I>
1102where
1103    D: DeserializeOwned + 'static + Send,
1104    I: 'static + Send,
1105{
1106    Box::pin(
1107        first
1108            .map_ok(move |(link, payload)| {
1109                let mut items = into_items(payload);
1110                items.reverse();
1111                stream::try_unfold(
1112                    (github, link, items),
1113                    move |(github, link, mut items)| async move {
1114                        match items.pop() {
1115                            Some(item) => Ok(Some((item, (github, link, items)))),
1116                            None => match link.and_then(|l| next_link(&l)) {
1117                                Some(url) => {
1118                                    let url = Url::from_str(&url).unwrap();
1119                                    let (link, payload) = github.get_pages_url(&url).await?;
1120                                    let mut items = into_items(payload);
1121                                    let item = items.remove(0);
1122                                    items.reverse();
1123                                    Ok(Some((item, (github, link, items))))
1124                                }
1125                                None => Ok(None),
1126                            },
1127                        }
1128                    },
1129                )
1130            })
1131            .try_flatten_stream(),
1132    )
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138
1139    #[test]
1140    fn credentials_impl_debug() {
1141        assert_eq!(
1142            format!("{:?}", Credentials::Token("secret".into())),
1143            "Credentials::Token(\"******\")"
1144        );
1145        assert_eq!(
1146            format!(
1147                "{:?}",
1148                Credentials::Client("client_id".into(), "client_secret".into())
1149            ),
1150            "Credentials::Client(\"client_id\", \"*************\")"
1151        );
1152    }
1153
1154    #[test]
1155    fn default_sort_direction() {
1156        let default: SortDirection = Default::default();
1157        assert_eq!(default, SortDirection::Asc)
1158    }
1159
1160    #[test]
1161    #[cfg(not(feature = "httpcache"))]
1162    fn header_values() {
1163        let empty = HeaderMap::new();
1164        let actual = get_header_values(&empty);
1165        let expected = (None, None);
1166        assert_eq!(actual, expected);
1167
1168        let mut all_valid = HeaderMap::new();
1169        all_valid.insert(X_RATELIMIT_REMAINING, HeaderValue::from_static("1234"));
1170        all_valid.insert(X_RATELIMIT_RESET, HeaderValue::from_static("5678"));
1171        let actual = get_header_values(&all_valid);
1172        let expected = (Some(1234), Some(5678));
1173        assert_eq!(actual, expected);
1174
1175        let mut invalid = HeaderMap::new();
1176        invalid.insert(X_RATELIMIT_REMAINING, HeaderValue::from_static("foo"));
1177        invalid.insert(X_RATELIMIT_RESET, HeaderValue::from_static("bar"));
1178        let actual = get_header_values(&invalid);
1179        let expected = (None, None);
1180        assert_eq!(actual, expected);
1181    }
1182
1183    #[test]
1184    #[cfg(feature = "httpcache")]
1185    fn header_values() {
1186        let empty = HeaderMap::new();
1187        let actual = get_header_values(&empty);
1188        let expected = (None, None, None);
1189        assert_eq!(actual, expected);
1190
1191        let mut all_valid = HeaderMap::new();
1192        all_valid.insert(X_RATELIMIT_REMAINING, HeaderValue::from_static("1234"));
1193        all_valid.insert(X_RATELIMIT_RESET, HeaderValue::from_static("5678"));
1194        all_valid.insert(ETAG, HeaderValue::from_static("foobar"));
1195        let actual = get_header_values(&all_valid);
1196        let expected = (Some(1234), Some(5678), Some(b"foobar".to_vec()));
1197        assert_eq!(actual, expected);
1198
1199        let mut invalid = HeaderMap::new();
1200        invalid.insert(X_RATELIMIT_REMAINING, HeaderValue::from_static("foo"));
1201        invalid.insert(X_RATELIMIT_RESET, HeaderValue::from_static("bar"));
1202        invalid.insert(ETAG, HeaderValue::from_static(""));
1203        let actual = get_header_values(&invalid);
1204        let expected = (None, None, Some(Vec::new()));
1205        assert_eq!(actual, expected);
1206    }
1207}