feat: expose workspace and profile api (#32)

* feat: expose workspace and profile api

* feat: add impl for client_api

* feat: add test case for workspace and profile
This commit is contained in:
Zack 2023-09-12 16:14:14 +08:00 committed by GitHub
parent 9ac53dca8e
commit 0d59211e55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 218 additions and 79 deletions

View File

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT * FROM public.af_workspace WHERE owner_uid = $1\n ",
"query": "\n SELECT * FROM public.af_workspace WHERE owner_uid = (\n SELECT uid FROM public.af_user WHERE uuid = $1\n )\n ",
"describe": {
"columns": [
{
@ -41,7 +41,7 @@
],
"parameters": {
"Left": [
"Int8"
"Uuid"
]
},
"nullable": [
@ -54,5 +54,5 @@
true
]
},
"hash": "23697d21dc4aa7a71027ac5671d415f3ead15394a18414911475fb946a00dd01"
"hash": "d37119628f436d151a5559e692a3d6a7a6bc3c8226631ce6a0bcd0c7f1c0a8c5"
}

14
Cargo.lock generated
View File

@ -161,7 +161,7 @@ dependencies = [
"futures-core",
"futures-util",
"mio",
"socket2 0.5.3",
"socket2 0.5.4",
"tokio",
"tracing",
]
@ -263,7 +263,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
"socket2 0.5.3",
"socket2 0.5.4",
"time",
"url",
]
@ -477,6 +477,7 @@ dependencies = [
"tracing-log",
"tracing-subscriber",
"unicode-segmentation",
"uuid",
"validator",
]
@ -824,6 +825,7 @@ dependencies = [
"reqwest",
"serde_json",
"shared_entity",
"storage",
]
[[package]]
@ -3105,9 +3107,9 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877"
checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
dependencies = [
"libc",
"windows-sys",
@ -3371,6 +3373,7 @@ dependencies = [
"thiserror",
"tokio",
"tracing",
"uuid",
"validator",
]
@ -3537,7 +3540,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.3",
"socket2 0.5.4",
"tokio-macros",
"windows-sys",
]
@ -3856,6 +3859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
dependencies = [
"getrandom 0.2.10",
"serde",
]
[[package]]

View File

@ -55,6 +55,7 @@ tracing-bunyan-formatter = "0.3.6"
tracing-actix-web = "0.7"
tracing-log = "0.1.1"
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] }
uuid = { version= "1.4.1", features = ["serde", "v4"] }
#Local crate
token = { path = "libs/token" }

View File

@ -12,3 +12,4 @@ gotrue = { path = "../gotrue" }
infra = { path = "../infra" }
serde_json = "1.0.105"
shared_entity = { path = "../shared-entity" }
storage = { path = "../storage" }

View File

