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

# はじめに
Goで既存ライブラリを使用せずにTwitterAPIの機能を実装してみました。基本はライブラリを使用するかと思うので、誰得な内容ですが、個人的には`multipart/form-data`の仕様など改めて確認することができました。
※使用したバージョンはv1.1です。

余談ですが
`golang twitter api`等でぐぐると大体以下の２つが有名どころかなと思います。
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以外のパラメータと機能ごとの追加パラメータを文字列順にソートし、パーセントエンコードを行い、`&`で結合
1. HTTPメソッドとAPIのエンドポイントをパーセントエンコードし、1で作成した文字列とさらに`&`で結合
1. 発行されたConsumerSecretとAccessSecretをパーセントエンコードし、`&`で結合
1. 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機能は以下の３つのAPIを実行します。

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