This commit is contained in:
2026-01-02 05:27:44 +01:00
commit 8dfb3c6922
30 changed files with 7394 additions and 0 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
/target

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

5536
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["template-domain", "template-infra", "template-api"]
resolver = "2"

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# k-template
A production-ready, modular Rust template for K-Suite applications, following Hexagonal Architecture principles.
## 🌟 Features
- **Hexagonal Architecture**: Clear separation of concerns between Domain, Infrastructure, and API layers.
- **Modular & Swappable**: Vendor implementations (databases, message brokers) are behind feature flags and trait objects.
- **Feature-Gated Dependencies**: Compile only what you need. Unused dependencies are not included in the build.
- **Cargo Generate Ready**: Pre-configured for `cargo-generate` to easily scaffold new services.
- **Testable**: Domain logic is pure and easily testable; Infrastructure is tested with integration tests.
## 🏗️ Project Structure
The workspace consists of three main crates:
- **`template-domain`**: The core business logic.
- Contains Entities, Value Objects, Repository Interfaces (Ports), and Services.
- **Dependencies**: Pure Rust only (no I/O, no heavy frameworks).
- **`template-infra`**: The adapters layer.
- Implements the Repository interfaces defined in `template-domain`.
- Content is heavily feature-gated (e.g., `sqlite`, `postgres`, `broker-nats`).
- **`template-api`**: The application entry point (Driving Adapter).
- Wires everything together using dependency injection.
- Handles HTTP/REST/gRPC interfaces.
## 🚀 Getting Started
### Prerequisites
- Rust (latest stable)
- `cargo-generate` (`cargo install cargo-generate`)
### Creating a New Project
Use `cargo-generate` to scaffold a new project from this template:
```bash
cargo generate --git https://github.com/your-org/k-template.git
```
You will be prompted for:
1. **Project Name**: The name of your new service.
2. **Database**: Choose between `sqlite` (default) or `postgres`.
The template will automatically clean up unused repository implementations based on your choice.
### Running Tests
```bash
# Run all tests
cargo test
# Run tests for a specific feature (e.g., postgres)
cargo test -p template-infra --no-default-features --features postgres
```
## ⚙️ Configuration & Feature Flags
This template uses Cargo features to control compilation of infrastructure adapters.
| Feature | Description | Crate |
|---------|-------------|-------|
| `sqlite` | Enables SQLite repository implementations and dependencies | `template-infra`, `template-api` |
| `postgres` | Enables PostgreSQL repository implementations and dependencies | `template-infra`, `template-api` |
| `broker-nats`| Enables NATS messaging support | `template-infra` |
| `smart-features` | Enables AI/Vector DB capabilities (Qdrant, FastEmbed) | `template-infra` |
### Switching Databases
To switch from the default SQLite to PostgreSQL in an existing project, update `Cargo.toml`:
**`template-api/Cargo.toml`**:
```toml
[features]
default = ["postgres"]
# ...
```
**`template-infra/Cargo.toml`**:
```toml
[features]
default = ["postgres"]
# ...
```
## 📐 Architecture Guide
### Adding a New Feature
1. **Domain**: Define the Entity, Value Objects, and Repository Interface in `template-domain`.
2. **Infra**: Implement the Repository Interface in `template-infra`.
- **Important**: Wrap your implementation in a feature flag (e.g., `#[cfg(feature = "my-feature")]`).
3. **API**: Wire the new service in `template-api/src/main.rs` or a dedicated module.
### Vendor Isolation
All external dependencies (SQLx, NATS, etc.) should stay within `template-infra` or `template-api`. The `template-domain` crate should remain agnostic to specific technologies.

12
cargo-generate.toml Normal file
View File

@@ -0,0 +1,12 @@
[template]
cargo_generate_version = ">=0.21.0"
ignore = [".git", "target", ".idea", ".vscode"]
[placeholders]
project_name = { type = "string", prompt = "Project name" }
database = { type = "string", prompt = "Database type", choices = ["sqlite", "postgres"], default = "sqlite" }
[conditional]
# Conditional dependencies based on database choice
sqlite = { condition = "database == 'sqlite'", ignore = ["template-infra/src/user_repository_postgres.rs"] }
postgres = { condition = "database == 'postgres'", ignore = ["template-infra/src/user_repository_sqlite.rs"] }

View File

@@ -0,0 +1,11 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
subject TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_subject ON users(subject);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);

72
template-api/Cargo.toml Normal file
View File

