init: scaffold from k-template with postgres + worker

This commit is contained in:
2026-05-31 03:08:38 +02:00
commit f9cb142c3b
70 changed files with 5269 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
[package]
name = "adapters-auth"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
jsonwebtoken = { workspace = true }
bcrypt = { workspace = true }
serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,74 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{errors::DomainError, ports::TokenIssuer, value_objects::{Role, UserId}};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub role: String,
pub exp: i64,
}
pub struct JwtTokenIssuer {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
expiry_hours: i64,
}
impl JwtTokenIssuer {
pub fn new(secret: &str) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
expiry_hours: 24,
}
}
}
#[async_trait]
impl TokenIssuer for JwtTokenIssuer {
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError> {
let claims = Claims {
sub: user_id.to_string(),
role: role.to_string(),
exp: (Utc::now() + chrono::Duration::hours(self.expiry_hours)).timestamp(),
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
let data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".to_string()))?;
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
.map_err(|_| DomainError::Unauthorized("Invalid token subject".to_string()))?;
let role = Role::from_str(&data.claims.role)
.map_err(|_| DomainError::Unauthorized("Invalid role in token".to_string()))?;
Ok((UserId::from_uuid(uuid), role))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn issue_and_verify_roundtrip() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let user_id = UserId::new();
let token = issuer.issue(&user_id, &Role::User).await.unwrap();
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
assert_eq!(verified_id, user_id);
assert_eq!(verified_role, Role::User);
}
#[tokio::test]
async fn rejects_invalid_token() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let result = issuer.verify("not.a.valid.jwt").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}
}

View File

@@ -0,0 +1,7 @@
pub mod jwt;
pub mod password;
pub use jwt::JwtTokenIssuer;
pub use password::BcryptPasswordHasher;

View File

@@ -0,0 +1,38 @@
use async_trait::async_trait;
use domain::{errors::DomainError, ports::PasswordHasher, value_objects::PasswordHash};
pub struct BcryptPasswordHasher;
#[async_trait]
impl PasswordHasher for BcryptPasswordHasher {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError> {
let password = password.to_owned();
let hash = tokio::task::spawn_blocking(move || bcrypt::hash(&password, 12))
.await
.map_err(|e| DomainError::Internal(e.to_string()))?
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(PasswordHash::from_hash(hash))
}
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
let password = password.to_owned();
let hash = hash.as_str().to_owned();
tokio::task::spawn_blocking(move || bcrypt::verify(&password, &hash))
.await
.map_err(|e| DomainError::Internal(e.to_string()))?
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn hash_and_verify_roundtrip() {
let h = BcryptPasswordHasher;
let hash = h.hash("mysecretpassword").await.unwrap();
assert!(h.verify("mysecretpassword", &hash).await.unwrap());
assert!(!h.verify("wrongpassword", &hash).await.unwrap());
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "adapters-postgres"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true, features = ["postgres"] }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,14 @@
pub type PgPool = sqlx::PgPool;
pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(url)
.await?;
Ok(pool)
}
pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(pool).await?;
Ok(())
}

View File

@@ -0,0 +1,5 @@
pub mod db;
pub mod user_repository;
pub use db::{connect, run_migrations, PgPool};
pub use user_repository::PostgresUserRepository;

View File

