Compare commits

...

4 Commits

Author SHA1 Message Date
f2761b8e97 feat: update README with improved adapter descriptions and Docker usage instructions 2026-05-10 19:00:21 +02:00
08d0734d03 feat: add axum dependency and implement wiring functions for federation repositories 2026-05-10 18:58:41 +02:00
595460373b Refactor event payload handling across adapters
- Introduced `event-payload` crate to centralize event payload definitions.
- Updated NATS and PostgreSQL adapters to use the new `EventPayload` type.
- Removed redundant event payload definitions and conversion implementations from NATS and PostgreSQL adapters.
- Simplified SQLite event queue to utilize the new `EventPayload`.
- Refactored wiring functions for PostgreSQL and SQLite to improve database connection handling and migration.
- Cleaned up presentation and worker crates by removing unused event publisher dependencies and related wiring functions.
2026-05-10 18:41:42 +02:00
3714b6d7a7 feat: add federation support with SQLite and Postgres repositories 2026-05-10 18:22:24 +02:00
28 changed files with 522 additions and 816 deletions

22
Cargo.lock generated
View File

@@ -10,6 +10,7 @@ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum",
"chrono", "chrono",
"domain", "domain",
"serde", "serde",
@@ -1625,6 +1626,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "event-payload"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"domain",
"serde",
"uuid",
]
[[package]] [[package]]
name = "event-publisher" name = "event-publisher"
version = "0.1.0" version = "0.1.0"
@@ -2750,6 +2762,7 @@ dependencies = [
name = "metadata" name = "metadata"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"async-trait", "async-trait",
"domain", "domain",
"reqwest 0.13.3", "reqwest 0.13.3",
@@ -2829,6 +2842,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"domain", "domain",
"event-payload",
"futures", "futures",
"serde", "serde",
"serde_json", "serde_json",
@@ -3373,6 +3387,7 @@ dependencies = [
name = "postgres" name = "postgres"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
"domain", "domain",
@@ -3390,6 +3405,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"domain", "domain",
"event-payload",
"futures", "futures",
"serde", "serde",
"serde_json", "serde_json",
@@ -3454,7 +3470,6 @@ dependencies = [
"doc", "doc",
"domain", "domain",
"dotenvy", "dotenvy",
"event-publisher",
"export", "export",
"http-body-util", "http-body-util",
"infer", "infer",
@@ -4532,6 +4547,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"domain", "domain",
"event-payload",
"futures", "futures",
"serde", "serde",
"serde_json", "serde_json",
@@ -6234,6 +6250,7 @@ dependencies = [
name = "worker" name = "worker"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub",
"anyhow", "anyhow",
"application", "application",
"async-trait", "async-trait",
@@ -6241,7 +6258,6 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"dotenvy", "dotenvy",
"event-publisher",
"export", "export",
"futures", "futures",
"metadata", "metadata",
@@ -6250,10 +6266,12 @@ dependencies = [
"poster-storage", "poster-storage",
"postgres", "postgres",
"postgres-event-queue", "postgres-event-queue",
"postgres-federation",
"serde", "serde",
"serde_json", "serde_json",
"sqlite", "sqlite",
"sqlite-event-queue", "sqlite-event-queue",
"sqlite-federation",
"sqlx", "sqlx",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",

View File

@@ -16,6 +16,7 @@ members = [
"crates/adapters/activitypub", "crates/adapters/activitypub",
"crates/adapters/activitypub-base", "crates/adapters/activitypub-base",
"crates/adapters/export", "crates/adapters/export",
"crates/adapters/event-payload",
"crates/adapters/nats", "crates/adapters/nats",
"crates/application", "crates/application",
"crates/domain", "crates/domain",
@@ -67,6 +68,7 @@ template-askama = { path = "crates/adapters/template-askama" }
activitypub = { path = "crates/adapters/activitypub" } activitypub = { path = "crates/adapters/activitypub" }
activitypub-base = { path = "crates/adapters/activitypub-base" } activitypub-base = { path = "crates/adapters/activitypub-base" }
doc = { path = "crates/doc" } doc = { path = "crates/doc" }
event-payload = { path = "crates/adapters/event-payload" }
nats = { path = "crates/adapters/nats" } nats = { path = "crates/adapters/nats" }
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" } sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" } postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }

View File

@@ -10,6 +10,7 @@ COPY .sqlx ./.sqlx
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml

View File

@@ -24,24 +24,28 @@ Hexagonal (Ports & Adapters) with Domain-Driven Design:
``` ```
domain — pure types and trait definitions, no external deps domain — pure types and trait definitions, no external deps
application — use cases / business logic orchestration application — use cases / business logic orchestration
presentation — Axum HTTP router, wires all adapters together presentation — Axum HTTP router, composition root for the HTTP process
worker — standalone worker binary (event consumer, poster sync, federation)
adapters/ adapters/
auth — JWT issuance and validation (Argon2 passwords) auth — JWT issuance and validation (Argon2 passwords)
sqlite — SQLite repository via sqlx sqlite — SQLite repository + connection factory
metadataOMDb HTTP client postgres PostgreSQL repository + connection factory
poster-fetcher — downloads poster images metadata — TMDB / OMDb HTTP client
poster-storageuploads posters to local filesystem or S3-compatible storage poster-fetcher downloads poster images
template-askama — Askama HTML rendering poster-storage — stores posters on local filesystem or S3-compatible storage
rss — RSS/Atom feed generation template-askama — Askama HTML rendering
exportCSV and JSON diary serialization rss RSS/Atom feed generation
sqlite-event-queue — polling event queue backed by SQLite (DB-queue mode) export — CSV and JSON diary serialization
postgres-event-queue — polling event queue backed by PostgreSQL (DB-queue mode) event-payload — shared event serialization DTOs (used by all event bus adapters)
sqlite-event-queue — durable polling event queue backed by SQLite
postgres-event-queue — durable polling event queue backed by PostgreSQL
nats — NATS Core / JetStream event publisher and consumer nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (used in tests) event-publisher — in-memory event channel (tests only)
activitypub — ActivityPub federation (follow, inbox/outbox, actor) activitypub — ActivityPub federation wiring (follow, inbox/outbox, actor)
activitypub-base — core ActivityPub types and repository traits activitypub-base — core ActivityPub protocol types and service
sqlite-federation — SQLite-backed federation repository
postgres-federation — PostgreSQL-backed federation repository
doc — OpenAPI spec assembly and Swagger UI / Scalar serving doc — OpenAPI spec assembly and Swagger UI / Scalar serving
worker — standalone worker binary (polls the event queue, syncs posters)
tui — terminal UI client (ratatui) tui — terminal UI client (ratatui)
``` ```
@@ -136,17 +140,39 @@ cargo test
## Docker ## Docker
The image contains both `presentation` (HTTP server) and `worker` (event processor). Run them as separate containers sharing the same data volume:
```bash ```bash
docker build -t movies-diary . # Build (SQLite + federation + NATS support)
docker build -t movies-diary \
--build-arg FEATURES=sqlite,sqlite-federation,nats .
# HTTP server
docker run -p 3000:3000 \ docker run -p 3000:3000 \
-e DATABASE_URL=sqlite:///data/movies.db \ -e DATABASE_URL=sqlite:///data/movies.db \
-e JWT_SECRET=change-me \ -e JWT_SECRET=change-me \
-e OMDB_API_KEY=your-key \ -e OMDB_API_KEY=your-key \
-e BASE_URL=https://yourdomain.example.com \ -e BASE_URL=https://yourdomain.example.com \
-e EVENT_BUS_BACKEND=nats \
-e NATS_URL=nats://nats:4222 \
-v $(pwd)/data:/data \ -v $(pwd)/data:/data \
movies-diary movies-diary
# Event worker (separate container, same image)
docker run \
-e DATABASE_URL=sqlite:///data/movies.db \
-e JWT_SECRET=change-me \
-e OMDB_API_KEY=your-key \
-e BASE_URL=https://yourdomain.example.com \
-e EVENT_BUS_BACKEND=nats \
-e NATS_URL=nats://nats:4222 \
-v $(pwd)/data:/data \
--entrypoint ./worker \
movies-diary
``` ```
To build for PostgreSQL: `--build-arg FEATURES=postgres,postgres-federation,nats`
## License ## License
MIT License. See [LICENSE](LICENSE). MIT License. See [LICENSE](LICENSE).

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
activitypub-base = { workspace = true } activitypub-base = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@@ -17,3 +17,49 @@ pub use port::{ActivityPubPort, NoopActivityPubService};
pub use remote_review_repository::RemoteReviewRepository; pub use remote_review_repository::RemoteReviewRepository;
pub use review_handler::ReviewObjectHandler; pub use review_handler::ReviewObjectHandler;
pub use user_adapter::DomainUserRepoAdapter; pub use user_adapter::DomainUserRepoAdapter;
pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
}
pub async fn wire(
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
) -> anyhow::Result<ActivityPubWire> {
let concrete = std::sync::Arc::new(
ActivityPubService::new(
federation_repo,
std::sync::Arc::new(DomainUserRepoAdapter(user_repo)),
std::sync::Arc::new(ReviewObjectHandler {
movie_repository: std::sync::Arc::clone(&movie_repo),
diary_repository: diary_repo,
review_store,
base_url: base_url.clone(),
}),
base_url.clone(),
cfg!(debug_assertions),
)
.await?,
);
let router = concrete.router();
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
movie_repo,
review_repo,
base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
Ok(ActivityPubWire {
service: concrete as std::sync::Arc<dyn ActivityPubPort>,
router,
event_handler,
})
}

View File

@@ -105,3 +105,14 @@ impl PasswordHasher for Argon2PasswordHasher {
.is_ok()) .is_ok())
} }
} }
pub fn create() -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::AuthService>,
std::sync::Arc<dyn domain::ports::PasswordHasher>,
)> {
let config = AuthConfig::from_env()?;
Ok((
std::sync::Arc::new(JwtAuthService::new(config)),
std::sync::Arc::new(Argon2PasswordHasher),
))
}

