Compare commits
2 Commits
ba42d3d445
...
da72ab1446
| Author | SHA1 | Date | |
|---|---|---|---|
| da72ab1446 | |||
| 93c65cd155 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[env]
|
||||||
|
SQLX_OFFLINE = "true"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DATABASE_URL=sqlite:./dev.db
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=
|
||||||
|
JWT_TTL_SECONDS=
|
||||||
|
ALLOW_REGISTRATION=true
|
||||||
|
OMDB_API_KEY=
|
||||||
32
.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json
generated
Normal file
32
.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json
generated
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
|
||||||
|
}
|
||||||
12
.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json
generated
Normal file
12
.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 4
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
|
||||||
|
}
|
||||||
769
Cargo.lock
generated
769
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@@ -3,7 +3,8 @@ members = [
|
|||||||
"crates/adapters/auth",
|
"crates/adapters/auth",
|
||||||
"crates/adapters/metadata",
|
"crates/adapters/metadata",
|
||||||
"crates/adapters/rss",
|
"crates/adapters/rss",
|
||||||
"crates/adapters/sqlite", "crates/adapters/template-askama",
|
"crates/adapters/sqlite",
|
||||||
|
"crates/adapters/template-askama",
|
||||||
"crates/application",
|
"crates/application",
|
||||||
"crates/common",
|
"crates/common",
|
||||||
"crates/domain",
|
"crates/domain",
|
||||||
@@ -13,6 +14,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
@@ -22,8 +24,13 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
|
sqlx = { version = "0.8.6", features = [
|
||||||
template-askama = { path = "crates/adapters/template-askama" }
|
"runtime-tokio-rustls",
|
||||||
|
"sqlite",
|
||||||
|
"uuid",
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
|
|
||||||
domain = { path = "crates/domain" }
|
domain = { path = "crates/domain" }
|
||||||
common = { path = "crates/common" }
|
common = { path = "crates/common" }
|
||||||
@@ -33,3 +40,4 @@ auth = { path = "crates/adapters/auth" }
|
|||||||
metadata = { path = "crates/adapters/metadata" }
|
metadata = { path = "crates/adapters/metadata" }
|
||||||
rss = { path = "crates/adapters/rss" }
|
rss = { path = "crates/adapters/rss" }
|
||||||
sqlite = { path = "crates/adapters/sqlite" }
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
|
template-askama = { path = "crates/adapters/template-askama" }
|
||||||
|
|||||||
@@ -6,3 +6,10 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
|||||||
@@ -1,13 +1,104 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{errors::DomainError, ports::AuthService, value_objects::UserId};
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
|
||||||
|
};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct StubAuthService;
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
|
value_objects::{PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct AuthConfig {
|
||||||
|
secret: String,
|
||||||
|
ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthConfig {
|
||||||
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
let secret = std::env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?;
|
||||||
|
if secret.is_empty() {
|
||||||
|
anyhow::bail!("JWT_SECRET must not be empty");
|
||||||
|
}
|
||||||
|
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(86400u64);
|
||||||
|
Ok(Self { secret, ttl_seconds })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JwtAuthService {
|
||||||
|
config: AuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtAuthService {
|
||||||
|
pub fn new(config: AuthConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AuthService for StubAuthService {
|
impl AuthService for JwtAuthService {
|
||||||
async fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
Err(DomainError::InfrastructureError(
|
let expires_at = Utc::now() + Duration::seconds(self.config.ttl_seconds as i64);
|
||||||
"auth service not implemented".into(),
|
let claims = Claims {
|
||||||
))
|
sub: user_id.value().to_string(),
|
||||||
|
exp: expires_at.timestamp() as u64,
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.config.secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(GeneratedToken { token, expires_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.config.secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".into()))?;
|
||||||
|
let uuid = Uuid::parse_str(&data.claims.sub)
|
||||||
|
.map_err(|_| DomainError::Unauthorized("Invalid token subject".into()))?;
|
||||||
|
Ok(UserId::from_uuid(uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Argon2PasswordHasher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PasswordHasher for Argon2PasswordHasher {
|
||||||
|
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(plain_password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
PasswordHash::new(hash).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||||
|
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain_password.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
pub fn add(left: u64, right: u64) -> u64 {
|
use async_trait::async_trait;
|
||||||
left + right
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::Movie,
|
||||||
|
ports::{MetadataClient, MetadataSearchCriteria},
|
||||||
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
|
};
|
||||||
|
|
||||||
|
mod omdb;
|
||||||
|
|
||||||
|
pub(crate) struct ProviderMovie {
|
||||||
|
pub imdb_id: ExternalMetadataId,
|
||||||
|
pub title: MovieTitle,
|
||||||
|
pub release_year: ReleaseYear,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_url: Option<PosterUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[async_trait]
|
||||||
mod tests {
|
pub(crate) trait MetadataProvider: Send + Sync {
|
||||||
use super::*;
|
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
pub struct MetadataClientImpl {
|
||||||
fn it_works() {
|
provider: Box<dyn MetadataProvider>,
|
||||||
let result = add(2, 2);
|
}
|
||||||
assert_eq!(result, 4);
|
|
||||||
|
impl MetadataClientImpl {
|
||||||
|
pub fn new_omdb(api_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
provider: Box::new(omdb::OmdbProvider::new(api_key)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MetadataClient for MetadataClientImpl {
|
||||||
|
async fn fetch_movie_metadata(
|
||||||
|
&self,
|
||||||
|
criteria: &MetadataSearchCriteria,
|
||||||
|
) -> Result<Movie, DomainError> {
|
||||||
|
let pm = self.provider.fetch(criteria).await?;
|
||||||
|
Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_poster_url(
|
||||||
|
&self,
|
||||||
|
external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<PosterUrl>, DomainError> {
|
||||||
|
let criteria = MetadataSearchCriteria::ImdbId(external_metadata_id.clone());
|
||||||
|
let pm = self.provider.fetch(&criteria).await?;
|
||||||
|
Ok(pm.poster_url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
crates/adapters/metadata/src/omdb.rs
Normal file
119
crates/adapters/metadata/src/omdb.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::MetadataSearchCriteria,
|
||||||
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{MetadataProvider, ProviderMovie};
|
||||||
|
|
||||||
|
pub(crate) struct OmdbProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OmdbProvider {
|
||||||
|
pub(crate) fn new(api_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
api_key,
|
||||||
|
base_url: "http://www.omdbapi.com/".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OmdbResponse {
|
||||||
|
#[serde(rename = "Title")]
|
||||||
|
title: String,
|
||||||
|
#[serde(rename = "Year")]
|
||||||
|
year: String,
|
||||||
|
#[serde(rename = "Director")]
|
||||||
|
director: String,
|
||||||
|
#[serde(rename = "Poster")]
|
||||||
|
poster: String,
|
||||||
|
#[serde(rename = "imdbID")]
|
||||||
|
imdb_id: String,
|
||||||
|
#[serde(rename = "Response")]
|
||||||
|
response: String,
|
||||||
|
#[serde(rename = "Error")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MetadataProvider for OmdbProvider {
|
||||||
|
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
|
||||||
|
let mut url = reqwest::Url::parse(&self.base_url)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut params = url.query_pairs_mut();
|
||||||
|
params.append_pair("apikey", &self.api_key);
|
||||||
|
match criteria {
|
||||||
|
MetadataSearchCriteria::ImdbId(id) => {
|
||||||
|
params.append_pair("i", id.value());
|
||||||
|
}
|
||||||
|
MetadataSearchCriteria::Title { title, year } => {
|
||||||
|
params.append_pair("t", title);
|
||||||
|
if let Some(y) = year {
|
||||||
|
params.append_pair("y", &y.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let http_resp = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
let resp: OmdbResponse = http_resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.response != "True" {
|
||||||
|
let msg = resp.error.unwrap_or_default();
|
||||||
|
return if msg.to_lowercase().contains("not found") {
|
||||||
|
Err(DomainError::NotFound(msg))
|
||||||
|
} else {
|
||||||
|
Err(DomainError::InfrastructureError(msg))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let year: u16 = resp
|
||||||
|
.year
|
||||||
|
.chars()
|
||||||
|
.take(4)
|
||||||
|
.collect::<String>()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| {
|
||||||
|
DomainError::InfrastructureError(format!("Unparseable year: {}", resp.year))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let imdb_id = ExternalMetadataId::new(resp.imdb_id)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let title = MovieTitle::new(resp.title)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let release_year = ReleaseYear::new(year)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
let director = match resp.director.as_str() {
|
||||||
|
"N/A" | "" => None,
|
||||||
|
d => Some(d.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let poster_url = match resp.poster.as_str() {
|
||||||
|
"N/A" | "" => None,
|
||||||
|
url => PosterUrl::new(url.to_string()).ok(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url })
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/adapters/sqlite/migrations/0002_users.sql
Normal file
6
crates/adapters/sqlite/migrations/0002_users.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -13,9 +13,12 @@ use sqlx::SqlitePool;
|
|||||||
|
|
||||||
mod migrations;
|
mod migrations;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod users;
|
||||||
|
|
||||||
use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str};
|
use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str};
|
||||||
|
|
||||||
|
pub use users::SqliteUserRepository;
|
||||||
|
|
||||||
pub struct SqliteMovieRepository {
|
pub struct SqliteMovieRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
|
|||||||
76
crates/adapters/sqlite/src/users.rs
Normal file
76
crates/adapters/sqlite/src/users.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::User,
|
||||||
|
ports::UserRepository,
|
||||||
|
value_objects::{Email, PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteUserRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteUserRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_err(e: sqlx::Error) -> DomainError {
|
||||||
|
tracing::error!("Database error: {:?}", e);
|
||||||
|
DomainError::InfrastructureError("Database operation failed".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserRepository for SqliteUserRepository {
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
|
let email_str = email.value();
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, email, password_hash FROM users WHERE email = ?",
|
||||||
|
email_str
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(r) => {
|
||||||
|
let id = uuid::Uuid::parse_str(&r.id)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let email = Email::new(r.email)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let hash = PasswordHash::new(r.password_hash)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(Some(User::from_persistence(UserId::from_uuid(id), email, hash)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
let id = user.id().value().to_string();
|
||||||
|
let email = user.email().value();
|
||||||
|
let hash = user.password_hash().value();
|
||||||
|
let created_at = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
hash,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DomainError::ValidationError("Email already registered".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,3 +18,13 @@ pub struct SyncPosterCommand {
|
|||||||
pub movie_id: Uuid,
|
pub movie_id: Uuid,
|
||||||
pub external_metadata_id: String,
|
pub external_metadata_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct LoginCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegisterCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|||||||
13
crates/application/src/config.rs
Normal file
13
crates/application/src/config.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub allow_registration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
Self { allow_registration }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher,
|
AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher,
|
||||||
PosterFetcherClient, PosterStorage,
|
PosterFetcherClient, PosterStorage, UserRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppContext {
|
pub struct AppContext {
|
||||||
pub repository: Arc<dyn MovieRepository>,
|
pub repository: Arc<dyn MovieRepository>,
|
||||||
@@ -14,4 +16,6 @@ pub struct AppContext {
|
|||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
pub auth_service: Arc<dyn AuthService>,
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
pub password_hasher: Arc<dyn PasswordHasher>,
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub user_repository: Arc<dyn UserRepository>,
|
||||||
|
pub config: AppConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod config;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{Movie, Review},
|
models::{Movie, Review},
|
||||||
|
ports::MetadataSearchCriteria,
|
||||||
value_objects::{Comment, ExternalMetadataId, MovieTitle, Rating, ReleaseYear, UserId},
|
value_objects::{Comment, ExternalMetadataId, MovieTitle, Rating, ReleaseYear, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,7 +48,11 @@ async fn resolve_external_movie(
|
|||||||
return Ok(Some((m, false)));
|
return Ok(Some((m, false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
match ctx.metadata_client.fetch_movie_metadata(&tmdb_id).await {
|
match ctx
|
||||||
|
.metadata_client
|
||||||
|
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(m) => Ok(Some((m, true))),
|
Ok(m) => Ok(Some((m, true))),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
39
crates/application/src/use_cases/login.rs
Normal file
39
crates/application/src/use_cases/login.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::{errors::DomainError, value_objects::Email};
|
||||||
|
|
||||||
|
use crate::{commands::LoginCommand, context::AppContext};
|
||||||
|
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
|
||||||
|
let email = Email::new(cmd.email)?;
|
||||||
|
let user = ctx
|
||||||
|
.user_repository
|
||||||
|
.find_by_email(&email)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?;
|
||||||
|
|
||||||
|
let valid = ctx
|
||||||
|
.password_hasher
|
||||||
|
.verify(&cmd.password, user.password_hash())
|
||||||
|
.await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(DomainError::Unauthorized("Invalid credentials".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let generated = ctx.auth_service.generate_token(user.id()).await?;
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
token: generated.token,
|
||||||
|
user_id: user.id().value(),
|
||||||
|
email: user.email().value().to_string(),
|
||||||
|
expires_at: generated.expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
pub mod get_diary;
|
pub mod get_diary;
|
||||||
pub mod get_review_history;
|
pub mod get_review_history;
|
||||||
pub mod log_review;
|
pub mod log_review;
|
||||||
|
pub mod login;
|
||||||
|
pub mod register;
|
||||||
pub mod sync_poster;
|
pub mod sync_poster;
|
||||||
|
|||||||
18
crates/application/src/use_cases/register.rs
Normal file
18
crates/application/src/use_cases/register.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use domain::{errors::DomainError, models::User, value_objects::Email};
|
||||||
|
|
||||||
|
use crate::{commands::RegisterCommand, context::AppContext};
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> {
|
||||||
|
if !ctx.config.allow_registration {
|
||||||
|
return Err(DomainError::Unauthorized("Registration is disabled".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = Email::new(cmd.email)?;
|
||||||
|
|
||||||
|
if ctx.user_repository.find_by_email(&email).await?.is_some() {
|
||||||
|
return Err(DomainError::ValidationError("Email already registered".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = ctx.password_hasher.hash(&cmd.password).await?;
|
||||||
|
ctx.user_repository.save(&User::new(email, hash)).await
|
||||||
|
}
|
||||||
@@ -13,4 +13,7 @@ pub enum DomainError {
|
|||||||
|
|
||||||
#[error("Infrastructure failure: {0}")]
|
#[error("Infrastructure failure: {0}")]
|
||||||
InfrastructureError(String),
|
InfrastructureError(String),
|
||||||
|
|
||||||
|
#[error("Unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
|
||||||
|
Self { id, email, password_hash }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_password(&mut self, new_hash: PasswordHash) {
|
pub fn update_password(&mut self, new_hash: PasswordHash) {
|
||||||
self.password_hash = new_hash;
|
self.password_hash = new_hash;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated},
|
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, ReleaseYear,
|
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||||
UserId,
|
ReleaseYear, UserId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,11 +34,16 @@ pub trait MovieRepository: Send + Sync {
|
|||||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum MetadataSearchCriteria {
|
||||||
|
ImdbId(ExternalMetadataId),
|
||||||
|
Title { title: String, year: Option<u16> },
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait MetadataClient: Send + Sync {
|
pub trait MetadataClient: Send + Sync {
|
||||||
async fn fetch_movie_metadata(
|
async fn fetch_movie_metadata(
|
||||||
&self,
|
&self,
|
||||||
external_metadata_id: &ExternalMetadataId,
|
criteria: &MetadataSearchCriteria,
|
||||||
) -> Result<Movie, DomainError>;
|
) -> Result<Movie, DomainError>;
|
||||||
async fn get_poster_url(
|
async fn get_poster_url(
|
||||||
&self,
|
&self,
|
||||||
@@ -61,11 +67,23 @@ pub trait PosterStorage: Send + Sync {
|
|||||||
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
|
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GeneratedToken {
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AuthService: Send + Sync {
|
pub trait AuthService: Send + Sync {
|
||||||
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
|
||||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserRepository: Send + Sync {
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait EventPublisher: Send + Sync {
|
pub trait EventPublisher: Send + Sync {
|
||||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ thiserror = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
@@ -21,6 +22,7 @@ async-trait = { workspace = true }
|
|||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
|
metadata = { workspace = true }
|
||||||
sqlite = { workspace = true }
|
sqlite = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
template-askama = { workspace = true }
|
template-askama = { workspace = true }
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ pub struct LoginRequest {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ impl IntoResponse for ApiError {
|
|||||||
DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()),
|
DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()),
|
||||||
DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
|
DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
|
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||||
DomainError::InfrastructureError(_) => {
|
DomainError::InfrastructureError(_) => {
|
||||||
tracing::error!("Internal Infrastructure Error: {:?}", self.0);
|
tracing::error!("Internal Infrastructure Error: {:?}", self.0);
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ where
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ApiError(DomainError::ValidationError(
|
ApiError(DomainError::Unauthorized(
|
||||||
"Missing auth token".into(),
|
"Missing or invalid auth token".into(),
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let user_id = app_state
|
let user_id = app_state
|
||||||
@@ -58,10 +58,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn missing_auth_header_returns_400() {
|
async fn missing_auth_header_returns_401() {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use application::context::AppContext;
|
use application::context::AppContext;
|
||||||
use auth::StubAuthService;
|
|
||||||
|
|
||||||
struct PanicRepo;
|
struct PanicRepo;
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -80,12 +79,14 @@ mod tests {
|
|||||||
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>) -> Result<String, String> { panic!() }
|
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher;
|
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
|
||||||
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||||
|
|
||||||
let state = crate::state::AppState {
|
let state = crate::state::AppState {
|
||||||
app_ctx: AppContext {
|
app_ctx: AppContext {
|
||||||
@@ -94,8 +95,10 @@ mod tests {
|
|||||||
poster_fetcher: Arc::new(PanicFetcher),
|
poster_fetcher: Arc::new(PanicFetcher),
|
||||||
poster_storage: Arc::new(PanicStorage),
|
poster_storage: Arc::new(PanicStorage),
|
||||||
event_publisher: Arc::new(PanicEvent),
|
event_publisher: Arc::new(PanicEvent),
|
||||||
auth_service: Arc::new(StubAuthService),
|
auth_service: Arc::new(PanicAuth),
|
||||||
password_hasher: Arc::new(PanicHasher),
|
password_hasher: Arc::new(PanicHasher),
|
||||||
|
user_repository: Arc::new(PanicUserRepo),
|
||||||
|
config: application::config::AppConfig { allow_registration: false },
|
||||||
},
|
},
|
||||||
html_renderer: Arc::new(PanicRenderer),
|
html_renderer: Arc::new(PanicRenderer),
|
||||||
};
|
};
|
||||||
@@ -111,6 +114,6 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ pub mod api {
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
commands::{LogReviewCommand, SyncPosterCommand},
|
commands::{LoginCommand, LogReviewCommand, RegisterCommand, SyncPosterCommand},
|
||||||
queries::{GetDiaryQuery, GetReviewHistoryQuery},
|
queries::{GetDiaryQuery, GetReviewHistoryQuery},
|
||||||
use_cases::{get_diary, get_review_history, log_review, sync_poster},
|
use_cases::{get_diary, get_review_history, log_review, login as login_uc, register as register_uc, sync_poster},
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
@@ -100,7 +100,7 @@ pub mod api {
|
|||||||
use crate::{
|
use crate::{
|
||||||
dtos::{
|
dtos::{
|
||||||
DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse,
|
DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse,
|
||||||
LogReviewRequest, MovieDto, ReviewDto, ReviewHistoryResponse,
|
LogReviewRequest, MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse,
|
||||||
},
|
},
|
||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
extractors::AuthenticatedUser,
|
extractors::AuthenticatedUser,
|
||||||
@@ -219,12 +219,32 @@ pub mod api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(_state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(_req): Json<LoginRequest>,
|
Json(req): Json<LoginRequest>,
|
||||||
) -> Json<LoginResponse> {
|
) -> Result<Json<LoginResponse>, ApiError> {
|
||||||
Json(LoginResponse {
|
let result = login_uc::execute(&state.app_ctx, LoginCommand {
|
||||||
token: "stub-token".to_string(),
|
email: req.email,
|
||||||
|
password: req.password,
|
||||||
})
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
token: result.token,
|
||||||
|
user_id: result.user_id,
|
||||||
|
email: result.email,
|
||||||
|
expires_at: result.expires_at.to_rfc3339(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RegisterRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
register_uc::execute(&state.app_ctx, RegisterCommand {
|
||||||
|
email: req.email,
|
||||||
|
password: req.password,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||||
|
|||||||
@@ -5,41 +5,21 @@ use async_trait::async_trait;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::Movie,
|
ports::{EventPublisher, PosterFetcherClient, PosterStorage},
|
||||||
ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage},
|
value_objects::{MovieId, PosterPath, PosterUrl},
|
||||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl},
|
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use application::context::AppContext;
|
use application::{config::AppConfig, context::AppContext};
|
||||||
use auth::StubAuthService;
|
use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService};
|
||||||
use sqlite::SqliteMovieRepository;
|
use metadata::MetadataClientImpl;
|
||||||
|
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
|
||||||
use template_askama::AskamaHtmlRenderer;
|
use template_askama::AskamaHtmlRenderer;
|
||||||
|
|
||||||
use presentation::{routes, state::AppState};
|
use presentation::{routes, state::AppState};
|
||||||
|
|
||||||
struct StubMetadataClient;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl MetadataClient for StubMetadataClient {
|
|
||||||
async fn fetch_movie_metadata(&self, _id: &ExternalMetadataId) -> Result<Movie, DomainError> {
|
|
||||||
Err(DomainError::InfrastructureError(
|
|
||||||
"metadata client not implemented".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_poster_url(
|
|
||||||
&self,
|
|
||||||
_id: &ExternalMetadataId,
|
|
||||||
) -> Result<Option<PosterUrl>, DomainError> {
|
|
||||||
Err(DomainError::InfrastructureError(
|
|
||||||
"metadata client not implemented".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StubPosterFetcher;
|
struct StubPosterFetcher;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -81,25 +61,9 @@ impl EventPublisher for StubEventPublisher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StubPasswordHasher;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl PasswordHasher for StubPasswordHasher {
|
|
||||||
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
|
|
||||||
Err(DomainError::InfrastructureError(
|
|
||||||
"password hasher not implemented".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
|
|
||||||
Err(DomainError::InfrastructureError(
|
|
||||||
"password hasher not implemented".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
let state = wire_dependencies()
|
let state = wire_dependencies()
|
||||||
@@ -116,24 +80,33 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn wire_dependencies() -> anyhow::Result<AppState> {
|
async fn wire_dependencies() -> anyhow::Result<AppState> {
|
||||||
|
let auth_config = AuthConfig::from_env()?;
|
||||||
|
let app_config = AppConfig::from_env();
|
||||||
|
let omdb_api_key = std::env::var("OMDB_API_KEY").context("OMDB_API_KEY must be set")?;
|
||||||
|
|
||||||
let pool = SqlitePool::connect("sqlite://reviews.db")
|
let pool = SqlitePool::connect("sqlite://reviews.db")
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to SQLite database")?;
|
.context("Failed to connect to SQLite database")?;
|
||||||
|
|
||||||
let repo = SqliteMovieRepository::new(pool);
|
let movie_repo = SqliteMovieRepository::new(pool.clone());
|
||||||
repo.migrate()
|
movie_repo
|
||||||
|
.migrate()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
.map_err(|e| anyhow::anyhow!("{}", e))
|
||||||
.context("Database migration failed")?;
|
.context("Database migration failed")?;
|
||||||
|
|
||||||
|
let user_repo = SqliteUserRepository::new(pool);
|
||||||
|
|
||||||
let app_ctx = AppContext {
|
let app_ctx = AppContext {
|
||||||
repository: Arc::new(repo),
|
repository: Arc::new(movie_repo),
|
||||||
metadata_client: Arc::new(StubMetadataClient),
|
metadata_client: Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)),
|
||||||
poster_fetcher: Arc::new(StubPosterFetcher),
|
poster_fetcher: Arc::new(StubPosterFetcher),
|
||||||
poster_storage: Arc::new(StubPosterStorage),
|
poster_storage: Arc::new(StubPosterStorage),
|
||||||
event_publisher: Arc::new(StubEventPublisher),
|
event_publisher: Arc::new(StubEventPublisher),
|
||||||
auth_service: Arc::new(StubAuthService),
|
auth_service: Arc::new(JwtAuthService::new(auth_config)),
|
||||||
password_hasher: Arc::new(StubPasswordHasher),
|
password_hasher: Arc::new(Argon2PasswordHasher),
|
||||||
|
user_repository: Arc::new(user_repo),
|
||||||
|
config: app_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ fn api_routes() -> Router<AppState> {
|
|||||||
"/movies/{id}/sync-poster",
|
"/movies/{id}/sync-poster",
|
||||||
routing::post(handlers::api::sync_poster),
|
routing::post(handlers::api::sync_poster),
|
||||||
)
|
)
|
||||||
.route("/auth/login", routing::post(handlers::api::login)),
|
.route("/auth/login", routing::post(handlers::api::login))
|
||||||
|
.route("/auth/register", routing::post(handlers::api::register)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use application::context::AppContext;
|
use application::{config::AppConfig, context::AppContext};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use auth::StubAuthService;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -11,9 +10,14 @@ use axum::{
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::Movie,
|
models::{Movie, User},
|
||||||
ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage},
|
ports::{
|
||||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl},
|
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
||||||
|
PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository,
|
||||||
|
},
|
||||||
|
value_objects::{
|
||||||
|
Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use presentation::{routes, state::AppState};
|
use presentation::{routes, state::AppState};
|
||||||
@@ -33,13 +37,10 @@ impl EventPublisher for NoopEventPublisher {
|
|||||||
struct PanicMeta;
|
struct PanicMeta;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl MetadataClient for PanicMeta {
|
impl MetadataClient for PanicMeta {
|
||||||
async fn fetch_movie_metadata(&self, _: &ExternalMetadataId) -> Result<Movie, DomainError> {
|
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
|
||||||
panic!("metadata not wired in tests")
|
panic!("metadata not wired in tests")
|
||||||
}
|
}
|
||||||
async fn get_poster_url(
|
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {
|
||||||
&self,
|
|
||||||
_: &ExternalMetadataId,
|
|
||||||
) -> Result<Option<PosterUrl>, DomainError> {
|
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,12 +67,22 @@ impl PosterStorage for PanicStorage {
|
|||||||
struct PanicHasher;
|
struct PanicHasher;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PasswordHasher for PanicHasher {
|
impl PasswordHasher for PanicHasher {
|
||||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
|
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
|
||||||
panic!()
|
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
|
||||||
}
|
}
|
||||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
|
|
||||||
panic!()
|
struct PanicAuth;
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthService for PanicAuth {
|
||||||
|
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
|
||||||
|
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NobodyUserRepo;
|
||||||
|
#[async_trait]
|
||||||
|
impl UserRepository for NobodyUserRepo {
|
||||||
|
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
|
||||||
|
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_app() -> Router {
|
async fn test_app() -> Router {
|
||||||
@@ -88,8 +99,10 @@ async fn test_app() -> Router {
|
|||||||
poster_fetcher: Arc::new(PanicFetcher),
|
poster_fetcher: Arc::new(PanicFetcher),
|
||||||
poster_storage: Arc::new(PanicStorage),
|
poster_storage: Arc::new(PanicStorage),
|
||||||
event_publisher: Arc::new(NoopEventPublisher),
|
event_publisher: Arc::new(NoopEventPublisher),
|
||||||
auth_service: Arc::new(StubAuthService),
|
auth_service: Arc::new(PanicAuth),
|
||||||
password_hasher: Arc::new(PanicHasher),
|
password_hasher: Arc::new(PanicHasher),
|
||||||
|
user_repository: Arc::new(NobodyUserRepo),
|
||||||
|
config: AppConfig { allow_registration: false },
|
||||||
},
|
},
|
||||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||||
};
|
};
|
||||||
@@ -101,12 +114,7 @@ async fn test_app() -> Router {
|
|||||||
async fn get_api_diary_returns_empty_list() {
|
async fn get_api_diary_returns_empty_list() {
|
||||||
let app = test_app().await;
|
let app = test_app().await;
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(
|
.oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap())
|
||||||
Request::builder()
|
|
||||||
.uri("/api/diary")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -122,7 +130,7 @@ async fn get_api_diary_returns_empty_list() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_api_reviews_without_auth_returns_400() {
|
async fn post_api_reviews_without_auth_returns_401() {
|
||||||
let app = test_app().await;
|
let app = test_app().await;
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -138,11 +146,11 @@ async fn post_api_reviews_without_auth_returns_400() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_api_auth_login_returns_stub_token() {
|
async fn post_api_auth_login_unknown_user_returns_401() {
|
||||||
let app = test_app().await;
|
let app = test_app().await;
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -156,9 +164,5 @@ async fn post_api_auth_login_returns_stub_token() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
|
||||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
||||||
assert_eq!(json["token"], "stub-token");
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user