@@ -0,0 +1,72 @@
[package]
name = "template-api"
version = "0.1.0"
edition = "2024"
default-run = "template-api"
[features]
default = ["sqlite", "smart-features"]
sqlite = [
"template-infra/sqlite",
"tower-sessions-sqlx-store/sqlite",
"sqlx/sqlite",
]
postgres = [
"template-infra/postgres",
"tower-sessions-sqlx-store/postgres",
"sqlx/postgres",
"k-core/postgres",
]
smart-features = ["template-infra/smart-features", "template-infra/broker-nats"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
] }
template-domain = { path = "../template-domain" }
template-infra = { path = "../template-infra", default-features = false, features = [
"sqlite",
] }
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
# Authentication
axum-login = "0.18"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
password-auth = "1.0"
time = "0.3"
async-trait = "0.1.89"
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Validation
validator = { version = "0.20", features = ["derive"] }
# Error handling
thiserror = "2.0.17"
anyhow = "1.0"
# Utilities
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
# Database
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }
dotenvy = "0.15.7"
# Configuration
config = "0.15.9"

101
template-api/src/auth.rs Normal file
View File

@@ -0,0 +1,101 @@
//! Authentication logic using axum-login
use std::sync::Arc;
use axum_login::{AuthnBackend, UserId};
use password_auth::verify_password;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use template_infra::session_store::InfraSessionStore;
use tower_sessions::SessionManagerLayer;
use crate::error::ApiError;
use template_domain::{User, UserRepository};
/// Wrapper around domain User to implement AuthUser
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthUser(pub User);
impl axum_login::AuthUser for AuthUser {
type Id = Uuid;
fn id(&self) -> Self::Id {
self.0.id
}
fn session_auth_hash(&self) -> &[u8] {
// Use password hash to invalidate sessions if password changes
self.0
.password_hash
.as_ref()
.map(|s| s.as_bytes())
.unwrap_or(&[])
}
}
#[derive(Clone)]
pub struct AuthBackend {
pub user_repo: Arc<dyn UserRepository>,
}
impl AuthBackend {
pub fn new(user_repo: Arc<dyn UserRepository>) -> Self {
Self { user_repo }
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
impl AuthnBackend for AuthBackend {
type User = AuthUser;
type Credentials = Credentials;
type Error = ApiError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_email(&creds.email)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
if let Some(user) = user {
if let Some(hash) = &user.password_hash {
// Verify password
if verify_password(&creds.password, hash).is_ok() {
return Ok(Some(AuthUser(user)));
}
}
}
Ok(None)
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_id(*user_id)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(user.map(AuthUser))
}
}
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
pub async fn setup_auth_layer(
session_layer: SessionManagerLayer<InfraSessionStore>,
user_repo: Arc<dyn UserRepository>,
) -> Result<axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>, ApiError> {
let backend = AuthBackend::new(user_repo);
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
Ok(auth_layer)
}

View File

@@ -0,0 +1,35 @@
//! Application Configuration
//!
//! Loads configuration from environment variables.
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub database_url: String,
pub session_secret: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_host")]
pub host: String,
}
fn default_port() -> u16 {
3000
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
impl Config {
pub fn new() -> Result<Self, config::ConfigError> {
config::Config::builder()
.add_source(config::Environment::default())
//.add_source(config::File::with_name(".env").required(false)) // Optional .env file
.build()?
.try_deserialize()
}
}

42
template-api/src/dto.rs Normal file
View File

@@ -0,0 +1,42 @@
//! Request and Response DTOs
//!
//! Data Transfer Objects for the API.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
/// Login request
#[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
}
/// Register request
#[derive(Debug, Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
}
/// User response DTO
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub created_at: DateTime<Utc>,
}
/// System configuration response
#[derive(Debug, Serialize)]
pub struct ConfigResponse {
pub allow_registration: bool,
}

121
template-api/src/error.rs Normal file
View File

@@ -0,0 +1,121 @@
//! API error handling
//!
//! Maps domain errors to HTTP responses
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
use template_domain::DomainError;
/// API-level errors
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{0}")]
Domain(#[from] DomainError),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
}
/// Error response body
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_response) = match &self {
ApiError::Domain(domain_error) => {
let status = match domain_error {
DomainError::UserNotFound(_) => StatusCode::NOT_FOUND,
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
DomainError::ValidationError(_) => StatusCode::BAD_REQUEST,
DomainError::Unauthorized(_) => StatusCode::FORBIDDEN,
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};
(
status,
ErrorResponse {
error: domain_error.to_string(),
details: None,
},
)
}
ApiError::Validation(msg) => (
StatusCode::BAD_REQUEST,
ErrorResponse {
error: "Validation error".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Internal(msg) => {
// Log internal errors but don't expose details
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse {
error: "Internal server error".to_string(),
details: None,
},
)
}
ApiError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
ErrorResponse {
error: "Forbidden".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
ErrorResponse {
error: "Unauthorized".to_string(),
details: Some(msg.clone()),
},
),
};
(status, Json(error_response)).into_response()
}
}
impl ApiError {
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
}
/// Result type alias for API handlers
pub type ApiResult<T> = Result<T, ApiError>;

93
template-api/src/main.rs Normal file
View File