View File

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

View File

@@ -0,0 +1,189 @@
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "data")]
pub enum EventPayload {
ReviewLogged {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
ReviewUpdated {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
MovieDiscovered {
movie_id: String,
external_metadata_id: String,
},
}
impl EventPayload {
pub fn event_type(&self) -> &'static str {
match self {
EventPayload::ReviewLogged { .. } => "ReviewLogged",
EventPayload::ReviewUpdated { .. } => "ReviewUpdated",
EventPayload::MovieDiscovered { .. } => "MovieDiscovered",
}
}
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
}
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.naive_utc())
.ok_or_else(|| DomainError::InfrastructureError(format!("invalid timestamp: {ts}")))
}
impl From<&DomainEvent> for EventPayload {
fn from(event: &DomainEvent) -> Self {
match event {
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
EventPayload::ReviewLogged {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
EventPayload::ReviewUpdated {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
EventPayload::MovieDiscovered {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(),
}
}
}
}
}
impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(payload: EventPayload) -> Result<Self, DomainError> {
match payload {
EventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
EventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
EventPayload::MovieDiscovered { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
}
fn review_logged() -> DomainEvent {
DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(4).unwrap(),
watched_at: fixed_dt(),
}
}
fn review_updated() -> DomainEvent {
DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(3).unwrap(),
watched_at: fixed_dt(),
}
}
fn movie_discovered() -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(Uuid::new_v4()),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
}
}
fn round_trip(event: DomainEvent) {
let payload = EventPayload::from(&event);
let json = serde_json::to_string(&payload).expect("serialize");
let back: EventPayload = serde_json::from_str(&json).expect("deserialize");
let recovered = DomainEvent::try_from(back).expect("try_from");
assert_eq!(EventPayload::from(&event), EventPayload::from(&recovered));
}
#[test]
fn round_trip_review_logged() {
round_trip(review_logged());
}
#[test]
fn round_trip_review_updated() {
round_trip(review_updated());
}
#[test]
fn round_trip_movie_discovered() {
round_trip(movie_discovered());
}
#[test]
fn serialized_format_is_tagged() {
let payload = EventPayload::from(&movie_discovered());
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""type":"MovieDiscovered""#));
assert!(json.contains(r#""data":"#));
}
#[test]
fn event_type_strings() {
assert_eq!(EventPayload::from(&review_logged()).event_type(), "ReviewLogged");
assert_eq!(EventPayload::from(&review_updated()).event_type(), "ReviewUpdated");
assert_eq!(EventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered");
}
}

View File

@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -65,3 +65,14 @@ impl MetadataClient for MetadataClientImpl {
Ok(pm.poster_url) Ok(pm.poster_url)
} }
} }
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::MetadataClient>> {
use anyhow::Context;
if let Ok(key) = std::env::var("TMDB_API_KEY") {
Ok(std::sync::Arc::new(MetadataClientImpl::new_tmdb(key)))
} else {
let key = std::env::var("OMDB_API_KEY")
.context("either TMDB_API_KEY or OMDB_API_KEY must be set")?;
Ok(std::sync::Arc::new(MetadataClientImpl::new_omdb(key)))
}
}

