what_git/
lib.rs

1// Copyright (c) 2018-2019, Wayfair LLC
2// All rights reserved.
3//
4// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
5// following conditions are met:
6//
7//  * Redistributions of source code must retain the above copyright notice, this list of conditions and the following
8//    disclaimer.
9//  * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
10//    following disclaimer in the documentation and/or other materials provided with the distribution.
11//
12// THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
13// BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
14// IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
15// OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
16// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
17// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
18// EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
19
20//! `what_git` provides an easy mechanism for associating a given Git repository URL with its source. It supports
21//! either GitHub, GitHub Enterprise, Gitlab, or Gitlab Enterprise repositories. Use this crate to structure
22//! SCM-agnostic code with minimal branching.
23//!
24//! # About
25//!
26//! `what_git` associates a repository URL to a known repository source. All you need is an HTTP or Git URL,
27//! and a personal access token to the API service your repository is associated with. Provide each of those to the
28//! [`what_git::what_git`] function, and that's it.
29//!
30//!
31//! [`what_git::what_git`]: ./fn.what_git.html
32
33use reqwest::header;
34use reqwest::{Client, Url};
35use std::env;
36use std::error;
37use std::fmt;
38use std::result;
39
40/// Determines what source control management (SCM) solution a repository URL belongs to. Returns a
41/// [`what_git::Result`] type describing the structure of the URL and the associated [`what_git::SCMKind`], or some
42/// error of type [`what_git::Error`].
43///
44/// # Examples
45///
46/// ```no_run
47/// use what_git::{what_git, SCMKind};
48///
49/// async {
50///     let scm = what_git("https://github.com/rust-lang/rust", "<PERSONAL ACCESS TOKEN>")
51///         .await
52///         .map(|scm| match scm.kind {
53///             SCMKind::GitHub => println!("Do something with GitHub..."),
54///             _ => unimplemented!(),
55///         });
56/// };
57/// ```
58/// [`what_git::Result`]: ./type.Result.html
59/// [`what_git::SCMKind`]: ./enum.SCMKind.html
60/// [`what_git::Error`]: ./enum.Error.html
61pub async fn what_git(repository: &str, token: &str) -> Result {
62    let url_str = scrub_git_url_if_needed(repository);
63    let url = Url::parse(&url_str).map_err(|_| Error::UnknownProvider(url_str.to_string()))?;
64    metadata_for_url(&url, token).await
65}
66
67/// Remove various non-standard decorations, such as SSH decorations, from a URL string to get a string conforming to
68/// the [URL Standard](http://url.spec.whatwg.org/).
69fn scrub_git_url_if_needed(repository: &str) -> String {
70    if repository.starts_with("git@") {
71        repository
72            .replacen(":", "/", 1)
73            .replacen("git@", "git://", 1)
74    } else {
75        repository.to_string()
76    }
77}
78
79/// Determines what source control management (SCM) solution a repository URL belongs to. Returns a [`what_git::Result`]
80/// type describing the structure of the URL and the associated [`what_git::SCMKind`], or some error of type
81/// [`what_git::Error`].
82async fn metadata_for_url(url: &Url, token: &str) -> Result {
83    // Extract the first two path components in the URL to guess at the repository owner and name.
84    let path_components = url
85        .path_segments()
86        .expect(
87            "URL path components could not be represented.
88    This is likely because it is not a valid URL for this tool.",
89        )
90        .take(2)
91        .collect::<Vec<&str>>();
92    let (owner, mut repo) = if let [own, rep] = path_components[..] {
93        (own, rep)
94    } else {
95        return Err(Error::UnknownProvider(url.to_string()));
96    };
97    if let Some(idx) = repo.rfind(".git") {
98        repo = &repo[..idx];
99    }
100
101    // Extract the hostname
102    let hostname = url
103        .domain()
104        .ok_or_else(|| Error::UnknownProvider(url.to_string()))?;
105
106    let base_url: String;
107    let kind: SCMKind;
108
109    if hostname == "github.com" || hostname == "www.github.com" {
110        // 1. If the repository is located on GitHub.com, proceed
111        base_url = "https://api.github.com".to_string();
112        kind = SCMKind::GitHub;
113    } else if hostname == "gitlab.com" || hostname == "www.gitlab.com" {
114        // 2. If the repository is located on Gitlab.com, proceed
115        base_url = "https://gitlab.com".to_string();
116        kind = SCMKind::Gitlab;
117    } else if let Ok(base) = env::var("GITHUB_BASE_URL") {
118        // 3. If the user has manually specified an API base URL for a GitHub repository, proceed
119        base_url = base;
120        kind = SCMKind::GitHub;
121    } else if let Ok(base) = env::var("GITLAB_BASE_URL") {
122        // 4. If the user has manually specified an API base URL for a Gitlab repository, proceed
123        base_url = base;
124        kind = SCMKind::GitHub;
125    } else {
126        // 5. Attempt to connect to an SCM's API using known unique endpoints, and match on the possible successes.
127        let base_url_candidate = format!("https://{}", hostname);
128        let github_result = verify_github(&base_url_candidate, token).await;
129        let gitlab_result = verify_gitlab(&base_url_candidate, token).await;
130        match (github_result, gitlab_result) {
131            (Ok(true), _) => {
132                base_url = format!("{}/api/v3", base_url_candidate);
133                kind = SCMKind::GitHub;
134            }
135            (_, Ok(true)) => {
136                base_url = base_url_candidate;
137                kind = SCMKind::Gitlab;
138            }
139            _ => return Err(Error::UnknownProvider(url.to_string())),
140        };
141    }
142    Ok(SCM {
143        base_url,
144        kind,
145        owner: owner.to_string(),
146        repo: repo.to_string(),
147    })
148}
149
150// Attempt to connect to the GitHub `/zen` endpoint, which is unique to GitHub's API.
151async fn verify_github(base_url: &str, token: &str) -> result::Result<bool, reqwest::Error> {
152    let url = format!("{}/api/v3/zen", base_url);
153
154    Client::new()
155        .get(&*url)
156        .header(header::ACCEPT, "application/vnd.github.v3+json")
157        .header(header::AUTHORIZATION, format!("Bearer {}", token))
158        .header(header::USER_AGENT, "com.wayfair.what_gitjson")
159        .send()
160        .await
161        .map(|res| res.status().is_success())
162}
163
164// Attempt to connect to the Gitlab `/version` endpoint, which is unique to Gitlab's API.
165async fn verify_gitlab(base_url: &str, token: &str) -> result::Result<bool, reqwest::Error> {
166    let url = format!("{}/api/v4/version", base_url);
167
168    Client::new()
169        .get(&*url)
170        .header("private-token", token)
171        .send()
172        .await
173        .map(|res| res.status().is_success())
174}
175
176/// Used to describe the structure of a repository on a supported source control management (SCM) solution.
177#[derive(Debug)]
178pub struct SCM {
179    pub kind: SCMKind,
180    /// The base URL used in API calls
181    pub base_url: String,
182    /// The user or organization space that owns the repository
183    pub owner: String,
184    /// The name of the repository
185    pub repo: String,
186}
187
188/// Supported SCMs. Currently, `what_git` only supports GitHub and Gitlab.
189#[derive(Debug, PartialEq)]
190pub enum SCMKind {
191    Unsupported,
192    GitHub,
193    Gitlab,
194}
195
196pub type Result = result::Result<SCM, Error>;
197
198#[derive(Debug)]
199pub enum Error {
200    UnknownProvider(String),
201}
202
203impl error::Error for Error {
204    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
205        match *self {
206            Error::UnknownProvider(_) => None,
207        }
208    }
209}
210impl fmt::Display for Error {
211    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
212        match *self {
213            Error::UnknownProvider(ref url) => write!(f, "Unknown provider for url {}", url),
214        }
215    }
216}
217
218mod tests {
219
220    #[test]
221    fn test_scrub_git_url() {
222        assert_eq!(
223            super::scrub_git_url_if_needed("file:///Users/wayfair/foxdie.git"),
224            "file:///Users/wayfair/foxdie.git"
225        );
226        assert_eq!(
227            super::scrub_git_url_if_needed("https://github.com/wayfair/foxdie"),
228            "https://github.com/wayfair/foxdie"
229        );
230        assert_eq!(
231            super::scrub_git_url_if_needed("https://github.com/wayfair/foxdie.git"),
232            "https://github.com/wayfair/foxdie.git"
233        );
234        assert_eq!(
235            super::scrub_git_url_if_needed("git@github.com:wayfair/foxdie"),
236            "git://github.com/wayfair/foxdie"
237        );
238        assert_eq!(
239            super::scrub_git_url_if_needed("git@github.com:wayfair/foxdie.git"),
240            "git://github.com/wayfair/foxdie.git"
241        );
242    }
243}