Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
12
crates/adapters/rss-adapter/Cargo.toml
Normal file
12
crates/adapters/rss-adapter/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "rss-adapter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rss = { workspace = true, features = ["atom"] }
|
||||
domain = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
chrono = { workspace = true }
|
||||
90
crates/adapters/rss-adapter/src/lib.rs
Normal file
90
crates/adapters/rss-adapter/src/lib.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::DiaryEntry;
|
||||
use domain::ports::{FeedChannel, RssFeedRenderer};
|
||||
use rss::extension::atom::{self, AtomExtensionBuilder, Link};
|
||||
use rss::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||
|
||||
pub struct RssAdapter {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl RssAdapter {
|
||||
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],
|
||||
channel: &FeedChannel<'_>,
|
||||
) -> Result<String, DomainError> {
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let item_title = format!(
|
||||
"{} ({})",
|
||||
e.movie().title().value(),
|
||||
e.movie().release_year().value()
|
||||
);
|
||||
let description = match (e.review().comment(), e.review().spoilers()) {
|
||||
(Some(_), true) => {
|
||||
format!("{}/5 — contains spoilers", e.review().rating().value())
|
||||
}
|
||||
(Some(c), false) => {
|
||||
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 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))
|
||||
.build()
|
||||
})
|
||||
.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()
|
||||
.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();
|
||||
|
||||
Ok(channel.to_string())
|
||||
}
|
||||
}
|
||||
73
crates/adapters/rss-adapter/tests/rss_adapter_test.rs
Normal file
73
crates/adapters/rss-adapter/tests/rss_adapter_test.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use domain::models::DiaryEntry;
|
||||
use domain::models::ReviewEdit;
|
||||
use domain::ports::{FeedChannel, RssFeedRenderer};
|
||||
use domain::testing::{test_movie_with_year, test_review};
|
||||
use domain::value_objects::{Comment, UserId};
|
||||
use rss_adapter::RssAdapter;
|
||||
|
||||
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 = test_movie_with_year("Blade Runner", 1982);
|
||||
let review = test_review(movie.id(), &UserId::generate(), 4).apply_edit(ReviewEdit {
|
||||
comment: Some(Some(Comment::new("replicants".into()).unwrap())),
|
||||
spoilers: Some(spoilers),
|
||||
..Default::default()
|
||||
});
|
||||
DiaryEntry::new(movie, review)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_feed_uses_provided_title() {
|
||||
let adapter = RssAdapter::new("http://example.com".into());
|
||||
let xml = adapter.render_feed(&[], &channel("Custom Title")).unwrap();
|
||||
assert!(xml.contains("<title>Custom Title</title>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_feed_empty_entries_produces_valid_xml() {
|
||||
let adapter = RssAdapter::new("http://example.com".into());
|
||||
let xml = adapter.render_feed(&[], &channel("My Feed")).unwrap();
|
||||
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(std::slice::from_ref(&e), &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"));
|
||||
}
|
||||
Reference in New Issue
Block a user