View File

@@ -7,6 +7,7 @@ edition = "2024"
async-nats = "0.48.0" async-nats = "0.48.0"
domain = { workspace = true } domain = { workspace = true }
event-payload = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }

View File

@@ -1,172 +1 @@
use chrono::NaiveDateTime; pub use event_payload::EventPayload as NatsEventPayload;
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "data")]
pub enum NatsEventPayload {
ReviewLogged {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
ReviewUpdated {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
MovieDiscovered {
movie_id: String,
external_metadata_id: String,
},
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
}
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.naive_utc())
.ok_or_else(|| DomainError::InfrastructureError(format!("invalid timestamp: {ts}")))
}
impl From<&DomainEvent> for NatsEventPayload {
fn from(event: &DomainEvent) -> Self {
match event {
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
NatsEventPayload::ReviewLogged {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
NatsEventPayload::ReviewUpdated {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
NatsEventPayload::MovieDiscovered {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(),
}
}
}
}
}
impl TryFrom<NatsEventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(payload: NatsEventPayload) -> Result<Self, DomainError> {
match payload {
NatsEventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
NatsEventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
NatsEventPayload::MovieDiscovered { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
}
fn review_logged() -> DomainEvent {
DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(4).unwrap(),
watched_at: fixed_dt(),
}
}
fn review_updated() -> DomainEvent {
DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(3).unwrap(),
watched_at: fixed_dt(),
}
}
fn movie_discovered() -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(Uuid::new_v4()),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
}
}
fn round_trip(event: DomainEvent) {
let payload = NatsEventPayload::from(&event);
let json = serde_json::to_string(&payload).expect("serialize");
let back: NatsEventPayload = serde_json::from_str(&json).expect("deserialize");
let recovered = DomainEvent::try_from(back).expect("try_from");
assert_eq!(NatsEventPayload::from(&event), NatsEventPayload::from(&recovered));
}
#[test]
fn round_trip_review_logged() {
round_trip(review_logged());
}
#[test]
fn round_trip_review_updated() {
round_trip(review_updated());
}
#[test]
fn round_trip_movie_discovered() {
round_trip(movie_discovered());
}
#[test]
fn serialized_format_is_tagged() {
let payload = NatsEventPayload::from(&movie_discovered());
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""type":"MovieDiscovered""#));
assert!(json.contains(r#""data":"#));
}
}

View File

@@ -36,3 +36,7 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
Ok(bytes.to_vec()) Ok(bytes.to_vec())
} }
} }
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?))
}

View File

