use jiff::Timestamp;
use rama_net::uri::Uri;
use crate::headers::ContentType;
use crate::service::web::response::{Headers, IntoResponse};
use crate::{Body, Response};
use super::atom::{AtomEntry, AtomFeed, AtomLink};
use super::error::FeedParseError;
use super::rss2::{Rss2Enclosure, Rss2Feed, Rss2Item};
use super::stream::FeedStream;
#[derive(Debug, Clone, PartialEq)]
pub enum Feed {
Rss2(Rss2Feed),
Atom(AtomFeed),
}
#[derive(Debug, Clone, PartialEq)]
pub enum FeedItem {
Rss2(Rss2Item),
Atom(AtomEntry),
}
impl From<Rss2Item> for FeedItem {
fn from(i: Rss2Item) -> Self {
Self::Rss2(i)
}
}
impl From<AtomEntry> for FeedItem {
fn from(e: AtomEntry) -> Self {
Self::Atom(e)
}
}
impl FeedItem {
#[must_use]
pub fn title(&self) -> Option<&str> {
match self {
Self::Rss2(i) => i.title.as_deref(),
Self::Atom(e) => Some(e.title.value.as_str()),
}
}
#[must_use]
pub fn id(&self) -> Option<ItemIdView<'_>> {
match self {
Self::Rss2(i) => i
.guid
.as_ref()
.map(|g| ItemIdView::Rss2Guid(g.value.as_str())),
Self::Atom(e) => Some(ItemIdView::AtomId(&e.id)),
}
}
#[must_use]
pub fn link(&self) -> Option<&Uri> {
match self {
Self::Rss2(i) => i.link.as_ref(),
Self::Atom(e) => pick_alternate(&e.links).map(|l| &l.href),
}
}
#[must_use]
pub fn summary(&self) -> Option<&str> {
match self {
Self::Rss2(i) => i.description.as_deref(),
Self::Atom(e) => e.summary.as_ref().map(|t| t.value.as_str()),
}
}
#[must_use]
pub fn content(&self) -> Option<&str> {
match self {
Self::Rss2(i) => i
.extensions
.content
.as_ref()
.and_then(|c| c.encoded.as_deref())
.or(i.description.as_deref()),
Self::Atom(e) => e
.content
.as_ref()
.filter(|c| c.src.is_none())
.map(|c| c.value.value.as_str()),
}
}
pub fn authors(&self) -> impl Iterator<Item = &str> {
use rama_core::combinators::Either;
match self {
Self::Rss2(i) => {
let primary = i.author.as_deref();
let dc_creator = i
.extensions
.dublin_core
.as_ref()
.and_then(|d| d.creator.as_deref());
Either::A(
[primary, dc_creator]
.into_iter()
.flatten()
.filter(|s| !s.is_empty())
.scan(None::<&str>, |last, s| {
if Some(s) == *last {
Some(None)
} else {
*last = Some(s);
Some(Some(s))
}
})
.flatten(),
)
}
Self::Atom(e) => Either::B(e.authors.iter().map(|p| p.name.as_str())),
}
}
#[must_use]
pub fn published(&self) -> Option<Timestamp> {
match self {
Self::Rss2(i) => i.pub_date,
Self::Atom(e) => e.published,
}
}
#[must_use]
pub fn updated(&self) -> Option<Timestamp> {
match self {
Self::Rss2(_) => None,
Self::Atom(e) => Some(e.updated),
}
}
pub fn categories(&self) -> impl Iterator<Item = &str> {
use rama_core::combinators::Either;
match self {
Self::Rss2(i) => Either::A(i.categories.iter().map(|c| c.name.as_str())),
Self::Atom(e) => Either::B(e.categories.iter().map(|c| c.term.as_str())),
}
}
pub fn enclosures(&self) -> impl Iterator<Item = EnclosureView<'_>> {
use rama_core::combinators::Either;
match self {
Self::Rss2(i) => Either::A(i.enclosures.iter().map(EnclosureView::from)),
Self::Atom(e) => Either::B(
e.links
.iter()
.filter(|l| l.rel.as_deref() == Some("enclosure"))
.map(EnclosureView::from),
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EnclosureView<'a> {
pub url: &'a Uri,
pub length: Option<u64>,
pub mime: Option<&'a str>,
}
impl<'a> From<&'a Rss2Enclosure> for EnclosureView<'a> {
fn from(e: &'a Rss2Enclosure) -> Self {
Self {
url: &e.url,
length: Some(e.length),
mime: Some(&e.type_),
}
}
}
impl<'a> From<&'a AtomLink> for EnclosureView<'a> {
fn from(l: &'a AtomLink) -> Self {
Self {
url: &l.href,
length: l.length,
mime: l.type_.as_deref(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemIdView<'a> {
Rss2Guid(&'a str),
AtomId(&'a Uri),
}
pub(super) fn pick_alternate(links: &[AtomLink]) -> Option<&AtomLink> {
links
.iter()
.find(|l| l.rel.as_deref() == Some("alternate"))
.or_else(|| links.iter().find(|l| l.rel.is_none()))
}
pub(super) fn pick_rel<'a>(links: &'a [AtomLink], rel: &str) -> Option<&'a AtomLink> {
links.iter().find(|l| l.rel.as_deref() == Some(rel))
}
impl Feed {
pub async fn from_body(body: Body) -> Result<Self, FeedParseError> {
match FeedStream::from_body(body).await?.collect().await {
Ok(feed) => Ok(feed),
Err(err) => Err(err.error),
}
}
pub async fn from_body_strict(body: Body) -> Result<Self, FeedParseError> {
match FeedStream::from_body_strict(body).await?.collect().await {
Ok(feed) => Ok(feed),
Err(err) => Err(err.error),
}
}
#[must_use]
pub fn is_rss2(&self) -> bool {
matches!(self, Self::Rss2(_))
}
#[must_use]
pub fn is_atom(&self) -> bool {
matches!(self, Self::Atom(_))
}
#[must_use]
pub fn as_rss2(&self) -> Option<&Rss2Feed> {
match self {
Self::Rss2(f) => Some(f),
Self::Atom(_) => None,
}
}
#[must_use]
pub fn as_atom(&self) -> Option<&AtomFeed> {
match self {
Self::Atom(f) => Some(f),
Self::Rss2(_) => None,
}
}
#[must_use]
pub fn title(&self) -> &str {
match self {
Self::Rss2(f) => &f.title,
Self::Atom(f) => f.title.value.as_str(),
}
}
#[must_use]
pub fn description(&self) -> Option<&str> {
match self {
Self::Rss2(f) => Some(&f.description),
Self::Atom(f) => f.subtitle.as_ref().map(|t| t.value.as_str()),
}
}
#[must_use]
pub fn link(&self) -> Option<&Uri> {
match self {
Self::Rss2(f) => Some(&f.link),
Self::Atom(f) => pick_alternate(&f.links).map(|l| &l.href),
}
}
#[must_use]
pub fn self_link(&self) -> Option<&Uri> {
match self {
Self::Rss2(f) => pick_rel(&f.atom_links, "self").map(|l| &l.href),
Self::Atom(f) => pick_rel(&f.links, "self").map(|l| &l.href),
}
}
#[must_use]
pub fn id(&self) -> Option<&Uri> {
match self {
Self::Rss2(_) => None,
Self::Atom(f) => Some(&f.id),
}
}
#[must_use]
pub fn language(&self) -> Option<&str> {
match self {
Self::Rss2(f) => f.language.as_deref(),
Self::Atom(_) => None,
}
}
#[must_use]
pub fn copyright(&self) -> Option<&str> {
match self {
Self::Rss2(f) => f.copyright.as_deref(),
Self::Atom(f) => f.rights.as_ref().map(|t| t.value.as_str()),
}
}
#[must_use]
pub fn generator(&self) -> Option<&str> {
match self {
Self::Rss2(f) => f.generator.as_deref(),
Self::Atom(f) => f.generator.as_ref().map(|g| g.value.as_str()),
}
}
#[must_use]
pub fn image_url(&self) -> Option<&Uri> {
match self {
Self::Rss2(f) => f.image.as_ref().map(|i| &i.url),
Self::Atom(f) => f.logo.as_ref(),
}
}
#[must_use]
pub fn icon_url(&self) -> Option<&Uri> {
match self {
Self::Rss2(_) => None,
Self::Atom(f) => f.icon.as_ref(),
}
}
#[must_use]
pub fn published(&self) -> Option<Timestamp> {
match self {
Self::Rss2(f) => f.pub_date,
Self::Atom(_) => None,
}
}
#[must_use]
pub fn updated(&self) -> Option<Timestamp> {
match self {
Self::Rss2(f) => f.last_build_date,
Self::Atom(f) => Some(f.updated),
}
}
pub fn authors(&self) -> impl Iterator<Item = &str> {
use rama_core::combinators::Either;
match self {
Self::Rss2(f) => Either::A(
[f.managing_editor.as_deref(), f.web_master.as_deref()]
.into_iter()
.flatten()
.filter(|s| !s.is_empty()),
),
Self::Atom(f) => Either::B(f.authors.iter().map(|p| p.name.as_str())),
}
}
pub fn categories(&self) -> impl Iterator<Item = &str> {
use rama_core::combinators::Either;
match self {
Self::Rss2(f) => Either::A(f.categories.iter().map(|c| c.name.as_str())),
Self::Atom(f) => Either::B(f.categories.iter().map(|c| c.term.as_str())),
}
}
}
impl From<Rss2Feed> for Feed {
fn from(f: Rss2Feed) -> Self {
Self::Rss2(f)
}
}
impl From<AtomFeed> for Feed {
fn from(f: AtomFeed) -> Self {
Self::Atom(f)
}
}
impl IntoResponse for Rss2Feed {
fn into_response(self) -> Response {
(
Headers::single(ContentType::rss()),
Body::from_stream(self.into_stream_writer()),
)
.into_response()
}
}
impl IntoResponse for AtomFeed {
fn into_response(self) -> Response {
(
Headers::single(ContentType::atom()),
Body::from_stream(self.into_stream_writer()),
)
.into_response()
}
}
impl IntoResponse for Feed {
fn into_response(self) -> Response {
match self {
Self::Rss2(f) => f.into_response(),
Self::Atom(f) => f.into_response(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{StatusCode, header};
use rama_net::uri::Uri;
#[test]
fn rss2_into_response_sets_content_type() {
let feed = Rss2Feed::builder()
.title("T")
.link(Uri::from_static("https://example.com"))
.description("D")
.build();
let resp = feed.into_response();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
assert!(ct.to_str().unwrap().contains("rss+xml"));
}
#[test]
fn atom_into_response_sets_content_type() {
use crate::protocols::rss::atom::{AtomFeed, AtomText};
use jiff::Timestamp;
let feed = AtomFeed::builder()
.id(Uri::from_static("urn:x:1"))
.title(AtomText::text("T"))
.updated(Timestamp::UNIX_EPOCH)
.build();
let resp = feed.into_response();
let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
assert!(ct.to_str().unwrap().contains("atom+xml"));
}
#[test]
fn feed_umbrella_round_trips() {
let rss = Rss2Feed::builder()
.title("Blog")
.link(Uri::from_static("https://blog.example.com"))
.description("A blog")
.build();
let feed: Feed = rss.into();
assert!(feed.is_rss2());
assert_eq!(feed.title(), "Blog");
assert!(feed.as_rss2().is_some());
assert!(feed.as_atom().is_none());
}
}