Skip to content

Commit

Permalink
feat(coin): add get balance endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Defelo committed Oct 19, 2024
1 parent 87977cd commit aa229f7
Show file tree
Hide file tree
Showing 27 changed files with 578 additions and 4 deletions.
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ academy_auth_impl.path = "academy_auth/impl"
academy_cache_contracts.path = "academy_cache/contracts"
academy_cache_valkey.path = "academy_cache/valkey"
academy_config.path = "academy_config"
academy_core_coin_contracts.path = "academy_core/coin/contracts"
academy_core_coin_impl.path = "academy_core/coin/impl"
academy_core_config_contracts.path = "academy_core/config/contracts"
academy_core_config_impl.path = "academy_core/config/impl"
academy_core_contact_contracts.path = "academy_core/contact/contracts"
Expand Down
1 change: 1 addition & 0 deletions academy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ academy_auth_impl.workspace = true
academy_cache_contracts.workspace = true
academy_cache_valkey.workspace = true
academy_config.workspace = true
academy_core_coin_impl.workspace = true
academy_core_config_impl.workspace = true
academy_core_contact_impl.workspace = true
academy_core_health_impl.workspace = true
Expand Down
7 changes: 6 additions & 1 deletion academy/src/environment/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use academy_auth_impl::{
refresh_token::AuthRefreshTokenServiceImpl, AuthServiceImpl,
};
use academy_cache_valkey::ValkeyCache;
use academy_core_coin_impl::CoinFeatureServiceImpl;
use academy_core_config_impl::ConfigFeatureServiceImpl;
use academy_core_contact_impl::ContactFeatureServiceImpl;
use academy_core_health_impl::HealthFeatureServiceImpl;
Expand All @@ -31,7 +32,7 @@ use academy_extern_impl::{
recaptcha::RecaptchaApiServiceImpl, vat::VatApiServiceImpl,
};
use academy_persistence_postgres::{
mfa::PostgresMfaRepository, oauth2::PostgresOAuth2Repository,
coin::PostgresCoinRepository, mfa::PostgresMfaRepository, oauth2::PostgresOAuth2Repository,
session::PostgresSessionRepository, user::PostgresUserRepository, PostgresDatabase,
};
use academy_shared_impl::{
Expand All @@ -50,6 +51,7 @@ pub type RestServer = academy_api_rest::RestServer<
ContactFeature,
MfaFeature,
OAuth2Feature,
CoinFeature,
Internal,
>;

Expand Down Expand Up @@ -87,6 +89,7 @@ pub type SessionRepo = PostgresSessionRepository;
pub type UserRepo = PostgresUserRepository;
pub type MfaRepo = PostgresMfaRepository;
pub type OAuth2Repo = PostgresOAuth2Repository;
pub type CoinRepo = PostgresCoinRepository;

// Auth
pub type Auth =
Expand Down Expand Up @@ -162,4 +165,6 @@ pub type OAuth2Link = OAuth2LinkServiceImpl<Id, Time, OAuth2Repo>;
pub type OAuth2Login = OAuth2LoginServiceImpl<OAuth2Api>;
pub type OAuth2Registration = OAuth2RegistrationServiceImpl<Secret, Cache>;

pub type CoinFeature = CoinFeatureServiceImpl<Database, Auth, UserRepo, CoinRepo>;

pub type Internal = InternalServiceImpl<Database, AuthInternal, UserRepo>;
1 change: 1 addition & 0 deletions academy_api/rest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ workspace = true

[dependencies]
academy_auth_contracts.workspace = true
academy_core_coin_contracts.workspace = true
academy_core_config_contracts.workspace = true
academy_core_contact_contracts.workspace = true
academy_core_health_contracts.workspace = true
Expand Down
11 changes: 8 additions & 3 deletions academy_api/rest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::{
sync::Arc,
};

use academy_core_coin_contracts::CoinFeatureService;
use academy_core_config_contracts::ConfigFeatureService;
use academy_core_contact_contracts::ContactFeatureService;
use academy_core_health_contracts::HealthFeatureService;
Expand Down Expand Up @@ -36,7 +37,7 @@ mod models;
mod routes;

#[derive(Debug, Clone, Build)]
pub struct RestServer<Health, Config, User, Session, Contact, Mfa, OAuth2, Internal> {
pub struct RestServer<Health, Config, User, Session, Contact, Mfa, OAuth2, Coin, Internal> {
_config: RestServerConfig,
health: Health,
config: Config,
Expand All @@ -45,6 +46,7 @@ pub struct RestServer<Health, Config, User, Session, Contact, Mfa, OAuth2, Inter
contact: Contact,
mfa: Mfa,
oauth2: OAuth2,
coin: Coin,
internal: Internal,
}

Expand All @@ -60,8 +62,8 @@ pub struct RestServerRealIpConfig {
pub set_from: IpAddr,
}

impl<Health, Config, User, Session, Contact, Mfa, OAuth2, Internal>
RestServer<Health, Config, User, Session, Contact, Mfa, OAuth2, Internal>
impl<Health, Config, User, Session, Contact, Mfa, OAuth2, Coin, Internal>
RestServer<Health, Config, User, Session, Contact, Mfa, OAuth2, Coin, Internal>
where
Health: HealthFeatureService,
Config: ConfigFeatureService,
Expand All @@ -70,6 +72,7 @@ where
Contact: ContactFeatureService,
Mfa: MfaFeatureService,
OAuth2: OAuth2FeatureService,
Coin: CoinFeatureService,
Internal: InternalService,
{
pub async fn serve(self) -> anyhow::Result<()> {
Expand All @@ -94,6 +97,7 @@ where
routes::session::TAG,
routes::mfa::TAG,
routes::oauth2::TAG,
routes::coin::TAG,
routes::internal::TAG,
]
.into_iter()
Expand Down Expand Up @@ -157,6 +161,7 @@ where
.merge(routes::contact::router(self.contact.into()))
.merge(routes::mfa::router(self.mfa.into()))
.merge(routes::oauth2::router(self.oauth2.into()))
.merge(routes::coin::router(self.coin.into()))
.merge(routes::internal::router(self.internal.into()))
}
}
Expand Down
21 changes: 21 additions & 0 deletions academy_api/rest/src/models/coin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use academy_models::coin::Balance;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ApiBalance {
/// Number of Morphcoins the user owns
pub coins: u64,
/// Number of Morphcoins withheld until the user completes their invoice
/// info
pub withheld_coins: u64,
}

impl From<Balance> for ApiBalance {
fn from(value: Balance) -> Self {
Self {
coins: value.coins,
withheld_coins: value.withheld_coins,
}
}
}
1 change: 1 addition & 0 deletions academy_api/rest/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use serde::Deserialize;

use crate::const_schema;

pub mod coin;
pub mod contact;
pub mod oauth2;
pub mod session;
Expand Down
54 changes: 54 additions & 0 deletions academy_api/rest/src/routes/coin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use std::sync::Arc;

use academy_core_coin_contracts::{CoinFeatureService, CoinGetBalanceError};
use aide::{
axum::{routing, ApiRouter},
transform::TransformOperation,
};
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};