@@ -68,6 +68,10 @@ impl PosterStorage for PosterStorageAdapter {
} }
} }
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterStorage>> {
Ok(std::sync::Arc::new(PosterStorageAdapter::from_config(StorageConfig::from_env()?)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] }
domain = { workspace = true } domain = { workspace = true }
event-payload = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -1,189 +1 @@
use chrono::NaiveDateTime; pub use event_payload::EventPayload as DbEventPayload;
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "data")]
pub enum DbEventPayload {
ReviewLogged {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
ReviewUpdated {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
MovieDiscovered {
movie_id: String,
external_metadata_id: String,
},
}
impl DbEventPayload {
pub fn event_type(&self) -> &'static str {
match self {
DbEventPayload::ReviewLogged { .. } => "ReviewLogged",
DbEventPayload::ReviewUpdated { .. } => "ReviewUpdated",
DbEventPayload::MovieDiscovered { .. } => "MovieDiscovered",
}
}
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
}
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.naive_utc())
.ok_or_else(|| DomainError::InfrastructureError(format!("invalid timestamp: {ts}")))
}
impl From<&DomainEvent> for DbEventPayload {
fn from(event: &DomainEvent) -> Self {
match event {
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
DbEventPayload::ReviewLogged {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
DbEventPayload::ReviewUpdated {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
DbEventPayload::MovieDiscovered {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(),
}
}
}
}
}
impl TryFrom<DbEventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(payload: DbEventPayload) -> Result<Self, DomainError> {
match payload {
DbEventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
DbEventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
DbEventPayload::MovieDiscovered { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
}
fn review_logged() -> DomainEvent {
DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(4).unwrap(),
watched_at: fixed_dt(),
}
}
fn review_updated() -> DomainEvent {
DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(3).unwrap(),
watched_at: fixed_dt(),
}
}
fn movie_discovered() -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(Uuid::new_v4()),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
}
}
fn round_trip(event: DomainEvent) {
let payload = DbEventPayload::from(&event);
let json = serde_json::to_string(&payload).expect("serialize");
let back: DbEventPayload = serde_json::from_str(&json).expect("deserialize");
let recovered = DomainEvent::try_from(back).expect("try_from");
assert_eq!(DbEventPayload::from(&event), DbEventPayload::from(&recovered));
}
#[test]
fn round_trip_review_logged() {
round_trip(review_logged());
}
#[test]
fn round_trip_review_updated() {
round_trip(review_updated());
}
#[test]
fn round_trip_movie_discovered() {
round_trip(movie_discovered());
}
#[test]
fn serialized_format_is_tagged() {
let payload = DbEventPayload::from(&movie_discovered());
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""type":"MovieDiscovered""#));
assert!(json.contains(r#""data":"#));
}
#[test]
fn event_type_strings() {
assert_eq!(DbEventPayload::from(&review_logged()).event_type(), "ReviewLogged");
assert_eq!(DbEventPayload::from(&review_updated()).event_type(), "ReviewUpdated");
assert_eq!(DbEventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered");
}
}

View File

@@ -474,3 +474,16 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
Ok(rows.into_iter().map(|(url, handle, display_name)| domain::ports::RemoteActorInfo { url, handle, display_name }).collect()) Ok(rows.into_iter().map(|(url, handle, display_name)| domain::ports::RemoteActorInfo { url, handle, display_name }).collect())
} }
} }
pub fn wire(pool: sqlx::PgPool) -> (
std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
) {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
(
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
fed as _,
)
}

View File

