19
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Go 4Advent Calendar 2020

Day 21

Goでライブラリを使用せずTwitterAPIを実装する

Last updated at Posted at 2020-12-20

はじめに

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

ここが一番複雑でした。より詳しい情報は公式ドキュメントや、参考記事を見ていただきたいですが、大まかな流れとしては以下

  1. oauth_signature以外のパラメータと機能ごとの追加パラメータを文字列順にソートし、パーセントエンコードを行い、&で結合
  2. HTTPメソッドとAPIのエンドポイントをパーセントエンコードし、1で作成した文字列とさらに&で結合
  3. 発行されたConsumerSecretとAccessSecretをパーセントエンコードし、&で結合
  4. 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を実行します。

  1. INIT
  2. APPEND
  3. 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などライブラリ使用時にはあまり意識することない部分を改めて実装してみて、ライブラリの便利さを実感しています。
ライブラリを使おう

19
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?