導入
PHPの cURL関数群 はC言語で書かれた libcurl の極薄ラッパーとして実装されていますが、ポストフィールドのファイル取り扱いに関する実装が クソ仕様 としか思えないようになっています。
以下の2つのファイルを用意します。
<?php
$params = /* something */ ;
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => 'http://localhost/respond.php',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $params,
));
curl_exec($ch);
<?php
$headers = apache_request_headers();
var_dump(compact('headers', '_POST', '_FILES'));
$params
が 文字列 のとき
$params = 'a=b.jpg&c=@d.jpg';
array(3) {
["headers"]=>
array(4) {
["Host"]=>
string(9) "localhost"
["Accept"]=>
string(3) "*/*"
["Content-Length"]=>
string(2) "16"
["Content-Type"]=>
string(33) "application/x-www-form-urlencoded"
}
["_POST"]=>
array(2) {
["a"]=>
string(5) "b.jpg"
["c"]=>
string(6) "@d.jpg"
}
["_FILES"]=>
array(0) {
}
}
全てが文字列のパラメータとして application/x-www-form-urlencoded
で送信されます。こっちは特に問題なし。
$params
が (1次元) 連想配列 のとき
$params = array('a' => 'b.jpg', 'c' => '@d.jpg');
array(3) {
["headers"]=>
array(5) {
["Host"]=>
string(9) "localhost"
["Accept"]=>
string(3) "*/*"
["Content-Length"]=>
string(6) "219587"
["Expect"]=>
string(12) "100-continue"
["Content-Type"]=>
string(70) "multipart/form-data; boundary=----------------------------300acbbc7ca5"
}
["_POST"]=>
array(1) {
["a"]=>
string(5) "b.jpg"
}
["_FILES"]=>
array(1) {
["c"]=>
array(5) {
["name"]=>
string(5) "d.jpg"
["type"]=>
string(24) "application/octet-stream"
["tmp_name"]=>
string(24) "/var/tmp/phpC9D4.tmp"
["error"]=>
int(0)
["size"]=>
int(219264)
}
}
}
(PHPバージョン5.5以降は非推奨とされていますが)@
を先頭につけたものはファイル名と見なされ、ファイルのアップロードが行われます。しかし何と… PHP5.4以前 のcURL関数群には@
をエスケープする手段が用意されていません!
問題点
ファイルをアップロードしなくてもいいものに関しては、単に http_build_query 関数でクエリ文字列化するなどして送れば解決します。しかし、ファイルのアップロードをしたい、且つ @
付きの文字列を送りたいときに困りますね。
具体的には…TwitterのAPI POST statuses/update_with_media を利用して @
付きのツイートを送りたいときに…
$params = array(
'status' => '@mpyw テストツイート!',
'media[]' => '@/home/mpyw/test.jpg',
);
真っ先に思いつくのはこういう方法です。
statusパラメータに関しては、送信するツイートの頭に半角スペースを付加し、Twitter側にトリミングさせることで解決する。
$params = array(
'status' => ' @mpyw テストツイート!',
'media[]' => '@/home/mpyw/test.jpg',
);
しかし、ライブラリを使用する側でそんな面倒なことしたくないし、それで解決したとしてもアプリケーション設計という観点からみればやはり大問題です。また、cURLを利用している多くのTwitterAPI用のライブラリは、このことを一切考慮しない実装となっていることが多いです。
解決策
array(3) {
["headers"]=>
array(5) {
["Host"]=>
string(9) "localhost"
["Accept"]=>
string(3) "*/*"
["Expect"]=>
string(12) "100-continue"
["Content-Type"]=>
string(83) "multipart/form-data; boundary=---------------------e00ed8ddd5d4d50c1dc97be740f0bb27"
["Content-Length"]=>
string(6) "219628"
}
["_POST"]=>
array(1) {
["status"]=>
string(30) "@mpyw テストツイート!"
}
["_FILES"]=>
array(1) {
["media"]=>
array(5) {
["name"]=>
array(1) {
[0]=>
string(5) "test.jpg"
}
["type"]=>
array(1) {
[0]=>
string(24) "application/octet-stream"
}
["tmp_name"]=>
array(1) {
[0]=>
string(24) "/var/tmp/php1EC9.tmp"
}
["error"]=>
array(1) {
[0]=>
int(0)
}
["size"]=>
array(1) {
[0]=>
int(219264)
}
}
}
}
PHP5.6以降
- CURLFile クラスを利用する。
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'http://localhost/respond.php',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'status' => '@mpyw テストツイート!',
'media[]' => new CURLFile('/home/mpyw/test.jpg'),
],
]);
curl_exec($ch);
PHP5.6以降ではデフォルトで @
が無効になっています。
PHP5.5以降
CURLOPT_SAFE_UPLOAD
を有効にする。- CURLFile クラスを利用する。
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'http://localhost/respond.php',
CURLOPT_POST => true,
CURLOPT_SAFE_UPLOAD => true,
CURLOPT_POSTFIELDS => [
'status' => '@mpyw テストツイート!',
'media[]' => new CURLFile('/home/mpyw/test.jpg'),
],
]);
curl_exec($ch);
CURLOPT_SAFE_UPLOAD
を有効にすることで、PHP5.6と同じ状態に出来ます。
PHP5.3以降
- 自前でマルチパートボディを組み立てて文字列にする。
- 自前でContent-Typeを変更する。
$ch = curl_init('http://localhost/respond.php');
curl_custom_postfields(
$ch,
array('status' => '@mpyw テストツイート!'),
array('media[]' => '/home/mpyw/test.jpg')
);
curl_exec($ch);
/**
* マルチパートボディを組み立てて文字列としてセットする。
*
* @param resource $ch cURLリソース
* @param array $assoc 「送信する名前 => 送信する値」の形の連想配列
* @param array $files 「送信する名前 => ファイルパス」の形の連想配列
* @return bool 成功 or 失敗
*/
function curl_custom_postfields($ch, array $assoc = array(), array $files = array())
{
$body = array();
foreach ($assoc as $k => $v) {
$k = str_replace(array("\0", "\"", "\r", "\n"), "_", $k);
$body[] = implode("\r\n", array(
"Content-Disposition: form-data; name=\"{$k}\"",
"",
filter_var($v),
));
}
foreach ($files as $k => $v) {
switch (true) {
case false === $v = realpath(filter_var($v)):
case !is_file($v):
case !is_readable($v):
continue; // return false や throw new InvalidArgumentException もアリ
}
$data = file_get_contents($v);
$v = call_user_func("end", explode(DIRECTORY_SEPARATOR, $v));
list($k, $v) = str_replace(array("\0", "\"", "\r", "\n"), "_", array($k, $v));
$body[] = implode("\r\n", array(
"Content-Disposition: form-data; name=\"{$k}\"; filename=\"{$v}\"",
"Content-Type: application/octet-stream",
"",
$data,
));
}
do {
$boundary = "---------------------" . md5(mt_rand() . microtime());
} while (preg_grep("/{$boundary}/", $body));
array_walk($body, function (&$part) use ($boundary) {
$part = "--{$boundary}\r\n{$part}";
});
$body[] = "--{$boundary}--";
$body[] = "";
return curl_setopt_array($ch, array(
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => implode("\r\n", $body),
CURLOPT_HTTPHEADER => array(
"Expect: 100-continue",
"Content-Type: multipart/form-data; boundary={$boundary}",
),
));
}
内部でクロージャを使用しているためPHP5.3以降とさせていただきました。
(書き方次第でPHP5.2以前にも対応出来ますが、今更サポートの切れたそんな古いバージョン使わないでください)