fix(rss): absolute item/channel links, atom self, application/xml
Some checks failed
CI / Check / Test (push) Failing after 30m12s

This commit is contained in:
2026-10-25 19:59:07 +01:00
parent 4f3d99ec8e
commit 2ce7db12c2
7 changed files with 148 additions and 19 deletions

1
Cargo.lock generated
View File

@@ -4670,6 +4670,7 @@ dependencies = [
name = "rss" name = "rss"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"domain", "domain",
"rss 2.0.13", "rss 2.0.13",
] ]

View File

@@ -4,5 +4,8 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
rss-feed = { package = "rss", version = "2" } rss-feed = { package = "rss", version = "2", features = ["atom"] }
domain = { workspace = true } domain = { workspace = true }
[dev-dependencies]
chrono = { workspace = true }

View File

@@ -1,19 +1,30 @@
use domain::models::DiaryEntry; 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}; use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
pub struct RssAdapter { pub struct RssAdapter {
feed_link: String, base_url: String,
} }
impl RssAdapter { impl RssAdapter {
pub fn new(feed_link: String) -> Self { pub fn new(base_url: String) -> Self {
Self { feed_link } 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 { impl RssFeedRenderer for RssAdapter {
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String> { fn render_feed(
&self,
entries: &[DiaryEntry],
channel: &FeedChannel<'_>,
) -> Result<String, String> {
let items = entries let items = entries
.iter() .iter()
.map(|e| { .map(|e| {
@@ -37,12 +48,14 @@ impl RssFeedRenderer for RssAdapter {
.and_utc() .and_utc()
.format("%a, %d %b %Y %H:%M:%S +0000") .format("%a, %d %b %Y %H:%M:%S +0000")
.to_string(); .to_string();
let link = self.absolute(&format!("/movies/{}", e.movie().id().value()));
let guid = GuidBuilder::default() let guid = GuidBuilder::default()
.value(e.review().id().value().to_string()) .value(e.review().id().value().to_string())
.permalink(false) .permalink(false)
.build(); .build();
ItemBuilder::default() ItemBuilder::default()
.title(Some(item_title)) .title(Some(item_title))
.link(Some(link))
.description(Some(description)) .description(Some(description))
.pub_date(Some(pub_date)) .pub_date(Some(pub_date))
.guid(Some(guid)) .guid(Some(guid))
@@ -50,10 +63,24 @@ impl RssFeedRenderer for RssAdapter {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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() let channel = ChannelBuilder::default()
.title(title.to_string()) .title(channel.title.to_string())
.link(self.feed_link.clone()) .link(self.absolute(channel.link_path))
.description(title.to_string()) .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) .items(items)
.build(); .build();

View File

@@ -1,15 +1,79 @@
use super::*; 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] #[test]
fn render_feed_uses_provided_title() { fn render_feed_uses_provided_title() {
let adapter = RssAdapter::new("http://example.com".into()); 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("<title>Custom Title</title>")); assert!(xml.contains("<title>Custom Title</title>"));
} }
#[test] #[test]
fn render_feed_empty_entries_produces_valid_xml() { fn render_feed_empty_entries_produces_valid_xml() {
let adapter = RssAdapter::new("http://example.com".into()); 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("<?xml") || xml.starts_with("<rss")); assert!(xml.starts_with("<?xml") || xml.starts_with("<rss"));
} }
#[test]
fn channel_links_are_absolute_with_atom_self() {
let adapter = RssAdapter::new("http://example.com/".into());
let xml = adapter.render_feed(&[], &channel("Feed")).unwrap();
assert!(xml.contains("<link>http://example.com/users/abc</link>"));
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("<language>en</language>"));
}
#[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!(
"<link>http://example.com/movies/{}</link>",
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"));
}

View File

@@ -1,5 +1,19 @@
use crate::models::DiaryEntry; use crate::models::DiaryEntry;
pub trait RssFeedRenderer: Send + Sync { /// Channel-level metadata for a rendered feed. Paths are site-relative
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>; /// (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<String, String>;
} }

View File

@@ -6,7 +6,7 @@ use axum::{
use uuid::Uuid; use uuid::Uuid;
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery}; 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}; use crate::{errors::ApiError, state::AppState};
@@ -21,10 +21,17 @@ pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse
let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, query).await?; let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, query).await?;
let xml = state let xml = state
.rss_renderer .rss_renderer
.render_feed(&page.items, "Movie Diary") .render_feed(
&page.items,
&FeedChannel {
title: "Movie Diary",
link_path: "/",
self_path: "/feed.rss",
},
)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
Ok(( Ok((
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], [(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
xml, xml,
)) ))
} }
@@ -41,14 +48,23 @@ pub async fn get_user_feed(
.await?; .await?;
let title = format!("{}'s Movie Diary", feed.author.display_name); let title = format!("{}'s Movie Diary", feed.author.display_name);
let link_path = format!("/users/{user_id}");
let self_path = format!("/users/{user_id}/feed.rss");
let xml = state let xml = state
.rss_renderer .rss_renderer
.render_feed(&feed.entries, &title) .render_feed(
&feed.entries,
&FeedChannel {
title: &title,
link_path: &link_path,
self_path: &self_path,
},
)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
Ok(( Ok((
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], [(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
xml, xml,
)) ))
} }

View File

@@ -416,7 +416,11 @@ impl domain::ports::DocumentParser for Panic {
} }
impl domain::ports::RssFeedRenderer for Panic { impl domain::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> { fn render_feed(
&self,
_: &[DiaryEntry],
_: &domain::ports::FeedChannel<'_>,
) -> Result<String, String> {
panic!() panic!()
} }
} }