スライド
部活内の発表に使ったスライドです。本記事はこのスライドを文章に起こしたものになります。
参考記事
経緯
2021年8月、Discord APIラッパーであるdiscord.pyの開発終了による激震が走った。
以前開発していたツイッター投稿Botや通話強制切断Botはdiscord.pyを使用している。
フォークされたライブラリとしてpycordやdysnakeといったライブラリがあるが、スラッシュコマンドの実装方法等に思想の違いがみえる。安定するまで保留したいところ。
Discord APIラッパーとして他の主要ライブラリとして挙げられるのはdiscord.js。スラッシュコマンドも対応しておりドキュメントや参考記事が豊富。
しかし最近興味のあるRustにもserenityというラッパーがあったため、書き換えるなら入門を兼ねてRustに決定。
通話強制切断Botは非同期処理や切断予定の保持、時間計算など調べることが多く、まずはツイッター投稿BotからRustで書き換えることにした。
そこでTwitter APIから手を付けた。Rustにはtweepyみたいに簡単にTwitter APIを叩けるライブラリ(クレート)がない。自分で認証を実装する必要がある。
プロジェクト
プロジェクトはGitHubレポジトリを参照。
雛形は以下の通り。機能追加によって変更される可能性あり。
- /
- src/
- lib.rs(Twitter APIラッパー)
- examples/
- tweet-demo.rs
- Cargo.toml
- Cargo.lock
- .gitignore
- src/
Twitter API
Twitter APIにはv1.1とv2があり、HTTPメソッドやエンドポイント、パラメータの指定方法が異なる。
Google検索で見つかる情報の多くはv1.1。混ぜるな危険。
ツイートやツイート削除のリクエストは以下の通り。v2はツイート削除にPOSTメソッドではなくDELETEメソッドを送るようになり分かりやすくなった。また、v1.1はパラメータを全てクエリに追加していたが、v2はBodyにJSONを渡すようになり統一感が出た。
今回はv2の仕様に沿って実装することにする。
Rustで利用するクレート
Cargo.tomlに記述している。
各クレートの用途を簡単に記載する。
HTTPリクエスト | reqwest |
---|---|
UNIX時刻取得 | chrono |
JSON取得 | serde, serde_derive, serde_json |
パーセントエンコード | percent-encoding |
HMAC-SHA1 | hmac-sha1 |
BASE64 | base64 |
Twitter OAuth 1.0a
Twitter APIの認証形式。読み取りだけならOAuth 2.0で実装が容易だが、ツイートする際はOAuth 1.0aが要求される。
認証方法はドキュメント通り。
まず、前提としてTwitter Developerの登録が必要。承認後v2対応のアプリケーションを作成し、
- Consumer key
- Consumer secret
- Access token
- Access token secret
の4つの文字列を生成する。
OAuth 2.0は上2つのみ用い、OAuth 1.0aは4つ全て使う。APIを叩くうえで毎回使うので構造体で保持することにした。
struct Twitter { consumer_key: String, consumer_secret: String, access_token_key: String, access_token_secret: String }
以降OAuth 1.0aの認証はOAuthシグネチャ(署名)の計算が大きな壁となる。シグネチャ計算のやツイートリクエストのメソッドはこの構造体に実装していく。
impl Twitter { pub fn new(consumer_key: String, consumer_secret: String, access_token_key: String, access_token_secret: String) -> Twitter { Twitter {consumer_key, consumer_secret, access_token_key, access_token_secret} } ... }
OAuthパラメータ
認証はOAuthパラメータをヘッダに付与することで行う。最小構成は以下の通り。
oauth_nonce | リクエスト毎に一意の文字列 |
---|---|
oauth_timestamp | 現在のUNIX時間 |
oauth_signature_method | ”HMAC-SHA1″ |
oauth_version | “1.0” |
oauth_consumer_key | Consumer key |
oauth_token | Access token |
oauth_signature | 上記パラメータをもとに計算 |
RustでUNIX時間を取得するにはchronoクレートを利用する。
use chrono::Utc; let timestamp = format!("{}", Utc::now().timestamp());
oauth_nonce
はリクエスト毎に一意であればよいのでUNIX時間と同じ値を使える。OAuthパラメータは以降昇順で扱うためstd::collections::BTreeMap
を用いる。
use std::collections::BTreeMap; let mut oauth_params: HashMap<&str, &str> = HashMap::new(); oauth_params.insert("oauth_nonce", ×tamp); oauth_params.insert("oauth_timestamp", ×tamp); oauth_params.insert("oauth_signature_method", "HMAC-SHA1"); oauth_params.insert("oauth_version", "1.0"); oauth_params.insert("oauth_consumer_key", &self.consummer_key); oauth_params.insert("oauth_token", &self.access_token_key);
OAuthシグネチャの計算
OAuthシグネチャの計算フローは以下の通り。v2はv1.1と異なりjsonパラメータを用いないことに注意。
一番左のブロックの値は全て分かっているので他4項目について記載する。
今回は以下パラメータを用い、v2のツイートリクエストを例にシグネチャ計算を説明する。
HTTPメソッド | “POST” ※大文字 |
---|---|
エンドポイント | “https://api.twitter.com/2/tweets” |
oauth_nonce | “1640553392” |
---|---|
oauth_timestamp | “1640553392” |
oauth_signature_method | ”HMAC-SHA1″ |
oauth_version | “1.0” |
oauth_consumer_key | “cChZNFj6T5R0TigYB9yd1w” |
oauth_token | “NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0” |
Consumer secret | “kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw” |
Access token secret | “LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE” |
補題:RFC3986に基づくパーセントエンコード
HTTPリクエストやOAuthシグネチャの計算過程でパーセントエンコード(URLエンコード)が必要となる。
Twitter APIではRFC3986という規格に基づいたパーセントエンコードを行う必要があり、これは
- *(アスタリスク)
- ー(ハイフン)
- _(アンダースコア)
- .(ピリオド)
の文字を除外して適用するパーセントエンコードである。(可読性のため全角で表記)
Rustではpercent-encodingというクレートがあり、コードのように除外する文字を指定できる。
use percent_encoding::{utf8_percent_encode, AsciiSet}; const FRAGMENT: AsciiSet = percent_encoding::NON_ALPHANUMERIC .remove(b'*') .remove(b'-') .remove(b'_') .remove(b'.');
utf8_percent_encode
メソッドの第2引数でFRAGMENT
を指定することでRFC3986に基づいたパーセントエンコードを行う。
1.シグネチャキーの生成
シグネチャキーは比較的容易に生成できる。
- Consumer secretとAccess token secretに対してRFCに基づいたパーセントエンコード
- 「&」で結合
Rustで実装したコードを以下に示す。
let key: String = format!("{}&{}", utf8_percent_encode(consummer_secret, &Self::FRAGMENT), utf8_percent_encode(access_token_secret, &Self::FRAGMENT));
(例)シグネチャキー:
2.パラメータ文字列の生成
ここでの「パラメータ」とは、v1ではOAuthパラメータとクエリパラメータの集合であり、v2ではOAuthパラメータの集合を指す。
- パラメータの各キーと値に対してRFC3986に基づいたパーセントエンコード
- パラメータとキーで昇順ソートして key1=val1&key2=val2&… のように「=」と「&」で結合
これをRustで実装したコードを以下に示す。またキーはエンコードの対象にならないので先に昇順ソートしてからパーセントエンコードと結合を適用している。
// エンコードして結合 let param_string = params .iter() .map(|(key, value)| { format!("{}={}", utf8_percent_encode(key, &Self::FRAGMENT), utf8_percent_encode(value, &Self::FRAGMENT)) }) .collect::<Vec<String>>() .join("&");
(例)昇順ソート後:
oauth_consumer_key | “cChZNFj6T5R0TigYB9yd1w” |
---|---|
oauth_nonce | “1640553392” |
oauth_signature_method | ”HMAC-SHA1″ |
oauth_timestamp | “1640553392” |
oauth_token | “NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0” |
oauth_version | “1.0” |
(例)パラメータ文字列:
3.シグネチャベースの生成
ここではHTTPメソッド、エンドポイント、パラメータ文字列の3つを用いる。HTTPメソッドは大文字であることに注意。
HTTPメソッド(method) | “POST” |
---|---|
エンドポイント(endpoint) | “https://api.twitter.com/2/tweets” |
パラメータ文字列(param_string) | “oauth_consumer_key=cChZNFj6T5R0TigYB9yd1w &oauth_nonce=1640553392 &oauth_signature_method=HMAC-SHA1 &oauth_timestamp=1640553392 &oauth_token=NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0 &oauth_version=1.0” |
- エンドポイントURLとパラメータ文字列をRFC3986に基づいてパーセントエンコード
- パラメータ結合に用いた「&」がエンコードされる
- HTTPメソッド、エンドポイント、パラメータ文字列を「&」で結合
Rustで実装したコードを以下に示す。
let base = format!("{}&{}&{}", utf8_percent_encode(method, &Self::FRAGMENT), utf8_percent_encode(endpoint, &Self::FRAGMENT), utf8_percent_encode(¶m_string, &Self::FRAGMENT));
(例)エンドポイントのエンコード:
(例)パラメータ文字列のエンコード:
(例)結合:
4.シグネチャの生成
パラメータ文字列やシグネチャベースの生成は少々複雑だったが、ここまでこればシグネチャは簡単に計算できる。
- シグネチャベースとシグネチャキーをHMAC-SHA1でハッシュ化
- (1)で得たバイナリ文字列をBASE64でエンコード(シグネチャ完成)
Rustで実装したコードは以下の通り。hmac-sha1クレートとbase64クレートを使えばいいのでメソッドを適用するだけ。
use base64; use hmacsha1; let hash = hmacsha1::hmac_sha1(key.as_bytes(), data.as_bytes()); let signature = base64::encode(&hash);
以上の手順1〜4をget_oauth_signature
メソッドとしてまとめた。
fn get_oauth_signature( &self, method: &str, endpoint: &str, consumer_secret: &str, access_token_secret: &str, params: &BTreeMap<&str, &str>) -> String { ... }
取得したOAuthシグネチャをHashMapに追加してヘッダーの生成に用いる。
let oauth_signature = self.get_oauth_signature( method, endpoint, &self.consumer_secret, &self.access_token_secret, &oauth_params); oauth_params.insert("oauth_signature", &oauth_signature);
(例)HMAC-SHA1でハッシュ化:
(例)BASE64でエンコード:
リクエストヘッダーの生成
上で計算したシグネチャを含むOAuthパラメータを用いてHTTPリクエストのヘッダーを生成する。
Authorizationリクエストヘッダーの値として、”OAuth”以降にOAuthパラメータを「<キー>=”<値>” 」のようにカンマ区切りで並べる。※値を””で囲う点に注意
またBodyにJSONパラメータを入れるのでContent-Typeの指定も行う。
oauth_consumer_key=“cChZNFj6T5R0TigYB9yd1w”,
oauth_nonce=“1640553392”,
oauth_signature_method=“HMAC-SHA1”,
oauth_timestamp=“1640553392”,
oauth_token=“NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0”,
oauth_version=“1.0”,
oauth_signature=“YjJkOWFmZmJkZDA1OTZhODFhOGMwNzYwM2E1ODllMTgzODlkNzczNg==”
Content-Type: application/json
これをRustではreqwestクレートのHeaderMapを用いて実現する。
Rustによる実装ではAuthorizationの値を生成するget_request_header
メソッドを作成した。
fn get_request_header(&self, method: &str, endpoint: &str) -> String { // ... let oauth_header = format!( "OAuth {}", oauth_params .into_iter() .map(|(key, value)| { format!(r#"{}="{}""#, utf8_percent_encode(key, &Self::FRAGMENT), utf8_percent_encode(value, &Self::FRAGMENT)) }) .collect::<Vec<String>>() .join(", ") ) }
リクエストヘッダーはreqwestクレートのHeaderMapを用いて実現した。
use reqwest::header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE}; let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, header_auth.parse().unwrap()); headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
ツイートリクエスト
以下の画像はツイート内容を「本文」としたときの例である。
※v2と異なりv1.1ではツイート内容をクエリで指定したため事前にパーセントエンコードする必要がある
これをRustでpost
メソッドとして実装した。HTTPリクエストにはreqwestクレート、返ってきたJSONのデシリアライズにserde_jsonクレートを用いる。
pub async fn post(&self, path: &str, params: HashMap<&str, &str>) -> Result<Value, reqwest::Error> { let endpoint = format!("https://api.twitter.com/2/{}", path); let header_auth = self.get_request_header("POST", &endpoint); let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, header_auth.parse().unwrap()); headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); reqwest::Client::new() .post(&endpoint) .headers(headers) .json(¶ms) .send() .await? .json::<Value>() .await }
reqwest v0.10.0以降はHTTPリクエストが標準で非同期処理となり、同期処理はreqwest::blocking
に移動している。またblockingはCargo.toml
にfeaturesとして明示しなければ利用できないので注意。
以上でRustによる簡易的な実装おわり。
余談:twitter.rsの使い方
本記事のGitHubリポジトリのコードはlib/lib.rs
をexamples/tweet-demo.rs
から呼び出すように設計している。
main.rs
を書き換えることで独自にTwitter APIを叩けるので方法を記載する。
まずプロジェクトのルートディレクトリに.env
というファイルを作成し、以下の形式でTwitter Applicationの4つの文字列を記述する。
CK="Consumer key" CS="Consumer secret" AT="Access token key" AS="Access token secret"
.env
ファイルは配布せず、これをdotenvクレートで読み込むことでコードを変更せずにプログラムを実行できる。
main.rs
は以下の通り。
use dotenv::dotenv; use std::env; use std::collections::HashMap; mod twitter; #[tokio::main] async fn main() { dotenv().ok(); let consumer_key = env::var("CK").expect("CK must be set."); let consumer_secret = env::var("CS").expect("CS must be set."); let access_token_key = env::var("AT").expect("AT must be set."); let access_token_secret = env::var("AS").expect("AS must be set."); let twitter = twitter::Twitter::new( consumer_key, consumer_secret, access_token_key, access_token_secret); let mut body = HashMap::new(); body.insert("text", "Rustでツイート"); let res = twitter.post("tweets", body).await.unwrap(); println!("{:?}", res); }
ツイートを削除したければbodyに{ id : <削除ツイートのID> }
を格納し、メソッドはdelete
, エンドポイントは”tweets”に変更して実行すればよい。
APIの詳細はドキュメントを参照すること
コメント