PHP
Twitter
TwitterAPI

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

More than 1 year has passed since last update.

前書き

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

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

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

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

通常の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
マルチパートの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なので、ここではそれに限定して説明していきます。

01.jpg

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

ここでは便宜上

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

と呼ぶことにします。

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 を利用する場合の具体例を示します。

1. ベースとキーの準備

// 最後まで使われる基本パラメータ
$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

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

uksort($base, 'strnatcmp');

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

ksort($base);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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);
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 関数などを使ってリクエストヘッダーをガリガリ書くよりも cURL関数群 を利用したほうがはるかに容易なので、後者の方法で実装することにします。

GETリクエストの場合

生成済みの Authorization ヘッダー $header を利用して GET users/show にリクエストする場合の具体例を示します。

$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 にリクエストする場合の具体例を示します。

$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 にリクエストする場合の具体例を示します。 http_build_query 関数を通さず、直接ポストフィールドに連想配列を渡すとマルチパートリクエストになります。

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 クラスや CURLOPT_SAFE_UPLOAD フラグが使えないため、少々面倒な方法を採ることになります。詳しくは以下を参照してください。

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

例外処理の前提

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

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

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

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

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

最も基本的なパターン
{
    "id" : 123456,
    "id_str" : "123456"
}
タイムライン取得など
[
    {
        "id" : 123456,
        "id_str" : "123456"
    },
    {
        "id" : 123456,
        "id_str" : "123456"
    }
]
トークン取得時
oauth_token=****&oauth_token_secret=****

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

最も基本的なパターン
{
    "errors" : [
        {
            "code"    : 123,
            "message" : "ERROR" 
        }
    ]
}
公式リツイート失敗時
{
    "errors" : "sharing is not permissible for this status (Share validations failed)"
}
許可の無いユーザのタイムラインを取得しようとしたとき
{
    "request" : "\/1.1\/statuses\/user_timeline.json?screen_name=******",
    "error"   : "Not authorized."
}
トークン取得においてシグネチャが不正であったとき
Failed to validate oauth signature and token
アクセストークン取得においてリクエストトークンが有効期限切れのとき
<?xml version="1.0" encoding="UTF-8"?>
<hash>
  <error>Invalid / expired Token</error>
  <request>/oauth/access_token</request>
</hash>
ユーザーストリーム規制時
Exceeded connection limit for user
ユーザーストリーム取得失敗時
<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>
高負荷でサーバーにリクエストを拒絶されたとき
(空文字列)

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

関数の実装

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

/**
 * 通常の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 の実行時間が無限大になります。通常の方法では対応できません。

生成済みの Authorization ヘッダー $header を利用して GET statuses/sample にリクエストする場合の具体例を示します。

$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']);

活用例

ライブラリの作成例

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