[PHP] ライブラリに頼らないTwitterAPI入門

# 前書き

**「ライブラリで何してんのか分からないから気持ち悪いんじゃヴォエエエ」** って人は読んでみるといいと思うよ（震え声）

HTTP通信について全く知らない人は、先に以下のリンク先を読んでおいてください。

- [とほほのWWW入門 - HTTP入門](http://www.tohoho-web.com/ex/http.htm)
- [リクエストパラメータ・セッションに関するまとめ](http://qiita.com/mpyw/items/7852213f478e8c5a2802)

**※ HTTP通信における改行コードは全て `\r\n` です。**

```http:GETリクエストヘッダーの例
GET /1.1/statuses/show.json?id=464526017030545409 HTTP/1.1
Host: api.twitter.com
Authorization: OAuth oauth_consumer_key="***", oauth_signature_method="HMAC-SHA1", oauth_timestamp="***", oauth_version="1.0a", oauth_nonce="***", oauth_signature="***", oauth_token="***"
Connection: close

```

```http:通常のPOSTリクエストヘッダーの例
POST /1.1/statuses/update.json HTTP/1.1
Host: api.twitter.com
Authorization: OAuth oauth_consumer_key="***", oauth_signature_method="HMAC-SHA1", oauth_timestamp="***", oauth_version="1.0a", oauth_nonce="***", oauth_signature="***", oauth_token="***"
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 11

status=test
```

```http:マルチパートのPOSTリクエストヘッダーの例
POST /1.1/statuses/update_with_media.json HTTP/1.1
Host: api.twitter.com
Authorization: OAuth oauth_consumer_key="***", oauth_signature_method="HMAC-SHA1", oauth_timestamp="***", oauth_version="1.0a", oauth_nonce="***", oauth_signature="***", oauth_token="***"
Connection: close
Content-Type: multipart/form-data; boundary=-----hogehoge
Content-Length: 232

-------hogehoge
Content-Disposition: form-data; name="status"

test
-------hogehoge
Content-Disposition: form-data; name="media[]"; filename="test.png"
Content-Type: application/octet-stream

臼NG........
-------hogehoge--
```

# OAuth認証の流れ

ライブラリを使ってTwitter連携機能を実装したことのある人なら分かりきっていると思いますが、念のため分かりやすい図を@ITさんの記事より引用させていただきます。現状メインで使われているのはやはりOAuth1.0および1.0aなので、ここではそれに限定して説明していきます。

- [APIアクセス権を委譲するプロトコル、
OAuthを知る](http://www.atmarkit.co.jp/fsecurity/special/106oauth/oauth01.html)
- [OAuth 2.0で
Webサービスの利用方法はどう変わるか](http://www.atmarkit.co.jp/fsmart/articles/oauth2/01.html)

![01.jpg](https://qiita-image-store.s3.amazonaws.com/0/25060/e1faf53e-c890-7120-5936-c2afa093cbf3.jpeg)

# `oauth_signature` 生成のために必要なパラメータ

ここでは便宜上

- 送信する情報の本体<br>→ **「ベース」**
- 送信する情報の正当性を保証する署名に使われる部分<br>→ **「キー」**

と呼ぶことにします。

## `oauth/request_token` の場合

### ベース

| 要素名 | 説明 |
|:---:|:---|
|oauth_consumer_key|コンシューマーキー|
|oauth_signature_method|`"HMAC-SHA1"`|
|oauth_timestamp|タイムスタンプ|
|oauth_version|`"1.0a"`|
|oauth_nonce|`bin2hex(openssl_random_pseudo_bytes(16))` で生成した乱数ハッシュなど|
|oauth_callback|認証後にリダイレクトして戻ってくるURL|

- `oauth_callback` は省略可能です。省略した場合はコンシューマーキー登録時のものが使用されます。
- `oauth_callback` に `"oob"` を指定した場合、リダイレクトする代わりにPIN入力になります。

### キー

| オフセット | 説明 |
|:---:|:---|
| 0 |コンシューマーシークレット|
| 1 | **`""` (空文字列)** |

## `oauth/access_token` の場合

### ベース

| 要素名 | 説明 |
|:---:|:---|
|oauth_consumer_key|コンシューマーキー|
|oauth_signature_method|`"HMAC-SHA1"`|
|oauth_timestamp|タイムスタンプ|
|oauth_version|`"1.0a"`|
|oauth_nonce|`bin2hex(openssl_random_pseudo_bytes(16))` で生成した乱数ハッシュなど|
|**oauth_verifier**|**ベリファイア(PIN)**|
|**oauth_token**|**リクエストトークン**|

### キー

| オフセット | 説明 |
|:---:|:---|
| 0 |コンシューマーシークレット|
| 1 |**リクエストトークンシークレット**|

## その他のリクエストの場合(通常)

### ベース

**※ 追加パラメータは `oauth_signature` 生成より後には除外されます。**

| 要素名 | 説明 |
|:---:|:---|
|oauth_consumer_key|コンシューマーキー|
|oauth_signature_method|`"HMAC-SHA1"`|
|oauth_timestamp|タイムスタンプ|
|oauth_version|`"1.0a"`|
|oauth_nonce|`bin2hex(openssl_random_pseudo_bytes(16))` で生成した乱数ハッシュなど|
|**oauth_token**|**アクセストークン**|
|**パラメータ1の名称**|**パラメータ1の値**|
|**パラメータ2の名称**|**パラメータ2の値**|
|**パラメータ3の名称**|**パラメータ3の値**|
|**...**|**...**|

### キー

| オフセット | 説明 |
|:---:|:---|
| 0 |コンシューマーシークレット|
| 1 |**アクセストークンシークレット**|

## その他のリクエストの場合(マルチパート)

### ベース

| 要素名 | 説明 |
|:---:|:---|
|oauth_consumer_key|コンシューマーキー|
|oauth_signature_method|`"HMAC-SHA1"`|
|oauth_timestamp|タイムスタンプ|
|oauth_version|`"1.0a"`|
|oauth_nonce|`bin2hex(openssl_random_pseudo_bytes(16))` で生成した乱数ハッシュなど|
|**oauth_token**|**アクセストークン**|

### キー

| オフセット | 説明 |
|:---:|:---|
| 0 |コンシューマーシークレット|
| 1 |**アクセストークンシークレット**|

# `Authorization` ヘッダー生成の手順

上記の **「その他のリクエストの場合(通常)」** で [POST statuses/update](https://dev.twitter.com/docs/api/1.1/post/statuses/update) を利用する場合の具体例を示します。

## 1. ベースとキーの準備

```php
// 最後まで使われる基本パラメータ
$oauth_params = [
    'oauth_consumer_key'     => 'コンシューマーキー',
    'oauth_signature_method' => 'HMAC-SHA1',
    'oauth_timestamp'        => time(),
    'oauth_version'          => '1.0a',
    'oauth_nonce'            => bin2hex(openssl_random_pseudo_bytes(16)),
    'oauth_token'            => 'アクセストークン',
];

// oauth_signature生成まで使われる追加パラメータ
$additional_params = [
    'status'                => 'ツイート内容',
    'in_reply_to_status_id' => 'リプライ先ステータスID',
];

// ベース
$base = $oauth_params + $additional_params;

// キー
$key = ['コンシューマーシークレット', 'アクセストークンシークレット'];
```

## 2. ベースを要素名自然順でソートする

通常のソートだと

```
a1, a10, a2, a3, a4, a5, a6, a7, a8, a9
```

となってしまうところを

```
a1, a2, a3, a4, a5, a6, a7, a8, a9, a10
```

とするのが自然順ソートです。以下のように記述します。

```php
uksort($base, 'strnatcmp');
```

実際にこのような差異がつくパラメータ名はTwitterAPIには採用されていないので、

```php
ksort($base);
```

と書いても特に問題ないかもしれません。

## 3. ベースをRFC3986に従ってクエリストリングにする

```php
$base = http_build_query($base, '', '&', PHP_QUERY_RFC3986);
```

## 4. 所定の手順に従ってベースを再構成する

**※ GETリクエストの場合、この「URL」にクエリストリングは含めません。**

```php
// リクエストメソッド、URL、ベースからなる配列を作る
$base = ['POST', 'https://api.twitter.com/1.1/statuses/update.json', $base];

// RFC3986に従ったURLエンコードを適用
$base = array_map('rawurlencode', $base);

// 「&」で結合する
$base = implode('&', $base);
```

## 5. 所定の手順に従ってキーを再構成する

```php
// RFC3986に従ったURLエンコードを適用
$key = array_map('rawurlencode', $key);

// 「&」で結合する
$key = implode('&', $key);
```

## 6. `oauth_signature` を生成して基本パラメータに追加する

HMACとかSHA-1とか聞かれても答えられないので暗号アルゴリズムについて自力で模索するぐらい意識の高い人以外は **おまじない** として軽くスルーしてください。Twitterがこの方法で署名を作れとしているので、単にそれに従っているだけです。

```php
$oauth_params['oauth_signature'] = base64_encode(hash_hmac('sha1', $base, $key, true));
```

## 7. ヘッダーの形に仕上げて完成

```php
foreach ($oauth_params as $name => $value) {
    $items[] = sprintf('%s="%s"', urlencode($name), urlencode($value));
}
$header = 'Authorization: OAuth ' . implode(', ', $items);
```

ここまでの手順を最短でまとめると以下のようになります。

```php:PHP5.4以降のみ
uksort($base, 'strnatcmp');
$oauth_params['oauth_signature'] = base64_encode(hash_hmac(
    'sha1',
    implode('&', array_map('rawurlencode', [
        'POST',
        'https://api.twitter.com/1.1/statuses/update.json',
        http_build_query($base, '', '&', PHP_QUERY_RFC3986)
    ])),
    implode('&', array_map('rawurlencode', $key)),
    true
));
foreach ($oauth_params as $name => $value) {
    $items[] = sprintf('%s="%s"', urlencode($name), urlencode($value));
}
$header = 'Authorization: OAuth ' . implode(', ', $items);
```

```php:PHP5.3以前でも可能な方法
uksort($base, 'strnatcmp');
$oauth_params['oauth_signature'] = base64_encode(hash_hmac(
    'sha1',
    implode('&', array_map('rawurlencode', array(
        'POST',
        'https://api.twitter.com/1.1/statuses/update.json',
        str_replace(
            array('+', '%7E'), 
            array('%20', '~'), 
            http_build_query($base, '', '&')
        )
    ))),
    implode('&', array_map('rawurlencode', $key)),
    true
));
foreach ($oauth_params as $name => $value) {
    $items[] = sprintf('%s="%s"', urlencode($name), urlencode($value));
}
$header = 'Authorization: OAuth ' . implode(', ', $items);
```

# リクエストの準備

[fsockopen](http://www.php.net/manual/ja/function.fsockopen.php) 関数などを使ってリクエストヘッダーをガリガリ書くよりも [cURL関数群](http://jp.php.net/manual/ja/book.curl.php) を利用したほうがはるかに容易なので、後者の方法で実装することにします。

## GETリクエストの場合

生成済みの `Authorization` ヘッダー _$header_ を利用して [GET users/show](https://dev.twitter.com/docs/api/1.1/get/users/show) にリクエストする場合の具体例を示します。

```php
$url = 'https://api.twitter.com/1.1/users/show.json';
$params = ['screen_name' => 'mpyw'];
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $url . '?' . http_build_query($params, '', '&'),
    CURLOPT_HTTPHEADER     => [$header],
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_ENCODING       => 'gzip',
]);
```

## POSTリクエストの場合(通常)

生成済みの `Authorization` ヘッダー _$header_ を利用して [POST statuses/update](https://dev.twitter.com/docs/api/1.1/post/statuses/update) にリクエストする場合の具体例を示します。

```php
$url = 'https://api.twitter.com/1.1/statuses/update.json';
$params = ['status' => 'test'];
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $url,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query($params, '', '&'),
    CURLOPT_HTTPHEADER     => [$header],
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_ENCODING       => 'gzip',
]);
```

## POSTリクエストの場合(マルチパート)

生成済みの `Authorization` ヘッダー _$header_ を利用して [POST statuses/update_with_media](https://dev.twitter.com/docs/api/1.1/post/statuses/update_with_media) にリクエストする場合の具体例を示します。 [http_build_query](http://www.php.net//manual/ja/function.http-build-query.php) 関数を通さず、直接ポストフィールドに連想配列を渡すとマルチパートリクエストになります。

```php:PHP5.5以降のみ
$url = 'https://api.twitter.com/1.1/statuses/update_with_media.json';
$params = [
    'status'  => 'test',
    'media[]' => new CURLFile('/home/mpyw/test.png'),
];
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $url,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $params,
    CURLOPT_SAFE_UPLOAD    => true,
    CURLOPT_HTTPHEADER     => [$header],
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_ENCODING       => 'gzip',
]);
```

PHP5.4以前では [CURLFile](http://php.net/manual/ja/class.curlfile.php) クラスや `CURLOPT_SAFE_UPLOAD` フラグが使えないため、少々面倒な方法を採ることになります。詳しくは以下を参照してください。

- [PHPでcURLのクソ仕様 "@" を回避する](http://qiita.com/mpyw/items/c2d2f9cf67072e926721)

# リクエストの実行・レスポンスのパース・例外処理

## 例外処理の前提

何らかのエラーが発生していた場合、以下のような形式で例外をスローすることにします。

```php
throw new RuntimeException('エラーメッセージ', 'HTTPステータスコード');
```

## 奇形フォーマットへの対応

Twitterには歴史的な理由でさまざまなフォーマットが存在するので、その全てに対応しなければなりません。その際の `Content-Type` もアテにならないので、実際にそれぞれの形式でデコードしてみる必要があります。

### リクエスト成功時のレスポンス

```json:最も基本的なパターン
{
    "id" : 123456,
    "id_str" : "123456"
}
```

```json:タイムライン取得など
[
    {
        "id" : 123456,
        "id_str" : "123456"
    },
    {
        "id" : 123456,
        "id_str" : "123456"
    }
]
```

```text:トークン取得時
oauth_token=****&oauth_token_secret=****
```

### リクエスト失敗時のレスポンス

```json:最も基本的なパターン
{
    "errors" : [
        {
            "code"    : 123,
            "message" : "ERROR" 
        }
    ]
}
```

```json:公式リツイート失敗時
{
    "errors" : "sharing is not permissible for this status (Share validations failed)"
}
```

```json:許可の無いユーザのタイムラインを取得しようとしたとき
{
    "request" : "\/1.1\/statuses\/user_timeline.json?screen_name=******",
    "error"   : "Not authorized."
}
```

```text:トークン取得においてシグネチャが不正であったとき
Failed to validate oauth signature and token
```

```xml:アクセストークン取得においてリクエストトークンが有効期限切れのとき
<?xml version="1.0" encoding="UTF-8"?>
<hash>
  <error>Invalid / expired Token</error>
  <request>/oauth/access_token</request>
</hash>
```

```text:ユーザーストリーム規制時
Exceeded connection limit for user
```

```html:ユーザーストリーム取得失敗時
<html>\n<head>\n<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>\n<title>Error 401 Unauthorized</title>
</head>
<body>
<h2>HTTP ERROR: 401</h2>
<p>Problem accessing '/1.1/user.json'. Reason:
<pre>    Unauthorized</pre>
</body>
</html>
```

```html:高負荷でサーバーにリクエストを拒絶されたとき
(空文字列)
```

XMLやHTMLを突然返してきたり、 `\n` という文字がそのまま返されてたりしてさんざんですね。

## 関数の実装

リクエストの準備を行ったcURLリソース _$ch_ を渡し、リクエストを実行してレスポンスをデコードしてオブジェクトまたは配列で返す関数を実装してみます。なお、実際にストリーミングAPIを利用するときにこの関数は使わないので、これらに関する例外処理は行いません。

```php
/**
 * 通常のcURLリクエストを実行します。
 *
 * @param resource $ch cURLリソース
 * @return stdClass|array デコード済みのメッセージ
 * @throw RuntimeException
 */
function curl_exec_decode($ch)
{    
    // リクエストを実行
    $response = curl_exec($ch);

    // cURLのメタデータを取得
    $info = curl_getinfo($ch);
    
    // cURLの実行自体にエラーが発生しているか
    if (curl_errno($ch)) {
        throw new RuntimeException(curl_error($ch), $info['http_code']);
    }
    
    // レスポンスがJSON形式またはXML形式であるか
    if (
        null  !== $obj = json_decode($response) or
        false !== $obj = json_decode(json_encode(@simplexml_load_string($response)))
    ) {
        // レスポンスが文字列形式のプロパティerrorを持っているか
        if (isset($obj->error)) {       
            throw new RuntimeException($obj->error, $info['http_code']);
        }
        // レスポンスがプロパティerrorsを持っているか
        if (isset($obj->errors)) {
            if (is_string($obj->errors)) {
                // 文字列形式の場合
                throw new RuntimeException($obj->errors, $info['http_code']);
            } else {
                // 配列形式の場合
                throw new RuntimeException($obj->errors[0]->message, $info['http_code']);
            }
        }
        return $obj;
    }

    // クエリストリングとしてパース
    parse_str($response, $obj);
    $obj = (object)$obj;

    // レスポンスが妥当な形式のクエリストリングであるか
    if (isset($obj->oauth_token, $obj->oauth_token_secret)) {
        return $obj;
    }
    
    // レスポンスはテキスト形式のエラーであるか
    if (strip_tags($response) === $response) {
        throw new RuntimeException(trim($response), $info['http_code']);
    }

    // 形式不明のレスポンスであった
    throw new RuntimeException('Malformed response detected.', $info['http_code']);
}
```

# ストリーミングAPIへの対応

ストリーミングAPIは無限にレスポンスを返し続けるので、 [curl_exec](http://www.php.net/manual/ja/function.curl-exec.php) の実行時間が無限大になります。通常の方法では対応できません。

生成済みの `Authorization` ヘッダー _$header_ を利用して [GET statuses/sample](https://dev.twitter.com/docs/api/1.1/get/statuses/sample) にリクエストする場合の具体例を示します。

```php
$url = 'https://stream.twitter.com/1.1/statuses/sample.json';

// コールバック関数を作成する(内部でstatic変数を利用しているため、必ずクロージャでなければならない)
$callback = function ($ch, $str) {
    // メッセージが蓄積されるバッファ
    static $buffer = '';
    
    // 新規メッセージを追記する
    $buffer .= $str;
    
    // デコード可能な状態であるか(末尾が改行コードであるか)
    if ($buffer[strlen($buffer) - 1] === "\n") {
        if (null !== $obj = json_decode($buffer)) {
            $info = curl_getinfo($ch);
            if (preg_match("@Reason:\n<pre>([^<]++)</pre>@", $buffer, $matches)) {
                // レスポンスがHTML形式のエラーである場合
                throw new RuntimeException(trim($matches[1]), $info['http_code']);
            }
            if (strip_tags($buffer) === $buffer) {
                // レスポンスがテキスト形式のエラーである場合
                throw new RuntimeException(trim($buffer), $info['http_code']);
            }
            // 形式不明のレスポンスであった
            throw new RuntimeException('Malformed response detected.', $info['http_code']);
        }
        if (isset($obj->disconnect)) {
            // レスポンスがJSON形式だが切断されたことを意味する場合
            $info = curl_getinfo($ch);
            throw new RuntimeException($obj->disconnect->reason, $info['http_code']);
        };
        // メッセージを使って目的の処理を行う（ここではダンプしている）
        var_dump($buffer);
        // バッファを空にする
        $buffer = '';
    }
    
    // 追記された長さを返す
    return strlen($str);   
};

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $url,
    CURLOPT_SSL_VERIFYPEER => false,
    CURLOPT_HTTPHEADER     => [$header],    
    CURLOPT_ENCODING       => 'gzip',
    CURLOPT_TIMEOUT        => 0,         // 無限に実行し続ける
    CURLOPT_WRITEFUNCTION  => $callback, // 各メッセージを処理する関数として設定する
]);
curl_exec($ch); // エラーが発生するまでここで停滞する

// ここに到達したことはcURL自体のエラーを意味する
$info = curl_getinfo($ch);
throw new RuntimeException(curl_error($ch), $info['http_code']);
```

# 活用例

- [今日が誕生日かもしれない人々を検索する](http://qiita.com/mpyw/items/cebb8ea59a6dfcd32a27)

# ライブラリの作成例

例外処理をガチガチに固めてあるので、これを使えば原因不明のエラーに悩まされることが無くなります。

- [GitHub - Cowitter](https://github.com/mpyw/cowitter)