Slack から KING OF TIME に打刻する社内ツールで Rust 入門しました

とりあえず [The Book](https://doc.rust-lang.org/book/) やってたんですが、座学続けられないタイプなもので 12 章あたりで疲れてしまったので、
もう書いてみるかということで、もともと TypeScript で書いて AWS Lambda で動かしている Slack [Slash Commands](https://api.slack.com/interactivity/slash-commands) で会社の勤怠管理システム [KING OF TIME](https://www.kingtime.jp/) に打刻するプログラムをリプレースしてみました。

## インストールしておくもの
- [Rust](https://www.rust-lang.org/)
- [musl-cross](https://github.com/FiloSottile/homebrew-musl-cross)
- [AWS CLI](https://aws.amazon.com/jp/cli/)

## とりあえず AWS のドキュメントを参考に Lambda で動かしてみる
[Rust Runtime for AWS Lambda](https://aws.amazon.com/jp/blogs/opensource/rust-runtime-for-aws-lambda/) と [awslabs/aws-lambda-rust-runtime](https://github.com/awslabs/aws-lambda-rust-runtime) を参考にしました。
Rust のバージョンは 1.44.1 で作ってます。

プロジェクト作って

```shell-session
$ cargo new examples-rust-slack-kot --bin
     Created binary (application) `examples-rust-slack-kot` package
```

Cargo.toml に必要なクレート（Rust のライブラリの呼び方？）とビルドの設定を書いて

```toml:Cargo.toml
[package]
name = "examples-rust-slack-kot"
version = "0.1.0"
authors = ["Boi Yamamoto <boiyaa@hotmail.com>"]
edition = "2018"
autobins = false

[dependencies]
lambda_runtime = "^0.1"
serde = "^1"
serde_json = "^1"
serde_derive = "^1"
log = "^0.4"
simple_logger = "^1"
simple-error = "^0.1"

[[bin]]
name = "bootstrap"
path = "src/main.rs"
```

main.rs 書いて

```rs:main.rs
use std::error::Error;

use lambda_runtime::{error::HandlerError, lambda, Context};
use log::{self, error};
use serde_derive::{Deserialize, Serialize};
use simple_error::bail;
use simple_logger;

#[derive(Deserialize)]
struct CustomEvent {
    #[serde(rename = "firstName")]
    first_name: String,
}

#[derive(Serialize)]
struct CustomOutput {
    message: String,
}

fn main() -> Result<(), Box<dyn Error>> {
    simple_logger::init_with_level(log::Level::Debug)?;
    lambda!(my_handler);

    Ok(())
}

fn my_handler(e: CustomEvent, c: Context) -> Result<CustomOutput, HandlerError> {
    if e.first_name == "" {
        error!("Empty first name in request {}", c.aws_request_id);
        bail!("Empty first name");
    }

    Ok(CustomOutput {
        message: format!("Hello, {}!", e.first_name),
    })
}
```

macOS で Linux ビルドする設定して

```shell-session
$ rustup target add x86_64-unknown-linux-musl
info: downloading component 'rust-std' for 'x86_64-unknown-linux-musl'
info: installing component 'rust-std' for 'x86_64-unknown-linux-musl'
info: Defaulting to 500.0 MiB unpack ram
$ mkdir .cargo
$ echo '[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"' > .cargo/config
```

ビルドして

```shell-session
$ cargo build --release --target x86_64-unknown-linux-musl
```

Lambda 用に Zip して

```shell-session
$ zip -j lambda.zip ./target/x86_64-unknown-linux-musl/release/bootstrap
```

Lambda にデプロイして

```shell-session
$ aws lambda create-function --function-name rustTest \
  --handler doesnt.matter \
  --zip-file fileb://./lambda.zip \
  --runtime provided \
  --role arn:aws:iam::XXXXXXXXXXXXX:role/your_lambda_execution_role \
  --environment Variables={RUST_BACKTRACE=1}
```

実行する

```shell-session
$ aws lambda invoke --function-name rustTest \
  --payload '{"firstName": "world"}' \
  --cli-binary-format raw-in-base64-out \
  output.json
$ cat output.json  # Prints: {"message":"Hello, world!"}
```

できました。


## 処理を書いてみる
処理の流れとしては、[プライベートリソースにアクセスする Slash Commands バックエンドの実装](https://qiita.com/boiyama/items/37a0da3343d0c3aa0a24)で書いたように API Gateway 越しに Lambda を呼ぶと [Slash Commands の 3秒ルール](https://api.slack.com/interactivity/slash-commands#responding_basic_receipt)でタイムアウトすることがあるので SQS で Lambda を呼んだところからスタートし、

- SQS イベントをパースする
- KING OF TIME の打刻 API を叩く
- Slack のレスポンス API を叩く

という感じになります。
順番に手探りでやってみました。


### SQS イベントをパースする
Lambda イベントの構造体を提供する [aws_lambda_events](https://crates.io/crates/aws_lambda_events) というクレートがあるとのことで使ってみました。

```toml:Cargo.toml
[dependencies]
aws_lambda_events = "^0.3"
```

```rs:main.rs
use aws_lambda_events::event::sqs::SqsEvent;

fn handler(e: SqsEvent, _c: Context) -> Result<(), HandlerError> {
    for record in &e.records {
```

イベントの body 要素には Slash Commands のペイロードがクエリストリングの形で入ってるので [serde_qs](https://crates.io/crates/serde_qs) を使って構造体にパースしてみました。

```toml:Cargo.toml
[dependencies]
serde = { version = "^1", features = ["derive"] }
serde_qs = "^0.6"
```

```rs:main.rs
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct Payload {
    token: String,
    team_id: String,
    team_domain: String,
    channel_id: String,
    channel_name: String,
    user_id: String,
    user_name: String,
    command: String,
    text: String,
    response_url: String,
    trigger_id: String,
}

fn handler(e: SqsEvent, _c: Context) -> Result<(), HandlerError> {
    for record in &e.records {
        let payload: Payload = serde_qs::from_str(record.body.as_ref().unwrap()).unwrap();
```


### KING OF TIME の打刻 API を叩く
Slack のユーザーID と KING OF TIME の従業員キーの名寄せデータをとりあえず [HashMap](https://doc.rust-lang.org/std/collections/struct.HashMap.html) で持っておきました。

```rs:main.rs
use std::collections::HashMap;

fn handler(e: SqsEvent, _c: Context) -> Result<(), HandlerError> {
    let mut slack_kot_user_map = HashMap::new();
    slack_kot_user_map.insert("slack user id 1", "kot employee key 1");
    slack_kot_user_map.insert("slack user id 2", "kot employee key 2");
```

KING OF TIME に送る時刻の作成に [chrono](https://crates.io/crates/chrono) を使いました。

```toml:Cargo.toml
[dependencies]
chrono = "^0.4"
```

```rs:main.rs
use chrono::{FixedOffset, Utc};

fn handler(e: SqsEvent, _c: Context) -> Result<(), HandlerError> {
    let today = Utc::now().with_timezone(&FixedOffset::east(9 * 3600));
    let date = today.format("%Y-%m-%d").to_string();
    let time = today.to_rfc3339();
```

HTTP クライアントは [reqwest](https://crates.io/crates/reqwest) を使ってみました。
依存の設定にちょっと手こずりました。[^1] [^2]

```toml:Cargo.toml
[dependencies.reqwest]
version = "^0.10"
default-features = false
features = ["blocking", "json", "rustls-tls"]
```

```rs:main.rs
use reqwest::blocking::Client;
use serde_json::json;

fn handler(e: SqsEvent, _c: Context) -> Result<(), HandlerError> {
    let kot_access_token = env::var("KOT_ACCESS_TOKEN").unwrap();

    for record in &e.records {
        let kot_employee_key = slack_kot_user_map.get(payload.user_id.as_str()).unwrap();
        let kot_url = format!(
            "https://api.kingtime.jp/v1.0/daily-workings/timerecord/{}",
            kot_employee_key
        );

        let kot_body = json!({
            "code": payload.text,
            "date": date,
            "time": time
        });

        let kot_response = Client::new()
            .post(&kot_url)
            .bearer_auth(&kot_access_token)
            .json(&kot_body)
            .send()
            .unwrap();
```


### Slack のレスポンス API を叩く
KING OF TIME の結果を見て同様に POST する感じです。

```rs:main.rs
fn handler(e: SqsEvent, _c: Context) -> Result<(), HandlerError> {
    for record in &e.records {
        let kot_response_status = kot_response.status();

        let slack_body =
            json!({ "text": if kot_response_status.is_success() { "Success" } else { "Error" } });

        let slack_response = Client::new()
            .post(&payload.response_url)
            .json(&slack_body)
            .send()
            .unwrap();
```

Slash Commands に登録して動くことを確認しました。
![Screen Shot 2020-07-19 at 22.23.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142668/82cf5456-15a8-f233-8437-33fe9ec05d5b.png)
![Screen Shot 2020-07-19 at 22.23.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142668/79f43d76-b132-9738-a8c1-d38ad0042e69.png)


### 名寄せを Google スプレッドシートから取得
ここまで書いてみて少し慣れてきたので、先ほど名寄せ情報をハードコーディングした部分を社内ツールのアカウント情報をメンテしているスプシから取得するように変更しました。

こういう感じでデータが入っているので、
![Screen Shot 2020-07-27 at 0.50.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142668/e6f84594-a973-7260-9693-c6f921d22556.png)
Google Sheets API の [Method: spreadsheets.values.get](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get) で取得します。
JWT の作成に [jsonwebtoken](https://crates.io/crates/jsonwebtoken) を使いました。

```toml:Cargo.toml
[dependencies]
jsonwebtoken = "^7"
```

```rs:main.rs
#[derive(Debug, Deserialize)]
struct GoogleCredential {
    private_key: String,
    client_email: String,
    token_uri: String,
}

#[derive(Debug, Serialize)]
struct Claims {
    iss: String,
    scope: String,
    aud: String,
    exp: i64,
    iat: i64,
}

#[derive(Debug, Deserialize)]
struct SheetsResponse {
    values: Vec<Vec<String>>,
}

fn handler(e: SqsEvent, _c: Context) -> Result<(), HandlerError> {
    let google_credential: GoogleCredential =
        serde_json::from_str(&env::var("GOOGLE_CREDENTIAL").unwrap()).unwrap();

    let now = Utc::now();
    let iat = now.timestamp();
    let exp = (now + Duration::minutes(60)).timestamp();

    let my_claims = Claims {
        iss: google_credential.client_email,
        scope: "https://www.googleapis.com/auth/spreadsheets".to_string(),
        aud: google_credential.token_uri,
        exp: exp,
        iat: iat,
    };

    let mut header = Header::default();
    header.typ = Some("JWT".to_string());
    header.alg = Algorithm::RS256;

    let jwt = encode(
        &header,
        &my_claims,
        &EncodingKey::from_rsa_pem(google_credential.private_key.as_bytes()).unwrap(),
    )
    .unwrap();

    let token_body = json!({
        "assertion": jwt,
        "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer"
    });

    let token_response = Client::new()
        .post(&my_claims.aud)
        .json(&token_body)
        .send()
        .unwrap();

    let token_response_body: Value = token_response.json().unwrap();
    let access_token = token_response_body
        .get("access_token")
        .unwrap()
        .as_str()
        .unwrap();

    let sheets_url = env::var("SHEETS_URL").unwrap();
    let sheets_response = Client::new()
        .get(&sheets_url)
        .bearer_auth(access_token)
        .send()
        .unwrap();

    let sheets_response_body: SheetsResponse = sheets_response.json().unwrap();

    let mut slack_kot_user_map = HashMap::new();
    for ids in sheets_response_body.values {
        slack_kot_user_map.insert(ids[0].clone(), ids[1].clone());
    }
```

このビルドの時 jsonwebtoken が依存する ring が cc がどうのこうのと怒ってくるので、シンボリックリンクを作る必要がありました。

```shell-session
$ ln -s /usr/local/bin/x86_64-linux-musl-gcc /usr/local/bin/musl-gcc
```


### コード全体
https://github.com/boiyama/examples-rust-slack-kot


## 感想
- 改めて書こうとすると The Book でやったことほとんど忘れちゃってたけど、エラーが何をしたらいいか教えてくれるので、とりあえず動くものなら作りやすいと感じました。
- 調べても理由がわからないこと（どう調べたらいいかわからないこと）は [rust-jp slack](https://rust-jp.herokuapp.com/) の初心者チャネルで質問したら丁寧に教えてもらえました。
- まだ動いたり動かなかったりする理由がわからない部分が多いし、コード分離したりテスト書いたりしてないので、ここから先の学習には苦難が待ってそう。
- そういう意味ではあまり保守性を問われない単発の社内ツール開発は入門者向きだと思いました。
- 変なところあればガンガンご指摘ください🙇



[^1]: native-tls はプラットフォームで提供されている実装を使い、 native-tls-vendored はシステムにインストールされている openssl ライブラリを使うのではなく、 openssl crate に同梱されたソースコードからビルドして生成した openssl を使い、 rustls-tls は rustls (TLS の Rust 実装) を使うとのことです。 lo48576 さんありがとうございます。
[^2]: default-features = false にしないと default (=native-tls) が有効なままなので rustls-tls を使う時は必要でした。 κeen さんありがとうございます。