@@ -0,0 +1,86 @@
use async_trait::async_trait;
use domain::{
entities::User,
errors::DomainError,
ports::UserRepository,
value_objects::{Email, PasswordHash, Role, UserId},
};
use std::str::FromStr;
use crate::db::PgPool;
pub struct PostgresUserRepository {
pool: PgPool,
}
impl PostgresUserRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } }
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let row = sqlx::query!(
"SELECT id, email, password_hash, role, created_at FROM users WHERE id = $1",
*id.as_uuid()
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| Ok(User {
id: UserId::from_uuid(r.id),
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
created_at: r.created_at,
}))
.transpose()
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let row = sqlx::query!(
"SELECT id, email, password_hash, role, created_at FROM users WHERE email = $1",
email.as_str()
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| Ok(User {
id: UserId::from_uuid(r.id),
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
created_at: r.created_at,
}))
.transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
sqlx::query!(
"INSERT INTO users (id, email, password_hash, role, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role",
*user.id.as_uuid(),
user.email.as_str(),
user.password_hash.as_str(),
user.role.to_string(),
user.created_at
)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "adapters-storage"
version = "0.1.0"
edition = "2024"
[features]
default = []
s3 = ["object_store/aws"]
gcs = ["object_store/gcp"]
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
object_store = { version = "0.11" }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,310 @@
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use futures::stream::StreamExt;
use object_store::{ObjectStore, path::Path, Error as OsError};
use domain::errors::DomainError;
use domain::ports::{DataStream, StorageReader, StorageWriter};
pub struct ObjectStorageAdapter {
store: Arc<dyn ObjectStore>,
prefix: String,
}
impl ObjectStorageAdapter {
pub fn new(store: Arc<dyn ObjectStore>, prefix: impl Into<String>) -> Result<Self, DomainError> {
let prefix = prefix.into();
if !prefix.is_empty() {
validate_key(&prefix)?;
}
Ok(Self { store, prefix })
}
fn path(&self, key: &str) -> Path {
if self.prefix.is_empty() {
Path::from(key)
} else {
Path::from(format!("{}/{key}", self.prefix))
}
}
}
fn map_err(e: OsError, key: &str) -> DomainError {
match e {
OsError::NotFound { .. } => DomainError::NotFound(key.to_string()),
e => DomainError::Internal(e.to_string()),
}
}
fn validate_key(key: &str) -> Result<(), DomainError> {
if key.is_empty() {
return Err(DomainError::Validation("storage key must not be empty".into()));
}
if key.starts_with('/') {
return Err(DomainError::Validation(
format!("storage key must not start with '/': {key}"),
));
}
if key.split('/').any(|seg| seg == ".." || seg == ".") {
return Err(DomainError::Validation(
format!("storage key contains invalid path segment: {key}"),
));
}
Ok(())
}
#[async_trait]
impl StorageWriter for ObjectStorageAdapter {
async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError> {
validate_key(key)?;
let path = self.path(key);
let mut upload = self
.store
.put_multipart(&path)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let mut stream = data;
while let Some(result) = stream.next().await {
match result {
Ok(bytes) => {
if let Err(e) = upload.put_part(bytes.into()).await {
let _ = upload.abort().await;
return Err(DomainError::Internal(e.to_string()));
}
}
Err(e) => {
let _ = upload.abort().await;
return Err(e);
}
}
}
upload.complete().await.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
validate_key(key)?;
let path = self.path(key);
match self.store.delete(&path).await {
Ok(()) => Ok(()),
Err(OsError::NotFound { .. }) => Ok(()),
Err(e) => Err(DomainError::Internal(e.to_string())),
}
}
}
#[async_trait]
impl StorageReader for ObjectStorageAdapter {
async fn get(&self, key: &str) -> Result<DataStream, DomainError> {
validate_key(key)?;
let path = self.path(key);
let result = self
.store
.get(&path)
.await
.map_err(|e| map_err(e, key))?;
let s = result
.into_stream()
.map(|r| r.map_err(|e| DomainError::Internal(e.to_string())));
Ok(Box::pin(s))
}
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, DomainError> {
if let Some(p) = prefix {
validate_key(p)?;
}
let list_prefix = match (prefix, self.prefix.is_empty()) {
(Some(p), false) => Some(Path::from(format!("{}/{p}", self.prefix))),
(Some(p), true) => Some(Path::from(p)),
(None, false) => Some(Path::from(self.prefix.as_str())),
(None, true) => None,
};
let mut result = Vec::new();
let mut stream = self.store.list(list_prefix.as_ref());
while let Some(meta) = stream.next().await {
let meta = meta.map_err(|e| DomainError::Internal(e.to_string()))?;
let key = meta.location.to_string();
let stripped = if !self.prefix.is_empty() {
key.strip_prefix(&format!("{}/", self.prefix))
.ok_or_else(|| DomainError::Internal(format!(
"listed key '{key}' does not start with expected prefix '{}'",
self.prefix
)))?
.to_string()
} else {
key
};
result.push(stripped);
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::{StorageReader, StorageWriter};
use futures::stream;
use object_store::memory::InMemory;
fn make_adapter() -> ObjectStorageAdapter {
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap()
}
fn one_shot(data: &'static [u8]) -> DataStream {
Box::pin(stream::once(async move { Ok(Bytes::from(data)) }))
}
#[tokio::test]
async fn put_get_roundtrip() {
let a = make_adapter();
a.put("hello.txt", one_shot(b"world")).await.unwrap();
let mut s = a.get("hello.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"world");
}
#[tokio::test]
async fn get_missing_is_not_found() {
let a = make_adapter();
assert!(matches!(a.get("nope.txt").await, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn delete_is_idempotent() {
let a = make_adapter();
a.delete("nope.txt").await.unwrap();
}
#[tokio::test]
async fn delete_removes_key() {
let a = make_adapter();
a.put("file.txt", one_shot(b"data")).await.unwrap();
a.delete("file.txt").await.unwrap();
assert!(matches!(a.get("file.txt").await, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn list_returns_keys_under_prefix() {
let a = make_adapter();
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
a.put("docs/guide.txt", one_shot(b"y")).await.unwrap();
a.put("other/file.txt", one_shot(b"z")).await.unwrap();
let keys = a.list(Some("docs")).await.unwrap();
assert_eq!(keys.len(), 2);
assert!(keys.iter().any(|k| k.ends_with("readme.txt")));
assert!(keys.iter().any(|k| k.ends_with("guide.txt")));
}
#[tokio::test]
async fn list_none_returns_all() {
let a = make_adapter();
a.put("a.txt", one_shot(b"1")).await.unwrap();
a.put("b.txt", one_shot(b"2")).await.unwrap();
let keys = a.list(None).await.unwrap();
assert_eq!(keys.len(), 2);
}
#[tokio::test]
async fn rejects_empty_key() {
let a = make_adapter();
assert!(matches!(a.put("", one_shot(b"x")).await, Err(DomainError::Validation(_))));
assert!(matches!(a.get("").await, Err(DomainError::Validation(_))));
assert!(matches!(a.delete("").await, Err(DomainError::Validation(_))));
}
#[tokio::test]
async fn rejects_absolute_key() {
let a = make_adapter();
assert!(matches!(
a.put("/etc/passwd", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_path_traversal() {
let a = make_adapter();
assert!(matches!(a.get("../escape").await, Err(DomainError::Validation(_))));
assert!(matches!(a.get("a/../../../etc").await, Err(DomainError::Validation(_))));
}
#[tokio::test]
async fn rejects_dot_segment() {
let a = make_adapter();
assert!(matches!(
a.put("./file.txt", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_invalid_list_prefix() {
let a = make_adapter();
assert!(matches!(a.list(Some("")).await, Err(DomainError::Validation(_))));
assert!(matches!(a.list(Some("../escape")).await, Err(DomainError::Validation(_))));
}
#[tokio::test]
async fn put_overwrites_existing() {
let a = make_adapter();
a.put("file.txt", one_shot(b"version1")).await.unwrap();
a.put("file.txt", one_shot(b"version2")).await.unwrap();
let mut s = a.get("file.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"version2");
}
#[tokio::test]
async fn list_returns_exact_key_paths() {
let a = make_adapter();
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
let mut keys = a.list(Some("docs")).await.unwrap();
keys.sort();
assert_eq!(keys, vec!["docs/readme.txt"]);
}
#[tokio::test]
async fn put_bytes_get_bytes_roundtrip() {
let a = make_adapter();
a.put_bytes("data.bin", Bytes::from("hello bytes")).await.unwrap();
let got = a.get_bytes("data.bin").await.unwrap();
assert_eq!(got.as_ref(), b"hello bytes");
}
#[tokio::test]
async fn get_bytes_missing_is_not_found() {
let a = make_adapter();
assert!(matches!(a.get_bytes("nope.bin").await, Err(DomainError::NotFound(_))));
}
#[test]
fn new_rejects_traversal_prefix() {
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil");
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[test]
fn new_rejects_absolute_prefix() {
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root");
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[test]
fn new_accepts_empty_prefix() {
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok());
}
#[test]
fn new_accepts_valid_prefix() {
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "my-bucket/data").is_ok());
}
}

View File

@@ -0,0 +1,90 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use object_store::ObjectStore;
use object_store::local::LocalFileSystem;
/// All storage configuration. Populate once via `from_env()` and pass
/// explicitly to `build_store` and `ObjectStorageAdapter::new`.
#[derive(Debug, Clone)]
pub struct StorageConfig {
pub backend: String,
pub prefix: String,
// local backend:
pub local_path: Option<String>,
// s3/minio backend:
pub s3_endpoint: Option<String>,
pub s3_access_key_id: Option<String>,
pub s3_secret_access_key: Option<String>,
pub s3_bucket: Option<String>,
pub s3_region: Option<String>,
// gcs backend:
pub gcs_bucket: Option<String>,
}
impl StorageConfig {
pub fn from_env() -> Result<Self> {
Ok(Self {
backend: std::env::var("STORAGE_BACKEND")
.context("STORAGE_BACKEND must be set (local, s3, gcs)")?,
prefix: std::env::var("STORAGE_PREFIX").unwrap_or_default(),
local_path: std::env::var("STORAGE_PATH").ok(),
s3_endpoint: std::env::var("S3_ENDPOINT").ok(),
s3_access_key_id: std::env::var("S3_ACCESS_KEY_ID").ok(),
s3_secret_access_key: std::env::var("S3_SECRET_ACCESS_KEY").ok(),
s3_bucket: std::env::var("S3_BUCKET").ok(),
s3_region: std::env::var("S3_REGION").ok(),
gcs_bucket: std::env::var("GCS_BUCKET").ok(),
})
}
}
pub fn build_store(config: &StorageConfig) -> Result<Arc<dyn ObjectStore>> {
match config.backend.as_str() {
"local" => {
let path = config.local_path.as_deref()
.context("STORAGE_PATH must be set when STORAGE_BACKEND=local")?;
std::fs::create_dir_all(path)
.with_context(|| format!("failed to create storage dir: {path}"))?;
let store = LocalFileSystem::new_with_prefix(path)?;
Ok(Arc::new(store))
}
#[cfg(feature = "s3")]
"s3" => {
use object_store::aws::AmazonS3Builder;
let store = AmazonS3Builder::new()
.with_endpoint(
config.s3_endpoint.as_deref().context("S3_ENDPOINT must be set")?,
)
.with_access_key_id(
config.s3_access_key_id.as_deref()
.context("S3_ACCESS_KEY_ID must be set")?,
)
.with_secret_access_key(
config.s3_secret_access_key.as_deref()
.context("S3_SECRET_ACCESS_KEY must be set")?,
)
.with_bucket_name(
config.s3_bucket.as_deref().context("S3_BUCKET must be set")?,
)
.with_region(config.s3_region.as_deref().unwrap_or("us-east-1"))
.with_allow_http(true)
.build()?;
Ok(Arc::new(store))
}
#[cfg(feature = "gcs")]
"gcs" => {
use object_store::gcp::GoogleCloudStorageBuilder;
let store = GoogleCloudStorageBuilder::new()
.with_bucket_name(
config.gcs_bucket.as_deref().context("GCS_BUCKET must be set")?,
)
.build()?;
Ok(Arc::new(store))
}
other => anyhow::bail!(
"unknown STORAGE_BACKEND={other:?}; compiled features: local{}{}",
if cfg!(feature = "s3") { ", s3" } else { "" },
if cfg!(feature = "gcs") { ", gcs" } else { "" },
),
}
}

View File

@@ -0,0 +1,5 @@
pub mod adapter;
pub mod config;
pub use adapter::ObjectStorageAdapter;
pub use config::{build_store, StorageConfig};

View File

@@ -0,0 +1,11 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
utoipa = { workspace = true }

View File

@@ -0,0 +1,2 @@
pub mod requests;
pub mod responses;

View File

@@ -0,0 +1,11 @@
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}

View File

@@ -0,0 +1,27 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub role: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AuthResponse {
pub token: String,
pub user: UserResponse,
}
impl UserResponse {
pub fn from_domain(user: &domain::entities::User) -> Self {
Self {
id: *user.id.as_uuid(),
email: user.email.to_string(),
role: user.role.to_string(),
created_at: user.created_at,
}
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,2 @@
pub mod testing;
pub mod use_cases;

View File

@@ -0,0 +1,79 @@
use std::collections::HashMap;
use async_trait::async_trait;
use tokio::sync::Mutex;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, TokenIssuer, UserRepository},
value_objects::{Email, PasswordHash, Role, UserId},
};
pub struct InMemoryUserRepository {
users: Mutex<HashMap<String, User>>,
}
impl InMemoryUserRepository {
pub fn new() -> Self {
Self { users: Mutex::new(HashMap::new()) }
}
pub async fn all(&self) -> Vec<User> {
self.users.lock().await.values().cloned().collect()
}
}
impl Default for InMemoryUserRepository {
fn default() -> Self { Self::new() }
}
#[async_trait]
impl UserRepository for InMemoryUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
Ok(self.users.lock().await.get(&id.to_string()).cloned())
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
Ok(self.users.lock().await.values()
.find(|u| u.email.as_str() == email.as_str())
.cloned())
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
self.users.lock().await.insert(user.id.to_string(), user.clone());
Ok(())
}
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
self.users.lock().await.remove(&id.to_string());
Ok(())
}
}
pub struct StubPasswordHasher;
#[async_trait]
impl PasswordHasher for StubPasswordHasher {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError> {
Ok(PasswordHash::from_hash(format!("hashed:{password}")))
}
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
Ok(hash.as_str() == format!("hashed:{password}"))
}
}
pub struct StubTokenIssuer;
#[async_trait]
impl TokenIssuer for StubTokenIssuer {
async fn issue(&self, user_id: &UserId, _role: &Role) -> Result<String, DomainError> {
Ok(format!("token:{user_id}"))
}
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
let id_str = token.strip_prefix("token:").ok_or_else(|| {
DomainError::Unauthorized("Invalid stub token".to_string())
})?;
let uuid = uuid::Uuid::parse_str(id_str)
.map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?;
Ok((UserId::from_uuid(uuid), Role::User))
}
}

View File

@@ -0,0 +1,40 @@
use std::sync::Arc;
use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId};
pub struct GetProfile {
repo: Arc<dyn UserRepository>,
}
impl GetProfile {
pub fn new(repo: Arc<dyn UserRepository>) -> Self { Self { repo } }
pub async fn execute(&self, user_id: &UserId) -> Result<User, DomainError> {
self.repo.find_by_id(user_id).await?
.ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{InMemoryUserRepository, StubPasswordHasher};
use crate::use_cases::register::RegisterUser;
#[tokio::test]
async fn get_profile_returns_existing_user() {
let repo = Arc::new(InMemoryUserRepository::new());
let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
let user = r.execute("user@example.com", "password123").await.unwrap();
let uc = GetProfile::new(repo);
let found = uc.execute(&user.id).await.unwrap();
assert_eq!(found.id, user.id);
}
#[tokio::test]
async fn get_profile_returns_not_found() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = GetProfile::new(repo);
let result = uc.execute(&UserId::new()).await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
}

View File

@@ -0,0 +1,74 @@
use std::sync::Arc;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, TokenIssuer, UserRepository},
value_objects::Email,
};
pub struct LoginUser {
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
issuer: Arc<dyn TokenIssuer>,
}
impl LoginUser {
pub fn new(
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
issuer: Arc<dyn TokenIssuer>,
) -> Self {
Self { repo, hasher, issuer }
}
pub async fn execute(&self, email: &str, password: &str) -> Result<(User, String), DomainError> {
let email = Email::new(email)?;
let user = self.repo.find_by_email(&email).await?
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".to_string()))?;
let valid = self.hasher.verify(password, &user.password_hash).await?;
if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
}
let token = self.issuer.issue(&user.id, &user.role).await?;
Ok((user, token))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{InMemoryUserRepository, StubPasswordHasher, StubTokenIssuer};
use crate::use_cases::register::RegisterUser;
async fn seeded_repo() -> Arc<InMemoryUserRepository> {
let repo = Arc::new(InMemoryUserRepository::new());
let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
r.execute("user@example.com", "password123").await.unwrap();
repo
}
#[tokio::test]
async fn login_returns_user_and_token() {
let repo = seeded_repo().await;
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
let (user, token) = uc.execute("user@example.com", "password123").await.unwrap();
assert_eq!(user.email.as_str(), "user@example.com");
assert!(token.starts_with("token:"));
}
#[tokio::test]
async fn login_rejects_wrong_password() {
let repo = seeded_repo().await;
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
let result = uc.execute("user@example.com", "wrongpassword").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}
#[tokio::test]
async fn login_rejects_unknown_email() {
let repo = seeded_repo().await;
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
let result = uc.execute("nobody@example.com", "password123").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}
}

View File

@@ -0,0 +1,7 @@
pub mod get_profile;
pub mod login;
pub mod register;
pub use get_profile::GetProfile;
pub use login::LoginUser;
pub use register::RegisterUser;

View File

@@ -0,0 +1,72 @@
use std::sync::Arc;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, UserRepository},
value_objects::{Email, UserId},
};
pub struct RegisterUser {
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
}
impl RegisterUser {
pub fn new(repo: Arc<dyn UserRepository>, hasher: Arc<dyn PasswordHasher>) -> Self {
Self { repo, hasher }
}
pub async fn execute(&self, email: &str, password: &str) -> Result<User, DomainError> {
if password.len() < 8 {
return Err(DomainError::Validation("Password must be at least 8 characters".to_string()));
}
let email = Email::new(email)?;
if self.repo.find_by_email(&email).await?.is_some() {
return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str())));
}
let hash = self.hasher.hash(password).await?;
let user = User::new(UserId::new(), email, hash);
self.repo.save(&user).await?;
Ok(user)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{InMemoryUserRepository, StubPasswordHasher};
#[tokio::test]
async fn register_creates_user() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
let user = uc.execute("test@example.com", "password123").await.unwrap();
assert_eq!(user.email.as_str(), "test@example.com");
assert_eq!(repo.all().await.len(), 1);
}
#[tokio::test]
async fn register_rejects_duplicate_email() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
uc.execute("test@example.com", "password123").await.unwrap();
let result = uc.execute("test@example.com", "different1").await;
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
#[tokio::test]
async fn register_rejects_short_password() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
let result = uc.execute("test@example.com", "short").await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[tokio::test]
async fn register_rejects_invalid_email() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
let result = uc.execute("notanemail", "password123").await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}
}

View File

@@ -0,0 +1,28 @@
[package]
name = "bootstrap"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "k_photos"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
application = { workspace = true }
adapters-auth = { workspace = true }
adapters-storage = { workspace = true, features = ["s3"] }
presentation = { workspace = true }
adapters-postgres = { path = "../adapters/postgres" }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
tower-http = { workspace = true }
axum = { workspace = true }

View File

@@ -0,0 +1,28 @@
#[derive(Debug, Clone)]
pub struct Config {
pub host: String,
pub port: u16,
pub database_url: String,
pub jwt_secret: String,
pub cors_allowed_origins: Vec<String>,
}
impl Config {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
port: std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000),
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:3000".to_string())
.split(',')
.map(|s| s.trim().to_string())
.collect(),
}
}
}

