Skip to content

Commit

Permalink
feat: organize code into layers
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
jifalops committed Dec 5, 2024
1 parent 2972864 commit 2c19f66
Show file tree
Hide file tree
Showing 19 changed files with 1,662 additions and 102 deletions.
1,229 changes: 1,214 additions & 15 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ lambda = ["dep:lambda_http"]

[dependencies]
api-rust-macros = { version = "*", path = "../macros" }
argon2 = "0.5.3"
async-trait = "0.1.83"
axum = { version = "0.7.9", features = ["macros"] }
lambda_http = { version = "0.13.0", optional = true, features = ["apigw_http"] }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133"
short-uuid = "0.1.4"
sqlx = { version = "0.8.2", features = ["postgres"] }
thiserror = "2.0.4"
tokio = { version = "1", features = ["full"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
validator = { version = "0.19.0", features = ["derive"] }

# https://www.cargo-lambda.info/commands/build.html#build-configuration-in-cargo-s-metadata
# [package.metadata.lambda.build]
25 changes: 21 additions & 4 deletions lib/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
use crate::auth::AuthService;
use crate::{
auth::AuthService,
user::{UserRepoAdapter, UserService},
};

pub trait App {
pub trait Service: Sync + Send + 'static {}
impl<T: Sync + Send + 'static> Service for T {}

pub trait App: Sync + Send + 'static {
type Auth: AuthService;
type UserRepo: UserRepoAdapter;

fn auth(&self) -> &Self::Auth;

fn user(&self) -> &UserService<Self::UserRepo>;
}

pub struct NewApp<Auth>
pub struct NewApp<Auth, UserRepo>
where
Auth: AuthService,
UserRepo: UserRepoAdapter,
{
pub auth: Auth,
pub user: UserService<UserRepo>,
}

impl<Auth> App for NewApp<Auth>
impl<Auth, UserRepo> App for NewApp<Auth, UserRepo>
where
Auth: AuthService,
UserRepo: UserRepoAdapter,
{
type Auth = Auth;
type UserRepo = UserRepo;

fn auth(&self) -> &Self::Auth {
&self.auth
}

fn user(&self) -> &UserService<Self::UserRepo> {
&self.user
}
}
6 changes: 5 additions & 1 deletion lib/src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
mod models;
mod router;
pub mod router_axum;
mod service;
mod service_jwt;

pub use service::AuthService;
pub use models::*;
pub use service::*;
pub use service_jwt::AuthServiceJwt;
35 changes: 35 additions & 0 deletions lib/src/auth/models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
use validator::Validate;

#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Invalid credential")]
InvalidCredential,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Token {
pub user_id: String,
pub token: String,
pub expires: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
pub struct SignUpData {
#[validate(email)]
pub email: String,
#[validate(length(min = 4, max = 64))]
pub password: String,
#[validate(length(min = 1, max = 64))]
pub name: Option<String>,
#[validate(url)]
pub photo_url: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
pub struct SignInData {
#[validate(email)]
pub email: String,
#[validate(length(min = 4, max = 64))]
pub password: String,
}
28 changes: 28 additions & 0 deletions lib/src/auth/router.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use crate::{
user::{NewUser, UserIdentifier},
App, AppError,
};

use super::{AuthService, SignInData, SignUpData, Token};

pub async fn sign_up<A: App>(data: SignUpData, app: &A) -> Result<Token, AppError> {
let new_user = NewUser {
email: data.email,
password_hash: app.auth().hash_password(data.password)?,
name: data.name,
photo_url: data.photo_url,
};
let user = app.user().create_user(new_user).await?;
let token = app.auth().generate_token(user).await?;
Ok(token)
}

pub async fn sign_in<A: App>(data: SignInData, app: &A) -> Result<Token, AppError> {
let user = app
.user()
.get_user(UserIdentifier::new(None, Some(data.email))?)
.await?;
app.auth()
.verify_password(data.password, &user.password_hash)?;
app.auth().generate_token(user).await
}
29 changes: 29 additions & 0 deletions lib/src/auth/router_axum.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use std::sync::Arc;

use axum::{extract::State, routing::post, Json, Router};

use crate::{app::App, AppError};

use super::{router, SignInData, SignUpData, Token};

pub fn create_router<A: App>() -> Router<Arc<A>> {
Router::new()
.route("/sign-up", post(sign_up))
.route("/sign-in", post(sign_in))
}

async fn sign_up<A: App>(
State(app): State<Arc<A>>,
Json(data): Json<SignUpData>,
) -> Result<Json<Token>, AppError> {
let token = router::sign_up(data, app.as_ref()).await?;
Ok(Json(token))
}

async fn sign_in<A: App>(
State(app): State<Arc<A>>,
Json(data): Json<SignInData>,
) -> Result<Json<Token>, AppError> {
let token = router::sign_in(data, app.as_ref()).await?;
Ok(Json(token))
}
28 changes: 25 additions & 3 deletions lib/src/auth/service.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
use axum::async_trait;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use async_trait::async_trait;

use crate::{app::Service, user::User, AppError};

use super::{AuthError, Token};

#[async_trait]
pub trait AuthService {
async fn authenticate(&self, token: &str) -> Result<(), ()>;
pub trait AuthService: Service {
fn hash_password(&self, password: String) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let hashed = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(e.to_string()))?
.to_string();
Ok(hashed)
}
fn verify_password(&self, password: String, hash: &str) -> Result<(), AppError> {
let parsed = PasswordHash::new(hash).map_err(|e| AppError::Internal(e.to_string()))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| AuthError::InvalidCredential.into())
}
async fn generate_token(&self, user: User) -> Result<Token, AppError>;
}
14 changes: 6 additions & 8 deletions lib/src/auth/service_jwt.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
use axum::async_trait;
use async_trait::async_trait;

use super::AuthService;
use crate::{user::User, AppError};

use super::{AuthService, Token};

pub struct AuthServiceJwt;

#[async_trait]
impl AuthService for AuthServiceJwt {
async fn authenticate(&self, token: &str) -> Result<(), ()> {
if token == "valid" {
Ok(())
} else {
Err(())
}
async fn generate_token(&self, user: User) -> Result<Token, AppError> {
todo!()
}
}
59 changes: 59 additions & 0 deletions lib/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use axum::{
body::Body,
http::{Response, StatusCode},
response::IntoResponse,
Json,
};
use serde_json::json;

use crate::auth::AuthError;

#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Authentication failed: {0}")]
Auth(#[from] AuthError),

#[error("Database error: {0}")]
Database(#[from] sqlx::Error),

#[error("Not found: {0}")]
NotFound(String),

#[error("Validation failed: {0}")]
Validation(String),

#[error("Internal error: {0}")]
Internal(String),

#[error("Internal error: {0}")]
MalformedData(#[from] serde_json::Error),
}

impl IntoResponse for AppError {
fn into_response(self) -> Response<Body> {
let (status, error_message) = match self {
AppError::Auth(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::Database(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
),
AppError::MalformedData(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error: Malformed data.".to_string(),
),
};

let body = Json(json!({
"error": error_message,
"code": status.as_u16()
}));

(status, body).into_response()
}
}
61 changes: 61 additions & 0 deletions lib/src/init.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use std::sync::Arc;

use axum::{response::Html, routing::get, Router};
use tracing::debug;

use crate::{
app::NewApp,
auth::{self, AuthServiceJwt},
user::{UserRepoPostgres, UserService},
};

pub async fn initialize() {
setup_tracing();

let app = NewApp {
auth: AuthServiceJwt,
user: UserService::new(UserRepoPostgres),
};

let router = Router::new()
.route("/", get(hello_world))
.nest("/auth", auth::router_axum::create_router())
.with_state(Arc::new(app));

start_api_server(router).await;
}

fn setup_tracing() {
#[cfg(not(feature = "lambda"))]
{
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
}
#[cfg(feature = "lambda")]
{
lambda_http::tracing::init_default_subscriber();
}
}

async fn start_api_server(router: Router) {
#[cfg(not(feature = "lambda"))]
{
debug!("DEV MODE");
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, router).await.unwrap();
}

#[cfg(feature = "lambda")]
{
debug!("LAMBDA MODE");
lambda_http::run(router).await.unwrap();
}
}

async fn hello_world() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
19 changes: 5 additions & 14 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
pub mod app;
pub mod auth;
mod error;
pub mod init;
pub mod user;

pub fn add(left: usize, right: usize) -> usize {
left + right
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
pub use app::App;
pub use error::AppError;
Loading

0 comments on commit 2c19f66

Please sign in to comment.