use super::user::UserNotFoundError;
use crate::{
docs::TransformOperationExt,
errors::{auth_error, auth_error_docs, internal_server_error, internal_server_error_docs},
extractors::auth::ApiToken,
models::{coin::ApiBalance, user::PathUserIdOrSelf},
};

pub const TAG: &str = "Coins";

pub fn router(service: Arc<impl CoinFeatureService>) -> ApiRouter<()> {
ApiRouter::new()
.api_route(
"/shop/coins/:user_id",
routing::get_with(get_balance, get_balance_docs),
)
.with_state(service)
.with_path_items(|op| op.tag(TAG))
}

async fn get_balance(
service: State<Arc<impl CoinFeatureService>>,
token: ApiToken,
Path(PathUserIdOrSelf { user_id }): Path<PathUserIdOrSelf>,
) -> Response {
match service.get_balance(&token.0, user_id.into()).await {
Ok(balance) => Json(ApiBalance::from(balance)).into_response(),
Err(CoinGetBalanceError::NotFound) => UserNotFoundError.into_response(),
Err(CoinGetBalanceError::Auth(err)) => auth_error(err),
Err(CoinGetBalanceError::Other(err)) => internal_server_error(err),
}
}

fn get_balance_docs(op: TransformOperation) -> TransformOperation {
op.summary("Return the Morphcoin balance of the given user.")
.add_response::<ApiBalance>(StatusCode::OK, None)
.add_error::<UserNotFoundError>()
.with(auth_error_docs)
.with(internal_server_error_docs)
}
1 change: 1 addition & 0 deletions academy_api/rest/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod coin;
pub mod config;
pub mod contact;
pub mod health;
Expand Down
15 changes: 15 additions & 0 deletions academy_core/coin/contracts/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "academy_core_coin_contracts"
version.workspace = true
edition.workspace = true
publish.workspace = true
homepage.workspace = true
repository.workspace = true

