Skip to content

Commit 2c19f66

Browse files
committed
feat: organize code into layers
- Service layer: core business logic. App is a collection of services. Services can optionally have underlying implementors (e.g. Firebase as an auth service). - Repo ports: business logic around persistence - Repo adapters: handle read/write of primitive (json) data in e.g. postgres. - Router ports: business logic for handling incoming requests. - Router adapters (e.g. axum): Parse raw requests into technology agnostic requests. Routing adapter -> Router -> Service -> Repo -> Repo adapter. App is a trait/struct that contains the service layer. Each service has it's own repo (if necessary). Routers are top level functions because there's nowhere to keep an instance. The global-ish app instance is the one given to Axum as the app's state. That's how services are called upon when handling a request.
1 parent 2972864 commit 2c19f66

File tree

19 files changed

+1662
-102
lines changed

19 files changed

+1662
-102
lines changed

Cargo.lock

Lines changed: 1214 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,19 @@ lambda = ["dep:lambda_http"]
1111

1212
[dependencies]
1313
api-rust-macros = { version = "*", path = "../macros" }
14+
argon2 = "0.5.3"
15+
async-trait = "0.1.83"
1416
axum = { version = "0.7.9", features = ["macros"] }
1517
lambda_http = { version = "0.13.0", optional = true, features = ["apigw_http"] }
18+
serde = { version = "1.0.215", features = ["derive"] }
19+
serde_json = "1.0.133"
20+
short-uuid = "0.1.4"
21+
sqlx = { version = "0.8.2", features = ["postgres"] }
22+
thiserror = "2.0.4"
1623
tokio = { version = "1", features = ["full"] }
1724
tracing = "0.1.41"
1825
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
26+
validator = { version = "0.19.0", features = ["derive"] }
1927

2028
# https://www.cargo-lambda.info/commands/build.html#build-configuration-in-cargo-s-metadata
2129
# [package.metadata.lambda.build]

lib/src/app.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,42 @@
1-
use crate::auth::AuthService;
1+
use crate::{
2+
auth::AuthService,
3+
user::{UserRepoAdapter, UserService},
4+
};
25

3-
pub trait App {
6+
pub trait Service: Sync + Send + 'static {}
7+
impl<T: Sync + Send + 'static> Service for T {}
8+
9+
pub trait App: Sync + Send + 'static {
410
type Auth: AuthService;
11+
type UserRepo: UserRepoAdapter;
512

613
fn auth(&self) -> &Self::Auth;
14+
15+
fn user(&self) -> &UserService<Self::UserRepo>;
716
}
817

9-
pub struct NewApp<Auth>
18+
pub struct NewApp<Auth, UserRepo>
1019
where
1120
Auth: AuthService,
21+
UserRepo: UserRepoAdapter,
1222
{
1323
pub auth: Auth,
24+
pub user: UserService<UserRepo>,
1425
}
1526

16-
impl<Auth> App for NewApp<Auth>
27+
impl<Auth, UserRepo> App for NewApp<Auth, UserRepo>
1728
where
1829
Auth: AuthService,
30+
UserRepo: UserRepoAdapter,
1931
{
2032
type Auth = Auth;
33+
type UserRepo = UserRepo;
2134

2235
fn auth(&self) -> &Self::Auth {
2336
&self.auth
2437
}
38+
39+
fn user(&self) -> &UserService<Self::UserRepo> {
40+
&self.user
41+
}
2542
}

lib/src/auth/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
mod models;
2+
mod router;
3+
pub mod router_axum;
14
mod service;
25
mod service_jwt;
36

4-
pub use service::AuthService;
7+
pub use models::*;
8+
pub use service::*;
59
pub use service_jwt::AuthServiceJwt;

lib/src/auth/models.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
use serde::{Deserialize, Serialize};
2+
use validator::Validate;
3+
4+
#[derive(Debug, thiserror::Error)]
5+
pub enum AuthError {
6+
#[error("Invalid credential")]
7+
InvalidCredential,
8+
}
9+
10+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11+
pub struct Token {
12+
pub user_id: String,
13+
pub token: String,
14+
pub expires: u64,
15+
}
16+
17+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
18+
pub struct SignUpData {
19+
#[validate(email)]
20+
pub email: String,
21+
#[validate(length(min = 4, max = 64))]
22+
pub password: String,
23+
#[validate(length(min = 1, max = 64))]
24+
pub name: Option<String>,
25+
#[validate(url)]
26+
pub photo_url: Option<String>,
27+
}
28+
29+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
30+
pub struct SignInData {
31+
#[validate(email)]
32+
pub email: String,
33+
#[validate(length(min = 4, max = 64))]
34+
pub password: String,
35+
}

lib/src/auth/router.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use crate::{
2+
user::{NewUser, UserIdentifier},
3+
App, AppError,
4+
};
5+
6+
use super::{AuthService, SignInData, SignUpData, Token};
7+
8+
pub async fn sign_up<A: App>(data: SignUpData, app: &A) -> Result<Token, AppError> {
9+
let new_user = NewUser {
10+
email: data.email,
11+
password_hash: app.auth().hash_password(data.password)?,
12+
name: data.name,
13+
photo_url: data.photo_url,
14+
};
15+
let user = app.user().create_user(new_user).await?;
16+
let token = app.auth().generate_token(user).await?;
17+
Ok(token)
18+
}
19+
20+
pub async fn sign_in<A: App>(data: SignInData, app: &A) -> Result<Token, AppError> {
21+
let user = app
22+
.user()
23+
.get_user(UserIdentifier::new(None, Some(data.email))?)
24+
.await?;
25+
app.auth()
26+
.verify_password(data.password, &user.password_hash)?;
27+
app.auth().generate_token(user).await
28+
}

