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}