[lints]
workspace = true

[dependencies]
academy_models.workspace = true
anyhow.workspace = true
thiserror.workspace = true
29 changes: 29 additions & 0 deletions academy_core/coin/contracts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use std::future::Future;

use academy_models::{
auth::{AccessToken, AuthError},
coin::Balance,
user::UserIdOrSelf,
};
use thiserror::Error;

pub trait CoinFeatureService: Send + Sync + 'static {
/// Return the Morphcoin balance of the given user.
///
/// Requires admin privileges if not used on the authenticated user.
fn get_balance(
&self,
token: &AccessToken,
user_id: UserIdOrSelf,
) -> impl Future<Output = Result<Balance, CoinGetBalanceError>> + Send;
}

#[derive(Debug, Error)]
pub enum CoinGetBalanceError {
#[error(transparent)]
Auth(#[from] AuthError),
#[error("The user does not exist.")]
NotFound,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
25 changes: 25 additions & 0 deletions academy_core/coin/impl/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "academy_core_coin_impl"
version.workspace = true
edition.workspace = true
publish.workspace = true
homepage.workspace = true
repository.workspace = true

[lints]
workspace = true

[dependencies]
academy_auth_contracts.workspace = true
academy_core_coin_contracts.workspace = true
academy_di.workspace = true
academy_models.workspace = true
academy_persistence_contracts.workspace = true
academy_utils.workspace = true
tracing.workspace = true

[dev-dependencies]
academy_auth_contracts = { workspace = true, features = ["mock"] }
academy_demo.workspace = true
academy_persistence_contracts = { workspace = true, features = ["mock"] }
tokio.workspace = true
51 changes: 51 additions & 0 deletions academy_core/coin/impl/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use academy_auth_contracts::{AuthResultExt, AuthService};
use academy_core_coin_contracts::{CoinFeatureService, CoinGetBalanceError};
use academy_di::Build;
use academy_models::{auth::AccessToken, coin::Balance, user::UserIdOrSelf};
use academy_persistence_contracts::{coin::CoinRepository, user::UserRepository, Database};
use academy_utils::trace_instrument;

#[cfg(test)]
mod tests;

#[derive(Debug, Clone, Default, Build)]
pub struct CoinFeatureServiceImpl<Db, Auth, UserRepo, CoinRepo> {
db: Db,
auth: Auth,
user_repo: UserRepo,
coin_repo: CoinRepo,
}

impl<Db, Auth, UserRepo, CoinRepo> CoinFeatureService
for CoinFeatureServiceImpl<Db, Auth, UserRepo, CoinRepo>
where
Db: Database,
Auth: AuthService<Db::Transaction>,
UserRepo: UserRepository<Db::Transaction>,
CoinRepo: CoinRepository<Db::Transaction>,
{
#[trace_instrument(skip(self))]
async fn get_balance(
&self,
token: &AccessToken,
user_id: UserIdOrSelf,
) -> Result<Balance, CoinGetBalanceError> {
let auth = self.auth.authenticate(token).await.map_auth_err()?;
let user_id = user_id.unwrap_or(auth.user_id);
auth.ensure_self_or_admin(user_id).map_auth_err()?;

let mut txn = self.db.begin_transaction().await?;

if !self.user_repo.exists(&mut txn, user_id).await? {
return Err(CoinGetBalanceError::NotFound);
}

let balance = self
.coin_repo
.get_balance(&mut txn, user_id)
.await?
.unwrap_or_default();

Ok(balance)
}
}
Loading

0 comments on commit aa229f7

Please sign in to comment.