fix(rss): absolute item/channel links, atom self, application/xml
Some checks failed
CI / Check / Test (push) Failing after 30m12s
Some checks failed
CI / Check / Test (push) Failing after 30m12s
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user