@ -8,6 +8,9 @@ use gotrue::models::{AccessTokenResponse, User};
use shared_entity::error::AppError;
use shared_entity::server_error::ErrorCode;
use storage::entities::AfUserProfileView;
use storage::entities::AfWorkspace;
use storage::entities::AfWorkspaces;
pub struct Client {
http_client: reqwest::Client,
@ -28,6 +31,32 @@ impl Client {
self.token.as_ref()
}
pub async fn profile(&self) -> Result<AfUserProfileView, AppError> {
let url = format!("{}/api/user/profile", self.base_url);
let resp = self
.http_client_with_auth(Method::GET, &url)?
.send()
.await?;
let profile = AppResponse::<AfUserProfileView>::from_response(resp)
.await?
.into_data()?
.ok_or::<AppError>(ErrorCode::MissingPayload.into())?;
Ok(profile)
}
pub async fn workspaces(&mut self) -> Result<AfWorkspaces, AppError> {
let url = format!("{}/api/user/workspaces", self.base_url);
let resp = self
.http_client_with_auth(Method::GET, &url)?
.send()
.await?;
let workspaces = AppResponse::<Vec<AfWorkspace>>::from_response(resp)
.await?
.into_data()?
.ok_or::<AppError>(ErrorCode::MissingPayload.into())?;
Ok(AfWorkspaces(workspaces))
}
pub async fn sign_in_password(&mut self, email: &str, password: &str) -> Result<(), AppError> {
let url = format!("{}/api/user/sign_in/password", self.base_url);
let payload = serde_json::json!({
@ -72,7 +101,7 @@ impl Client {
let new_user = AppResponse::<User>::from_response(resp)
.await?
.into_data()?
.ok_or::<Error>(ErrorCode::Unhandled.into())?;
.ok_or::<AppError>(ErrorCode::Unhandled.into())?;
if let Some(t) = self.token.as_mut() {
t.user = new_user;
}

View File

@ -24,6 +24,9 @@ pub enum ErrorCode {
#[error("OAuth authentication error.")]
OAuthError = 1003,
#[error("Missing Payload")]
MissingPayload = 1004,
}
/// Implements conversion from `anyhow::Error` to `ErrorCode`.

View File

@ -16,9 +16,11 @@ serde = { version = "1.0.130", features = ["derive"] }
serde_json = "1.0.68"
thiserror = "1.0.47"
secrecy = { version = "0.8", features = ["serde"] }
sqlx = { version = "0.7", default-features = false, features = ["postgres", "chrono", "uuid", "macros", "runtime-tokio-rustls"] }
tracing = { version = "0.1.37" }
validator = { version = "0.16", features = ["validator_derive", "derive"] }
uuid = { version = "1.4.1", features = ["serde", "v4"] }
[features]
default = []
default = []

View File

@ -53,7 +53,7 @@ pub struct QueryCollabParams {
pub collab_type: CollabType,
}
#[derive(Debug, Clone, sqlx::FromRow)]
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct AfWorkspace {
pub workspace_id: uuid::Uuid,
pub database_storage_id: Option<sqlx::types::uuid::Uuid>,
@ -64,21 +64,23 @@ pub struct AfWorkspace {
pub workspace_name: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
#[derive(Debug, sqlx::FromRow, Deserialize, Serialize)]
pub struct AfUserProfileView {
pub uid: Option<i64>, // Made this field nullable based on the error
pub uuid: Option<uuid::Uuid>, // Made this field nullable based on the error
pub email: Option<String>, // Made this field nullable based on the error
pub password: Option<String>, // Made this field nullable based on the error
pub name: Option<String>, // Made this field nullable based on the error
pub encryption_sign: Option<String>, // Made this field nullable based on the error
pub uid: Option<i64>,
pub uuid: Option<uuid::Uuid>,
pub email: Option<String>,
pub password: Option<String>,
pub name: Option<String>,
pub encryption_sign: Option<String>,
pub deleted_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub created_at: Option<DateTime<Utc>>,
pub latest_workspace_id: Option<uuid::Uuid>,
}
#[derive(Debug, sqlx::FromRow, Deserialize, Serialize)]
pub struct AfWorkspaces(pub Vec<AfWorkspace>);
impl AfWorkspaces {
pub fn get_latest(&self, profile: AfUserProfileView) -> Option<AfWorkspace> {
match profile.latest_workspace_id {

View File

@ -26,14 +26,16 @@ pub async fn create_user_if_not_exists(
pub async fn select_all_workspaces_owned(
pool: &PgPool,
owner_uid: i64,
owner_uuid: &Uuid,
) -> Result<Vec<AfWorkspace>, sqlx::Error> {
sqlx::query_as!(
AfWorkspace,
r#"
SELECT * FROM public.af_workspace WHERE owner_uid = $1
SELECT * FROM public.af_workspace WHERE owner_uid = (
SELECT uid FROM public.af_user WHERE uuid = $1
)
"#,
owner_uid
owner_uuid
)
.fetch_all(pool)
.await
@ -41,7 +43,7 @@ pub async fn select_all_workspaces_owned(
pub async fn select_user_profile_view_by_uuid(
pool: &PgPool,
gotrue_uuid: Uuid,
user_uuid: &Uuid,
) -> Result<Option<AfUserProfileView>, sqlx::Error> {
sqlx::query_as!(
AfUserProfileView,
@ -49,7 +51,7 @@ pub async fn select_user_profile_view_by_uuid(
SELECT *
FROM public.af_user_profile_view WHERE uuid = $1
"#,
gotrue_uuid
user_uuid
)
.fetch_optional(pool)
.await

View File

@ -9,8 +9,9 @@ use crate::domain::{UserEmail, UserName, UserPassword};
use crate::state::State;
use gotrue::models::{AccessTokenResponse, User};
use shared_entity::data::{AppResponse, JsonAppResponse};
use storage::entities::{AfUserProfileView, AfWorkspaces};
use crate::component::auth::jwt::Authorization;
use crate::component::auth::jwt::{Authorization, UserUuid};
use actix_web::web::{Data, Json};
use actix_web::HttpRequest;
use actix_web::Result;
@ -25,6 +26,9 @@ pub fn user_scope() -> Scope {
.service(web::resource("/sign_out").route(web::post().to(sign_out_handler)))
.service(web::resource("/update").route(web::post().to(update_handler)))
.service(web::resource("/workspaces").route(web::get().to(workspaces_handler)))
.service(web::resource("/profile").route(web::get().to(profile_handler)))
// native
.service(web::resource("/login").route(web::post().to(login_handler)))
.service(web::resource("/logout").route(web::get().to(logout_handler)))
@ -32,6 +36,22 @@ pub fn user_scope() -> Scope {
.service(web::resource("/password").route(web::post().to(change_password_handler)))
}
async fn profile_handler(
uuid: UserUuid,
state: Data<State>,
) -> Result<JsonAppResponse<AfUserProfileView>> {
let profile = biz::user::user_profile(&state.pg_pool, &uuid.0).await?;
Ok(AppResponse::Ok().with_data(profile).into())
}
async fn workspaces_handler(
uuid: UserUuid,
state: Data<State>,
) -> Result<JsonAppResponse<AfWorkspaces>> {
let workspaces = biz::user::user_workspaces(&state.pg_pool, &uuid.0).await?;
Ok(AppResponse::Ok().with_data(workspaces).into())
}
async fn update_handler(
auth: Authorization,
req: Json<LoginRequest>,
@ -71,7 +91,13 @@ async fn sign_up_handler(
req: Json<LoginRequest>,
state: Data<State>,
) -> Result<JsonAppResponse<()>> {
biz::user::sign_up(&state.gotrue_client, &req.email, &req.password).await?;
biz::user::sign_up(
&state.pg_pool,
&state.gotrue_client,
&req.email,
&req.password,
)
.await?;
Ok(AppResponse::Ok().into())
}

View File

@ -8,15 +8,41 @@ use gotrue::{
};
use shared_entity::{error::AppError, server_error};
use storage::entities::{AfUserProfileView, AfWorkspaces};
use validator::validate_email;
use crate::domain::validate_password;
use sqlx::{types::uuid, PgPool};
pub async fn sign_up(gotrue_client: &Client, email: &str, password: &str) -> Result<(), AppError> {
pub async fn user_workspaces(
pg_pool: &PgPool,
uuid: &uuid::Uuid,
) -> Result<AfWorkspaces, AppError> {
let workspaces = storage::workspace::select_all_workspaces_owned(pg_pool, uuid).await?;
Ok(AfWorkspaces(workspaces))
}
pub async fn user_profile(
pg_pool: &PgPool,
uuid: &uuid::Uuid,
) -> Result<AfUserProfileView, AppError> {
let profile = storage::workspace::select_user_profile_view_by_uuid(pg_pool, uuid)
.await?
.ok_or(sqlx::Error::RowNotFound)?;
Ok(profile)
}
pub async fn sign_up(
pg_pool: &PgPool,
gotrue_client: &Client,
email: &str,
password: &str,
) -> Result<(), AppError> {
validate_email_password(email, password)?;
let user = gotrue_client.sign_up(email, password).await??;
tracing::info!("user sign up: {:?}", user);
if user.confirmed_at.is_some() {
storage::workspace::create_user_if_not_exists(pg_pool, uuid::Uuid::from_str(&user.id)?).await?;
}
Ok(())
}

View File

@ -10,80 +10,119 @@ lazy_static::lazy_static! {
pub static ref VALIDATION: Validation = Validation::new(Algorithm::HS256);
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserUuid(pub uuid::Uuid);
impl FromRequest for UserUuid {
type Error = actix_web::Error;
type Future = std::future::Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let auth = get_auth_from_request(req);
match auth {
Ok(auth) => {
let sub = auth.claims.sub.ok_or(actix_web::error::ErrorUnauthorized(
"Invalid Authorization header, missing sub(uuid)",
));
match sub {
Ok(sub) => std::future::ready(Ok(UserUuid(uuid::Uuid::parse_str(&sub).unwrap()))),
Err(e) => std::future::ready(Err(e)),
}
},
Err(e) => std::future::ready(Err(e)),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Authorization {
pub token: String,
pub claims: GoTrueJWTClaims,
}
impl Authorization {
pub fn uuid(&self) -> Option<String> {
self.claims.sub.clone()
}
}
impl FromRequest for Authorization {
type Error = actix_web::Error;
type Future = std::future::Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let state = req.app_data::<Data<State>>().unwrap();
let bearer = req.headers().get("Authorization");
match bearer {
None => std::future::ready(Err(actix_web::error::ErrorUnauthorized(
"No Authorization header",
))),
Some(bearer) => {
let bearer = bearer.to_str();
match bearer {
Err(e) => std::future::ready(Err(actix_web::error::ErrorUnauthorized(e))),
Ok(bearer) => {
let pair_opt = bearer.split_once("Bearer "); // Authorization: Bearer <token>
match pair_opt {
None => std::future::ready(Err(actix_web::error::ErrorUnauthorized(
"Invalid Authorization header, missing Bearer",
))),
Some(pair) => {
match GoTrueJWTClaims::verify(
pair.1,
state.config.gotrue.jwt_secret.expose_secret().as_bytes(),
) {
Err(e) => std::future::ready(Err(actix_web::error::ErrorUnauthorized(e))),
Ok(t) => std::future::ready(Ok(Authorization {
token: pair.1.to_string(),
claims: t,
})),
}
},
}
},
}
},
let auth = get_auth_from_request(req);
match auth {
Ok(auth) => std::future::ready(Ok(auth)),
Err(e) => std::future::ready(Err(e)),
}
}
}
fn get_auth_from_request(req: &HttpRequest) -> Result<Authorization, actix_web::Error> {
let state = req.app_data::<Data<State>>().unwrap();
let bearer = req.headers().get("Authorization");
match bearer {
None => Err(actix_web::error::ErrorUnauthorized(
"No Authorization header",
)),
Some(bearer) => {
let bearer = bearer.to_str();
match bearer {
Err(e) => Err(actix_web::error::ErrorUnauthorized(e)),
Ok(bearer) => {
let pair_opt = bearer.split_once("Bearer "); // Authorization: Bearer <token>
match pair_opt {
None => Err(actix_web::error::ErrorUnauthorized(
"Invalid Authorization header, missing Bearer",
)),
Some(pair) => {
match GoTrueJWTClaims::verify(
pair.1,
state.config.gotrue.jwt_secret.expose_secret().as_bytes(),
) {
Err(e) => Err(actix_web::error::ErrorUnauthorized(e)),
Ok(t) => Ok(Authorization {
token: pair.1.to_string(),
claims: t,
}),
}
},
}
},
}
},
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoTrueJWTClaims {
// JWT standard claims
aud: Option<String>,
exp: Option<i64>,
jti: Option<String>,
iat: Option<i64>,
iss: Option<String>,
nbf: Option<i64>,
sub: Option<String>,
pub aud: Option<String>,
pub exp: Option<i64>,
pub jti: Option<String>,
pub iat: Option<i64>,
pub iss: Option<String>,
pub nbf: Option<i64>,
pub sub: Option<String>,
email: String,
phone: String,
app_metadata: serde_json::Value,
user_metadata: serde_json::Value,
role: String,
aal: Option<String>,
amr: Option<Vec<Amr>>,
session_id: Option<String>,
pub email: String,
pub phone: String,
pub app_metadata: serde_json::Value,
pub user_metadata: serde_json::Value,
pub role: String,
pub aal: Option<String>,
pub amr: Option<Vec<Amr>>,
pub session_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Amr {
method: String,
timestamp: u64,
provider: Option<String>,
pub struct Amr {
pub method: String,
pub timestamp: u64,
pub provider: Option<String>,
}
impl GoTrueJWTClaims {

View File

@ -58,5 +58,9 @@ async fn sign_in_success() {
let token = c.token().unwrap();
assert!(token.user.confirmed_at.is_some());
// TODO: check that workspace is created for user
let workspaces = c.workspaces().await.unwrap();
assert!(!workspaces.0.is_empty());
let profile = c.profile().await.unwrap();
let latest_workspace = workspaces.get_latest(profile);
assert!(latest_workspace.is_some());
}