//! HTML resources that help users troubleshoot problems.
//!
//! This provides endpoints with helpful troubleshooting advice, as well as
//! necessary application data to power them. The endpoints can be mounted on an
//! actix_web App using the exposed `make_service(…)` function.
//!
//! # Examples
//!
//! ```
//! # use things_to_check::view;
//! # #[actix_web::main]
//! # async fn main() -> std::result::Result<(), things_to_check::view::Error> {
//! use actix_web::{App, HttpServer};
//!
//! let service = view::make_service()?;
//! let app_factory = move ||
//! App::new()
//! .configure(|cfg| service(cfg));
//!
//! HttpServer::new(app_factory);
//! # Ok(())
//! # }
//! ```
//!
//! # Endpoints
//!
//! * `/` (`GET`): an HTML page suggesting one thing to check.
//!
//! Takes an optional `item` URL parameter, which must be an integer between 0
//! and the number of options available (not provided). If `item` is provided,
//! this endpoint returns a fixed result (the `item`th suggestion in the
//! backing data); otherwise, it returns a randomly-selected result, for
//! fortuitous suggesting.
//!
//! The returned page is always `text/html` on success. Invalid `item` indices
//! will return an error.
//!
//! * `/slack/troubleshoot` (`POST`): a Slack slash command endpoint suggesting
//! one thing to check.
//!
//! For information on the protocol, see [Slack's own
//! documentation](https://api.slack.com/interactivity/slash-commands). This
//! endpoint cheats furiously, and ignores Slack's recommendations around
//! validating requests, as there is no sensitive information returned from or
//! stored by this service.
//!
//! This returns a JSON message object in a Slack-compatible format, which
//! will print the suggestion to the channel where the `/troubleshoot` command
//! is invoked.
//!
//! # Data
//!
//! This module creates a data item in the configured application, consisting of
//! a list of strings loaded from a YAML constant. The data comes from a file in
//! this module parsed at compile time — our target deployment environments
//! don't support modifying it without triggering a rebuild anyways. It's parsed
//! on startup, however, and invalid data can cause `make_service` to fail.
//!
//! When adding suggestions, add them at the end. This will ensure that existing
//! links to existing items are not invalidated or changed - the `item`
//! parameter to the `/` endpoint is a literal index into this list.
use actix_web::{error, get, post, web, HttpRequest, HttpResponse, Responder};
use askama::Template;
use pulldown_cmark::{html, Options, Parser};
use rand::seq::SliceRandom;
use rand::thread_rng;
use serde::{Deserialize, Serialize};
use serde_urlencoded::ser;
use std::iter;
use thiserror::Error;
#[derive(Error, Debug)]
enum UrlError {
#[error("Unable to generate URL: {0}")]
UrlGenerationError(error::UrlGenerationError),
#[error("Unable to generate URL: {0}")]
SerializationError(#[from] ser::Error),
}
// In actix-web-2.0.0, UrlGenerationError neither implements Error nor Fail,
// so thiserror can't automatically generate a From implementation for us.
// This isn't perfect, but it gets the thing shipped. This omission is fixed in
// actix_web 3.0.0, which is in alpha as of this writing.
impl From for UrlError {
fn from(err: error::UrlGenerationError) -> Self {
UrlError::UrlGenerationError(err)
}
}
impl From for error::Error {
fn from(err: UrlError) -> Self {
error::ErrorInternalServerError(err)
}
}
trait Urls {
fn index(&self, query: &ItemQuery) -> Result;
// Askama always passes parameters from templates to functions as borrows,
// regardless of type; receiving a reference to a usize is silly, but as a
// result, necessary.
fn suggestion(&self, idx: &usize) -> Result {
self.index(&ItemQuery::from(*idx))
}
fn new_suggestion(&self) -> Result {
self.index(&ItemQuery::default())
}
}
impl Urls for HttpRequest {
fn index(&self, query: &ItemQuery) -> Result {
let mut url = self.url_for("index", iter::empty::<&str>())?;
let query = serde_urlencoded::to_string(query)?;
url.set_query(Some(&query));
Ok(url)
}
}
#[derive(Serialize, Deserialize, Default)]
struct ItemQuery {
item: Option,
}
impl From for ItemQuery {
fn from(idx: usize) -> Self {
ItemQuery { item: Some(idx) }
}
}
#[derive(Template)]
#[template(path = "index.html")]
struct Suggestion {
thing: Thing,
req: HttpRequest,
index: usize,
}
#[get("/")]
async fn index(
req: HttpRequest,
data: web::Data,
query: web::Query,
) -> error::Result {
let thing = match query.item {
Some(index) => data.0.get(index),
None => data.0.choose(&mut thread_rng()),
};
let thing = thing.ok_or_else(|| error::ErrorNotFound("Not found"))?;
let (index, thing) = thing.to_owned();
let response = Suggestion { thing, req, index };
let response = response
.customize()
.insert_header(("Cache-Control", "no-store"));
Ok(response)
}
#[derive(Serialize)]
struct SlackMessage<'a> {
response_type: &'static str,
text: &'a String,
}
#[post("/slack/troubleshoot")]
async fn slack_troubleshoot(data: web::Data) -> error::Result {
let thing = data.0.choose(&mut thread_rng());
let (_, thing) = thing.ok_or_else(|| error::ErrorNotFound("Not found"))?;
let response = SlackMessage {
response_type: "in_channel",
text: &thing.markdown,
};
Ok(HttpResponse::Ok().json(response))
}
const THINGS: &str = include_str!("things-to-check.yml");
#[derive(Clone)]
struct Thing {
markdown: String,
html: String,
}
impl From for Thing {
fn from(markdown: String) -> Self {
let options = Options::empty();
let parser = Parser::new_ext(&markdown, options);
let mut html = String::new();
html::push_html(&mut html, parser);
Thing { markdown, html }
}
}
#[derive(Clone)]
struct Things(Vec<(usize, Thing)>);
fn load_things(src: &str) -> serde_yaml::Result {
let raw_things: Vec = serde_yaml::from_str(src)?;
Ok(Things(
raw_things
.into_iter()
.map(Thing::from)
.enumerate()
.collect(),
))
}
/// Errors that can arise initializing the service.
#[derive(Error, Debug)]
pub enum Error {
/// Indicates that the included YAML was invalid in some way. This is only
/// fixable by recompiling the program with correct YAML.
#[error("Unable to load Things To Check YAML: {0}")]
DeserializeError(#[from] serde_yaml::Error),
}
/// Set up an instance of this service.
///
/// The returned function will configure any actix-web App with the necessary
/// state to tell people how to troubleshoot problems.
pub fn make_service() -> Result {
let things = load_things(THINGS)?;
Ok(move |cfg: &mut web::ServiceConfig| {
cfg.app_data(web::Data::new(things.clone()))
.service(index)
.service(slack_troubleshoot);
})
}