@@ -0,0 +1,93 @@
use std::net::SocketAddr;
use std::time::Duration as StdDuration;
use template_domain::UserService;
use template_infra::factory::build_user_repository;
use template_infra::{db, session_store};
use k_core::logging;
use tokio::net::TcpListener;
use tower_sessions::{Expiry, SessionManagerLayer};
use tracing::info;
mod auth;
mod config;
mod dto;
mod error;
mod routes;
mod state;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Initialize logging
logging::init("template-api");
// 2. Load configuration
// We use dotenvy explicitly here since config crate might not pick up .env automatically reliably
dotenvy::dotenv().ok();
let config = Config::new().expect("Failed to load configuration");
info!("Starting server on {}:{}", config.host, config.port);
// 3. Connect to database
let db_config = db::DatabaseConfig {
url: config.database_url.clone(),
max_connections: 5,
min_connections: 1,
acquire_timeout: StdDuration::from_secs(30),
};
// We assume generic connection logic in k-core/template-infra
// But here we use k-core via template-infra
#[cfg(feature = "sqlite")]
let pool = k_core::db::connect_sqlite(&db_config.url).await?;
#[cfg(feature = "postgres")]
let pool = k_core::db::connect_postgres(&db_config.url).await?;
#[cfg(feature = "sqlite")]
let db_pool = template_infra::db::DatabasePool::Sqlite(pool.clone());
#[cfg(feature = "postgres")]
let db_pool = template_infra::db::DatabasePool::Postgres(pool.clone());
// 4. Run migrations
db::run_migrations(&db_pool).await?;
// 5. Initialize Services
let user_repo = build_user_repository(&db_pool).await?;
let user_service = UserService::new(user_repo.clone());
// 6. Setup Session Store
#[cfg(feature = "sqlite")]
let session_store = session_store::InfraSessionStore::Sqlite(
tower_sessions_sqlx_store::SqliteStore::new(pool.clone())
);
#[cfg(feature = "postgres")]
let session_store = session_store::InfraSessionStore::Postgres(
tower_sessions_sqlx_store::PostgresStore::new(pool.clone())
);
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false) // Set to true in production with HTTPS
.with_expiry(Expiry::OnInactivity(time::Duration::hours(1)));
// 7. Setup Auth
let auth_layer = auth::setup_auth_layer(session_layer, user_repo.clone()).await?;
// 8. Create App State
let state = AppState::new(user_service, config.clone());
// 9. Build Router
let app = routes::api_v1_router()
.layer(auth_layer)
.with_state(state);
// 10. Start Server
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -0,0 +1,89 @@
use axum::{
extract::{State, Json},
response::IntoResponse,
Router, routing::post,
};
use axum::http::StatusCode;
use crate::{
dto::{LoginRequest, RegisterRequest, UserResponse},
error::ApiError,
state::AppState,
};
use template_domain::{DomainError, Email};
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/register", post(register))
.route("/logout", post(logout))
.route("/me", post(me))
}
async fn login(
mut auth_session: crate::auth::AuthSession,
Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, ApiError> {
let user = match auth_session.authenticate(crate::auth::Credentials {
email: payload.email,
password: payload.password,
}).await {
Ok(Some(user)) => user,
Ok(None) => return Err(ApiError::Validation("Invalid credentials".to_string())),
Err(_) => return Err(ApiError::Internal("Authentication failed".to_string())),
};
auth_session.login(&user).await.map_err(|_| ApiError::Internal("Login failed".to_string()))?;
Ok((StatusCode::OK, Json(UserResponse {
id: user.0.id,
email: user.0.email.into_inner(),
created_at: user.0.created_at,
})))
}
async fn register(
State(state): State<AppState>,
mut auth_session: crate::auth::AuthSession,
Json(payload): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
if state.user_service.find_by_email(&payload.email).await?.is_some() {
return Err(ApiError::Domain(DomainError::UserAlreadyExists(payload.email)));
}
// Note: In a real app, you would hash the password here.
// This template uses a simplified User::new which doesn't take password.
// You should extend User to handle passwords or use an OIDC flow.
let email = Email::try_from(payload.email).map_err(|e| ApiError::Validation(e.to_string()))?;
// Using email as subject for local auth for now
let user = state.user_service.find_or_create(&email.as_ref().to_string(), email.as_ref()).await?;
// Log the user in
let auth_user = crate::auth::AuthUser(user.clone());
auth_session.login(&auth_user).await.map_err(|_| ApiError::Internal("Login failed".to_string()))?;
Ok((StatusCode::CREATED, Json(UserResponse {
id: user.id,
email: user.email.into_inner(),
created_at: user.created_at,
})))
}
async fn logout(mut auth_session: crate::auth::AuthSession) -> impl IntoResponse {
match auth_session.logout().await {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
async fn me(auth_session: crate::auth::AuthSession) -> Result<impl IntoResponse, ApiError> {
let user = auth_session.user.ok_or(ApiError::Unauthorized("Not logged in".to_string()))?;
Ok(Json(UserResponse {
id: user.0.id,
email: user.0.email.into_inner(),
created_at: user.0.created_at,
}))
}

View File

@@ -0,0 +1,13 @@
use axum::{Json, Router, routing::get};
use crate::dto::ConfigResponse;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(get_config))
}
async fn get_config() -> Json<ConfigResponse> {
Json(ConfigResponse {
allow_registration: true, // Default to true for template
})
}

View File

@@ -0,0 +1,16 @@
//! API Routes
//!
//! Defines the API endpoints and maps them to handler functions.
use crate::state::AppState;
use axum::Router;
pub mod auth;
pub mod config;
/// Construct the API v1 router
pub fn api_v1_router() -> Router<AppState> {
Router::new()
.nest("/auth", auth::router())
.nest("/config", config::router())
}

36
template-api/src/state.rs Normal file
View File

@@ -0,0 +1,36 @@
//! Application State
//!
//! Holds shared state for the application.
use axum::extract::FromRef;
use std::sync::Arc;
use crate::config::Config;
use template_domain::UserService;
#[derive(Clone)]
pub struct AppState {
pub user_service: Arc<UserService>,
pub config: Arc<Config>,
}
impl AppState {
pub fn new(user_service: UserService, config: Config) -> Self {
Self {
user_service: Arc::new(user_service),
config: Arc::new(config),
}
}
}
impl FromRef<AppState> for Arc<UserService> {
fn from_ref(input: &AppState) -> Self {
input.user_service.clone()
}
}
impl FromRef<AppState> for Arc<Config> {
fn from_ref(input: &AppState) -> Self {
input.config.clone()
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "template-domain"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.146"
thiserror = "2.0.17"
tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] }
futures-core = "0.3"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }

