Add REST APIs for signing the CLA, retrieving users who've signed the CLA (#4202)

This commit is contained in:
Max Brunsfeld
2024-01-22 14:20:39 -08:00
committed by GitHub
18 changed files with 305 additions and 78 deletions
Generated
+1
View File
@@ -1463,6 +1463,7 @@ dependencies = [
"base64 0.13.1",
"call",
"channel",
"chrono",
"clap 3.2.25",
"client",
"clock",
+1
View File
@@ -93,6 +93,7 @@ resolver = "2"
anyhow = { version = "1.0.57" }
async-trait = { version = "0.1" }
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
chrono = { version = "0.4", features = ["serde"] }
ctor = "0.2.6"
derive_more = { version = "0.99.17" }
env_logger = { version = "0.9" }
+1 -1
View File
@@ -30,7 +30,7 @@ workspace = { path = "../workspace" }
uuid.workspace = true
log.workspace = true
anyhow.workspace = true
chrono = { version = "0.4", features = ["serde"] }
chrono.workspace = true
futures.workspace = true
indoc.workspace = true
isahc.workspace = true
+1
View File
@@ -27,6 +27,7 @@ axum = { version = "0.5", features = ["json", "headers", "ws"] }
axum-extra = { version = "0.3", features = ["erased-json"] }
base64 = "0.13"
clap = { version = "3.1", features = ["derive"], optional = true }
chrono.workspace = true
dashmap = "5.4"
envy = "0.4.2"
futures.workspace = true
@@ -344,3 +344,9 @@ CREATE INDEX
"index_notifications_on_recipient_id_is_read_kind_entity_id"
ON "notifications"
("recipient_id", "is_read", "kind", "entity_id");
CREATE TABLE contributors (
user_id INTEGER REFERENCES users(id),
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id)
);
@@ -0,0 +1,5 @@
CREATE TABLE contributors (
user_id INTEGER REFERENCES users(id),
signed_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id)
);
+65 -4
View File
@@ -1,6 +1,6 @@
use crate::{
auth,
db::{User, UserId},
db::{ContributorSelector, User, UserId},
rpc, AppState, Error, Result,
};
use anyhow::anyhow;
@@ -14,6 +14,7 @@ use axum::{
Extension, Json, Router,
};
use axum_extra::response::ErasedJson;
use chrono::SecondsFormat;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower::ServiceBuilder;
@@ -25,6 +26,8 @@ pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body
.route("/users/:id/access_tokens", post(create_access_token))
.route("/panic", post(trace_panic))
.route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
.route("/contributors", get(get_contributors).post(add_contributor))
.route("/contributor", get(check_is_contributor))
.layer(
ServiceBuilder::new()
.layer(Extension(state))
@@ -66,7 +69,7 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
#[derive(Debug, Deserialize)]
struct AuthenticatedUserParams {
github_user_id: Option<i32>,
github_user_id: i32,
github_login: String,
github_email: Option<String>,
}
@@ -88,8 +91,7 @@ async fn get_authenticated_user(
params.github_user_id,
params.github_email.as_deref(),
)
.await?
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?;
.await?;
let metrics_id = app.db.get_user_metrics_id(user.id).await?;
return Ok(Json(AuthenticatedUserResponse { user, metrics_id }));
}
@@ -133,6 +135,65 @@ async fn get_rpc_server_snapshot(
Ok(ErasedJson::pretty(rpc_server.snapshot().await))
}
async fn get_contributors(Extension(app): Extension<Arc<AppState>>) -> Result<Json<Vec<String>>> {
Ok(Json(app.db.get_contributors().await?))
}
#[derive(Debug, Deserialize)]
struct CheckIsContributorParams {
github_user_id: Option<i32>,
github_login: Option<String>,
}
impl CheckIsContributorParams {
fn as_contributor_selector(self) -> Result<ContributorSelector> {
if let Some(github_user_id) = self.github_user_id {
return Ok(ContributorSelector::GitHubUserId { github_user_id });
}
if let Some(github_login) = self.github_login {
return Ok(ContributorSelector::GitHubLogin { github_login });
}
Err(anyhow!(
"must be one of `github_user_id` or `github_login`."
))?
}
}
#[derive(Debug, Serialize)]
struct CheckIsContributorResponse {
signed_at: Option<String>,
}
async fn check_is_contributor(
Extension(app): Extension<Arc<AppState>>,
Query(params): Query<CheckIsContributorParams>,
) -> Result<Json<CheckIsContributorResponse>> {
let params = params.as_contributor_selector()?;
Ok(Json(CheckIsContributorResponse {
signed_at: app
.db
.get_contributor_sign_timestamp(&params)
.await?
.map(|ts| ts.and_utc().to_rfc3339_opts(SecondsFormat::Millis, true)),
}))
}
async fn add_contributor(
Json(params): Json<AuthenticatedUserParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<()> {
Ok(app
.db
.add_contributor(
&params.github_login,
params.github_user_id,
params.github_email.as_deref(),
)
.await?)
}
#[derive(Deserialize)]
struct CreateAccessTokenQueryParams {
public_key: String,
+1 -1
View File
@@ -68,7 +68,7 @@ async fn main() {
user_count += 1;
db.get_or_create_user_by_github_account(
&github_user.login,
Some(github_user.id),
github_user.id,
github_user.email.as_deref(),
)
.await
+1
View File
@@ -44,6 +44,7 @@ use tables::*;
use tokio::sync::{Mutex, OwnedMutexGuard};
pub use ids::*;
pub use queries::contributors::ContributorSelector;
pub use sea_orm::ConnectOptions;
pub use tables::user::Model as User;
+1
View File
@@ -4,6 +4,7 @@ pub mod access_tokens;
pub mod buffers;
pub mod channels;
pub mod contacts;
pub mod contributors;
pub mod messages;
pub mod notifications;
pub mod projects;
@@ -0,0 +1,89 @@
use super::*;
#[derive(Debug)]
pub enum ContributorSelector {
GitHubUserId { github_user_id: i32 },
GitHubLogin { github_login: String },
}
impl Database {
/// Retrieves the GitHub logins of all users who have signed the CLA.
pub async fn get_contributors(&self) -> Result<Vec<String>> {
self.transaction(|tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryGithubLogin {
GithubLogin,
}
Ok(contributor::Entity::find()
.inner_join(user::Entity)
.order_by_asc(contributor::Column::SignedAt)
.select_only()
.column(user::Column::GithubLogin)
.into_values::<_, QueryGithubLogin>()
.all(&*tx)
.await?)
})
.await
}
/// Records that a given user has signed the CLA.
pub async fn get_contributor_sign_timestamp(
&self,
selector: &ContributorSelector,
) -> Result<Option<DateTime>> {
self.transaction(|tx| async move {
let condition = match selector {
ContributorSelector::GitHubUserId { github_user_id } => {
user::Column::GithubUserId.eq(*github_user_id)
}
ContributorSelector::GitHubLogin { github_login } => {
user::Column::GithubLogin.eq(github_login)
}
};
let Some(user) = user::Entity::find().filter(condition).one(&*tx).await? else {
return Ok(None);
};
let Some(contributor) = contributor::Entity::find_by_id(user.id).one(&*tx).await?
else {
return Ok(None);
};
Ok(Some(contributor.signed_at))
})
.await
}
/// Records that a given user has signed the CLA.
pub async fn add_contributor(
&self,
github_login: &str,
github_user_id: i32,
github_email: Option<&str>,
) -> Result<()> {
self.transaction(|tx| async move {
let user = self
.get_or_create_user_by_github_account_tx(
github_login,
github_user_id,
github_email,
&*tx,
)
.await?;
contributor::Entity::insert(contributor::ActiveModel {
user_id: ActiveValue::Set(user.id),
signed_at: ActiveValue::NotSet,
})
.on_conflict(
OnConflict::column(contributor::Column::UserId)
.do_nothing()
.to_owned(),
)
.exec_without_returning(&*tx)
.await?;
Ok(())
})
.await
}
}
+49 -41
View File
@@ -72,53 +72,61 @@ impl Database {
pub async fn get_or_create_user_by_github_account(
&self,
github_login: &str,
github_user_id: Option<i32>,
github_user_id: i32,
github_email: Option<&str>,
) -> Result<Option<User>> {
) -> Result<User> {
self.transaction(|tx| async move {
let tx = &*tx;
if let Some(github_user_id) = github_user_id {
if let Some(user_by_github_user_id) = user::Entity::find()
.filter(user::Column::GithubUserId.eq(github_user_id))
.one(tx)
.await?
{
let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
Ok(Some(user_by_github_user_id.update(tx).await?))
} else if let Some(user_by_github_login) = user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(tx)
.await?
{
let mut user_by_github_login = user_by_github_login.into_active_model();
user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
Ok(Some(user_by_github_login.update(tx).await?))
} else {
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(github_email.map(|email| email.into())),
github_login: ActiveValue::set(github_login.into()),
github_user_id: ActiveValue::set(Some(github_user_id)),
admin: ActiveValue::set(false),
invite_count: ActiveValue::set(0),
invite_code: ActiveValue::set(None),
metrics_id: ActiveValue::set(Uuid::new_v4()),
..Default::default()
})
.exec_with_returning(&*tx)
.await?;
Ok(Some(user))
}
} else {
Ok(user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(tx)
.await?)
}
self.get_or_create_user_by_github_account_tx(
github_login,
github_user_id,
github_email,
&*tx,
)
.await
})
.await
}
pub async fn get_or_create_user_by_github_account_tx(
&self,
github_login: &str,
github_user_id: i32,
github_email: Option<&str>,
tx: &DatabaseTransaction,
) -> Result<User> {
if let Some(user_by_github_user_id) = user::Entity::find()
.filter(user::Column::GithubUserId.eq(github_user_id))
.one(tx)
.await?
{
let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
Ok(user_by_github_user_id.update(tx).await?)
} else if let Some(user_by_github_login) = user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(tx)
.await?
{
let mut user_by_github_login = user_by_github_login.into_active_model();
user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
Ok(user_by_github_login.update(tx).await?)
} else {
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(github_email.map(|email| email.into())),
github_login: ActiveValue::set(github_login.into()),
github_user_id: ActiveValue::set(Some(github_user_id)),
admin: ActiveValue::set(false),
invite_count: ActiveValue::set(0),
invite_code: ActiveValue::set(None),
metrics_id: ActiveValue::set(Uuid::new_v4()),
..Default::default()
})
.exec_with_returning(&*tx)
.await?;
Ok(user)
}
}
/// get_all_users returns the next page of users. To get more call again with
/// the same limit and the page incremented by 1.
pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {
+1
View File
@@ -9,6 +9,7 @@ pub mod channel_member;
pub mod channel_message;
pub mod channel_message_mention;
pub mod contact;
pub mod contributor;
pub mod feature_flag;
pub mod follower;
pub mod language_server;
@@ -0,0 +1,30 @@
use crate::db::UserId;
use sea_orm::entity::prelude::*;
use serde::Serialize;
/// A user who has signed the CLA.
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "contributors")]
pub struct Model {
#[sea_orm(primary_key)]
pub user_id: UserId,
pub signed_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl ActiveModelBehavior for ActiveModel {}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
+2
View File
@@ -31,6 +31,8 @@ pub enum Relation {
ChannelMemberships,
#[sea_orm(has_many = "super::user_feature::Entity")]
UserFeatures,
#[sea_orm(has_one = "super::contributor::Entity")]
Contributor,
}
impl Related<super::access_token::Entity> for Entity {
+1
View File
@@ -1,5 +1,6 @@
mod buffer_tests;
mod channel_tests;
mod contributor_tests;
mod db_tests;
mod feature_flag_tests;
mod message_tests;
@@ -0,0 +1,37 @@
use super::Database;
use crate::{db::NewUserParams, test_both_dbs};
use std::sync::Arc;
test_both_dbs!(
test_contributors,
test_contributors_postgres,
test_contributors_sqlite
);
async fn test_contributors(db: &Arc<Database>) {
db.create_user(
&format!("user1@example.com"),
false,
NewUserParams {
github_login: format!("user1"),
github_user_id: 1,
},
)
.await
.unwrap()
.user_id;
assert_eq!(db.get_contributors().await.unwrap(), Vec::<String>::new());
db.add_contributor("user1", 1, None).await.unwrap();
assert_eq!(
db.get_contributors().await.unwrap(),
vec!["user1".to_string()]
);
db.add_contributor("user2", 2, None).await.unwrap();
assert_eq!(
db.get_contributors().await.unwrap(),
vec!["user1".to_string(), "user2".to_string()]
);
}
+13 -31
View File
@@ -80,18 +80,17 @@ test_both_dbs!(
);
async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
let user_id1 = db
.create_user(
"user1@example.com",
false,
NewUserParams {
github_login: "login1".into(),
github_user_id: 101,
},
)
.await
.unwrap()
.user_id;
db.create_user(
"user1@example.com",
false,
NewUserParams {
github_login: "login1".into(),
github_user_id: 101,
},
)
.await
.unwrap()
.user_id;
let user_id2 = db
.create_user(
"user2@example.com",
@@ -106,33 +105,16 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
.user_id;
let user = db
.get_or_create_user_by_github_account("login1", None, None)
.get_or_create_user_by_github_account("the-new-login2", 102, None)
.await
.unwrap()
.unwrap();
assert_eq!(user.id, user_id1);
assert_eq!(&user.github_login, "login1");
assert_eq!(user.github_user_id, Some(101));
assert!(db
.get_or_create_user_by_github_account("non-existent-login", None, None)
.await
.unwrap()
.is_none());
let user = db
.get_or_create_user_by_github_account("the-new-login2", Some(102), None)
.await
.unwrap()
.unwrap();
assert_eq!(user.id, user_id2);
assert_eq!(&user.github_login, "the-new-login2");
assert_eq!(user.github_user_id, Some(102));
let user = db
.get_or_create_user_by_github_account("login3", Some(103), Some("user3@example.com"))
.get_or_create_user_by_github_account("login3", 103, Some("user3@example.com"))
.await
.unwrap()
.unwrap();
assert_eq!(&user.github_login, "login3");
assert_eq!(user.github_user_id, Some(103));