sfr-server 0.1.1

The server implementation for a Slack App.
Documentation
//! Convert the HTTP request from Slack to the internal type ([`SlackSlashCommand`]).

use sfr_core as sc;

use crate::error::ResponseError;
use axum::async_trait;
use axum::body::{Body, Bytes};
use axum::extract::FromRequest;
use axum::http::{header, Method, Request};
use sc::{Slack, SlashCommandBody};

/// The type that represents a slash command in Slack.
pub struct SlackSlashCommand(pub SlashCommandBody);

/// The key of header for signature.
const HEADER_KEY_SIGNATURE: &str = "X-Slack-Signature";
/// The key of header for timestamp.
const HEADER_KEY_TIMESTAMP: &str = "X-Slack-Request-Timestamp";
/// The value of `Content-Type` that is `application/x-www-form-urlencoded`.
const CONTENT_TYPE_VALUE_APPLICATION_WWW_FORM_URLENCODED: &str =
    "application/x-www-form-urlencoded";

#[async_trait]
impl<S> FromRequest<S> for SlackSlashCommand
where
    S: Send + Sync,
{
    type Rejection = ResponseError;

    async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
        let slack = req
            .extensions()
            .get::<Slack>()
            .ok_or(ResponseError::Unknown)?
            .clone();

        // > A payload is sent via an HTTP POST request to your app.
        // > https://api.slack.com/interactivity/slash-commands#getting_started
        if req.method() != Method::POST {
            return Err(ResponseError::MethodNotAllowed);
        }

        let signature = take_header(&req, HEADER_KEY_SIGNATURE)?.to_string();
        let timestamp = take_header(&req, HEADER_KEY_TIMESTAMP)?.to_string();
        if !has_content_type(&req, CONTENT_TYPE_VALUE_APPLICATION_WWW_FORM_URLENCODED) {
            return Err(ResponseError::InvalidHeader(String::from("Content-Type")));
        }

        let body = Bytes::from_request(req, state)
            .await
            .map_err(|_| ResponseError::ReadBody)?;

        slack
            .validate_request(&signature, &timestamp, body.as_ref())
            .map_err(|e| {
                ResponseError::InvalidHeader(format!("error in signature validation: {:?}", e))
            })?;

        let value: SlashCommandBody = serde_urlencoded::from_bytes(&body)
            .map_err(|e| ResponseError::DeserializeBody(Box::new(e)))?;
        slack
            .verification_token(&value)
            .map_err(|_| ResponseError::InvalidToken)?;
        Ok(SlackSlashCommand(value))
    }
}

/// Takes the header value from key.
fn take_header<'a>(req: &'a Request<Body>, name: &'a str) -> Result<&'a str, ResponseError> {
    let value = req
        .headers()
        .get(name)
        .and_then(|x| x.to_str().ok())
        .ok_or_else(|| ResponseError::InvalidHeader(format!("`{name}` is not found")))?;
    Ok(value)
}

/// Validates to match the `Content-Type` in the request and the argument (`content_type`).
fn has_content_type(req: &Request<Body>, content_type: &str) -> bool {
    let Ok(value) = take_header(req, header::CONTENT_TYPE.as_str()) else {
        return false;
    };
    value.starts_with(content_type)
}