View File

@@ -0,0 +1,64 @@
//! Domain entities
//!
//! This module contains pure domain types with no I/O dependencies.
//! These represent the core business concepts of the application.
pub use crate::value_objects::{Email, UserId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user in the system.
///
/// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub subject: String,
pub email: Email,
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(subject: impl Into<String>, email: Email) -> Self {
Self {
id: Uuid::new_v4(),
subject: subject.into(),
email,
password_hash: None,
created_at: Utc::now(),
}
}
pub fn with_id(
id: Uuid,
subject: impl Into<String>,
email: Email,
password_hash: Option<String>,
created_at: DateTime<Utc>,
) -> Self {
Self {
id,
subject: subject.into(),
email,
password_hash,
created_at,
}
}
pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
subject: format!("local|{}", Uuid::new_v4()),
email,
password_hash: Some(password_hash.into()),
created_at: Utc::now(),
}
}
/// Helper to get email as string
pub fn email_str(&self) -> &str {
self.email.as_ref()
}
}

View File

@@ -0,0 +1,67 @@
//! Domain errors for K-Notes
//!
//! Uses `thiserror` for ergonomic error definitions.
//! These errors represent domain-level failures and will be mapped
//! to HTTP status codes in the API layer.
use thiserror::Error;
use uuid::Uuid;
/// Domain-level errors for K-Notes operations
#[derive(Debug, Error)]
pub enum DomainError {
/// The requested user was not found
#[error("User not found: {0}")]
UserNotFound(Uuid),
/// User with this email/subject already exists
#[error("User already exists: {0}")]
UserAlreadyExists(String),
/// A validation error occurred
#[error("Validation error: {0}")]
ValidationError(String),
/// User is not authorized to perform this action
#[error("Unauthorized: {0}")]
Unauthorized(String),
/// A repository/infrastructure error occurred
#[error("Repository error: {0}")]
RepositoryError(String),
/// An infrastructure adapter error occurred
#[error("Infrastructure error: {0}")]
InfrastructureError(String),
}
impl DomainError {
/// Create a validation error
pub fn validation(message: impl Into<String>) -> Self {
Self::ValidationError(message.into())
}
/// Create an unauthorized error
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::Unauthorized(message.into())
}
/// Check if this error indicates a "not found" condition
pub fn is_not_found(&self) -> bool {
matches!(self, DomainError::UserNotFound(_))
}
/// Check if this error indicates a conflict (already exists)
pub fn is_conflict(&self) -> bool {
matches!(self, DomainError::UserAlreadyExists(_))
}
}
impl From<crate::value_objects::ValidationError> for DomainError {
fn from(error: crate::value_objects::ValidationError) -> Self {
DomainError::ValidationError(error.to_string())
}
}
/// Result type alias for domain operations
pub type DomainResult<T> = Result<T, DomainError>;

View File

