#![expect(
clippy::expect_used,
reason = "example/test/bench: panic-on-error and print-for-output are the standard patterns for demos and harnesses"
)]
use std::{convert::Infallible, sync::Arc, time::Duration};
use jiff::Timestamp;
use rama::{
Layer,
futures::async_stream::stream_fn,
http::{
Body,
headers::ContentType,
layer::{error_handling::ErrorHandlerLayer, trace::TraceLayer},
protocols::rss::{
Rss2Channel, Rss2Enclosure, Rss2Feed, Rss2Guid, Rss2Item, Rss2StreamWriter,
feed_ext::{
FeedExtensions, ITunes, ITunesFeed, ItemExtensions, Podcast, PodcastEpisode,
PodcastFeed, PodcastSeason,
},
},
server::HttpServer,
service::web::{
Router,
response::{Headers, IntoResponse},
},
},
net::{address::SocketAddress, uri::Uri},
rt::Executor,
tcp::server::TcpListener,
telemetry::tracing::{
self,
level_filters::LevelFilter,
subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt},
},
};
async fn podcast_feed() -> impl IntoResponse {
Rss2Feed::builder()
.title("Netstack.FM")
.link(Uri::from_static("https://netstack.fm"))
.description("The podcast about Rust networking and systems programming.")
.with_language("en")
.with_generator("rama/http_rss_podcast example")
.with_feed_extensions(FeedExtensions {
itunes: Some(Box::new(ITunesFeed {
author: Some("Netstack.FM".into()),
owner_name: Some("Glen De Cauwsemaecker".into()),
owner_email: Some("glen@plabayo.tech".into()),
categories: vec!["Technology".into(), "Software How-To".into()],
explicit: Some(false),
type_: Some("episodic".into()),
summary: Some("The podcast about Rust networking and systems programming.".into()),
..Default::default()
})),
podcast: Some(Box::new(PodcastFeed {
locked: Some(false),
medium: Some("podcast".into()),
..Default::default()
})),
..Default::default()
})
.with_items(EPISODES.iter().map(make_episode_item))
.build()
}
async fn podcast_stream() -> impl IntoResponse {
let channel = Rss2Channel {
title: "Netstack.FM (streaming)".into(),
link: Uri::from_static("https://netstack.fm"),
description: "Streamed podcast feed.".into(),
language: Some("en".into()),
generator: Some("rama Rss2StreamWriter".into()),
..Default::default()
};
let item_stream = stream_fn(move |mut yielder| async move {
let store = EpisodeStore::new(EPISODES, 2);
let mut offset = 0;
while let Some(page) = store.page(offset).await {
offset += page.len();
for ep in page {
yielder
.yield_item(Ok::<_, Infallible>(make_episode_item(ep)))
.await;
}
}
});
(
Headers::single(ContentType::rss()),
Body::from_stream(Rss2StreamWriter::new(channel, item_stream)),
)
}
struct EpisodeStore<'a> {
episodes: &'a [Episode],
page_size: usize,
}
impl<'a> EpisodeStore<'a> {
fn new(episodes: &'a [Episode], page_size: usize) -> Self {
Self {
episodes,
page_size,
}
}
async fn page(&self, offset: usize) -> Option<&'a [Episode]> {
if offset >= self.episodes.len() {
return None;
}
let end = (offset + self.page_size).min(self.episodes.len());
Some(&self.episodes[offset..end])
}
}
fn make_episode_item(ep: &Episode) -> Rss2Item {
Rss2Item::new()
.with_title(ep.title)
.with_description(ep.description)
.with_guid(Rss2Guid::permalink(ep.url))
.with_link(Uri::from_static(ep.url))
.with_pub_date(ep.pub_date)
.with_enclosure(Rss2Enclosure::new(
Uri::from_static(ep.audio_url),
ep.audio_bytes,
ep.audio_type,
))
.with_extensions(ItemExtensions {
itunes: Some(Box::new(ITunes {
title: Some(ep.title.into()),
duration: Some(ep.duration.into()),
episode: Some(ep.episode_number),
season: Some(ep.season),
episode_type: Some("full".into()),
explicit: Some(false),
..Default::default()
})),
podcast: Some(Box::new(Podcast {
season: Some(PodcastSeason {
number: ep.season,
name: Some(format!("Season {}", ep.season)),
}),
episode: Some(PodcastEpisode {
number: ep.episode_number as f64,
display: None,
}),
..Default::default()
})),
..Default::default()
})
}
#[tokio::main]
async fn main() {
tracing::subscriber::registry()
.with(fmt::layer())
.with(
EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env_lossy(),
)
.init();
let graceful = rama::graceful::Shutdown::default();
let exec = Executor::graceful(graceful.guard());
let listener = TcpListener::bind_address(SocketAddress::default_ipv4(62057), exec.clone())
.await
.expect("bind address");
let bind_address = listener.local_addr().expect("local addr");
tracing::info!(
network.local.address = %bind_address.ip(),
network.local.port = %bind_address.port(),
"http rss podcast listening",
);
tracing::info!(
"one-shot: curl http://{bind_address}/podcast.rss | streamed: curl http://{bind_address}/podcast-stream.rss"
);
graceful.spawn_task(async move {
let middlewares = (TraceLayer::new_for_http(), ErrorHandlerLayer::new());
let app = middlewares.into_layer(Arc::new(
Router::new()
.with_get("/podcast.rss", podcast_feed)
.with_get("/podcast-stream.rss", podcast_stream),
));
listener.serve(HttpServer::auto(exec).service(app)).await;
});
graceful
.shutdown_with_limit(Duration::from_secs(30))
.await
.expect("graceful shutdown");
}
struct Episode {
title: &'static str,
description: &'static str,
url: &'static str,
audio_url: &'static str,
audio_bytes: u64,
audio_type: &'static str,
duration: &'static str,
pub_date: Timestamp,
episode_number: u64,
season: u64,
}
static EPISODES: &[Episode] = &[
Episode {
title: "Episode 1 - Hello Networking",
description: "We kick off the show with an overview of Rust networking.",
url: "https://netstack.fm/episodes/1",
audio_url: "https://cdn.netstack.fm/audio/ep001.mp3",
audio_bytes: 48_000_000,
audio_type: "audio/mpeg",
duration: "50:12",
pub_date: Timestamp::UNIX_EPOCH,
episode_number: 1,
season: 1,
},
Episode {
title: "Episode 2 - TCP Deep Dive",
description: "A deep dive into TCP internals and how Rama models them.",
url: "https://netstack.fm/episodes/2",
audio_url: "https://cdn.netstack.fm/audio/ep002.mp3",
audio_bytes: 52_000_000,
audio_type: "audio/mpeg",
duration: "55:40",
pub_date: Timestamp::UNIX_EPOCH,
episode_number: 2,
season: 1,
},
Episode {
title: "Episode 3 - Proxies Demystified",
description: "Everything you need to know about building proxies in Rust.",
url: "https://netstack.fm/episodes/3",
audio_url: "https://cdn.netstack.fm/audio/ep003.mp3",
audio_bytes: 61_000_000,
audio_type: "audio/mpeg",
duration: "63:18",
pub_date: Timestamp::UNIX_EPOCH,
episode_number: 3,
season: 1,
},
];