feat(rss): implement RSS feed adapter and integrate with application state

This commit is contained in:
2026-05-04 12:03:17 +02:00
parent edcf3c1170
commit f790fa2a0f
12 changed files with 234 additions and 11 deletions

126
Cargo.lock generated
View File

@@ -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"

View File

@@ -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 }

View File

@@ -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<String, String> {
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::<Vec<_>>();
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())
}
}

View File

@@ -3,3 +3,7 @@ use domain::models::{DiaryEntry, collections::Paginated};
pub trait HtmlRenderer: Send + Sync {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>) -> Result<String, String>;
}
pub trait RssFeedRenderer: Send + Sync {
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String>;
}

View File

@@ -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"] }

View File

@@ -79,6 +79,11 @@ mod tests {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>) -> Result<String, String> { panic!() }
}
struct PanicRssRenderer;
impl crate::ports::RssFeedRenderer for PanicRssRenderer {
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { 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<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, 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<Vec<u8>, 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);

View File

@@ -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<AppState>) -> Result<impl IntoResponse, ApiError> {
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,

View File

@@ -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<AppState> {
Ok(AppState {
app_ctx,
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new(
"Movie Diary".into(),
"http://localhost:3000".into(),
)),
})
}

View File

@@ -1 +1,2 @@
pub use application::ports::HtmlRenderer;
pub use application::ports::RssFeedRenderer;

View File

@@ -16,6 +16,7 @@ fn html_routes() -> Router<AppState> {
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<AppState> {

View File

@@ -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<dyn HtmlRenderer>,
pub rss_renderer: Arc<dyn RssFeedRenderer>,
}

View File

@@ -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)