@@ -0,0 +1,17 @@
//! Domain Logic
//!
//! This crate contains the core business logic, entities, and repository interfaces.
//! It is completely independent of the infrastructure layer (databases, HTTP, etc.).
pub mod entities;
pub mod errors;
pub mod repositories;
pub mod services;
pub mod value_objects;
// Re-export commonly used types
pub use entities::*;
pub use errors::{DomainError, DomainResult};
pub use repositories::*;
pub use services::UserService;
pub use value_objects::*;

View File

@@ -0,0 +1,28 @@
//! Reference Repository ports (traits)
//!
//! These traits define the interface for data persistence.
use async_trait::async_trait;
use uuid::Uuid;
use crate::entities::User;
use crate::errors::DomainResult;
/// Repository port for User persistence
#[async_trait]
pub trait UserRepository: Send + Sync {
/// Find a user by their internal ID
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>>;
/// Find a user by their OIDC subject (used for authentication)
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
/// Find a user by their email
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
/// Save a new user or update an existing one
async fn save(&self, user: &User) -> DomainResult<()>;
/// Delete a user by their ID
async fn delete(&self, id: Uuid) -> DomainResult<()>;
}

View File

@@ -0,0 +1,57 @@
//! Domain Services
//!
//! Services contain the business logic of the application.
use std::sync::Arc;
use uuid::Uuid;
use crate::entities::User;
use crate::errors::{DomainError, DomainResult};
use crate::repositories::UserRepository;
use crate::value_objects::Email;
/// Service for managing users
pub struct UserService {
user_repository: Arc<dyn UserRepository>,
}
impl UserService {
pub fn new(user_repository: Arc<dyn UserRepository>) -> Self {
Self { user_repository }
}
pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult<User> {
// 1. Try to find by subject (OIDC id)
if let Some(user) = self.user_repository.find_by_subject(subject).await? {
return Ok(user);
}
// 2. Try to find by email
if let Some(mut user) = self.user_repository.find_by_email(email).await? {
// Link subject if missing (account linking logic)
if user.subject != subject {
user.subject = subject.to_string();
self.user_repository.save(&user).await?;
}
return Ok(user);
}
// 3. Create new user
let email = Email::try_from(email)?;
let user = User::new(subject, email);
self.user_repository.save(&user).await?;
Ok(user)
}
pub async fn find_by_id(&self, id: Uuid) -> DomainResult<User> {
self.user_repository
.find_by_id(id)
.await?
.ok_or(DomainError::UserNotFound(id))
}
pub async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
self.user_repository.find_by_email(email).await
}
}

View File