View File

@@ -0,0 +1,58 @@
use std::sync::Arc;
use anyhow::Result;
use axum::Router;
use axum::http::HeaderValue;
use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer};
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
use adapters_postgres::{connect, run_migrations, PostgresUserRepository};
use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store};
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
use presentation::{routes::app_router, state::AppState};
use crate::config::Config;
pub async fn build_app(config: &Config) -> Result<Router> {
let pool = connect(&config.database_url).await?;
run_migrations(&pool).await?;
let user_repo = Arc::new(PostgresUserRepository::new(pool));
let hasher = Arc::new(BcryptPasswordHasher);
let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret));
let register_uc = Arc::new(RegisterUser::new(user_repo.clone(), hasher.clone()));
let login_uc = Arc::new(LoginUser::new(user_repo.clone(), hasher, issuer.clone()));
let get_profile_uc = Arc::new(GetProfile::new(user_repo));
let storage_cfg = StorageConfig::from_env()?;
let store = build_store(&storage_cfg)?;
// To inject storage into a use case, clone it into the constructor:
// let my_uc = Arc::new(MyUseCase::new(repo, storage.clone()));
let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?);
let state = AppState::new(register_uc, login_uc, get_profile_uc, issuer, storage);
let cors = CorsLayer::new()
.allow_origin(
config.cors_allowed_origins.iter()
.filter_map(|o| o.parse::<HeaderValue>().ok())
.collect::<Vec<_>>(),
)
.allow_methods(Any)
.allow_headers(Any);
Ok(app_router()
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(cors))
}

