Skip to content

Commit 8979f79

Browse files
committed
Auto merge of #3218 - Turbo87:github-client, r=locks
Extract `GitHubClient` struct This PR aims to simplify our GitHub API request code a little bit by extracting a `GitHubClient` struct, and adding several request methods on it. It also allows to set a custom `base_url`, which will be useful in the future when used in combination with https://docs.rs/mockito. r? `@pietroalbini`
2 parents 096d254 + 73b5b0c commit 8979f79

File tree

6 files changed

+172
-104
lines changed

6 files changed

+172
-104
lines changed

src/app.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use crate::{db, Config, Env};
44
use std::{sync::Arc, time::Duration};
55

6+
use crate::github::GitHubClient;
67
use diesel::r2d2;
78
use oauth2::basic::BasicClient;
89
use reqwest::blocking::Client;
@@ -19,8 +20,11 @@ pub struct App {
1920
/// The read-only replica database connection pool
2021
pub read_only_replica_database: Option<db::DieselPool>,
2122

23+
/// GitHub API client
24+
pub github: GitHubClient,
25+
2226
/// The GitHub OAuth2 configuration
23-
pub github: BasicClient,
27+
pub github_oauth: BasicClient,
2428

2529
/// A unique key used with conduit_cookie to generate cookies
2630
pub session_key: String,
@@ -47,7 +51,9 @@ impl App {
4751
pub fn new(config: Config, http_client: Option<Client>) -> App {
4852
use oauth2::{AuthUrl, ClientId, ClientSecret, TokenUrl};
4953

50-
let github = BasicClient::new(
54+
let github = GitHubClient::new(http_client.clone(), config.gh_base_url.clone());
55+
56+
let github_oauth = BasicClient::new(
5157
ClientId::new(config.gh_client_id.clone()),
5258
Some(ClientSecret::new(config.gh_client_secret.clone())),
5359
AuthUrl::new(String::from("https://github.com/login/oauth/authorize")).unwrap(),
@@ -122,6 +128,7 @@ impl App {
122128
primary_database,
123129
read_only_replica_database,
124130
github,
131+
github_oauth,
125132
session_key: config.session_key.clone(),
126133
config,
127134
http_client,

src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub struct Config {
77
pub session_key: String,
88
pub gh_client_id: String,
99
pub gh_client_secret: String,
10+
pub gh_base_url: String,
1011
pub db_url: String,
1112
pub replica_db_url: Option<String>,
1213
pub env: Env,
@@ -131,6 +132,7 @@ impl Default for Config {
131132
session_key: env("SESSION_KEY"),
132133
gh_client_id: env("GH_CLIENT_ID"),
133134
gh_client_secret: env("GH_CLIENT_SECRET"),
135+
gh_base_url: "https://api.github.com".to_string(),
134136
db_url: env("DATABASE_URL"),
135137
replica_db_url: dotenv::var("READ_ONLY_REPLICA_URL").ok(),
136138
env: cargo_env,

src/controllers/user/session.rs

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use crate::controllers::frontend_prelude::*;
22

3-
use crate::github;
43
use conduit_cookie::RequestSession;
54
use oauth2::reqwest::http_client;
65
use oauth2::{AuthorizationCode, Scope, TokenResponse};
76

7+
use crate::github::GithubUser;
88
use crate::middleware::current_user::TrustedUserId;
99
use crate::models::{NewUser, User};
1010
use crate::schema::users;
@@ -28,7 +28,7 @@ use crate::util::errors::ReadOnlyMode;
2828
pub fn begin(req: &mut dyn RequestExt) -> EndpointResult {
2929
let (url, state) = req
3030
.app()
31-
.github
31+
.github_oauth
3232
.authorize_url(oauth2::CsrfToken::new_random)
3333
.add_scope(Scope::new("read:org".to_string()))
3434
.url();
@@ -95,15 +95,15 @@ pub fn authorize(req: &mut dyn RequestExt) -> EndpointResult {
9595
let code = AuthorizationCode::new(code);
9696
let token = req
9797
.app()
98-
.github
98+
.github_oauth
9999
.exchange_code(code)
100100
.request(http_client)
101101
.chain_error(|| server_error("Error obtaining token"))?;
102102
let token = token.access_token();
103103

104104
// Fetch the user info from GitHub using the access token we just got and create a user record
105-
let ghuser = github::github_api::<GithubUser>(req.app(), "/user", token)?;
106-
let user = ghuser.save_to_database(&token.secret(), &*req.db_conn()?)?;
105+
let ghuser = req.app().github.current_user(token)?;
106+
let user = save_user_to_database(&ghuser, &token.secret(), &*req.db_conn()?)?;
107107

108108
// Log in by setting a cookie and the middleware authentication
109109
req.session_mut()
@@ -113,40 +113,33 @@ pub fn authorize(req: &mut dyn RequestExt) -> EndpointResult {
113113
super::me::me(req)
114114
}
115115

116-
#[derive(Deserialize)]
117-
struct GithubUser {
118-
email: Option<String>,
119-
name: Option<String>,
120-
login: String,
121-
id: i32,
122-
avatar_url: Option<String>,
123-
}
124-
125-
impl GithubUser {
126-
fn save_to_database(&self, access_token: &str, conn: &PgConnection) -> AppResult<User> {
127-
NewUser::new(
128-
self.id,
129-
&self.login,
130-
self.name.as_deref(),
131-
self.avatar_url.as_deref(),
132-
access_token,
133-
)
134-
.create_or_update(self.email.as_deref(), conn)
135-
.map_err(Into::into)
136-
.or_else(|e: Box<dyn AppError>| {
137-
// If we're in read only mode, we can't update their details
138-
// just look for an existing user
139-
if e.is::<ReadOnlyMode>() {
140-
users::table
141-
.filter(users::gh_id.eq(self.id))
142-
.first(conn)
143-
.optional()?
144-
.ok_or(e)
145-
} else {
146-
Err(e)
147-
}
148-
})
149-
}
116+
fn save_user_to_database(
117+
user: &GithubUser,
118+
access_token: &str,
119+
conn: &PgConnection,
120+
) -> AppResult<User> {
121+
NewUser::new(
122+
user.id,
123+
&user.login,
124+
user.name.as_deref(),
125+
user.avatar_url.as_deref(),
126+
access_token,
127+
)
128+
.create_or_update(user.email.as_deref(), conn)
129+
.map_err(Into::into)
130+
.or_else(|e: Box<dyn AppError>| {
131+
// If we're in read only mode, we can't update their details
132+
// just look for an existing user
133+
if e.is::<ReadOnlyMode>() {
134+
users::table
135+
.filter(users::gh_id.eq(user.id))
136+
.first(conn)
137+
.optional()?
138+
.ok_or(e)
139+
} else {
140+
Err(e)
141+
}
142+
})
150143
}
151144

152145
/// Handles the `DELETE /api/private/session` route.
@@ -175,7 +168,7 @@ mod tests {
175168
id: -1,
176169
avatar_url: None,
177170
};
178-
let result = gh_user.save_to_database("arbitrary_token", &conn);
171+
let result = save_user_to_database(&gh_user, "arbitrary_token", &conn);
179172

180173
assert!(
181174
result.is_ok(),

src/github.rs

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,89 @@ use serde::de::DeserializeOwned;
77

88
use std::str;
99

10-
use crate::app::App;
1110
use crate::util::errors::{cargo_err, internal, not_found, AppError, AppResult};
11+
use reqwest::blocking::Client;
1212

13-
/// Does all the nonsense for sending a GET to Github. Doesn't handle parsing
14-
/// because custom error-code handling may be desirable. Use
15-
/// `parse_github_response` to handle the "common" processing of responses.
16-
pub fn github_api<T>(app: &App, url: &str, auth: &AccessToken) -> AppResult<T>
17-
where
18-
T: DeserializeOwned,
19-
{
20-
let url = format!("{}://api.github.com{}", app.config.api_protocol, url);
21-
info!("GITHUB HTTP: {}", url);
22-
23-
app.http_client()
24-
.get(&url)
25-
.header(header::ACCEPT, "application/vnd.github.v3+json")
26-
.header(header::AUTHORIZATION, format!("token {}", auth.secret()))
27-
.header(header::USER_AGENT, "crates.io (https://crates.io)")
28-
.send()?
29-
.error_for_status()
30-
.map_err(|e| handle_error_response(&e))?
31-
.json()
32-
.map_err(Into::into)
13+
#[derive(Debug)]
14+
pub struct GitHubClient {
15+
base_url: String,
16+
client: Option<Client>,
17+
}
18+
19+
impl GitHubClient {
20+
pub fn new(client: Option<Client>, base_url: String) -> Self {
21+
Self { client, base_url }
22+
}
23+
24+
pub fn current_user(&self, auth: &AccessToken) -> AppResult<GithubUser> {
25+
self.request("/user", auth)
26+
}
27+
28+
pub fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> AppResult<GitHubOrganization> {
29+
let url = format!("/orgs/{}", org_name);
30+
self.request(&url, auth)
31+
}
32+
33+
pub fn team_by_name(
34+
&self,
35+
org_name: &str,
36+
team_name: &str,
37+
auth: &AccessToken,
38+
) -> AppResult<GitHubTeam> {
39+
let url = format!("/orgs/{}/teams/{}", org_name, team_name);
40+
self.request(&url, auth)
41+
}
42+
43+
pub fn team_membership(
44+
&self,
45+
org_id: i32,
46+
team_id: i32,
47+
username: &str,
48+
auth: &AccessToken,
49+
) -> AppResult<GitHubTeamMembership> {
50+
let url = format!(
51+
"/organizations/{}/team/{}/memberships/{}",
52+
org_id, team_id, username
53+
);
54+
self.request(&url, auth)
55+
}
56+
57+
/// Does all the nonsense for sending a GET to Github. Doesn't handle parsing
58+
/// because custom error-code handling may be desirable. Use
59+
/// `parse_github_response` to handle the "common" processing of responses.
60+
pub fn request<T>(&self, url: &str, auth: &AccessToken) -> AppResult<T>
61+
where
62+
T: DeserializeOwned,
63+
{
64+
let url = format!("{}{}", self.base_url, url);
65+
info!("GITHUB HTTP: {}", url);
66+
67+
self.client()
68+
.get(&url)
69+
.header(header::ACCEPT, "application/vnd.github.v3+json")
70+
.header(header::AUTHORIZATION, format!("token {}", auth.secret()))
71+
.header(header::USER_AGENT, "crates.io (https://crates.io)")
72+
.send()?
73+
.error_for_status()
74+
.map_err(|e| handle_error_response(&e))?
75+
.json()
76+
.map_err(Into::into)
77+
}
78+
79+
/// Returns a client for making HTTP requests to upload crate files.
80+
///
81+
/// The client will go through a proxy if the application was configured via
82+
/// `TestApp::with_proxy()`.
83+
///
84+
/// # Panics
85+
///
86+
/// Panics if the application was not initialized with a client. This should only occur in
87+
/// tests that were not properly initialized.
88+
fn client(&self) -> &Client {
89+
self.client
90+
.as_ref()
91+
.expect("No HTTP client is configured. In tests, use `TestApp::with_proxy()`.")
92+
}
3393
}
3494

3595
fn handle_error_response(error: &reqwest::Error) -> Box<dyn AppError> {
@@ -52,6 +112,33 @@ fn handle_error_response(error: &reqwest::Error) -> Box<dyn AppError> {
52112
}
53113
}
54114

115+
#[derive(Debug, Deserialize)]
116+
pub struct GithubUser {
117+
pub avatar_url: Option<String>,
118+
pub email: Option<String>,
119+
pub id: i32,
120+
pub login: String,
121+
pub name: Option<String>,
122+
}
123+
124+
#[derive(Debug, Deserialize)]
125+
pub struct GitHubOrganization {
126+
pub id: i32, // unique GH id (needed for membership queries)
127+
pub avatar_url: Option<String>,
128+
}
129+
130+
#[derive(Debug, Deserialize)]
131+
pub struct GitHubTeam {
132+
pub id: i32, // unique GH id (needed for membership queries)
133+
pub name: Option<String>, // Pretty name
134+
pub organization: GitHubOrganization,
135+
}
136+
137+
#[derive(Debug, Deserialize)]
138+
pub struct GitHubTeamMembership {
139+
pub state: String,
140+
}
141+
55142
pub fn team_url(login: &str) -> String {
56143
let mut login_pieces = login.split(':');
57144
login_pieces.next();

0 commit comments

Comments
 (0)