はじめに
Goで既存ライブラリを使用せずにTwitterAPIの機能を実装してみました。基本はライブラリを使用するかと思うので、誰得な内容ですが、個人的にはmultipart/form-data
の仕様など改めて確認することができました。
※使用したバージョンはv1.1です。
余談ですが
golang twitter api
等でぐぐると大体以下の2つが有名どころかなと思います。
https://github.com/dghubble/go-twitter
https://github.com/ChimeraCoder/anaconda
#実装してみる
今回は以下を実装してみました。
・テキストのツイート
・画像・動画などのメディア付きのツイート
1.TwitterAPIの利用登録
以下から利用登録を行います。登録方法などについては、他の記事でたくさん紹介されていると思うのでここでは割愛します。
https://developer.twitter.com/en
2.認証機能作成
認証を実装します。
作成にあたっては、以下を参考にさせていただきました。
https://developer.twitter.com/en/docs/authentication/oauth-1-0a/creating-a-signature
https://qiita.com/kerupani129/items/ee9d894cc67101f16c3f
リクエストヘッダーにAuthorization
を追加します。
各値の生成方法について、説明していきます。
header 'authorization: OAuth
oauth_consumer_key="oauth_customer_key",
oauth_nonce="generated_oauth_nonce",
oauth_signature="generated_oauth_signature",
oauth_signature_method="HMAC-SHA1",
oauth_timestamp="generated_timestamp",
oauth_token="oauth_token",
oauth_version="1.0"'
oauth_consumer_key
,oauth_token
は発行されたキーを指定
oauth_signature_method
,oauth_version
は固定値を指定
oauth_timestamp
はUNIXエポックを指定
oauth_nonce
,oauth_signature
については後述
2.1 oauth_nonce
ユニークなランダム文字列であれば問題ないので、公式に乗っている通り、32バイトのランダムデータをbase64でエンコードし、単語以外の文字をすべて削除する方法を取りました
func createoauthNonce() string {
key := make([]byte, 32)
rand.Read(key)
enc := base64.StdEncoding.EncodeToString(key)
replaceStr := []string{"+", "/", "="}
for _, str := range replaceStr {
enc = strings.Replace(enc, str, "", -1)
}
return enc
}
2.2 oauth_signature
ここが一番複雑でした。より詳しい情報は公式ドキュメントや、参考記事を見ていただきたいですが、大まかな流れとしては以下
- oauth_signature以外のパラメータと機能ごとの追加パラメータを文字列順にソートし、パーセントエンコードを行い、
&
で結合 - HTTPメソッドとAPIのエンドポイントをパーセントエンコードし、1で作成した文字列とさらに
&
で結合 - 発行されたConsumerSecretとAccessSecretをパーセントエンコードし、
&
で結合 - 2および3で生成した文字列に対して、3をキーにして、HMACSHA1で計算したものに対し、BASE64エンコード
2.2.1 oauth_signature以外のパラメータと機能ごとの追加パラメータを文字列順にソートし、パーセントエンコードを行い、&で結合
type sortedQuery struct {
m map[string]string
keys []string
}
func mapMerge(m1, m2 map[string]string) map[string]string {
m := map[string]string{}
for k, v := range m1 {
m[k] = v
}
for k, v := range m2 {
m[k] = v
}
return m
}
func sortedQueryString(m map[string]string) string {
sq := &sortedQuery{
m: m,
keys: make([]string, len(m)),
}
var i int
for key := range m {
sq.keys[i] = key
i++
}
sort.Strings(sq.keys)
values := make([]string, len(sq.keys))
for i, key := range sq.keys {
values[i] = fmt.Sprintf("%s=%s", url.QueryEscape(key), url.QueryEscape(sq.m[key]))
}
return strings.Join(values, "&")
}
2.2.2 HTTPメソッドとAPIのエンドポイントをパーセントエンコードし、1で作成した文字列とさらに&
で結合
baseQueryString := sortedQueryString(mapMerge(m, additionalParam))
base := []string{}
base = append(base, url.QueryEscape(httpMethod))
base = append(base, url.QueryEscape(uri))
base = append(base, url.QueryEscape(baseQueryString))
signatureBase := strings.Join(base, "&")
2.2.3 発行されたConsumerSecretとAccessSecretをパーセントエンコードし、&
で結合
signatureKey := url.QueryEscape(creds.ConsumerSecret) + "&" + url.QueryEscape(creds.AccessSecret)
2.2.4 2および3で生成した文字列に対して、3をキーにして、HMACSHA1で計算したものに対し、BASE64エンコード
oauthSignature := calcHMACSHA1(signatureBase, signatureKey)
func calcHMACSHA1(base, key string) string {
b := []byte(key)
h := hmac.New(sha1.New, b)
io.WriteString(h, base)
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
以上から認証ヘッダーの生成方法をまとめると以下のようになります。
func manualOauthSettings(creds *creds, additionalParam map[string]string, httpMethod, uri string) string {
m := map[string]string{}
m["oauth_consumer_key"] = creds.ConsumerKey
m["oauth_nonce"] = createoauthNonce()
m["oauth_signature_method"] = "HMAC-SHA1"
m["oauth_timestamp"] = strconv.FormatInt(time.Now().Unix(), 10)
m["oauth_token"] = creds.AccessToken
m["oauth_version"] = "1.0"
baseQueryString := sortedQueryString(mapMerge(m, additionalParam))
base := []string{}
base = append(base, url.QueryEscape(httpMethod))
base = append(base, url.QueryEscape(uri))
base = append(base, url.QueryEscape(baseQueryString))
signatureBase := strings.Join(base, "&")
signatureKey := url.QueryEscape(creds.ConsumerSecret) + "&" + url.QueryEscape(creds.AccessSecret)
m["oauth_signature"] = calcHMACSHA1(signatureBase, signatureKey)
authHeader := fmt.Sprintf("OAuth oauth_consumer_key=\"%s\", oauth_nonce=\"%s\", oauth_signature=\"%s\", oauth_signature_method=\"%s\", oauth_timestamp=\"%s\", oauth_token=\"%s\", oauth_version=\"%s\"",
url.QueryEscape(m["oauth_consumer_key"]),
url.QueryEscape(m["oauth_nonce"]),
url.QueryEscape(m["oauth_signature"]),
url.QueryEscape(m["oauth_signature_method"]),
url.QueryEscape(m["oauth_timestamp"]),
url.QueryEscape(m["oauth_token"]),
url.QueryEscape(m["oauth_version"]),
)
return authHeader
}
3.ツイート機能
テキストをツイートする機能を作ってみます。
以下のように、ツイートするメッセージ(status
パラメータ)をURLパラメータに指定する必要があり、また前述した通り、認証のoauth_signature
を作る際にもパラメータが必要になります。
https://api.twitter.com/1.1/statuses/update.json?status=hello
認証機能も合わせたコードはこうなりました。
func tweet(creds *creds, message string) (*http.Response, error) {
addtionalParam := map[string]string{"status": message}
authHeader := manualOauthSettings(creds, addtionalParam, "POST", "https://api.twitter.com/1.1/statuses/update.json")
req, err := http.NewRequest("POST", "https://api.twitter.com/1.1/statuses/update.json", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.URL.RawQuery = sortedQueryString(addtionalParam)
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return resp, nil
}
4.メディア付きツイート機能
画像や動画を投稿する機能を作ってみます。
media/upload機能は以下の3つのAPIを実行します。
- INIT
- APPEND
- FINALIZE
4.1 INIT
指定したメディアのContent-type
やファイルサイズなどを指定します。
テキストのツイート同様、必要パラメータをURLパラメータに指定し、認証の作成にも必要になります。
基本的にはツイート機能とほぼ同じになります。
リクエストが正常であれば、後続処理で必要になるmedia_id
,media_id_string
が返却されます。
type uploadMediaResponse struct {
MediaID int64 `json:"media_id"`
MediaIDString string `json:"media_id_string"`
Size int `json:"size"`
ExpiresAfterSecs int `json:"expires_after_secs"`
Image imageInfo `json:"image"`
}
type imageInfo struct {
ImageType string `json:"image_type"`
Width int `json:"w"`
Height int `json:"h"`
}
func mediaInit(creds *creds, file *os.File) (*uploadMediaResponse, error) {
//fileからcontentTypeを読み取る
buffer := make([]byte, 512)
file.Read(buffer)
contentType := http.DetectContentType(buffer)
//読み取りポインタをリセットする
file.Seek(0, 0)
fileInfo, err := file.Stat()
if err != nil {
return nil, err
}
//指定パラメータを設定
additionalParam := map[string]string{
"command": "INIT",
"media_type": contentType,
"total_bytes": strconv.FormatInt(fileInfo.Size(), 10),
}
authHeader := manualOauthSettings(creds, additionalParam, "POST", "https://upload.twitter.com/1.1/media/upload.json")
req, err := http.NewRequest("POST", "https://upload.twitter.com/1.1/media/upload.json", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.URL.RawQuery = sortedQueryString(additionalParam)
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var res uploadMediaResponse
err = json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
return nil, err
}
return &res, nil
}
4.2 APPEND
INITにて取得したmedia_id
を指定して、チャンクによって、指定したバイト数分ループを行います。
こちらはmultipart/form-data
形式になるため、Bodyにリクエストパラメータを指定します。また、oauth_signatureの生成に追加のパラメータは必要ありません。
ループ数分だけ、segment_index
をインクリメントしていきます。
func mediaAppend(creds *creds, initRes uploadMediaResponse, file *os.File) (*http.Response, error) {
chunked := make([]byte, 3523218)
segmentIndex := 0
var res *http.Response
for {
//boundaryBody作成
var body bytes.Buffer
mpWriter := multipart.NewWriter(&body)
boundary := "END_OF_PART"
if err := mpWriter.SetBoundary(boundary); err != nil {
return nil, err
}
{
//part作成(メディアデータ本体)
part := make(textproto.MIMEHeader)
part.Set("Content-Disposition", "form-data; name=\"media_data\";")
writer, err := mpWriter.CreatePart(part)
if err != nil {
return nil, err
}
//指定バイト数だけチャンク
n, err := file.Read(chunked)
if n == 0 {
break
}
if err != nil {
return nil, err
}
b64Buf := base64.StdEncoding.EncodeToString(chunked)
writer.Write([]byte(b64Buf))
}
{
//その他パラメータの作成
part := make(textproto.MIMEHeader)
additionalParam := map[string]string{
"command": "APPEND",
"media_id": initRes.MediaIDString,
"segment_index": strconv.Itoa(segmentIndex),
}
for k, v := range additionalParam {
part.Set("Content-Disposition", fmt.Sprintf("form-data; name=\"%s\";", k))
writer, err := mpWriter.CreatePart(part)
if err != nil {
return nil, err
}
writer.Write([]byte(v))
}
}
mpWriter.Close()
authHeader := manualOauthSettings(creds, map[string]string{}, "POST", "https://upload.twitter.com/1.1/media/upload.json")
req, err := http.NewRequest("POST", "https://upload.twitter.com/1.1/media/upload.json", bytes.NewReader(body.Bytes()))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
client := http.Client{}
res, err := client.Do(req)
if err != nil {
return res, err
}
defer res.Body.Close()
segmentIndex++
}
return res, nil
}
(ファイルサイズ以下を指定した場合、FINALIZE処理にて、エラーが発生するので、ここではファイルサイズ相当のバイトを読み込むようにしてます(チャンクの意味がないですが、、))
リクエスト成功後、204 No Content
が返却され、Bodyは特にありません。
multipart/form-data
のバウンダリの作成方法については以下を参考にさせていただきました。
https://qiita.com/kazuhikoh/items/18492762633ea596d4bc
4.3 FINALIZE
アップロード完了を処理するための最後のAPIを実行します。
INIT処理と同様に必要パラメータをURLパラメータに指定し、認証の作成にも必要になります。
func mediaFinalize(creds *creds, initRes uploadMediaResponse) (*http.Response, error) {
param := map[string]string{
"command": "FINALIZE",
"media_id": initRes.MediaIDString,
}
authHeader := manualOauthSettings(creds, param, "POST", "https://upload.twitter.com/1.1/media/upload.json")
req, err := http.NewRequest("POST", "https://upload.twitter.com/1.1/media/upload.json", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.URL.RawQuery = sortedQueryString(param)
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return resp, nil
}
リクエスト成功後、201 Created
が返却され、以下のレスポンスが返却されます。
expires_after_secs
の指定時間以内に、media_idを指定してツイートをすると画像(動画)付きでツイートすることが出来ます。
{
"media_id": 1340531471866450000,
"media_id_string": "1340531471866449922",
"size": 3523218,
"expires_after_secs": 86400,
"video": {
"video_type": "video/mp4"
}
}
4.4 ツイート機能を変更する
一番はじめに作成したツイート機能にmedia_id
も指定できるように変更しました。
func tweet(creds *creds, message string, mediaIDs []string) (*http.Response, error) {
addtionalParam := map[string]string{"status": message}
if len(mediaIDs) != 0 {
addtionalParam["media_ids"] = strings.Join(mediaIDs, ",")
}
authHeader := manualOauthSettings(creds, addtionalParam, "POST", "https://api.twitter.com/1.1/statuses/update.json")
req, err := http.NewRequest("POST", "https://api.twitter.com/1.1/statuses/update.json", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.URL.RawQuery = sortedQueryString(addtionalParam)
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return resp, nil
}
4.5 メディアアップロード機能
改めて、INIT,APPEND,FINALIZE,ツイートの一連の流れを関数として定義すると以下のようになります。
func tweetWithMedia(creds *creds, fileStr string) (*http.Response, error) {
file, err := os.Open(fileStr)
if err != nil {
return nil, err
}
defer func() {
file.Close()
}()
initRes, err := mediaInit(creds, file)
if err != nil {
return nil, err
}
res, err := mediaAppend(creds, *initRes, file)
if err != nil {
return nil, err
}
res, err = mediaFinalize(creds, *initRes)
if err != nil {
return nil, err
}
res, err = tweet(creds, "動画投稿テスト", []string{initRes.MediaIDString})
if err != nil {
return nil, err
}
return res, nil
}
まとめ
認証ヘッダー作成、multipart/form-data
などライブラリ使用時にはあまり意識することない部分を改めて実装してみて、ライブラリの便利さを実感しています。
ライブラリを使おう