Twitter API を使って Rust からツイートする
これは何
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 を生成する方法は次の通りです:
- API secret key と access token secret を
&
で繋いだ文字列を signing key とする. - signing key を鍵として, signature base string を HMAC-SHA1 でハッシュ化する.
- 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 を作る方法は次の通りです:
- method と URL 以外のパラメータについて,キーと値の両方をパーセントエンコードする.
- パーセントエンコードしたキーによってパラメータを辞書順に並べ,「キー
=
値」の形式で書いて&
でつないだものを parameter string とする. - 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",
¶meters,
)
.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", ×tamp),
("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(¶meter_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 を書き始めてからまだ日が浅いので,良くない書き方をしている箇所などあるかと思います.もし気付いたことがあれば指摘していただけると幸いです.