View File

View File

@@ -0,0 +1,28 @@
use std::net::SocketAddr;
use tracing::info;
mod config;
mod factory;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("bootstrap=info".parse()?)
.add_directive("tower_http=debug".parse()?),
)
.init();
let config = config::Config::from_env();
let app = factory::build_app(&config).await?;
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("🚀 Server running at http://{addr}");
info!("📖 Scalar docs at http://{addr}/scalar");
axum::serve(listener, app).await?;
Ok(())
}

13
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "domain"
version = "0.1.0"
edition = "2024"
[dependencies]
uuid = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }

View File

@@ -0,0 +1,2 @@
mod user;
pub use user::User;

View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{Email, PasswordHash, Role, UserId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: UserId,
pub email: Email,
pub password_hash: PasswordHash,
pub role: Role,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash, role: Role::User, created_at: Utc::now() }
}
}

View File

@@ -0,0 +1,13 @@
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal error: {0}")]
Internal(String),
}

View File

@@ -0,0 +1,7 @@
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum DomainEvent {
UserRegistered { user_id: Uuid },
UserLoggedIn { user_id: Uuid },
}

5
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod entities;
pub mod errors;
pub mod events;
pub mod ports;
pub mod value_objects;

View File

@@ -0,0 +1,14 @@
use async_trait::async_trait;
use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}};
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait TokenIssuer: Send + Sync {
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError>;
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>;
}

