1use 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#[derive(Debug, Clone, PartialEq)]
31pub enum Feed {
32 Rss2(Rss2Feed),
33 Atom(AtomFeed),
34}
35
36#[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 #[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 #[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 #[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 #[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 #[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 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 .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 #[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 #[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 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum ItemIdView<'a> {
245 Rss2Guid(&'a str),
246 AtomId(&'a Uri),
247}
248
249pub(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 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 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 #[must_use]
300 pub fn is_rss2(&self) -> bool {
301 matches!(self, Self::Rss2(_))
302 }
303
304 #[must_use]
306 pub fn is_atom(&self) -> bool {
307 matches!(self, Self::Atom(_))
308 }
309
310 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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
494impl 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#[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}