diff --git a/Cargo.lock b/Cargo.lock index 5a8dbd3..8c44f36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atom_syndication" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -460,6 +473,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -480,6 +528,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -492,6 +571,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "diligent-date-parser" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" +dependencies = [ + "chrono", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1108,6 +1196,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1403,6 +1497,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1683,6 +1783,7 @@ dependencies = [ "metadata", "poster-fetcher", "poster-storage", + "rss 0.1.0", "serde", "serde_json", "sqlite", @@ -1722,6 +1823,7 @@ version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ + "encoding_rs", "memchr", "serde", ] @@ -2017,6 +2119,24 @@ dependencies = [ [[package]] name = "rss" version = "0.1.0" +dependencies = [ + "application", + "chrono", + "domain", + "rss 2.0.12", +] + +[[package]] +name = "rss" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" +dependencies = [ + "atom_syndication", + "derive_builder", + "never", + "quick-xml", +] [[package]] name = "rustc-hash" @@ -2614,6 +2734,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" diff --git a/crates/adapters/rss/Cargo.toml b/crates/adapters/rss/Cargo.toml index 2a66bef..1f69056 100644 --- a/crates/adapters/rss/Cargo.toml +++ b/crates/adapters/rss/Cargo.toml @@ -4,3 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +rss-feed = { package = "rss", version = "2" } +chrono = { workspace = true } +domain = { workspace = true } +application = { workspace = true } diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index b93cf3f..7ebca2b 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -1,14 +1,58 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +use application::ports::RssFeedRenderer; +use domain::models::DiaryEntry; +use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder}; + +pub struct RssAdapter { + feed_title: String, + feed_link: String, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); +impl RssAdapter { + pub fn new(feed_title: String, feed_link: String) -> Self { + Self { feed_title, feed_link } + } +} + +impl RssFeedRenderer for RssAdapter { + fn render_feed(&self, entries: &[DiaryEntry]) -> Result { + let items = entries + .iter() + .map(|e| { + let title = format!( + "{} ({})", + e.movie().title().value(), + e.movie().release_year().value() + ); + let description = match e.review().comment() { + Some(c) => format!("{}/5 — {}", e.review().rating().value(), c.value()), + None => format!("{}/5", e.review().rating().value()), + }; + let pub_date = e + .review() + .watched_at() + .and_utc() + .format("%a, %d %b %Y %H:%M:%S +0000") + .to_string(); + let guid = GuidBuilder::default() + .value(e.review().id().value().to_string()) + .permalink(false) + .build(); + ItemBuilder::default() + .title(Some(title)) + .description(Some(description)) + .pub_date(Some(pub_date)) + .guid(Some(guid)) + .build() + }) + .collect::>(); + + let channel = ChannelBuilder::default() + .title(self.feed_title.clone()) + .link(self.feed_link.clone()) + .description(self.feed_title.clone()) + .items(items) + .build(); + + Ok(channel.to_string()) } } diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 79c8d6a..80458b9 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -3,3 +3,7 @@ use domain::models::{DiaryEntry, collections::Paginated}; pub trait HtmlRenderer: Send + Sync { fn render_diary_page(&self, data: &Paginated) -> Result; } + +pub trait RssFeedRenderer: Send + Sync { + fn render_feed(&self, entries: &[DiaryEntry]) -> Result; +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 8199f8b..4571aa4 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -28,6 +28,7 @@ poster-storage = { workspace = true } sqlite = { workspace = true } sqlx = { workspace = true } template-askama = { workspace = true } +rss = { workspace = true } [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 1a8c0ef..7a25960 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -79,6 +79,11 @@ mod tests { fn render_diary_page(&self, _: &domain::models::collections::Paginated) -> Result { panic!() } } + struct PanicRssRenderer; + impl crate::ports::RssFeedRenderer for PanicRssRenderer { + fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result { panic!() } + } + 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::ports::MetadataSearchCriteria) -> Result { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result, 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, domain::errors::DomainError> { panic!() } } @@ -101,6 +106,7 @@ mod tests { config: application::config::AppConfig { allow_registration: false }, }, html_renderer: Arc::new(PanicRenderer), + rss_renderer: Arc::new(PanicRssRenderer), }; let app = test_router(state); diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 1789736..76d2000 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -75,6 +75,34 @@ pub mod html { } } +pub mod rss { + use axum::{ + extract::State, + http::header, + response::IntoResponse, + }; + + use application::{queries::GetDiaryQuery, use_cases::get_diary}; + use domain::{errors::DomainError, models::SortDirection}; + + use crate::{errors::ApiError, state::AppState}; + + pub async fn get_feed(State(state): State) -> Result { + let query = GetDiaryQuery { + limit: Some(50), + offset: Some(0), + sort_by: Some(SortDirection::Descending), + movie_id: None, + }; + let page = get_diary::execute(&state.app_ctx, query).await?; + let xml = state + .rss_renderer + .render_feed(&page.items) + .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; + Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml)) + } +} + pub mod api { use axum::{ Json, diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 1fee34c..4f17d1c 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -13,6 +13,7 @@ use metadata::MetadataClientImpl; use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher}; use poster_storage::{PosterStorageAdapter, StorageConfig}; use sqlite::{SqliteMovieRepository, SqliteUserRepository}; +use rss::RssAdapter; use template_askama::AskamaHtmlRenderer; use presentation::{routes, state::AppState}; @@ -78,6 +79,10 @@ async fn wire_dependencies() -> anyhow::Result { Ok(AppState { app_ctx, html_renderer: Arc::new(AskamaHtmlRenderer::new()), + rss_renderer: Arc::new(RssAdapter::new( + "Movie Diary".into(), + "http://localhost:3000".into(), + )), }) } diff --git a/crates/presentation/src/ports.rs b/crates/presentation/src/ports.rs index a8ce63b..a91cf69 100644 --- a/crates/presentation/src/ports.rs +++ b/crates/presentation/src/ports.rs @@ -1 +1,2 @@ pub use application::ports::HtmlRenderer; +pub use application::ports::RssFeedRenderer; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 8dc3af1..c53822a 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -16,6 +16,7 @@ fn html_routes() -> Router { Router::new() .route("/diary", routing::get(handlers::html::get_diary_page)) .route("/reviews", routing::post(handlers::html::post_review)) + .route("/feed.rss", routing::get(handlers::rss::get_feed)) } fn api_routes() -> Router { diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 374f280..7da77ff 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -2,10 +2,11 @@ use std::sync::Arc; use application::context::AppContext; -use crate::ports::HtmlRenderer; +use crate::ports::{HtmlRenderer, RssFeedRenderer}; #[derive(Clone)] pub struct AppState { pub app_ctx: AppContext, pub html_renderer: Arc, + pub rss_renderer: Arc, } diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index bd900b5..8a20325 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -23,6 +23,7 @@ use http_body_util::BodyExt; use presentation::{routes, state::AppState}; use sqlite::SqliteMovieRepository; use sqlx::SqlitePool; +use rss::RssAdapter; use template_askama::AskamaHtmlRenderer; use tower::ServiceExt; @@ -105,6 +106,7 @@ async fn test_app() -> Router { config: AppConfig { allow_registration: false }, }, html_renderer: Arc::new(AskamaHtmlRenderer::new()), + rss_renderer: Arc::new(RssAdapter::new("Movie Diary".into(), "http://localhost:3000".into())), }; routes::build_router(state)