View File

@@ -0,0 +1,7 @@
mod auth;
mod storage;
mod user_repo;
pub use auth::{PasswordHasher, TokenIssuer};
pub use storage::{DataStream, StoragePort, StorageReader, StorageWriter};
pub use user_repo::UserRepository;

View File

@@ -0,0 +1,52 @@
use async_trait::async_trait;
use bytes::Bytes;
use futures::stream::{self, BoxStream, StreamExt};
use crate::errors::DomainError;
pub type DataStream = BoxStream<'static, Result<Bytes, DomainError>>;
/// Read operations on object storage. Keys are full paths relative to the adapter root.
#[async_trait]
pub trait StorageReader: Send + Sync {
/// Returns the content of `key` as a stream. Returns `DomainError::NotFound` if absent.
async fn get(&self, key: &str) -> Result<DataStream, DomainError>;
/// Lists all keys whose path begins with `prefix`, or all keys when `prefix` is `None`.
/// Returned keys are **full paths from the adapter root**, not relative to `prefix`.
/// Example: `list(Some("docs"))` returns `["docs/readme.txt"]`, not `["readme.txt"]`.
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, DomainError>;
/// Convenience: reads the entire content of `key` into memory. Wraps `get`.
async fn get_bytes(&self, key: &str) -> Result<Bytes, DomainError> {
let mut stream = self.get(key).await?;
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
buf.extend_from_slice(&chunk?);
}
Ok(Bytes::from(buf))
}
}
/// Write operations on object storage.
#[async_trait]
pub trait StorageWriter: Send + Sync {
/// Stores `data` at `key`. Overwrites any existing content at that key silently.
async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>;
/// Deletes `key`. Returns `Ok(())` even if the key does not exist (idempotent).
async fn delete(&self, key: &str) -> Result<(), DomainError>;
/// Convenience: stores an in-memory buffer at `key`. Wraps `put`.
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), DomainError> {
self.put(key, Box::pin(stream::once(async move { Ok(data) }))).await
}
}
/// Combined read + write storage interface.
///
/// **Usage note:** `Arc<dyn StoragePort>` is the intended DI type everywhere.
/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not
/// support narrowing `Arc<dyn StoragePort>` to `Arc<dyn StorageReader>` at runtime.
/// Inject `Arc<dyn StoragePort>` into constructors and pass `.clone()` from the factory.
pub trait StoragePort: StorageReader + StorageWriter {}
impl<T: StorageReader + StorageWriter> StoragePort for T {}

