~nickbp/twitch-rss

38d1842d9cfc7b2846ced7c545d8887840db62e6 — Nick Parker 3 months ago 1e4bcf4
HTTP RSS server built, next is the twitch client/parser
4 files changed, 211 insertions(+), 4 deletions(-)

M Cargo.toml
A src/http.rs
A src/logging.rs
M src/main.rs
M Cargo.toml => Cargo.toml +2 -2
@@ 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"

A src/http.rs => src/http.rs +178 -0
@@ 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)
    }
}

A src/logging.rs => src/logging.rs +16 -0
@@ 0,0 1,16 @@
use tracing;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::{fmt, EnvFilter};

pub fn init_logging() {
    let filter_layer = EnvFilter::try_from_env("LOG_LEVEL")
        .or_else(|_| EnvFilter::try_new("info"))
        .expect("Failed to initialize filter layer");

    tracing::subscriber::set_global_default(
        tracing_subscriber::Registry::default()
            .with(filter_layer)
            .with(fmt::layer()),
    )
    .expect("Failed to set default subscriber");
}

M src/main.rs => src/main.rs +15 -2
@@ 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))
}