Twitter API を使って Rust からツイートする

公開:2020/10/27
更新:2020/12/17
10 min読了の目安(約9600字TECH技術記事

これは何

Twitter API を使用してツイートを投稿するプログラムを, Rust で書く記事です.公式の解説この記事を参考にしながら書いています.

Crates

使用クレートは以下の通りです:

crate version features
base64 0.13.0
chrono 0.4.19
hmac-sha1 0.1.3
percent-encoding 2.1.0
rand 0.7.3
reqwest 0.10.8
tokio 0.2.22 macros

reqwest 0.10.8 が tokio 0.3.0 以降で正常に動かないため, tokio 0.2.22 以前を使用することに注意して下さい. tokio 以外は全て記事執筆時点 (2020/10/27) で最新のバージョンです.

OAuth

Twitter が使用している OAuth 1.0 についての説明です.

Keys & Tokens

ここの右上にある Create an app ボタンを押して app を作り, Details ボタンから Keys and tokens タブを開いて API key / API secret key / access token / access token secret の 4 つを手に入れます.これは手元の適当なファイルに保存しておき,後でプログラムから読み取ります.

signature

API secret key と access token secret は, signature の生成に使われます.ツイートする際にクライアントは signature base string という文字列を構成し,これと API secret key / access token secret から signature を生成します.サーバー側でこの signature が確認され,認可が行われます.

signature base string , API secret key , access token secret から signature を生成する方法は次の通りです:

  1. API secret key と access token secret を & で繋いだ文字列を signing key とする.
  2. signing key を鍵として, signature base string を HMAC-SHA1 でハッシュ化する.
  3. base64 でエンコードする.

signature base string

上で登場した signature base string は,次のパラメータを用いて作られます.

  • HTTP method (ツイートするときは POST)

  • URL (ツイートするときは https://api.twitter.com/1.1/statuses/update.json)

  • ツイートの内容に関するパラメータ

    • status: ツイート本文.
    • in_reply_to_status_id: 返信先ツイートの ID .

    など(他のパラメータについてはこちら

  • それ以外のパラメータ

    • oauth_consumer_key: API key
    • oauth_token: access token
    • oauth_signature method: twitter が使用しているのは HMAC-SHA1
    • oauth_version: twitter が使用している OAuth のバージョンは 1.0
    • oauth_timestamp: 現在時刻.これがズレていると認可が下りないことがある.
    • oauth_nonce: ランダムな文字列.

ただし HTTP method と URL 以外のパラメータは「キー: 値の説明」の順に書いています.

これらのパラメータをもとに signature base string を作り,そこから signature を生成します.同時に,これらのパラメータ自体も HTTP に乗せて送ります.ツイートの内容に関するパラメータは, URL の末尾に付け加えます.それ以外のパラメータは, signature と一緒に HTTP の Authorization ヘッダに乗せます.

パラメータから signature base string を作る方法は次の通りです:

  1. method と URL 以外のパラメータについて,キーと値の両方をパーセントエンコードする.
  2. パーセントエンコードしたキーによってパラメータを辞書順に並べ,「キー=値」の形式で書いて & でつないだものを parameter string とする.
  3. method , URL をパーセントエンコードしたもの, parameter string をパーセントエンコードしたものの 3 つを,これらの順に並べて & でつなぐ.

設計

struct Client に API key / API secret key / access token / access token secret を持たせます.

struct Client {
    api_key: String,
    api_secret_key: String,
    access_token: String,
    access_token_secret: String,
}

impl Client 内に以下の関数を用意します:

  • 関連関数 from_config() を使って config ファイルからパラメータを読み込むようにします.
  • Client に,ツイートするための関数 tweet() を定義します.ツイートする文字列を &str で受け取ってツイートし,レスポンスを返します.
  • tweet() の中身は, method と URL と「ツイートの内容に関するパラメータ」を受け取ってリクエストを送る関数 request() を呼び出すだけにします.
  • request() は「ツイートの内容に関するパラメータ」を &std::collections::BTreeMap<&str, &str> として受け取ることにします.
  • request() は method , URL ,「ツイートの内容に関するパラメータ」を関数 authorization() に渡し, authorization() は HTTP の Authorization ヘッダの内容を String で返します. signature の生成は authorization() 内で行うため, request() の内部で「それ以外のパラメータ」は必要ありません.
  • request() は HTTP の他の要素も揃え, reqwest で送ります.
  • authorization() の中で, timestamp や nonce の生成を行い,「それ以外のパラメータ」を Vec<(&str, &str)> として揃えます.
  • authorization() は method , URL ,ツイートの内容に関するパラメータ,その他のパラメータを全て関数 signature() に渡し, signature() は signature を String で返します. signature() の中で API secret key と access token secret を使用します.

実装

以下,一つ一つの関数を実装していきます.

from_config()

指定された名前のファイルを開き, API key , API secret key , access token , access token secret の順に一行ずつ読み込みます.

fn from_config(filename: &str) -> Result<Client, Box<dyn std::error::Error>> {
    let config = std::fs::File::open(filename)?;
    let mut reader = std::io::BufReader::new(config);
    fn read_line<T: std::io::BufRead>(
        reader: &mut T,
    ) -> Result<String, Box<dyn std::error::Error>> {
        let mut s = String::new();
        reader.read_line(&mut s)?;
	s.pop();
        Ok(s)
    }
    Ok(Client {
        api_key: read_line(&mut reader)?,
        api_secret_key: read_line(&mut reader)?,
        access_token: read_line(&mut reader)?,
        access_token_secret: read_line(&mut reader)?,
    })
}

tweet()

tweet() から request() を呼び出します.

async fn tweet(&self, status: &str) -> Result<reqwest::Response, reqwest::Error> {
    let mut parameters = std::collections::BTreeMap::new();
    parameters.insert("status", status);
    self.request(
        reqwest::Method::POST,
        "https://api.twitter.com/1.1/statuses/update.json",
        &parameters,
    )
    .await
}

request()

request() は reqwest を使って HTTP リクエストを送ります.ヘッダーを作る際に authorization() を呼び出します.

async fn request(
    &self,
    method: reqwest::Method,
    url: &str,
    parameters: &std::collections::BTreeMap<&str, &str>,
) -> Result<reqwest::Response, reqwest::Error> {
    let header_map = {
        use reqwest::header::*;
        let mut map = HeaderMap::new();
        map.insert(
            AUTHORIZATION,
            self.authorization(&method, url, parameters)
                .parse()
                .unwrap(),
        );
        map.insert(
            CONTENT_TYPE,
            HeaderValue::from_static("application/x-www-form-urlencoded"),
        );
        map
    };
    let url_with_parameters = format!(
        "{}?{}",
        url,
        equal_collect(parameters.iter().map(|(key, value)| { (*key, *value) })).join("&")
    );

    let client = reqwest::Client::new();
    client
        .request(method, &url_with_parameters)
        .headers(header_map)
        .send()
        .await
}

equal_collect()

request() の中で URL の末尾にツイートの内容に関するパラメータを追加しますが,このときに次のような関数 equal_collect() を使用します.

fn equal_collect<'a, T: Iterator<Item = (&'a str, &'a str)>>(iter: T) -> Vec<String> {
    iter.map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
        .collect()
}

これによって, {"in_reply_to_status_id": "0000", "status": "hello"} のような BTreeMap["in_reply_to_status_id=0000", "status=Hello"] のような Vec に変換できて,これを join("&") することで "in_reply_to_status_id=0000&status=Hello" のような文字列が得られます.

equal_collect() は, authorization()signature() の中でも使用します.

percent_encode()

また, equal_collect() の中では次のような関数 percent_encode() を使用します.

fn percent_encode(s: &str) -> percent_encoding::PercentEncode {
    use percent_encoding::*;
    const FRAGMENT: &AsciiSet = &NON_ALPHANUMERIC
        .remove(b'*')
        .remove(b'-')
        .remove(b'.')
        .remove(b'_');
    utf8_percent_encode(s, FRAGMENT)
}

percent_encode()signature() の中でも使用します.

authorization()

authorization() で「それ以外のパラメータ」をそろえ, signature() を呼び出します.「それ以外のパラメータ」に signature を追加し, Authorization ヘッダを生成します.

fn authorization(
    &self,
    method: &reqwest::Method,
    url: &str,
    parameters: &std::collections::BTreeMap<&str, &str>,
) -> String {
    let timestamp = format!("{}", chrono::Utc::now().timestamp());
    let nonce: String = {
        use rand::prelude::*;
        let mut rng = thread_rng();
        std::iter::repeat(())
            .map(|()| rng.sample(rand::distributions::Alphanumeric))
            .take(32)
            .collect()
    };

    let mut other_parameters: Vec<(&str, &str)> = vec![
        ("oauth_consumer_key", &self.api_key),
        ("oauth_token", &self.access_token),
        ("oauth_signature_method", "HMAC-SHA1"),
        ("oauth_version", "1.0"),
        ("oauth_timestamp", &timestamp),
        ("oauth_nonce", &nonce),
    ];

    let signature = self.signature(method, url, parameters.clone(), &other_parameters);

    other_parameters.push(("oauth_signature", &signature));

    format!(
        "OAuth {}",
        equal_collect(other_parameters.into_iter()).join(", ")
    )
}

signature()

signature() は, method , URL ,ツイートの内容に関するパラメータ,それ以外のパラメータを全てあわせて, API secret key と access token secret を用いて signature を生成します.

fn signature<'a>(
    &self,
    method: &reqwest::Method,
    url: &str,
    mut parameters: std::collections::BTreeMap<&'a str, &'a str>,
    other_parameters: &Vec<(&'a str, &'a str)>,
) -> String {
    for (key, value) in other_parameters {
        parameters.insert(key, value);
    }
    let parameter_string = equal_collect(parameters.into_iter()).join("&");

    let signature_base_string = format!(
        "{}&{}&{}",
        method,
        percent_encode(url),
        percent_encode(&parameter_string)
    );
    let signing_key = format!("{}&{}", self.api_secret_key, self.access_token_secret);
    base64::encode(hmacsha1::hmac_sha1(
        signing_key.as_bytes(),
        signature_base_string.as_bytes(),
    ))
}

ツイート

これで, main() 関数からツイートすることができるようになりました.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    match Client::from_config("config") {
        Ok(client) => {
            let response = client.tweet("hello").await?;
            let text = response.text().await?;
            println!("{}", text);
        }
        Err(err) => {
            println!("failed to read config file: {}", err);
        }
    }
    Ok(())
}

まとめ

全体のコードはこちらです.

この記事ではツイートすることだけを目標としましたが,実は上で出てきた Client::request() 関数に渡すものを変えるだけで,返信したり TL を読み込んだりすることもできるようになります.

私は Rust を書き始めてからまだ日が浅いので,良くない書き方をしている箇所などあるかと思います.もし気付いたことがあれば指摘していただけると幸いです.