View File

@@ -0,0 +1,10 @@
use async_trait::async_trait;
use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}};
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn delete(&self, id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,42 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Email(String);
impl Email {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let value = value.into().trim().to_lowercase();
if value.is_empty() || !value.contains('@') {
return Err(DomainError::Validation("Invalid email address".to_string()));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl std::fmt::Display for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() { assert!(Email::new("").is_err()); }
#[test]
fn rejects_no_at() { assert!(Email::new("notanemail").is_err()); }
#[test]
fn accepts_valid() { assert!(Email::new("user@example.com").is_ok()); }
#[test]
fn lowercases_and_trims() {
let email = Email::new(" User@Example.Com ").unwrap();
assert_eq!(email.as_str(), "user@example.com");
}
}

View File

@@ -0,0 +1,9 @@
mod email;
mod password;
mod role;
mod user_id;
pub use email::Email;
pub use password::PasswordHash;
pub use role::Role;
pub use user_id::UserId;

View File

@@ -0,0 +1,14 @@
// Manual Debug — redacts hash to prevent it appearing in logs
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct PasswordHash(String);
impl std::fmt::Debug for PasswordHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("PasswordHash").field(&"[redacted]").finish()
}
}
impl PasswordHash {
pub fn from_hash(hash: String) -> Self { Self(hash) }
pub fn as_str(&self) -> &str { &self.0 }
}