@@ -12,6 +12,7 @@ sqlx = { version = "0.8.6", features = [
"chrono", "chrono",
] } ] }
domain = { workspace = true } domain = { workspace = true }
anyhow = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -767,3 +767,33 @@ impl StatsRepository for PostgresRepository {
}) })
} }
} }
pub async fn wire(database_url: &str) -> anyhow::Result<(
sqlx::PgPool,
std::sync::Arc<dyn domain::ports::MovieRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>,
std::sync::Arc<dyn domain::ports::DiaryRepository>,
std::sync::Arc<dyn domain::ports::StatsRepository>,
std::sync::Arc<dyn domain::ports::UserRepository>,
)> {
use anyhow::Context;
let pool = sqlx::PgPool::connect(database_url)
.await
.context("Failed to connect to PostgreSQL database")?;
let repo = std::sync::Arc::new(PostgresRepository::new(pool.clone()));
repo.migrate()
.await
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?;
Ok((
pool.clone(),
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::new(PostgresUserRepository::new(pool)) as _,
))
}

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "macros", "chrono"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "macros", "chrono"] }
domain = { workspace = true } domain = { workspace = true }
event-payload = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -1,189 +1 @@
use chrono::NaiveDateTime; pub use event_payload::EventPayload as DbEventPayload;
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "data")]
pub enum DbEventPayload {
ReviewLogged {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
ReviewUpdated {
review_id: String,
movie_id: String,
user_id: String,
rating: u8,
watched_at: i64,
},
MovieDiscovered {
movie_id: String,
external_metadata_id: String,
},
}
impl DbEventPayload {
pub fn event_type(&self) -> &'static str {
match self {
DbEventPayload::ReviewLogged { .. } => "ReviewLogged",
DbEventPayload::ReviewUpdated { .. } => "ReviewUpdated",
DbEventPayload::MovieDiscovered { .. } => "MovieDiscovered",
}
}
}
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
}
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.naive_utc())
.ok_or_else(|| DomainError::InfrastructureError(format!("invalid timestamp: {ts}")))
}
impl From<&DomainEvent> for DbEventPayload {
fn from(event: &DomainEvent) -> Self {
match event {
DomainEvent::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
DbEventPayload::ReviewLogged {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
DbEventPayload::ReviewUpdated {
review_id: review_id.value().to_string(),
movie_id: movie_id.value().to_string(),
user_id: user_id.value().to_string(),
rating: rating.value(),
watched_at: watched_at.and_utc().timestamp(),
}
}
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
DbEventPayload::MovieDiscovered {
movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.value().to_owned(),
}
}
}
}
}
impl TryFrom<DbEventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(payload: DbEventPayload) -> Result<Self, DomainError> {
match payload {
DbEventPayload::ReviewLogged { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
DbEventPayload::ReviewUpdated { review_id, movie_id, user_id, rating, watched_at } => {
Ok(DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(parse_uuid(&review_id, "review_id")?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
rating: Rating::new(rating)?,
watched_at: parse_ts(watched_at)?,
})
}
DbEventPayload::MovieDiscovered { movie_id, external_metadata_id } => {
Ok(DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id: ExternalMetadataId::new(external_metadata_id)?,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_dt() -> NaiveDateTime {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap().naive_utc()
}
fn review_logged() -> DomainEvent {
DomainEvent::ReviewLogged {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(4).unwrap(),
watched_at: fixed_dt(),
}
}
fn review_updated() -> DomainEvent {
DomainEvent::ReviewUpdated {
review_id: ReviewId::from_uuid(Uuid::new_v4()),
movie_id: MovieId::from_uuid(Uuid::new_v4()),
user_id: UserId::from_uuid(Uuid::new_v4()),
rating: Rating::new(3).unwrap(),
watched_at: fixed_dt(),
}
}
fn movie_discovered() -> DomainEvent {
DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(Uuid::new_v4()),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
}
}
fn round_trip(event: DomainEvent) {
let payload = DbEventPayload::from(&event);
let json = serde_json::to_string(&payload).expect("serialize");
let back: DbEventPayload = serde_json::from_str(&json).expect("deserialize");
let recovered = DomainEvent::try_from(back).expect("try_from");
assert_eq!(DbEventPayload::from(&event), DbEventPayload::from(&recovered));
}
#[test]
fn round_trip_review_logged() {
round_trip(review_logged());
}
#[test]
fn round_trip_review_updated() {
round_trip(review_updated());
}
#[test]
fn round_trip_movie_discovered() {
round_trip(movie_discovered());
}
#[test]
fn serialized_format_is_tagged() {
let payload = DbEventPayload::from(&movie_discovered());
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""type":"MovieDiscovered""#));
assert!(json.contains(r#""data":"#));
}
#[test]
fn event_type_strings() {
assert_eq!(DbEventPayload::from(&review_logged()).event_type(), "ReviewLogged");
assert_eq!(DbEventPayload::from(&review_updated()).event_type(), "ReviewUpdated");
assert_eq!(DbEventPayload::from(&movie_discovered()).event_type(), "MovieDiscovered");
}
}

View File

@@ -537,6 +537,19 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
} }
} }
pub fn wire(pool: sqlx::SqlitePool) -> (
std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
) {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
(
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
fed as _,
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -759,6 +759,43 @@ impl StatsRepository for SqliteMovieRepository {
} }
} }
pub async fn wire(database_url: &str) -> anyhow::Result<(
sqlx::SqlitePool,
std::sync::Arc<dyn domain::ports::MovieRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>,
std::sync::Arc<dyn domain::ports::DiaryRepository>,
std::sync::Arc<dyn domain::ports::StatsRepository>,
std::sync::Arc<dyn domain::ports::UserRepository>,
)> {
use std::str::FromStr;
use anyhow::Context;
use sqlx::sqlite::SqliteConnectOptions;
let opts = SqliteConnectOptions::from_str(database_url)
.context("Invalid DATABASE_URL")?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = sqlx::SqlitePool::connect_with(opts)
.await
.context("Failed to connect to SQLite database")?;
let repo = std::sync::Arc::new(SqliteMovieRepository::new(pool.clone()));
repo.migrate()
.await
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?;
Ok((
pool.clone(),
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::new(SqliteUserRepository::new(pool)) as _,
))
}
#[cfg(test)] #[cfg(test)]
mod feed_filter_tests { mod feed_filter_tests {
use super::*; use super::*;

View File

@@ -49,7 +49,6 @@ metadata = { workspace = true }
poster-fetcher = { workspace = true } poster-fetcher = { workspace = true }
poster-storage = { workspace = true } poster-storage = { workspace = true }
template-askama = { workspace = true } template-askama = { workspace = true }
event-publisher = { workspace = true }
nats = { workspace = true, optional = true } nats = { workspace = true, optional = true }
rss = { workspace = true } rss = { workspace = true }
export = { workspace = true } export = { workspace = true }

View File

@@ -1,33 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use std::str::FromStr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(feature = "sqlite")]
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
#[cfg(feature = "sqlite-federation")]
use sqlite_federation::SqliteFederationRepository;
#[cfg(feature = "postgres")]
use postgres::{PostgresRepository, PostgresUserRepository};
#[cfg(feature = "postgres-federation")]
use postgres_federation::PostgresFederationRepository;
#[cfg(feature = "federation")]
use activitypub::{
ActivityPubPort, ActivityPubService, DomainUserRepoAdapter,
ReviewObjectHandler,
};
use application::{config::AppConfig, context::AppContext}; use application::{config::AppConfig, context::AppContext};
use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService};
use export::ExportAdapter; use export::ExportAdapter;
use metadata::MetadataClientImpl;
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
use poster_storage::{PosterStorageAdapter, StorageConfig};
use rss::RssAdapter; use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer; use template_askama::AskamaHtmlRenderer;
@@ -35,11 +14,7 @@ use doc::ApiDocExt;
use presentation::{openapi::ApiDoc, routes, state::AppState}; use presentation::{openapi::ApiDoc, routes, state::AppState};
use utoipa::OpenApi as _; use utoipa::OpenApi as _;
use domain::ports::{ use domain::ports::{DiaryExporter, EventPublisher};
AuthService, DiaryExporter, DiaryRepository, EventPublisher, MetadataClient,
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository,
StatsRepository, UserRepository,
};
#[cfg(not(any(feature = "sqlite", feature = "postgres")))] #[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
@@ -66,37 +41,25 @@ async fn main() -> anyhow::Result<()> {
} }
async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let auth_config = AuthConfig::from_env()?;
let storage_config = StorageConfig::from_env()?;
let app_config = AppConfig::from_env(); let app_config = AppConfig::from_env();
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string());
let metadata_client: Arc<dyn MetadataClient> = let (auth_service, password_hasher) = auth::create()?;
if let Ok(tmdb_key) = std::env::var("TMDB_API_KEY") { let metadata_client = metadata::create()?;
Arc::new(MetadataClientImpl::new_tmdb(tmdb_key)) let poster_fetcher = poster_fetcher::create()?;
} else { let poster_storage = poster_storage::create()?;
let omdb_key = std::env::var("OMDB_API_KEY")
.context("Either TMDB_API_KEY or OMDB_API_KEY must be set")?;
Arc::new(MetadataClientImpl::new_omdb(omdb_key))
};
let poster_fetcher: Arc<dyn PosterFetcherClient> =
Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
let poster_storage: Arc<dyn PosterStorage> =
Arc::new(PosterStorageAdapter::from_config(storage_config));
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, db_pool) = let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, db_pool) =
match backend.as_str() { match backend.as_str() {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
"postgres" => { "postgres" => {
let (pool, m, r, d, s, u) = wire_postgres(&database_url).await?; let (pool, m, r, d, s, u) = postgres::wire(&database_url).await?;
(m, r, d, s, u, DbPool::Postgres(pool)) (m, r, d, s, u, DbPool::Postgres(pool))
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
_ => { _ => {
let (pool, m, r, d, s, u) = wire_sqlite(&database_url).await?; let (pool, m, r, d, s, u) = sqlite::wire(&database_url).await?;
(m, r, d, s, u, DbPool::Sqlite(pool)) (m, r, d, s, u, DbPool::Sqlite(pool))
} }
#[cfg(not(feature = "sqlite"))] #[cfg(not(feature = "sqlite"))]
@@ -108,44 +71,26 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let (event_publisher_arc, ap_router, ap_service, social_query) = { let (event_publisher_arc, ap_router, ap_service, social_query) = {
let (federation_repo, social_query_arc, review_store): ( let (federation_repo, social_query_arc, review_store) = match &db_pool {
Arc<dyn activitypub::FederationRepository>,
Arc<dyn domain::ports::SocialQueryPort>,
Arc<dyn activitypub::RemoteReviewRepository>,
) = match &db_pool {
#[cfg(feature = "postgres-federation")] #[cfg(feature = "postgres-federation")]
DbPool::Postgres(pool) => { DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
let fed = Arc::new(PostgresFederationRepository::new(pool.clone()));
(Arc::clone(&fed) as _, Arc::clone(&fed) as _, fed as _)
}
#[cfg(feature = "sqlite-federation")] #[cfg(feature = "sqlite-federation")]
DbPool::Sqlite(pool) => { DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
let fed = Arc::new(SqliteFederationRepository::new(pool.clone()));
(Arc::clone(&fed) as _, Arc::clone(&fed) as _, fed as _)
}
#[cfg(not(feature = "sqlite-federation"))] #[cfg(not(feature = "sqlite-federation"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} federation is not supported by this build"), _ => anyhow::bail!("DATABASE_BACKEND={backend} federation is not supported by this build"),
}; };
let user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository))); let ap = activitypub::wire(
let review_handler = Arc::new(ReviewObjectHandler { federation_repo,
movie_repository: Arc::clone(&movie_repository),
diary_repository: Arc::clone(&diary_repository),
review_store, review_store,
base_url: app_config.base_url.clone(), Arc::clone(&user_repository),
}); Arc::clone(&movie_repository),
let concrete_ap_service = Arc::new( Arc::clone(&review_repository),
ActivityPubService::new( Arc::clone(&diary_repository),
federation_repo, app_config.base_url.clone(),
user_repo_adapter, ).await?;
review_handler, let ap_router = ap.router;
app_config.base_url.clone(), let ap_service_arc = ap.service;
cfg!(debug_assertions),
)
.await?,
);
let ap_router = concrete_ap_service.router();
let ap_service_arc: Arc<dyn ActivityPubPort> = concrete_ap_service;
let ep: Arc<dyn EventPublisher> = match event_bus { let ep: Arc<dyn EventPublisher> = match event_bus {
EventBusBackend::Db => { EventBusBackend::Db => {
@@ -230,73 +175,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
Ok((state, ap_router)) Ok((state, ap_router))
} }
#[cfg(feature = "sqlite")]
async fn wire_sqlite(database_url: &str) -> anyhow::Result<(
sqlx::SqlitePool,
Arc<dyn MovieRepository>,
Arc<dyn ReviewRepository>,
Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>,
Arc<dyn UserRepository>,
)> {
use sqlx::sqlite::SqliteConnectOptions;
let opts = SqliteConnectOptions::from_str(database_url)
.context("Invalid DATABASE_URL")?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = sqlx::SqlitePool::connect_with(opts)
.await
.context("Failed to connect to SQLite database")?;
let sqlite_repo = Arc::new(SqliteMovieRepository::new(pool.clone()));
sqlite_repo
.migrate()
.await
.map_err(|e| anyhow::anyhow!("{}", e))
.context("Database migration failed")?;
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _;
let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&sqlite_repo) as _;
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _;
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _;
let user_repository: Arc<dyn UserRepository> =
Arc::new(SqliteUserRepository::new(pool.clone()));
Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository))
}
#[cfg(feature = "postgres")]
async fn wire_postgres(database_url: &str) -> anyhow::Result<(
sqlx::PgPool,
Arc<dyn MovieRepository>,
Arc<dyn ReviewRepository>,
Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>,
Arc<dyn UserRepository>,
)> {
let pool = sqlx::PgPool::connect(database_url)
.await
.context("Failed to connect to PostgreSQL database")?;
let pg_repo = Arc::new(PostgresRepository::new(pool.clone()));
pg_repo
.migrate()
.await
.map_err(|e| anyhow::anyhow!("{}", e))
.context("Database migration failed")?;
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&pg_repo) as _;
let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&pg_repo) as _;
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&pg_repo) as _;
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&pg_repo) as _;
let user_repository: Arc<dyn UserRepository> =
Arc::new(PostgresUserRepository::new(pool.clone()));
Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository))
}
enum DbPool { enum DbPool {
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool), Sqlite(sqlx::SqlitePool),

View File

@@ -8,11 +8,13 @@ default = ["sqlite"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue"] sqlite = ["dep:sqlite", "dep:sqlite-event-queue"]
postgres = ["dep:postgres", "dep:postgres-event-queue"] postgres = ["dep:postgres", "dep:postgres-event-queue"]
nats = ["dep:nats"] nats = ["dep:nats"]
federation = []
sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"]
postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "federation"]
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true } application = { workspace = true }
event-publisher = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -38,3 +40,8 @@ sqlite = { workspace = true, optional = true }
postgres = { workspace = true, optional = true } postgres = { workspace = true, optional = true }
sqlite-event-queue = { workspace = true, optional = true } sqlite-event-queue = { workspace = true, optional = true }
postgres-event-queue = { workspace = true, optional = true } postgres-event-queue = { workspace = true, optional = true }
# Optional — federation
activitypub = { workspace = true, optional = true }
sqlite-federation = { workspace = true, optional = true }
postgres-federation = { workspace = true, optional = true }

