slack-web-api 0.2.0

Typed async Slack Web API client for Rust: all 330 methods incl. admin, Block Kit, pagination, file upload, rate-limit retry (reqwest + rustls, no OpenSSL)
Documentation
use slack_web_api::{FileUpload, SlackClient, SlackError, UploadDestination};
use wiremock::matchers::{body_string, body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn json(body: String) -> ResponseTemplate {
    ResponseTemplate::new(200).set_body_raw(body, "application/json")
}

async fn setup() -> (MockServer, SlackClient) {
    let server = MockServer::start().await;
    let client = SlackClient::builder()
        .token("xoxb-1")
        .base_url(format!("{}/api", server.uri()))
        .build();
    (server, client)
}

async fn mount_upload_url(server: &MockServer, filename: &str, file_id: &str) {
    Mock::given(path("/api/files.getUploadURLExternal"))
        .and(body_string_contains(format!("filename={filename}")))
        .respond_with(json(format!(
            r#"{{"ok":true,"upload_url":"{}/upload/{file_id}","file_id":"{file_id}"}}"#,
            server.uri()
        )))
        .mount(server)
        .await;
}

#[tokio::test]
async fn uploads_files_in_parallel_and_completes_in_order() {
    let (server, client) = setup().await;
    mount_upload_url(&server, "a.txt", "F1").await;
    mount_upload_url(&server, "b.png", "F2").await;
    Mock::given(method("POST"))
        .and(path("/upload/F1"))
        .and(body_string("hello"))
        .respond_with(ResponseTemplate::new(200).set_body_string("OK - 5"))
        .expect(1)
        .mount(&server)
        .await;
    Mock::given(method("POST"))
        .and(path("/upload/F2"))
        .respond_with(ResponseTemplate::new(200))
        .expect(1)
        .mount(&server)
        .await;
    Mock::given(path("/api/files.completeUploadExternal"))
        .and(body_string(
            "files=%5B%7B%22id%22%3A%22F1%22%2C%22title%22%3A%22a.txt%22%7D%2C%7B%22id%22%3A%22F2%22%2C%22title%22%3A%22Chart%22%7D%5D\
             &channel_id=C1&thread_ts=1.2&initial_comment=see",
        ))
        .respond_with(json(r#"{"ok":true,"files":[{"id":"F1","title":"a.txt"},{"id":"F2","title":"Chart"}]}"#.into()))
        .expect(1)
        .mount(&server)
        .await;

    let res = client
        .upload_files(
            vec![
                FileUpload::new("a.txt", "hello"),
                FileUpload::new("b.png", vec![1u8, 2, 3])
                    .title("Chart")
                    .alt_txt("chart")
                    .snippet_type("png"),
            ],
            UploadDestination::channel("C1")
                .thread_ts("1.2")
                .initial_comment("see"),
        )
        .await
        .unwrap();
    let ids: Vec<_> = res.files.iter().filter_map(|f| f.id.as_deref()).collect();
    assert_eq!(ids, vec!["F1", "F2"]);
}

#[tokio::test]
async fn missing_upload_url_is_an_error() {
    let (server, client) = setup().await;
    Mock::given(path("/api/files.getUploadURLExternal"))
        .respond_with(json(r#"{"ok":true}"#.into()))
        .mount(&server)
        .await;
    let err = client
        .upload_files(
            vec![FileUpload::new("a.txt", "x")],
            UploadDestination::default(),
        )
        .await
        .unwrap_err();
    assert!(matches!(err, SlackError::MissingField(_)));
    assert!(err.to_string().starts_with("response is missing"));
}

#[tokio::test]
async fn failed_upload_stops_before_completing() {
    let (server, client) = setup().await;
    mount_upload_url(&server, "a.txt", "F1").await;
    Mock::given(path("/upload/F1"))
        .respond_with(ResponseTemplate::new(413).set_body_string("too large"))
        .mount(&server)
        .await;
    Mock::given(path("/api/files.completeUploadExternal"))
        .respond_with(json(r#"{"ok":true}"#.into()))
        .expect(0)
        .mount(&server)
        .await;
    let err = client
        .upload_files(
            vec![FileUpload::new("a.txt", "x")],
            UploadDestination::default(),
        )
        .await
        .unwrap_err();
    assert!(matches!(err, SlackError::Http { status: 413, ref body } if body == "too large"));
}

#[tokio::test]
async fn upload_url_errors_propagate() {
    let (server, client) = setup().await;
    Mock::given(path("/api/files.getUploadURLExternal"))
        .respond_with(json(r#"{"ok":false,"error":"not_authed"}"#.into()))
        .mount(&server)
        .await;
    let err = client
        .upload_files(
            vec![FileUpload::new("a.txt", "x")],
            UploadDestination::default(),
        )
        .await
        .unwrap_err();
    assert_eq!(err.api_error(), Some("not_authed"));
}

#[tokio::test]
async fn transport_failure_on_upload() {
    let (server, client) = setup().await;
    Mock::given(path("/api/files.getUploadURLExternal"))
        .respond_with(json(
            r#"{"ok":true,"upload_url":"not a url","file_id":"F1"}"#.into(),
        ))
        .mount(&server)
        .await;
    let err = client
        .upload_files(
            vec![FileUpload::new("a.txt", "x")],
            UploadDestination::default(),
        )
        .await
        .unwrap_err();
    assert!(matches!(err, SlackError::Transport(_)));
}

#[tokio::test]
async fn blocks_are_sent_with_the_completion() {
    use slack_web_api::blocks::{Block, DividerBlock};
    let (server, client) = setup().await;
    mount_upload_url(&server, "a.txt", "F1").await;
    Mock::given(path("/upload/F1"))
        .respond_with(ResponseTemplate::new(200))
        .mount(&server)
        .await;
    Mock::given(path("/api/files.completeUploadExternal"))
        .and(body_string_contains(
            "blocks=%5B%7B%22type%22%3A%22divider%22%7D%5D",
        ))
        .respond_with(json(r#"{"ok":true}"#.into()))
        .expect(1)
        .mount(&server)
        .await;
    client
        .upload_files(
            vec![FileUpload::new("a.txt", "x")],
            UploadDestination::channel("C1").blocks(vec![Block::from(DividerBlock::new())]),
        )
        .await
        .unwrap();
}

#[test]
fn builders_keep_values() {
    let a = FileUpload::new("a", "x").title("t");
    assert_eq!(a.clone(), a);
    assert!(format!("{a:?}").contains("title: Some(\"t\")"));
    let d = UploadDestination::channel("C1");
    assert_eq!(d.clone(), d);
    assert!(format!("{d:?}").contains("C1"));
}