@@ -0,0 +1,242 @@
//! Value Objects for K-Notes Domain
//!
//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern.
//! These types can only be constructed if the input is valid, providing compile-time guarantees.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use thiserror::Error;
use uuid::Uuid;
pub type UserId = Uuid;
// ============================================================================
// Validation Error
// ============================================================================
/// Errors that occur when parsing/validating value objects
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ValidationError {
#[error("Invalid email format: {0}")]
InvalidEmail(String),
#[error("Password must be at least {min} characters, got {actual}")]
PasswordTooShort { min: usize, actual: usize },
}
// ============================================================================
// Email
// ============================================================================
/// A validated email address.
///
/// Simple validation: must contain exactly one `@` with non-empty parts on both sides.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(String);
impl Email {
/// Minimum validation: contains @ with non-empty local and domain parts
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
let trimmed = value.trim().to_lowercase();
// Basic email validation
let parts: Vec<&str> = trimmed.split('@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(ValidationError::InvalidEmail(value));
}
// Domain must contain at least one dot
if !parts[1].contains('.') {
return Err(ValidationError::InvalidEmail(value));
}
Ok(Self(trimmed))
}
/// Get the inner value
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for Email {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for Email {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Email {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl Serialize for Email {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Email {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
// ============================================================================
// Password
// ============================================================================
/// A validated password input (NOT the hash).
///
/// Enforces minimum length of 6 characters.
#[derive(Clone, PartialEq, Eq)]
pub struct Password(String);
/// Minimum password length
pub const MIN_PASSWORD_LENGTH: usize = 6;
impl Password {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.len() < MIN_PASSWORD_LENGTH {
return Err(ValidationError::PasswordTooShort {
min: MIN_PASSWORD_LENGTH,
actual: value.len(),
});
}
Ok(Self(value))
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Password {
fn as_ref(&self) -> &str {
&self.0
}
}
// Intentionally hide password content in Debug
impl fmt::Debug for Password {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Password(***)")
}
}
impl TryFrom<String> for Password {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Password {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl<'de> Deserialize<'de> for Password {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
// Note: Password should NOT implement Serialize to prevent accidental exposure
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
mod email_tests {
use super::*;
#[test]
fn test_valid_email() {
assert!(Email::new("user@example.com").is_ok());
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
}
#[test]
fn test_email_normalizes() {
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
assert_eq!(email.as_ref(), "user@example.com");
}
#[test]
fn test_invalid_email_no_at() {
assert!(Email::new("userexample.com").is_err());
}
#[test]
fn test_invalid_email_no_domain() {
assert!(Email::new("user@").is_err());
}
#[test]
fn test_invalid_email_no_local() {
assert!(Email::new("@example.com").is_err());
}
#[test]
fn test_invalid_email_no_dot_in_domain() {
assert!(Email::new("user@localhost").is_err());
}
}
mod password_tests {
use super::*;
#[test]
fn test_valid_password() {
assert!(Password::new("secret123").is_ok());
assert!(Password::new("123456").is_ok()); // Exactly 6 chars
}
#[test]
fn test_password_too_short() {
assert!(Password::new("12345").is_err()); // 5 chars
assert!(Password::new("").is_err());
}
#[test]
fn test_password_debug_hides_content() {
let password = Password::new("supersecret").unwrap();
let debug = format!("{:?}", password);
assert!(!debug.contains("supersecret"));
assert!(debug.contains("***"));
}
}
}

37
template-infra/Cargo.toml Normal file
View File

@@ -0,0 +1,37 @@
[package]
name = "template-infra"
version = "0.1.0"
edition = "2024"
[features]
default = ["sqlite", "smart-features", "broker-nats"]
sqlite = ["sqlx/sqlite", "tower-sessions-sqlx-store/sqlite"]
postgres = [
"sqlx/postgres",
"tower-sessions-sqlx-store/postgres",
"k-core/postgres",
]
smart-features = ["dep:fastembed", "dep:qdrant-client"]
broker-nats = ["dep:async-nats", "dep:futures-util"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"db-sqlx",
] }
template-domain = { path = "../template-domain" }
async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "chrono", "migrate"] }
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] }
tower-sessions = "0.14.0"
tower-sessions-sqlx-store = { version = "0.15.0", default-features = false }
fastembed = { version = "5.4", optional = true }
qdrant-client = { version = "1.16", optional = true }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
async-nats = { version = "0.45", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = "0.3"

126
template-infra/src/db.rs Normal file
View File

@@ -0,0 +1,126 @@
//! Database connection pool management
use sqlx::Pool;
#[cfg(feature = "postgres")]
use sqlx::Postgres;
#[cfg(feature = "sqlite")]
use sqlx::Sqlite;
#[cfg(feature = "sqlite")]
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
#[cfg(feature = "sqlite")]
use std::str::FromStr;
use std::time::Duration;
/// Configuration for the database connection
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
pub min_connections: u32,
pub acquire_timeout: Duration,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: "sqlite:data.db?mode=rwc".to_string(),
max_connections: 5,
min_connections: 1,
acquire_timeout: Duration::from_secs(5),
}
}
}
impl DatabaseConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
..Default::default()
}
}
pub fn in_memory() -> Self {
Self {
url: "sqlite::memory:".to_string(),
max_connections: 1, // SQLite in-memory is single-connection
min_connections: 1,
..Default::default()
}
}
}
#[derive(Clone, Debug)]
pub enum DatabasePool {
#[cfg(feature = "sqlite")]
Sqlite(Pool<Sqlite>),
#[cfg(feature = "postgres")]
Postgres(Pool<Postgres>),
}
/// Create a database connection pool
#[cfg(feature = "sqlite")]
pub async fn create_pool(config: &DatabaseConfig) -> Result<SqlitePool, sqlx::Error> {
let options = SqliteConnectOptions::from_str(&config.url)?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
.busy_timeout(Duration::from_secs(30));
let pool = SqlitePoolOptions::new()
.max_connections(config.max_connections)
.min_connections(config.min_connections)
.acquire_timeout(config.acquire_timeout)
.connect_with(options)
.await?;
Ok(pool)
}
/// Run database migrations
pub async fn run_migrations(pool: &DatabasePool) -> Result<(), sqlx::Error> {
match pool {
#[cfg(feature = "sqlite")]
DatabasePool::Sqlite(pool) => {
sqlx::migrate!("../migrations").run(pool).await?;
}
#[cfg(feature = "postgres")]
DatabasePool::Postgres(_pool) => {
// Placeholder for Postgres migrations
// sqlx::migrate!("../migrations/postgres").run(_pool).await?;
tracing::warn!("Postgres migrations not yet implemented");
return Err(sqlx::Error::Configuration(
"Postgres migrations not yet implemented".into(),
));
}
#[allow(unreachable_patterns)]
_ => {
return Err(sqlx::Error::Configuration(
"No database feature enabled".into(),
));
}
}
tracing::info!("Database migrations completed successfully");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_create_in_memory_pool() {
let config = DatabaseConfig::in_memory();
let pool = create_pool(&config).await;
assert!(pool.is_ok());
}
#[tokio::test]
async fn test_run_migrations() {
let config = DatabaseConfig::in_memory();
let pool = create_pool(&config).await.unwrap();
let db_pool = DatabasePool::Sqlite(pool);
let result = run_migrations(&db_pool).await;
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,52 @@
use std::sync::Arc;
use crate::db::DatabasePool;
#[cfg(feature = "sqlite")]
use crate::SqliteUserRepository;
use template_domain::UserRepository;
#[derive(Debug, thiserror::Error)]
pub enum FactoryError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Not implemented: {0}")]
NotImplemented(String),
#[error("Infrastructure error: {0}")]
Infrastructure(#[from] template_domain::DomainError),
}
pub type FactoryResult<T> = Result<T, FactoryError>;
pub async fn build_user_repository(pool: &DatabasePool) -> FactoryResult<Arc<dyn UserRepository>> {
match pool {
#[cfg(feature = "sqlite")]
DatabasePool::Sqlite(pool) => Ok(Arc::new(SqliteUserRepository::new(pool.clone()))),
#[cfg(feature = "postgres")]
DatabasePool::Postgres(pool) => Ok(Arc::new(crate::user_repository::PostgresUserRepository::new(pool.clone()))),
#[allow(unreachable_patterns)]
_ => Err(FactoryError::NotImplemented(
"No database feature enabled".to_string(),
)),
}
}
pub async fn build_session_store(
pool: &DatabasePool,
) -> FactoryResult<crate::session_store::InfraSessionStore> {
match pool {
#[cfg(feature = "sqlite")]
DatabasePool::Sqlite(pool) => {
let store = tower_sessions_sqlx_store::SqliteStore::new(pool.clone());
Ok(crate::session_store::InfraSessionStore::Sqlite(store))
}
#[cfg(feature = "postgres")]
DatabasePool::Postgres(pool) => {
let store = tower_sessions_sqlx_store::PostgresStore::new(pool.clone());
Ok(crate::session_store::InfraSessionStore::Postgres(store))
}
#[allow(unreachable_patterns)]
_ => Err(FactoryError::NotImplemented(
"No database feature enabled".to_string(),
)),
}
}

25
template-infra/src/lib.rs Normal file
View File

@@ -0,0 +1,25 @@
//! K-Notes Infrastructure Layer
//!
//! This crate provides concrete implementations (adapters) for the
//! repository ports defined in the domain layer.
//!
//! ## Adapters
//!
//! - [`SqliteNoteRepository`] - SQLite adapter for notes with FTS5 search
//! - [`SqliteUserRepository`] - SQLite adapter for users (OIDC-ready)
//! - [`SqliteTagRepository`] - SQLite adapter for tags
//!
//! ## Database
//!
//! - [`db::create_pool`] - Create a database connection pool
//! - [`db::run_migrations`] - Run database migrations
pub mod db;
pub mod factory;
pub mod session_store;
mod user_repository;
// Re-export for convenience
pub use db::{DatabaseConfig, run_migrations};
#[cfg(feature = "sqlite")]
pub use user_repository::SqliteUserRepository;

View File

@@ -0,0 +1,73 @@
use async_trait::async_trait;
use sqlx;
use tower_sessions::{
SessionStore,
session::{Id, Record},
};
#[cfg(feature = "postgres")]
use tower_sessions_sqlx_store::PostgresStore;
#[cfg(feature = "sqlite")]
use tower_sessions_sqlx_store::SqliteStore;
#[derive(Clone, Debug)]
pub enum InfraSessionStore {
#[cfg(feature = "sqlite")]
Sqlite(SqliteStore),
#[cfg(feature = "postgres")]
Postgres(PostgresStore),
}
#[async_trait]
impl SessionStore for InfraSessionStore {
async fn save(&self, session_record: &Record) -> tower_sessions::session_store::Result<()> {
match self {
#[cfg(feature = "sqlite")]
Self::Sqlite(store) => store.save(session_record).await,
#[cfg(feature = "postgres")]
Self::Postgres(store) => store.save(session_record).await,
#[allow(unreachable_patterns)]
_ => Err(tower_sessions::session_store::Error::Backend(
"No backend enabled".to_string(),
)),
}
}
async fn load(&self, session_id: &Id) -> tower_sessions::session_store::Result<Option<Record>> {
match self {
#[cfg(feature = "sqlite")]
Self::Sqlite(store) => store.load(session_id).await,
#[cfg(feature = "postgres")]
Self::Postgres(store) => store.load(session_id).await,
#[allow(unreachable_patterns)]
_ => Err(tower_sessions::session_store::Error::Backend(
"No backend enabled".to_string(),
)),
}
}
async fn delete(&self, session_id: &Id) -> tower_sessions::session_store::Result<()> {
match self {
#[cfg(feature = "sqlite")]
Self::Sqlite(store) => store.delete(session_id).await,
#[cfg(feature = "postgres")]
Self::Postgres(store) => store.delete(session_id).await,
#[allow(unreachable_patterns)]
_ => Err(tower_sessions::session_store::Error::Backend(
"No backend enabled".to_string(),
)),
}
}
}
impl InfraSessionStore {
pub async fn migrate(&self) -> Result<(), sqlx::Error> {
match self {
#[cfg(feature = "sqlite")]
Self::Sqlite(store) => store.migrate().await,
#[cfg(feature = "postgres")]
Self::Postgres(store) => store.migrate().await,
#[allow(unreachable_patterns)]
_ => Err(sqlx::Error::Configuration("No backend enabled".into())),
}
}
}

View File

@@ -0,0 +1,306 @@
//! SQLite implementation of UserRepository
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use uuid::Uuid;
use template_domain::{DomainError, DomainResult, Email, User, UserRepository};
/// SQLite adapter for UserRepository
#[cfg(feature = "sqlite")]
#[derive(Clone)]
pub struct SqliteUserRepository {
pool: SqlitePool,
}
#[cfg(feature = "sqlite")]
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
/// Row type for SQLite query results
#[derive(Debug, FromRow)]
struct UserRow {
id: String,
subject: String,
email: String,
password_hash: Option<String>,
created_at: String,
}
impl TryFrom<UserRow> for User {
type Error = DomainError;
fn try_from(row: UserRow) -> Result<Self, Self::Error> {
let id = Uuid::parse_str(&row.id)
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| {
// Fallback for SQLite datetime format
chrono::NaiveDateTime::parse_from_str(&row.created_at, "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc())
})
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?;
// Parse email from string - it was validated when originally stored
let email = Email::try_from(row.email)
.map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?;
Ok(User::with_id(
id,
row.subject,
email,
row.password_hash,
created_at,
))
}
}
#[cfg(feature = "sqlite")]
#[async_trait]
impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> {
let id_str = id.to_string();
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = ?",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = ?",
)
.bind(subject)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = ?",
)
.bind(email)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn save(&self, user: &User) -> DomainResult<()> {
let id = user.id.to_string();
let created_at = user.created_at.to_rfc3339();
sqlx::query(
r#"
INSERT INTO users (id, subject, email, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject,
email = excluded.email,
password_hash = excluded.password_hash
"#,
)
.bind(&id)
.bind(&user.subject)
.bind(user.email.as_ref()) // Use .as_ref() to get the inner &str
.bind(&user.password_hash)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: Uuid) -> DomainResult<()> {
let id_str = id.to_string();
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(())
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
use crate::db::{DatabaseConfig, DatabasePool, create_pool, run_migrations};
async fn setup_test_db() -> SqlitePool {
let config = DatabaseConfig::in_memory();
let pool = create_pool(&config).await.unwrap();
let db_pool = DatabasePool::Sqlite(pool.clone());
run_migrations(&db_pool).await.unwrap();
pool
}
#[tokio::test]
async fn test_save_and_find_user() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("test@example.com").unwrap();
let user = User::new("oidc|123", email);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.subject, "oidc|123");
assert_eq!(found.email_str(), "test@example.com");
assert!(found.password_hash.is_none());
}
#[tokio::test]
async fn test_save_and_find_user_with_password() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("local@example.com").unwrap();
let user = User::new_local(email, "hashed_pw");
repo.save(&user).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.email_str(), "local@example.com");
assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
}
#[tokio::test]
async fn test_find_by_subject() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("user@gmail.com").unwrap();
let user = User::new("google|456", email);
repo.save(&user).await.unwrap();
let found = repo.find_by_subject("google|456").await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().id, user.id);
}
#[tokio::test]
async fn test_delete_user() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("delete@test.com").unwrap();
let user = User::new("test|789", email);
repo.save(&user).await.unwrap();
repo.delete(user.id).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_none());
}
}
/// PostgreSQL adapter for UserRepository
#[cfg(feature = "postgres")]
#[derive(Clone)]
pub struct PostgresUserRepository {
pool: sqlx::Pool<sqlx::Postgres>,
}
#[cfg(feature = "postgres")]
impl PostgresUserRepository {
pub fn new(pool: sqlx::Pool<sqlx::Postgres>) -> Self {
Self { pool }
}
}
#[cfg(feature = "postgres")]
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> {
let id_str = id.to_string();
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = $1",
)
.bind(subject)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = $1",
)
.bind(email)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn save(&self, user: &User) -> DomainResult<()> {
let id = user.id.to_string();
let created_at = user.created_at.to_rfc3339();
sqlx::query(
r#"
INSERT INTO users (id, subject, email, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject,
email = excluded.email,
password_hash = excluded.password_hash
"#,
)
.bind(&id)
.bind(&user.subject)
.bind(user.email.as_ref())
.bind(&user.password_hash)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: Uuid) -> DomainResult<()> {
let id_str = id.to_string();
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(())
}
}