View File

@@ -1,26 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use std::str::FromStr;
use anyhow::Context; use anyhow::Context;
use application::{config::AppConfig, context::AppContext, event_handlers::PosterSyncHandler, worker::WorkerService}; use application::{config::AppConfig, context::AppContext, event_handlers::PosterSyncHandler, worker::WorkerService};
use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService};
use export::ExportAdapter; use export::ExportAdapter;
use metadata::MetadataClientImpl;
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
use poster_storage::{PosterStorageAdapter, StorageConfig};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(feature = "sqlite")]
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
#[cfg(feature = "postgres")] use domain::ports::{DiaryExporter, EventHandler};
use postgres::{PostgresRepository, PostgresUserRepository};
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, MetadataClient, MovieRepository,
PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository,
UserRepository,
};
#[cfg(not(any(feature = "sqlite", feature = "postgres")))] #[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
@@ -32,35 +18,23 @@ async fn main() -> anyhow::Result<()> {
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string());
let auth_config = AuthConfig::from_env()?;
let storage_config = StorageConfig::from_env()?;
let app_config = AppConfig::from_env(); let app_config = AppConfig::from_env();
let metadata_client: Arc<dyn MetadataClient> = let (auth_service, password_hasher) = auth::create()?;
if let Ok(tmdb_key) = std::env::var("TMDB_API_KEY") { let metadata_client = metadata::create()?;
Arc::new(MetadataClientImpl::new_tmdb(tmdb_key)) let poster_fetcher = poster_fetcher::create()?;
} else { let poster_storage = poster_storage::create()?;
let omdb_key = std::env::var("OMDB_API_KEY")
.context("Either TMDB_API_KEY or OMDB_API_KEY must be set")?;
Arc::new(MetadataClientImpl::new_omdb(omdb_key))
};
let poster_fetcher: Arc<dyn PosterFetcherClient> =
Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
let poster_storage: Arc<dyn PosterStorage> =
Arc::new(PosterStorageAdapter::from_config(storage_config));
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, db_pool) = let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, db_pool) =
match backend.as_str() { match backend.as_str() {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
"postgres" => { "postgres" => {
let (pool, m, r, d, s, u) = wire_postgres(&database_url).await?; let (pool, m, r, d, s, u) = postgres::wire(&database_url).await?;
(m, r, d, s, u, DbPool::Postgres(pool)) (m, r, d, s, u, DbPool::Postgres(pool))
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
_ => { _ => {
let (pool, m, r, d, s, u) = wire_sqlite(&database_url).await?; let (pool, m, r, d, s, u) = sqlite::wire(&database_url).await?;
(m, r, d, s, u, DbPool::Sqlite(pool)) (m, r, d, s, u, DbPool::Sqlite(pool))
} }
#[cfg(not(feature = "sqlite"))] #[cfg(not(feature = "sqlite"))]
@@ -73,11 +47,11 @@ async fn main() -> anyhow::Result<()> {
) = match EventBusBackend::from_env()? { ) = match EventBusBackend::from_env()? {
EventBusBackend::Db => { EventBusBackend::Db => {
tracing::info!("event bus: DB queue"); tracing::info!("event bus: DB queue");
match db_pool { match &db_pool {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_channel(pool).await?, DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_channel(pool.clone()).await?,
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_channel(pool).await?, DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?,
} }
} }
#[cfg(feature = "nats")] #[cfg(feature = "nats")]
@@ -89,6 +63,16 @@ async fn main() -> anyhow::Result<()> {
} }
}; };
// Clone what federation handler needs before ctx and app_config are consumed.
#[cfg(feature = "federation")]
let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, base_url) = (
Arc::clone(&movie_repository),
Arc::clone(&review_repository),
Arc::clone(&diary_repository),
Arc::clone(&user_repository),
app_config.base_url.clone(),
);
let ctx = AppContext { let ctx = AppContext {
movie_repository, movie_repository,
review_repository, review_repository,
@@ -105,8 +89,37 @@ async fn main() -> anyhow::Result<()> {
config: app_config, config: app_config,
}; };
let poster_handler = Arc::new(PosterSyncHandler::new(ctx, 3)); let handlers: Vec<Arc<dyn EventHandler>> = {
let worker = WorkerService::new(consumer_arc, vec![poster_handler]); let poster = Arc::new(PosterSyncHandler::new(ctx, 3)) as Arc<dyn EventHandler>;
#[cfg(not(feature = "federation"))]
{ vec![poster] }
#[cfg(feature = "federation")]
{
let (federation_repo, _social_query, review_store) = match &db_pool {
#[cfg(feature = "sqlite-federation")]
DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(feature = "postgres-federation")]
DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
};
let ap = activitypub::wire(
federation_repo,
review_store,
fed_user_repo,
fed_movie_repo,
fed_review_repo,
fed_diary_repo,
base_url,
).await?.event_handler;
tracing::info!("federation event handler registered");
vec![poster, ap]
}
};
let worker = WorkerService::new(consumer_arc, handlers);
tracing::info!("worker started"); tracing::info!("worker started");
worker.run().await; worker.run().await;
@@ -154,69 +167,3 @@ fn init_tracing() {
.init(); .init();
} }
#[cfg(feature = "sqlite")]
async fn wire_sqlite(database_url: &str) -> anyhow::Result<(
sqlx::SqlitePool,
Arc<dyn MovieRepository>,
Arc<dyn ReviewRepository>,
Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>,
Arc<dyn UserRepository>,
)> {
use sqlx::sqlite::SqliteConnectOptions;
let opts = SqliteConnectOptions::from_str(database_url)
.context("Invalid DATABASE_URL")?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = sqlx::SqlitePool::connect_with(opts)
.await
.context("Failed to connect to SQLite database")?;
let sqlite_repo = Arc::new(SqliteMovieRepository::new(pool.clone()));
sqlite_repo
.migrate()
.await
.map_err(|e| anyhow::anyhow!("{}", e))
.context("Database migration failed")?;
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _;
let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&sqlite_repo) as _;
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _;
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _;
let user_repository: Arc<dyn UserRepository> =
Arc::new(SqliteUserRepository::new(pool.clone()));
Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository))
}
#[cfg(feature = "postgres")]
async fn wire_postgres(database_url: &str) -> anyhow::Result<(
sqlx::PgPool,
Arc<dyn MovieRepository>,
Arc<dyn ReviewRepository>,
Arc<dyn DiaryRepository>,
Arc<dyn StatsRepository>,
Arc<dyn UserRepository>,
)> {
let pool = sqlx::PgPool::connect(database_url)
.await
.context("Failed to connect to PostgreSQL database")?;
let pg_repo = Arc::new(PostgresRepository::new(pool.clone()));
pg_repo
.migrate()
.await
.map_err(|e| anyhow::anyhow!("{}", e))
.context("Database migration failed")?;
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&pg_repo) as _;
let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&pg_repo) as _;
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&pg_repo) as _;
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&pg_repo) as _;
let user_repository: Arc<dyn UserRepository> =
Arc::new(PostgresUserRepository::new(pool.clone()));
Ok((pool, movie_repository, review_repository, diary_repository, stats_repository, user_repository))
}