diff --git a/Cargo.lock b/Cargo.lock index efa04004..b4736427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4670,6 +4670,7 @@ dependencies = [ name = "rss" version = "0.1.0" dependencies = [ + "chrono", "domain", "rss 2.0.13", ] diff --git a/crates/adapters/rss/Cargo.toml b/crates/adapters/rss/Cargo.toml index bde6130f..2de1ac45 100644 --- a/crates/adapters/rss/Cargo.toml +++ b/crates/adapters/rss/Cargo.toml @@ -4,5 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -rss-feed = { package = "rss", version = "2" } +rss-feed = { package = "rss", version = "2", features = ["atom"] } domain = { workspace = true } + +[dev-dependencies] +chrono = { workspace = true } diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs index 180ba269..d11d5ff8 100644 --- a/crates/adapters/rss/src/lib.rs +++ b/crates/adapters/rss/src/lib.rs @@ -1,19 +1,30 @@ use domain::models::DiaryEntry; -use domain::ports::RssFeedRenderer; +use domain::ports::{FeedChannel, RssFeedRenderer}; +use rss_feed::extension::atom::{self, AtomExtensionBuilder, Link}; use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder}; pub struct RssAdapter { - feed_link: String, + base_url: String, } impl RssAdapter { - pub fn new(feed_link: String) -> Self { - Self { feed_link } + pub fn new(base_url: String) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + } + } + + fn absolute(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) } } impl RssFeedRenderer for RssAdapter { - fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result { + fn render_feed( + &self, + entries: &[DiaryEntry], + channel: &FeedChannel<'_>, + ) -> Result { let items = entries .iter() .map(|e| { @@ -37,12 +48,14 @@ impl RssFeedRenderer for RssAdapter { .and_utc() .format("%a, %d %b %Y %H:%M:%S +0000") .to_string(); + let link = self.absolute(&format!("/movies/{}", e.movie().id().value())); let guid = GuidBuilder::default() .value(e.review().id().value().to_string()) .permalink(false) .build(); ItemBuilder::default() .title(Some(item_title)) + .link(Some(link)) .description(Some(description)) .pub_date(Some(pub_date)) .guid(Some(guid)) @@ -50,10 +63,24 @@ impl RssFeedRenderer for RssAdapter { }) .collect::>(); + let self_link = Link { + href: self.absolute(channel.self_path), + rel: "self".to_string(), + mime_type: Some("application/rss+xml".to_string()), + ..Default::default() + }; + let channel = ChannelBuilder::default() - .title(title.to_string()) - .link(self.feed_link.clone()) - .description(title.to_string()) + .title(channel.title.to_string()) + .link(self.absolute(channel.link_path)) + .description(channel.title.to_string()) + .language(Some("en".to_string())) + .namespace(("atom".to_string(), atom::NAMESPACE.to_string())) + .atom_ext(Some( + AtomExtensionBuilder::default() + .links(vec![self_link]) + .build(), + )) .items(items) .build(); diff --git a/crates/adapters/rss/src/tests/lib.rs b/crates/adapters/rss/src/tests/lib.rs index fd2c9cbb..0fe98bf4 100644 --- a/crates/adapters/rss/src/tests/lib.rs +++ b/crates/adapters/rss/src/tests/lib.rs @@ -1,15 +1,79 @@ use super::*; +use domain::models::{Movie, Review}; +use domain::value_objects::{Comment, MovieTitle, Rating, ReleaseYear, UserId}; + +fn channel<'a>(title: &'a str) -> FeedChannel<'a> { + FeedChannel { + title, + link_path: "/users/abc", + self_path: "/users/abc/feed.rss", + } +} + +fn entry(spoilers: bool) -> DiaryEntry { + let movie = Movie::new( + None, + MovieTitle::new("Blade Runner".into()).unwrap(), + ReleaseYear::new(1982).unwrap(), + None, + None, + ); + let review = Review::new( + movie.id().clone(), + UserId::generate(), + Rating::new(4).unwrap(), + Some(Comment::new("replicants".into()).unwrap()), + chrono::Utc::now().naive_utc(), + None, + spoilers, + ) + .unwrap(); + DiaryEntry::new(movie, review) +} #[test] fn render_feed_uses_provided_title() { let adapter = RssAdapter::new("http://example.com".into()); - let xml = adapter.render_feed(&[], "Custom Title").unwrap(); + let xml = adapter.render_feed(&[], &channel("Custom Title")).unwrap(); assert!(xml.contains("Custom Title")); } #[test] fn render_feed_empty_entries_produces_valid_xml() { let adapter = RssAdapter::new("http://example.com".into()); - let xml = adapter.render_feed(&[], "My Feed").unwrap(); + let xml = adapter.render_feed(&[], &channel("My Feed")).unwrap(); assert!(xml.starts_with("http://example.com/users/abc")); + assert!(xml.contains(r#"xmlns:atom="http://www.w3.org/2005/Atom""#)); + assert!(xml.contains(r#"href="http://example.com/users/abc/feed.rss""#)); + assert!(xml.contains(r#"rel="self""#)); + assert!(xml.contains("en")); +} + +#[test] +fn item_links_to_absolute_movie_page() { + let adapter = RssAdapter::new("http://example.com".into()); + let e = entry(false); + let xml = adapter.render_feed(&[e.clone()], &channel("Feed")).unwrap(); + let expected = format!( + "http://example.com/movies/{}", + e.movie().id().value() + ); + assert!(xml.contains(&expected), "{xml}"); +} + +#[test] +fn spoiler_comment_is_omitted() { + let adapter = RssAdapter::new("http://example.com".into()); + let xml = adapter + .render_feed(&[entry(true)], &channel("Feed")) + .unwrap(); + assert!(xml.contains("contains spoilers")); + assert!(!xml.contains("replicants")); +} diff --git a/crates/domain/src/ports/rss.rs b/crates/domain/src/ports/rss.rs index 5b420476..3062a934 100644 --- a/crates/domain/src/ports/rss.rs +++ b/crates/domain/src/ports/rss.rs @@ -1,5 +1,19 @@ use crate::models::DiaryEntry; -pub trait RssFeedRenderer: Send + Sync { - fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result; +/// Channel-level metadata for a rendered feed. Paths are site-relative +/// (e.g. `/users/{id}`); the renderer resolves them against its base URL. +pub struct FeedChannel<'a> { + pub title: &'a str, + /// Human-readable page the channel represents. + pub link_path: &'a str, + /// Where the feed itself is served, advertised as `atom:link rel="self"`. + pub self_path: &'a str, +} + +pub trait RssFeedRenderer: Send + Sync { + fn render_feed( + &self, + entries: &[DiaryEntry], + channel: &FeedChannel<'_>, + ) -> Result; } diff --git a/crates/presentation/src/handlers/rss.rs b/crates/presentation/src/handlers/rss.rs index d83d7650..ed4d13e4 100644 --- a/crates/presentation/src/handlers/rss.rs +++ b/crates/presentation/src/handlers/rss.rs @@ -6,7 +6,7 @@ use axum::{ use uuid::Uuid; use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery}; -use domain::{errors::DomainError, models::ReviewSortBy}; +use domain::{errors::DomainError, models::ReviewSortBy, ports::FeedChannel}; use crate::{errors::ApiError, state::AppState}; @@ -21,10 +21,17 @@ pub async fn get_feed(State(state): State) -> Result Result { + fn render_feed( + &self, + _: &[DiaryEntry], + _: &domain::ports::FeedChannel<'_>, + ) -> Result { panic!() } }