lib/src/auth/router_axum.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
use std::sync::Arc;
2+
3+
use axum::{extract::State, routing::post, Json, Router};
4+
5+
use crate::{app::App, AppError};
6+
7+
use super::{router, SignInData, SignUpData, Token};
8+
9+
pub fn create_router<A: App>() -> Router<Arc<A>> {
10+
Router::new()
11+
.route("/sign-up", post(sign_up))
12+
.route("/sign-in", post(sign_in))
13+
}
14+
15+
async fn sign_up<A: App>(
16+
State(app): State<Arc<A>>,
17+
Json(data): Json<SignUpData>,
18+
) -> Result<Json<Token>, AppError> {
19+
let token = router::sign_up(data, app.as_ref()).await?;
20+
Ok(Json(token))
21+
}
22+
23+
async fn sign_in<A: App>(
24+
State(app): State<Arc<A>>,
25+
Json(data): Json<SignInData>,
26+
) -> Result<Json<Token>, AppError> {
27+
let token = router::sign_in(data, app.as_ref()).await?;
28+
Ok(Json(token))
29+
}

lib/src/auth/service.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
1-
use axum::async_trait;
1+
use argon2::{
2+
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
3+
Argon2,
4+
};
5+
use async_trait::async_trait;
6+
7+
use crate::{app::Service, user::User, AppError};
8+
9+
use super::{AuthError, Token};
210

311
#[async_trait]
4-
pub trait AuthService {
5-
async fn authenticate(&self, token: &str) -> Result<(), ()>;
12+
pub trait AuthService: Service {
13+
fn hash_password(&self, password: String) -> Result<String, AppError> {
14+
let salt = SaltString::generate(&mut OsRng);
15+
let hashed = Argon2::default()
16+
.hash_password(password.as_bytes(), &salt)
17+
.map_err(|e| AppError::Internal(e.to_string()))?
18+
.to_string();
19+
Ok(hashed)
20+
}
21+
fn verify_password(&self, password: String, hash: &str) -> Result<(), AppError> {
22+
let parsed = PasswordHash::new(hash).map_err(|e| AppError::Internal(e.to_string()))?;
23+
Argon2::default()
24+
.verify_password(password.as_bytes(), &parsed)
25+
.map_err(|_| AuthError::InvalidCredential.into())
26+
}
27+
async fn generate_token(&self, user: User) -> Result<Token, AppError>;
628
}

lib/src/auth/service_jwt.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
use axum::async_trait;
1+
use async_trait::async_trait;
22

3-
use super::AuthService;
3+
use crate::{user::User, AppError};
4+
5+
use super::{AuthService, Token};
46

57
pub struct AuthServiceJwt;
68

79
#[async_trait]
810
impl AuthService for AuthServiceJwt {
9-
async fn authenticate(&self, token: &str) -> Result<(), ()> {
10-
if token == "valid" {
11-
Ok(())
12-
} else {
13-
Err(())
14-
}
11+
async fn generate_token(&self, user: User) -> Result<Token, AppError> {
12+
todo!()
1513
}
1614
}

lib/src/error.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use axum::{
2+
body::Body,
3+
http::{Response, StatusCode},
4+
response::IntoResponse,
5+
Json,
6+
};
7+
use serde_json::json;
8+
9+
use crate::auth::AuthError;
10+
11+
#[derive(Debug, thiserror::Error)]
12+
pub enum AppError {
13+
#[error("Authentication failed: {0}")]
14+
Auth(#[from] AuthError),
15+
16+
#[error("Database error: {0}")]
17+
Database(#[from] sqlx::Error),
18+
19+
#[error("Not found: {0}")]
20+
NotFound(String),
21+
22+
#[error("Validation failed: {0}")]
23+
Validation(String),
24+
25+
#[error("Internal error: {0}")]
26+
Internal(String),
27+
28+
#[error("Internal error: {0}")]
29+
MalformedData(#[from] serde_json::Error),
30+
}
31+
32+
impl IntoResponse for AppError {
33+
fn into_response(self) -> Response<Body> {
34+
let (status, error_message) = match self {
35+
AppError::Auth(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
36+
AppError::Database(_) => (
37+
StatusCode::INTERNAL_SERVER_ERROR,
38+
"Internal server error".to_string(),
39+
),
40+
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
41+
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
42+
AppError::Internal(_) => (
43+
StatusCode::INTERNAL_SERVER_ERROR,
44+
"Internal server error".to_string(),
45+
),
46+
AppError::MalformedData(_) => (
47+
StatusCode::INTERNAL_SERVER_ERROR,
48+
"Internal server error: Malformed data.".to_string(),
49+
),
50+
};
51+
52+
let body = Json(json!({
53+
"error": error_message,
54+
"code": status.as_u16()
55+
}));
56+
57+
(status, body).into_response()
58+
}
59+
}

0 commit comments

Comments
 (0)