feat(rss): implement RSS feed adapter and integrate with application state
This commit is contained in:
126
Cargo.lock
generated
126
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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::*;
|
||||
impl RssAdapter {
|
||||
pub fn new(feed_title: String, feed_link: String) -> Self {
|
||||
Self { feed_title, feed_link }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub use application::ports::HtmlRenderer;
|
||||
pub use application::ports::RssFeedRenderer;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user