@@ 15,11 15,11 @@ anyhow = "1.0"
rss = "1.10"
# async support
async-std = "1.8"
-async-trait = "0.1"
# http client
surf = { version = "2.2", default-features = false, features = ["h1-client-rustls"] }
# http server
+async-trait = "0.1"
tide = { version = "0.16", default-features = false, features = ["h1-server"] }
# logging
tracing = "0.1"
-tracing-subscriber = "0.2">
\ No newline at end of file
+tracing-subscriber = "0.2"
@@ 0,0 1,178 @@
+use anyhow::{anyhow, Context, Result};
+use async_std::task;
+use rss::{ChannelBuilder, ItemBuilder};
+use tide::Request;
+use tracing::{debug, error, info, warn};
+use std::collections::HashMap;
+
+#[derive(Clone)]
+struct State {
+ api_key: String,
+ account_cache: HashMap<String, String>,
+}
+
+pub fn run(listen_endpoint: &str, api_key: String) -> Result<()> {
+ let state = State {
+ api_key,
+ account_cache: HashMap::new()
+ };
+
+ let mut app = tide::with_state(state);
+ // Enable request logging if debug (or trace)
+ app.with(LogMiddleware {});
+ app.at("/").get(handle_index);
+ app.at("/rss").get(handle_rss);
+ info!("Listening at {}", listen_endpoint);
+ return task::block_on(app.listen(listen_endpoint)).context("HTTP listener exited");
+}
+
+async fn handle_index(req: Request<State>) -> tide::Result {
+ Ok(tide::Response::builder(200)
+ .body(format!("<html>
+<head><title>twitch-rss</title></head>
+<body text='#fff' bgcolor='#000'><table width=100% height=100%><tr><td valign=center align=center>
+<h2>twitch-rss</h2>
+<p>Add this URL to your RSS reader:</p>
+<p>{}rss?account=<b>twitch-account</b></p>
+</td></tr></table></body>
+</html>
+", req.url()))
+ .content_type(tide::http::mime::HTML)
+ .build())
+}
+
+async fn handle_rss(req: Request<State>) -> tide::Result {
+ // Step 1: Show user+code input form for loading details, with prefill from get params (email link)
+ let mut query_account = None;
+ for (key, val) in req.url().query_pairs() {
+ if key == "account" {
+ query_account = Some(val.to_string());
+ }
+ }
+
+ match query_account {
+ Some(account) => {
+ let mut items = vec![];
+ // TODO fetch items from twitch over HTTP client and record them here...
+ let mut response = surf::get("http://example.com").body(req.state().api_key.as_str()).await?;
+ if !response.status().is_success() {
+ // Pass through underlying response as-is, so that RSS reader can see it
+ let response_body = response.body_string().await
+ .map_or_else(
+ |err| format!("Error when fetching response body: {}", err),
+ |body| body
+ );
+ return Ok(tide::Response::builder(response.status())
+ .body(response_body)
+ .content_type(response.content_type().unwrap_or(tide::http::mime::PLAIN))
+ .build());
+ }
+ // use req.state().api_key
+ items.push(
+ ItemBuilder::default()
+ .title("TODO video title".to_string())
+ .link(format!("http://twitch.tv/{}/video/idhere", account))
+ .description(format!("TODO video description"))
+ .pub_date(format!("1970-01-01T00:00:00Z")) // TODO RFC2822
+ .build()
+ .map_err(|e| anyhow!("Error when rendering RSS item: {}", e))?
+ );
+ let channel = ChannelBuilder::default()
+ .title(format!("Twitch: {}", account))
+ .link(format!("https://twitch.tv/{}", account))
+ .description(format!("twitch-rss feed for {}", account))
+ .generator("twitch-rss".to_string())
+ .items(items)
+ .build()
+ .map_err(|e| anyhow!("Error when rendering RSS channel: {}", e))?;
+ Ok(tide::Response::builder(200)
+ .body(channel.to_string())
+ .content_type("application/rss+xml")
+ .build())
+ },
+ None => {
+ Ok(tide::Response::builder(400)
+ .body("400 Bad Request: Missing 'account' parameter, e.g. /rss?account=x")
+ .content_type(tide::http::mime::PLAIN)
+ .build())
+ }
+ }
+}
+
+/// Copy/reimplementation of Tide's LogMiddleware, modified to instead work with the tracing library.
+#[derive(Debug, Default, Clone)]
+struct LogMiddleware {}
+
+struct LogMiddlewareHasBeenRun;
+
+#[async_trait::async_trait]
+impl<State: Clone + Send + Sync + 'static> tide::Middleware<State> for LogMiddleware {
+ async fn handle(&self, mut req: Request<State>, next: tide::Next<'_, State>) -> tide::Result {
+ if req.ext::<LogMiddlewareHasBeenRun>().is_some() {
+ return Ok(next.run(req).await);
+ }
+ req.set_ext(LogMiddlewareHasBeenRun);
+
+ let path = req.url().path().to_owned();
+ let method = req.method().to_string();
+ debug!("<-- Request received: method={} path={}", method, path);
+ let start = std::time::Instant::now();
+ let response = next.run(req).await;
+ let status = response.status();
+ if status.is_server_error() {
+ if let Some(error) = response.error() {
+ error!(
+ "Internal error --> Response sent: method={} path={} duration={:?} status={}/{} error={:?}/{:?}",
+ method,
+ path,
+ start.elapsed(),
+ status as u16,
+ status.canonical_reason(),
+ error.type_name(),
+ error,
+ );
+ } else {
+ error!(
+ "Internal error --> Response sent: method={} path={} duration={:?} status={}/{}",
+ method,
+ path,
+ start.elapsed(),
+ status as u16,
+ status.canonical_reason(),
+ );
+ }
+ } else if status.is_client_error() {
+ if let Some(error) = response.error() {
+ warn!(
+ "Client error --> Response sent: method={} path={} duration={:?} status={}/{} error={:?}/{:?}",
+ method,
+ path,
+ start.elapsed(),
+ status as u16,
+ status.canonical_reason(),
+ error.type_name(),
+ error,
+ );
+ } else {
+ warn!(
+ "Client error --> Response sent: method={} path={} duration={:?} status={}/{}",
+ method,
+ path,
+ start.elapsed(),
+ status as u16,
+ status.canonical_reason(),
+ );
+ }
+ } else {
+ info!(
+ "--> Response sent: method={} path={} duration={:?} status={}/{}",
+ method,
+ path,
+ start.elapsed(),
+ status as u16,
+ status.canonical_reason(),
+ );
+ }
+ Ok(response)
+ }
+}
@@ 1,3 1,16 @@
-fn main() {
- println!("Hello, world!");
+mod http;
+mod logging;
+
+use anyhow::{Context, Result};
+use std::env;
+
+fn main() -> Result<()> {
+ logging::init_logging();
+ let listen_endpoint = getenv("LISTEN", "Endpoint for HTTP service to listen on")?;
+ let api_key = getenv("API_KEY", "Twitch API key for querying feeds")?;
+ http::run(listen_endpoint.as_str(), api_key)
+}
+
+fn getenv(name: &str, desc: &str) -> Result<String> {
+ env::var(name).with_context(|| format!("Missing required envvar: {} ({})", name, desc))
}