Skip to main content

rama_http/protocols/rss/
feed.rs

1//! [`Feed`] / [`FeedItem`] umbrella types, [`IntoResponse`] impls, the
2//! `from_body` convenience that drains [`FeedStream`] into an in-memory feed,
3//! and the format-agnostic aggregator-style accessors on the two enums.
4//!
5//! There is **no synchronous high-level parser** — everything goes through
6//! the async streaming reader. Callers who need item-by-item processing use
7//! [`super::FeedStream`] directly; callers who just want the whole document
8//! call [`Feed::from_body`] (which is the "collect" adapter on top).
9//!
10//! The cross-format accessors on `Feed` / `FeedItem` (and the parallel ones
11//! on [`super::FeedStream`]) are for callers who don't know the upstream
12//! format ahead of time and just want title / link / author / etc. The
13//! per-format types ([`Rss2Feed`], [`AtomFeed`], …) keep their public fields
14//! and aren't decorated with these — direct field access is the obvious move
15//! when you already know the format.
16
17use jiff::Timestamp;
18use rama_net::uri::Uri;
19
20use crate::headers::ContentType;
21use crate::service::web::response::{Headers, IntoResponse};
22use crate::{Body, Response};
23
24use super::atom::{AtomEntry, AtomFeed, AtomLink};
25use super::error::FeedParseError;
26use super::rss2::{Rss2Enclosure, Rss2Feed, Rss2Item};
27use super::stream::FeedStream;
28
29/// A feed in either RSS 2.0 or Atom 1.0 format.
30#[derive(Debug, Clone, PartialEq)]
31pub enum Feed {
32    Rss2(Rss2Feed),
33    Atom(AtomFeed),
34}
35
36/// One item or entry, regardless of the originating feed format. Yielded by
37/// [`FeedStream`] when iterated as a [`Stream`]; convert into a strongly-typed
38/// `Rss2Item` / `AtomEntry` with the obvious `match`, or use the
39/// cross-format accessors directly on this enum.
40///
41/// [`Stream`]: rama_core::futures::Stream
42#[derive(Debug, Clone, PartialEq)]
43pub enum FeedItem {
44    Rss2(Rss2Item),
45    Atom(AtomEntry),
46}
47
48impl From<Rss2Item> for FeedItem {
49    fn from(i: Rss2Item) -> Self {
50        Self::Rss2(i)
51    }
52}
53
54impl From<AtomEntry> for FeedItem {
55    fn from(e: AtomEntry) -> Self {
56        Self::Atom(e)
57    }
58}
59
60impl FeedItem {
61    /// Atom requires a title; RSS makes it optional. Caller should be ready
62    /// for `None` on an RSS item that only carries `<description>`.
63    #[must_use]
64    pub fn title(&self) -> Option<&str> {
65        match self {
66            Self::Rss2(i) => i.title.as_deref(),
67            Self::Atom(e) => Some(e.title.value.as_str()),
68        }
69    }
70
71    /// RSS `<guid>` (optional) | Atom `<id>` (required).
72    #[must_use]
73    pub fn id(&self) -> Option<ItemIdView<'_>> {
74        match self {
75            Self::Rss2(i) => i
76                .guid
77                .as_ref()
78                .map(|g| ItemIdView::Rss2Guid(g.value.as_str())),
79            Self::Atom(e) => Some(ItemIdView::AtomId(&e.id)),
80        }
81    }
82
83    /// RSS `<link>` | Atom `<link rel="alternate">` (or first link without
84    /// `rel`, since Atom defaults `rel` to `"alternate"`).
85    #[must_use]
86    pub fn link(&self) -> Option<&Uri> {
87        match self {
88            Self::Rss2(i) => i.link.as_ref(),
89            Self::Atom(e) => pick_alternate(&e.links).map(|l| &l.href),
90        }
91    }
92
93    /// Short excerpt. RSS `<description>` | Atom `<summary>`.
94    #[must_use]
95    pub fn summary(&self) -> Option<&str> {
96        match self {
97            Self::Rss2(i) => i.description.as_deref(),
98            Self::Atom(e) => e.summary.as_ref().map(|t| t.value.as_str()),
99        }
100    }
101
102    /// Long-form body. Atom `<content>` (any of text/html/xhtml). For RSS,
103    /// returns `<content:encoded>` if present, otherwise falls back to
104    /// `<description>` — many publishers put the full body there. The fallback
105    /// means `summary()` and `content()` may return the same string on RSS
106    /// items without `content:encoded`.
107    ///
108    /// For Atom out-of-line content (`<content src="..." type="..."/>`)
109    /// returns `None`: the body lives at the remote URL, not in the feed.
110    /// Use the per-format types directly if you need the `src` / `type`
111    /// pair.
112    #[must_use]
113    pub fn content(&self) -> Option<&str> {
114        match self {
115            Self::Rss2(i) => i
116                .extensions
117                .content
118                .as_ref()
119                .and_then(|c| c.encoded.as_deref())
120                .or(i.description.as_deref()),
121            Self::Atom(e) => e
122                .content
123                .as_ref()
124                .filter(|c| c.src.is_none())
125                .map(|c| c.value.value.as_str()),
126        }
127    }
128
129    /// Item-level authors. RSS yields `<author>` (if any) plus the
130    /// `dc:creator` extension (if set, deduplicated against `<author>`);
131    /// Atom yields the `<author>` Person names. Empty strings are dropped.
132    pub fn authors(&self) -> impl Iterator<Item = &str> {
133        use rama_core::combinators::Either;
134        match self {
135            Self::Rss2(i) => {
136                let primary = i.author.as_deref();
137                let dc_creator = i
138                    .extensions
139                    .dublin_core
140                    .as_ref()
141                    .and_then(|d| d.creator.as_deref());
142                Either::A(
143                    [primary, dc_creator]
144                        .into_iter()
145                        .flatten()
146                        .filter(|s| !s.is_empty())
147                        // Two-element dedup: if dc:creator == <author>, skip it.
148                        .scan(None::<&str>, |last, s| {
149                            if Some(s) == *last {
150                                Some(None)
151                            } else {
152                                *last = Some(s);
153                                Some(Some(s))
154                            }
155                        })
156                        .flatten(),
157                )
158            }
159            Self::Atom(e) => Either::B(e.authors.iter().map(|p| p.name.as_str())),
160        }
161    }
162
163    /// RSS `<pubDate>` | Atom `<published>`.
164    #[must_use]
165    pub fn published(&self) -> Option<Timestamp> {
166        match self {
167            Self::Rss2(i) => i.pub_date,
168            Self::Atom(e) => e.published,
169        }
170    }
171
172    /// Atom `<updated>` (required) | RSS has no per-item updated — `None`.
173    #[must_use]
174    pub fn updated(&self) -> Option<Timestamp> {
175        match self {
176            Self::Rss2(_) => None,
177            Self::Atom(e) => Some(e.updated),
178        }
179    }
180
181    /// Item-level category names / terms.
182    pub fn categories(&self) -> impl Iterator<Item = &str> {
183        use rama_core::combinators::Either;
184        match self {
185            Self::Rss2(i) => Either::A(i.categories.iter().map(|c| c.name.as_str())),
186            Self::Atom(e) => Either::B(e.categories.iter().map(|c| c.term.as_str())),
187        }
188    }
189
190    /// Attached binaries (podcast audio etc.). RSS `<enclosure>` | Atom
191    /// `<link rel="enclosure">`. The two encodings normalise to the same
192    /// `(url, length, mime)` triple via [`EnclosureView`].
193    pub fn enclosures(&self) -> impl Iterator<Item = EnclosureView<'_>> {
194        use rama_core::combinators::Either;
195        match self {
196            Self::Rss2(i) => Either::A(i.enclosures.iter().map(EnclosureView::from)),
197            Self::Atom(e) => Either::B(
198                e.links
199                    .iter()
200                    .filter(|l| l.rel.as_deref() == Some("enclosure"))
201                    .map(EnclosureView::from),
202            ),
203        }
204    }
205}
206
207// ---------------------------------------------------------------------------
208// EnclosureView
209// ---------------------------------------------------------------------------
210
211/// Normalised view over an enclosure: RSS `<enclosure>` and Atom
212/// `<link rel="enclosure">` collapse to the same `(url, length, mime)` shape.
213/// `length` and `mime` are required by RSS but optional in Atom.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct EnclosureView<'a> {
216    pub url: &'a Uri,
217    pub length: Option<u64>,
218    pub mime: Option<&'a str>,
219}
220
221impl<'a> From<&'a Rss2Enclosure> for EnclosureView<'a> {
222    fn from(e: &'a Rss2Enclosure) -> Self {
223        Self {
224            url: &e.url,
225            length: Some(e.length),
226            mime: Some(&e.type_),
227        }
228    }
229}
230
231impl<'a> From<&'a AtomLink> for EnclosureView<'a> {
232    fn from(l: &'a AtomLink) -> Self {
233        Self {
234            url: &l.href,
235            length: l.length,
236            mime: l.type_.as_deref(),
237        }
238    }
239}
240
241/// Normalised view over an item identifier. RSS GUID values are opaque
242/// strings, while Atom IDs are URI values.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum ItemIdView<'a> {
245    Rss2Guid(&'a str),
246    AtomId(&'a Uri),
247}
248
249// ---------------------------------------------------------------------------
250// Small helpers used by both Feed and FeedItem accessors.
251// ---------------------------------------------------------------------------
252
253/// Pick the `alternate` link from a slice of Atom-style links, falling back
254/// to a link with no `rel` (Atom defaults `rel` to `"alternate"`). Shared
255/// with [`super::FeedStream`].
256pub(super) fn pick_alternate(links: &[AtomLink]) -> Option<&AtomLink> {
257    links
258        .iter()
259        .find(|l| l.rel.as_deref() == Some("alternate"))
260        .or_else(|| links.iter().find(|l| l.rel.is_none()))
261}
262
263pub(super) fn pick_rel<'a>(links: &'a [AtomLink], rel: &str) -> Option<&'a AtomLink> {
264    links.iter().find(|l| l.rel.as_deref() == Some(rel))
265}
266
267impl Feed {
268    /// Parse a feed from a [`Body`] by draining a [`FeedStream`]. This is the
269    /// convenience "collect" adapter — fine for the typical "give me the
270    /// whole document" client / proxy case, but it does buffer every item /
271    /// entry into memory.
272    ///
273    /// Use [`FeedStream::from_body`] directly if you want to process items
274    /// incrementally (e.g. filter podcast episodes as they stream in, or stop
275    /// after the first N items).
276    ///
277    /// For defence-in-depth on untrusted feeds, apply a `BodyLimit` layer
278    /// upstream — the streaming reader is bounded-memory per item but does
279    /// not cap the total document size on its own.
280    pub async fn from_body(body: Body) -> Result<Self, FeedParseError> {
281        match FeedStream::from_body(body).await?.collect().await {
282            Ok(feed) => Ok(feed),
283            Err(err) => Err(err.error),
284        }
285    }
286
287    /// Strict variant of [`Self::from_body`]. Collapses the
288    /// [`super::FeedCollectError`] from the underlying [`FeedStream::collect`]
289    /// into just its [`FeedParseError`]; if you need the partial feed on a
290    /// mid-stream error, drain a [`FeedStream`] yourself.
291    pub async fn from_body_strict(body: Body) -> Result<Self, FeedParseError> {
292        match FeedStream::from_body_strict(body).await?.collect().await {
293            Ok(feed) => Ok(feed),
294            Err(err) => Err(err.error),
295        }
296    }
297
298    /// Returns `true` if this is an RSS 2.0 feed.
299    #[must_use]
300    pub fn is_rss2(&self) -> bool {
301        matches!(self, Self::Rss2(_))
302    }
303
304    /// Returns `true` if this is an Atom feed.
305    #[must_use]
306    pub fn is_atom(&self) -> bool {
307        matches!(self, Self::Atom(_))
308    }
309
310    /// Returns the inner RSS 2.0 feed, if this is one.
311    #[must_use]
312    pub fn as_rss2(&self) -> Option<&Rss2Feed> {
313        match self {
314            Self::Rss2(f) => Some(f),
315            Self::Atom(_) => None,
316        }
317    }
318
319    /// Returns the inner Atom feed, if this is one.
320    #[must_use]
321    pub fn as_atom(&self) -> Option<&AtomFeed> {
322        match self {
323            Self::Atom(f) => Some(f),
324            Self::Rss2(_) => None,
325        }
326    }
327
328    // -----------------------------------------------------------------
329    // Cross-format accessors.
330    //
331    // The two specs use different vocabularies for the same concepts
332    // (RSS pubDate ↔ Atom published, RSS description ↔ Atom subtitle /
333    // summary / content, RSS <author> string ↔ Atom Person, …). These
334    // accessors return the obvious mapping; per-spec divergences are
335    // documented on each method. Per-format fields that have no
336    // counterpart in the other spec (Atom <id>, RSS <ttl>, iTunes /
337    // Podcasting 2.0 extensions, …) stay behind the per-format types
338    // and the Self::Rss2(_) / Self::Atom(_) discrimination.
339    // -----------------------------------------------------------------
340
341    /// Feed title (Atom requires; RSS technically requires too — empty if a
342    /// malformed feed lacks it).
343    #[must_use]
344    pub fn title(&self) -> &str {
345        match self {
346            Self::Rss2(f) => &f.title,
347            Self::Atom(f) => f.title.value.as_str(),
348        }
349    }
350
351    /// RSS `<description>` (required) | Atom `<subtitle>` (optional).
352    #[must_use]
353    pub fn description(&self) -> Option<&str> {
354        match self {
355            Self::Rss2(f) => Some(&f.description),
356            Self::Atom(f) => f.subtitle.as_ref().map(|t| t.value.as_str()),
357        }
358    }
359
360    /// Human-readable home URL of the feed. RSS `<link>` | Atom
361    /// `<link rel="alternate">` (or first link without `rel`, since Atom
362    /// defaults `rel` to `"alternate"`).
363    #[must_use]
364    pub fn link(&self) -> Option<&Uri> {
365        match self {
366            Self::Rss2(f) => Some(&f.link),
367            Self::Atom(f) => pick_alternate(&f.links).map(|l| &l.href),
368        }
369    }
370
371    /// Canonical URL of the feed document itself. RSS
372    /// `<atom:link rel="self">` | Atom `<link rel="self">`.
373    #[must_use]
374    pub fn self_link(&self) -> Option<&Uri> {
375        match self {
376            Self::Rss2(f) => pick_rel(&f.atom_links, "self").map(|l| &l.href),
377            Self::Atom(f) => pick_rel(&f.links, "self").map(|l| &l.href),
378        }
379    }
380
381    /// Atom `<id>` (required). RSS has no equivalent — always `None` for RSS.
382    #[must_use]
383    pub fn id(&self) -> Option<&Uri> {
384        match self {
385            Self::Rss2(_) => None,
386            Self::Atom(f) => Some(&f.id),
387        }
388    }
389
390    /// RSS `<language>` | Atom currently `None` (xml:lang on the root isn't
391    /// captured yet).
392    #[must_use]
393    pub fn language(&self) -> Option<&str> {
394        match self {
395            Self::Rss2(f) => f.language.as_deref(),
396            Self::Atom(_) => None,
397        }
398    }
399
400    /// RSS `<copyright>` | Atom `<rights>`.
401    #[must_use]
402    pub fn copyright(&self) -> Option<&str> {
403        match self {
404            Self::Rss2(f) => f.copyright.as_deref(),
405            Self::Atom(f) => f.rights.as_ref().map(|t| t.value.as_str()),
406        }
407    }
408
409    /// Generator string. RSS `<generator>` | Atom `<generator>` value (the
410    /// uri/version attributes are dropped — use the per-format type to keep
411    /// them).
412    #[must_use]
413    pub fn generator(&self) -> Option<&str> {
414        match self {
415            Self::Rss2(f) => f.generator.as_deref(),
416            Self::Atom(f) => f.generator.as_ref().map(|g| g.value.as_str()),
417        }
418    }
419
420    /// RSS `<image><url>` | Atom `<logo>`.
421    #[must_use]
422    pub fn image_url(&self) -> Option<&Uri> {
423        match self {
424            Self::Rss2(f) => f.image.as_ref().map(|i| &i.url),
425            Self::Atom(f) => f.logo.as_ref(),
426        }
427    }
428
429    /// Atom `<icon>` | RSS has no equivalent.
430    #[must_use]
431    pub fn icon_url(&self) -> Option<&Uri> {
432        match self {
433            Self::Rss2(_) => None,
434            Self::Atom(f) => f.icon.as_ref(),
435        }
436    }
437
438    /// RSS `<pubDate>` | Atom has no feed-level "first published" — `None`.
439    #[must_use]
440    pub fn published(&self) -> Option<Timestamp> {
441        match self {
442            Self::Rss2(f) => f.pub_date,
443            Self::Atom(_) => None,
444        }
445    }
446
447    /// RSS `<lastBuildDate>` | Atom `<updated>` (required).
448    #[must_use]
449    pub fn updated(&self) -> Option<Timestamp> {
450        match self {
451            Self::Rss2(f) => f.last_build_date,
452            Self::Atom(f) => Some(f.updated),
453        }
454    }
455
456    /// Feed-level authors. RSS yields `[managingEditor, webMaster]` (filtered,
457    /// in declaration order). Atom yields the `<author>` Person list (names).
458    pub fn authors(&self) -> impl Iterator<Item = &str> {
459        use rama_core::combinators::Either;
460        match self {
461            Self::Rss2(f) => Either::A(
462                [f.managing_editor.as_deref(), f.web_master.as_deref()]
463                    .into_iter()
464                    .flatten()
465                    .filter(|s| !s.is_empty()),
466            ),
467            Self::Atom(f) => Either::B(f.authors.iter().map(|p| p.name.as_str())),
468        }
469    }
470
471    /// Feed-level category names (RSS `<category>` names / Atom `<category>`
472    /// terms).
473    pub fn categories(&self) -> impl Iterator<Item = &str> {
474        use rama_core::combinators::Either;
475        match self {
476            Self::Rss2(f) => Either::A(f.categories.iter().map(|c| c.name.as_str())),
477            Self::Atom(f) => Either::B(f.categories.iter().map(|c| c.term.as_str())),
478        }
479    }
480}
481
482impl From<Rss2Feed> for Feed {
483    fn from(f: Rss2Feed) -> Self {
484        Self::Rss2(f)
485    }
486}
487
488impl From<AtomFeed> for Feed {
489    fn from(f: AtomFeed) -> Self {
490        Self::Atom(f)
491    }
492}
493
494// ---------------------------------------------------------------------------
495// IntoResponse
496// ---------------------------------------------------------------------------
497
498impl IntoResponse for Rss2Feed {
499    fn into_response(self) -> Response {
500        (
501            Headers::single(ContentType::rss()),
502            Body::from_stream(self.into_stream_writer()),
503        )
504            .into_response()
505    }
506}
507
508impl IntoResponse for AtomFeed {
509    fn into_response(self) -> Response {
510        (
511            Headers::single(ContentType::atom()),
512            Body::from_stream(self.into_stream_writer()),
513        )
514            .into_response()
515    }
516}
517
518impl IntoResponse for Feed {
519    fn into_response(self) -> Response {
520        match self {
521            Self::Rss2(f) => f.into_response(),
522            Self::Atom(f) => f.into_response(),
523        }
524    }
525}
526
527// ---------------------------------------------------------------------------
528// Tests
529// ---------------------------------------------------------------------------
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::{StatusCode, header};
535    use rama_net::uri::Uri;
536
537    #[test]
538    fn rss2_into_response_sets_content_type() {
539        let feed = Rss2Feed::builder()
540            .title("T")
541            .link(Uri::from_static("https://example.com"))
542            .description("D")
543            .build();
544        let resp = feed.into_response();
545        assert_eq!(resp.status(), StatusCode::OK);
546        let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
547        assert!(ct.to_str().unwrap().contains("rss+xml"));
548    }
549
550    #[test]
551    fn atom_into_response_sets_content_type() {
552        use crate::protocols::rss::atom::{AtomFeed, AtomText};
553        use jiff::Timestamp;
554        let feed = AtomFeed::builder()
555            .id(Uri::from_static("urn:x:1"))
556            .title(AtomText::text("T"))
557            .updated(Timestamp::UNIX_EPOCH)
558            .build();
559        let resp = feed.into_response();
560        let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
561        assert!(ct.to_str().unwrap().contains("atom+xml"));
562    }
563
564    #[test]
565    fn feed_umbrella_round_trips() {
566        let rss = Rss2Feed::builder()
567            .title("Blog")
568            .link(Uri::from_static("https://blog.example.com"))
569            .description("A blog")
570            .build();
571        let feed: Feed = rss.into();
572        assert!(feed.is_rss2());
573        assert_eq!(feed.title(), "Blog");
574        assert!(feed.as_rss2().is_some());
575        assert!(feed.as_atom().is_none());
576    }
577}