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

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)