View File

@@ -0,0 +1,23 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role { User, Admin }
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::User => write!(f, "user"),
Role::Admin => write!(f, "admin"),
}
}
}
impl std::str::FromStr for Role {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(Role::User),
"admin" => Ok(Role::Admin),
other => Err(format!("Unknown role: {other}")),
}
}
}

View File

@@ -0,0 +1,22 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct UserId(uuid::Uuid);
impl UserId {
pub fn new() -> Self { Self(uuid::Uuid::new_v4()) }
pub fn from_uuid(id: uuid::Uuid) -> Self { Self(id) }
pub fn as_uuid(&self) -> &uuid::Uuid { &self.0 }
}
impl Default for UserId {
fn default() -> Self { Self::new() }
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<uuid::Uuid> for UserId {
fn from(id: uuid::Uuid) -> Self { Self(id) }
}

View File

@@ -0,0 +1,19 @@
[package]
name = "presentation"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
application = { workspace = true }
api-types = { path = "../api-types" }
axum = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
utoipa = { workspace = true }
utoipa-scalar = { workspace = true }

View File

@@ -0,0 +1,25 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use domain::errors::DomainError;
use serde_json::json;
pub struct AppError(DomainError);
impl From<DomainError> for AppError {
fn from(e: DomainError) -> Self { Self(e) }
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self.0 {
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
DomainError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
DomainError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
DomainError::Internal(msg) => {
tracing::error!("Internal error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}

View File

@@ -0,0 +1,38 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json,
};
use domain::value_objects::{Role, UserId};
use serde_json::json;
use crate::state::AppState;
pub struct JwtClaims {
pub user_id: UserId,
pub role: Role,
}
impl FromRequestParts<AppState> for JwtClaims {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Missing Authorization header" }))).into_response()
})?;
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid Authorization format" }))).into_response()
})?;
let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid or expired token" }))).into_response()
})?;
Ok(JwtClaims { user_id, role })
}
}

View File

@@ -0,0 +1,28 @@
use axum::{
extract::{rejection::JsonRejection, FromRequest, Request},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::de::DeserializeOwned;
use serde_json::json;
pub struct ValidatedJson<T>(pub T);
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
type Rejection = Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
Json::<T>::from_request(req, state)
.await
.map(|Json(value)| ValidatedJson(value))
.map_err(|rejection| {
(StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": rejection.body_text() }))).into_response()
})
}
}

View File

@@ -0,0 +1,5 @@
pub mod auth;
pub mod json;
pub use auth::JwtClaims;
pub use json::ValidatedJson;

View File

@@ -0,0 +1,56 @@
use axum::{extract::State, http::StatusCode, Json};
use api_types::{
requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, UserResponse},
};
use crate::{errors::AppError, extractors::{JwtClaims, ValidatedJson}, state::AppState};
#[utoipa::path(
post, path = "/api/v1/auth/register",
request_body = RegisterRequest,
responses(
(status = 201, description = "User registered", body = AuthResponse),
(status = 409, description = "Email already taken"),
(status = 422, description = "Validation error")
)
)]
pub async fn register(
State(state): State<AppState>,
ValidatedJson(req): ValidatedJson<RegisterRequest>,
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
let user = state.register_uc.execute(&req.email, &req.password).await?;
let token = state.token_issuer.issue(&user.id, &user.role).await.map_err(AppError::from)?;
Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) })))
}
#[utoipa::path(
post, path = "/api/v1/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = AuthResponse),
(status = 401, description = "Invalid credentials")
)
)]
pub async fn login(
State(state): State<AppState>,
ValidatedJson(req): ValidatedJson<LoginRequest>,
) -> Result<Json<AuthResponse>, AppError> {
let (user, token) = state.login_uc.execute(&req.email, &req.password).await?;
Ok(Json(AuthResponse { token, user: UserResponse::from_domain(&user) }))
}
#[utoipa::path(
get, path = "/api/v1/auth/me",
security(("bearer_token" = [])),
responses(
(status = 200, description = "Current user profile", body = UserResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn me(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<UserResponse>, AppError> {
let user = state.get_profile_uc.execute(&claims.user_id).await?;
Ok(Json(UserResponse::from_domain(&user)))
}

View File

@@ -0,0 +1,7 @@
use axum::{http::StatusCode, Json};
use serde_json::json;
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service is healthy")))]
pub async fn health() -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}

View File

@@ -0,0 +1,2 @@
pub mod auth;
pub mod health;

View File

@@ -0,0 +1,27 @@
// Example: stream a stored file as an HTTP response.
// Remove this file or replace with your own handlers.
//
// To use, add to your router:
// .route("/files/*key", get(storage_example::get_file))
//
// use axum::{
// body::Body,
// extract::{Path, State},
// http::StatusCode,
// response::IntoResponse,
// };
// use futures::StreamExt;
// use crate::state::AppState;
//
// pub async fn get_file(
// Path(key): Path<String>,
// State(state): State<AppState>,
// ) -> Result<impl IntoResponse, StatusCode> {
// let stream = state
// .storage
// .get(&key)
// .await
// .map_err(|_| StatusCode::NOT_FOUND)?;
// let body = Body::from_stream(stream.map(|r| r.map_err(|e| e.to_string())));
// Ok(body)
// }

View File

@@ -0,0 +1,6 @@
pub mod errors;
pub mod extractors;
pub mod handlers;
pub mod openapi;
pub mod routes;
pub mod state;

View File

@@ -0,0 +1,41 @@
use utoipa::{openapi::security::{Http, HttpAuthScheme, SecurityScheme}, Modify, OpenApi};
use utoipa_scalar::{Scalar, Servable};
use axum::Router;
use crate::state::AppState;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::health::health,
crate::handlers::auth::register,
crate::handlers::auth::login,
crate::handlers::auth::me,
),
components(schemas(
api_types::requests::RegisterRequest,
api_types::requests::LoginRequest,
api_types::responses::AuthResponse,
api_types::responses::UserResponse,
)),
modifiers(&SecurityAddon),
info(title = "k-template", version = "0.1.0")
)]
pub struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer_token",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
}
pub fn openapi_router() -> Router<AppState> {
Router::new()
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.route("/api-docs/openapi.json", axum::routing::get(|| async { axum::Json(ApiDoc::openapi()) }))
}

