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::api::{ConversationsListRequest, ConversationsListResponse};
use slack_web_api::{SlackClient, SlackError};
use std::time::Duration;
use wiremock::matchers::{body_string, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

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

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

#[tokio::test]
async fn sends_form_body_with_bearer_token() {
    let (server, client) = server().await;
    Mock::given(method("POST"))
        .and(path("/api/conversations.list"))
        .and(header("authorization", "Bearer xoxb-1"))
        .and(header("content-type", "application/x-www-form-urlencoded"))
        .and(body_string("limit=2&types=public_channel%2Cim"))
        .respond_with(json(
            r#"{"ok":true,"channels":[{"id":"C1","name":"general","is_channel":true}]}"#,
        ))
        .expect(1)
        .mount(&server)
        .await;
    let res = client
        .conversations_list(
            &ConversationsListRequest::new()
                .limit(2)
                .types("public_channel,im"),
        )
        .await
        .unwrap();
    assert_eq!(res.channels[0].id.as_deref(), Some("C1"));
    assert_eq!(res.channels[0].is_channel, Some(true));
}

#[tokio::test]
async fn api_errors_carry_details() {
    let (server, client) = server().await;
    Mock::given(path("/api/chat.postMessage"))
        .respond_with(json(
            r#"{"ok":false,"error":"invalid_blocks","warning":"missing_charset","response_metadata":{"messages":["[ERROR] bad"]}}"#,
        ))
        .mount(&server)
        .await;
    let err = client
        .call_raw("chat.postMessage", &serde_json::json!({"channel": "C1"}))
        .await
        .unwrap_err();
    assert_eq!(err.api_error(), Some("invalid_blocks"));
    assert_eq!(err.to_string(), "slack api error: invalid_blocks");
    let SlackError::Api(api) = err else {
        panic!("expected api error")
    };
    assert_eq!(api.warning.as_deref(), Some("missing_charset"));
    assert_eq!(
        api.response_metadata.unwrap().messages,
        vec!["[ERROR] bad".to_string()]
    );
}

#[tokio::test]
async fn api_error_without_error_field() {
    let (server, client) = server().await;
    Mock::given(path("/api/api.test"))
        .respond_with(json(r#"{"ok":false}"#))
        .mount(&server)
        .await;
    let err = client.call_raw("api.test", &()).await.unwrap_err();
    assert_eq!(err.api_error(), Some(""));
}

#[tokio::test]
async fn retries_after_rate_limit() {
    let (server, client) = server().await;
    Mock::given(path("/api/api.test"))
        .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
        .up_to_n_times(2)
        .mount(&server)
        .await;
    Mock::given(path("/api/api.test"))
        .respond_with(json(r#"{"ok":true,"args":{}}"#))
        .mount(&server)
        .await;
    let res = client.call_raw("api.test", &()).await.unwrap();
    assert_eq!(res["ok"], true);
}

#[tokio::test]
async fn rate_limit_without_retry_after_waits_one_second() {
    let (server, client) = server().await;
    Mock::given(path("/api/api.test"))
        .respond_with(ResponseTemplate::new(429))
        .up_to_n_times(1)
        .mount(&server)
        .await;
    Mock::given(path("/api/api.test"))
        .respond_with(json(r#"{"ok":true}"#))
        .mount(&server)
        .await;
    let started = std::time::Instant::now();
    client.call_raw("api.test", &()).await.unwrap();
    assert!(started.elapsed() >= Duration::from_secs(1));
}

#[tokio::test]
async fn gives_up_after_max_retries() {
    let server = MockServer::start().await;
    let client = SlackClient::builder()
        .base_url(format!("{}/api/", server.uri()))
        .max_retries(0)
        .build();
    Mock::given(path("/api/api.test"))
        .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "30"))
        .mount(&server)
        .await;
    let err = client.call_raw("api.test", &()).await.unwrap_err();
    assert!(
        matches!(err, SlackError::RateLimited { retry_after: Some(d) } if d == Duration::from_secs(30))
    );
    assert_eq!(err.api_error(), None);
}

#[tokio::test]
async fn non_json_error_status_is_http_error() {
    let (server, client) = server().await;
    Mock::given(path("/api/api.test"))
        .respond_with(ResponseTemplate::new(503).set_body_string("down"))
        .mount(&server)
        .await;
    let err = client.call_raw("api.test", &()).await.unwrap_err();
    assert!(matches!(err, SlackError::Http { status: 503, ref body } if body == "down"));
}

#[tokio::test]
async fn undecodable_bodies_are_decode_errors() {
    let (server, client) = server().await;
    Mock::given(path("/api/api.test"))
        .respond_with(ResponseTemplate::new(200).set_body_string("<html>"))
        .mount(&server)
        .await;
    Mock::given(path("/api/conversations.list"))
        .respond_with(json(r#"{"ok":true,"channels":[1]}"#))
        .mount(&server)
        .await;
    let err = client.call_raw("api.test", &()).await.unwrap_err();
    assert!(matches!(err, SlackError::Decode { ref body, .. } if body == "<html>"));
    let err = client
        .conversations_list(&ConversationsListRequest::new())
        .await
        .unwrap_err();
    assert!(err.to_string().starts_with("failed to decode response"));
}

#[tokio::test]
async fn encode_errors_are_reported_before_sending() {
    let (_server, client) = server().await;
    let err = client.call_raw("api.test", &1).await.unwrap_err();
    assert_eq!(
        err.to_string(),
        "failed to encode request: form body must be a struct or map"
    );
}

#[tokio::test]
async fn transport_errors_are_reported() {
    let client = SlackClient::builder().base_url("not a url").build();
    let err = client.call_raw("api.test", &()).await.unwrap_err();
    assert!(matches!(err, SlackError::Transport(_)));
}

#[tokio::test]
async fn with_token_shares_settings_and_swaps_token() {
    let (server, client) = server().await;
    Mock::given(path("/api/auth.test"))
        .and(header("authorization", "Bearer xoxb-2"))
        .respond_with(json(r#"{"ok":true,"team_id":"T2"}"#))
        .expect(1)
        .mount(&server)
        .await;
    let other = client.with_token("xoxb-2");
    let res = other
        .auth_test(&slack_web_api::api::AuthTestRequest::new())
        .await
        .unwrap();
    assert_eq!(res.team_id.as_deref(), Some("T2"));
}

#[test]
fn debug_hides_the_token() {
    let client = SlackClient::new("xoxb-secret");
    let debug = format!("{client:?}");
    assert!(!debug.contains("secret"));
    assert!(debug.contains("***"));
    assert!(debug.contains("https://slack.com/api/"));
    let anonymous = SlackClient::builder()
        .http_client(reqwest::Client::new())
        .build();
    assert!(format!("{anonymous:?}").contains("token: None"));
}

#[tokio::test]
async fn pages_follow_next_cursor() {
    let (server, client) = server().await;
    Mock::given(path("/api/conversations.list"))
        .and(body_string("limit=1"))
        .respond_with(json(
            r#"{"ok":true,"channels":[{"id":"C1"}],"response_metadata":{"next_cursor":"abc"}}"#,
        ))
        .mount(&server)
        .await;
    Mock::given(path("/api/conversations.list"))
        .and(body_string("cursor=abc&limit=1"))
        .respond_with(json(
            r#"{"ok":true,"channels":[{"id":"C2"}],"response_metadata":{"next_cursor":""}}"#,
        ))
        .mount(&server)
        .await;
    let mut pages = client.pages(ConversationsListRequest::new().limit(1));
    let mut ids = vec![];
    while let Some(page) = pages.next_page().await {
        let page: ConversationsListResponse = page.unwrap();
        ids.extend(page.channels.into_iter().filter_map(|c| c.id));
    }
    assert_eq!(ids, vec!["C1", "C2"]);
    assert!(pages.next_page().await.is_none());
}

#[tokio::test]
async fn pages_stop_after_an_error() {
    let (server, client) = server().await;
    Mock::given(path("/api/conversations.list"))
        .respond_with(json(r#"{"ok":false,"error":"invalid_cursor"}"#))
        .mount(&server)
        .await;
    let mut pages = client.pages(ConversationsListRequest::new());
    assert!(pages.next_page().await.unwrap().is_err());
    assert!(pages.next_page().await.is_none());
}

#[tokio::test]
async fn file_download_methods_return_bytes_or_errors() {
    use slack_web_api::api::AdminAnalyticsGetFileRequest;
    let (server, client) = server().await;
    let req = AdminAnalyticsGetFileRequest::new("member").date("2020-09-01");
    Mock::given(path("/api/admin.analytics.getFile"))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0x1f, 0x8b, 0x08]))
        .up_to_n_times(1)
        .mount(&server)
        .await;
    assert_eq!(
        &client.admin_analytics_get_file(&req).await.unwrap()[..],
        &[0x1f, 0x8b, 0x08]
    );

    Mock::given(path("/api/admin.analytics.getFile"))
        .respond_with(json(r#" {"ok":false,"error":"file_not_yet_available"}"#))
        .up_to_n_times(1)
        .mount(&server)
        .await;
    let err = client.admin_analytics_get_file(&req).await.unwrap_err();
    assert_eq!(err.api_error(), Some("file_not_yet_available"));

    Mock::given(path("/api/admin.analytics.getFile"))
        .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
        .mount(&server)
        .await;
    let err = client.admin_analytics_get_file(&req).await.unwrap_err();
    assert!(matches!(err, SlackError::Http { status: 500, .. }));
}

#[tokio::test]
async fn empty_block_lists_are_sent_as_json_arrays() {
    use slack_web_api::api::ChatUpdateRequest;
    let (server, client) = server().await;
    Mock::given(path("/api/chat.update"))
        .and(body_string(
            "channel=C1&ts=1.2&attachments=%5B%5D&blocks=%5B%5D&text=t",
        ))
        .respond_with(json(r#"{"ok":true,"channel":"C1","ts":"1.2"}"#))
        .expect(1)
        .mount(&server)
        .await;
    client
        .chat_update(
            &ChatUpdateRequest::new("C1", "1.2")
                .attachments(vec![])
                .blocks(vec![])
                .text("t"),
        )
        .await
        .unwrap();
}

#[tokio::test]
async fn metadata_without_payload_and_unknown_fields_are_kept() {
    use slack_web_api::api::{ConversationsHistoryRequest, EmojiListRequest, ReactionsAddRequest};
    let (server, client) = server().await;
    Mock::given(path("/api/conversations.history"))
        .respond_with(json(r#"{"ok":true,"messages":[{"ts":"1.2","text":"x","metadata":{"event_type":"task_created"}}]}"#))
        .mount(&server)
        .await;
    Mock::given(path("/api/emoji.list"))
        .respond_with(json(
            r#"{"ok":true,"emoji":{"party_parrot":"https://e/p.gif"}}"#,
        ))
        .mount(&server)
        .await;
    Mock::given(path("/api/reactions.add"))
        .respond_with(json(r#"{"ok":true,"undocumented":1}"#))
        .mount(&server)
        .await;
    let history = client
        .conversations_history(&ConversationsHistoryRequest::new("C1"))
        .await
        .unwrap();
    let metadata = history.messages[0].metadata.as_ref().unwrap();
    assert_eq!(metadata.event_type, "task_created");
    assert!(metadata.event_payload.is_null());
    let emoji = client.emoji_list(&EmojiListRequest::new()).await.unwrap();
    assert_eq!(emoji.emoji.unwrap()["party_parrot"], "https://e/p.gif");
    let added = client
        .reactions_add(&ReactionsAddRequest::new("C1", "thumbsup", "1.2"))
        .await
        .unwrap();
    assert_eq!(added.extra["undocumented"], 1);
}