View File

@@ -0,0 +1,16 @@
use axum::{routing::{get, post}, Router};
use crate::{handlers::{auth, health}, openapi::openapi_router, state::AppState};
pub fn api_v1_router() -> Router<AppState> {
Router::new()
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/me", get(auth::me))
}
pub fn app_router() -> Router<AppState> {
Router::new()
.route("/health", get(health::health))
.nest("/api/v1", api_v1_router())
.merge(openapi_router())
}

View File

@@ -0,0 +1,26 @@
use std::sync::Arc;
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
use domain::ports::{StoragePort, TokenIssuer};
#[derive(Clone)]
pub struct AppState {
pub register_uc: Arc<RegisterUser>,
pub login_uc: Arc<LoginUser>,
pub get_profile_uc: Arc<GetProfile>,
pub token_issuer: Arc<dyn TokenIssuer>,
pub storage: Arc<dyn StoragePort>,
}
impl AppState {
pub fn new(
register_uc: Arc<RegisterUser>,
login_uc: Arc<LoginUser>,
get_profile_uc: Arc<GetProfile>,
token_issuer: Arc<dyn TokenIssuer>,
storage: Arc<dyn StoragePort>,
) -> Self {
Self { register_uc, login_uc, get_profile_uc, token_issuer, storage }
}
}

21
crates/worker/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "worker"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "k_photos-worker"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
adapters-postgres = { path = "../adapters/postgres" }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,18 @@
#[derive(Debug, Clone)]
pub struct WorkerConfig {
pub database_url: String,
pub example_job_interval_secs: u64,
}
impl WorkerConfig {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
example_job_interval_secs: std::env::var("EXAMPLE_JOB_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60),
}
}
}

7
crates/worker/src/job.rs Normal file
View File

@@ -0,0 +1,7 @@
use async_trait::async_trait;
#[async_trait]
pub trait Job: Send + Sync {
fn name(&self) -> &str;
async fn run(&self) -> anyhow::Result<()>;
}

View File

@@ -0,0 +1,14 @@
use async_trait::async_trait;
use tracing::info;
use crate::job::Job;
pub struct ExampleJob;
#[async_trait]
impl Job for ExampleJob {
fn name(&self) -> &str { "example" }
async fn run(&self) -> anyhow::Result<()> {
info!("example job ran — replace with real work");
Ok(())
}
}

View File

@@ -0,0 +1,2 @@
pub mod example;
pub use example::ExampleJob;

34
crates/worker/src/main.rs Normal file
View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use std::time::Duration;
use tracing::info;
mod config;
mod job;
mod jobs;
mod runner;
use jobs::ExampleJob;
use runner::JobRunner;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("worker=info".parse()?),
)
.init();
let config = config::WorkerConfig::from_env();
info!("Worker starting");
let _pool = adapters_sqlite::connect(&config.database_url).await?;
adapters_sqlite::run_migrations(&_pool).await?;
let interval = Duration::from_secs(config.example_job_interval_secs);
let runner = JobRunner::new().register(Arc::new(ExampleJob), interval);
info!("Worker running");
runner.run().await;
Ok(())
}

View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, info};
use crate::job::Job;
pub struct JobRunner {
jobs: Vec<(Arc<dyn Job>, Duration)>,
}
impl JobRunner {
pub fn new() -> Self { Self { jobs: vec![] } }
pub fn register(mut self, job: Arc<dyn Job>, interval: Duration) -> Self {
self.jobs.push((job, interval));
self
}
pub async fn run(self) {
let handles: Vec<_> = self.jobs.into_iter().map(|(job, interval)| {
tokio::spawn(async move {
loop {
info!(job = job.name(), "running job");
if let Err(e) = job.run().await {
error!(job = job.name(), error = %e, "job failed");
}
tokio::time::sleep(interval).await;
}
})
}).collect();
for handle in handles { let _ = handle.await; }
}
}
impl Default for JobRunner { fn default() -> Self { Self::new() } }