【CakePHP3】Http Clientを使用した場合のプロトコルのデフォルトがHTTP/1.1と見せかけてHTTP/1.0だった話

# はじめに

CakePHP3に実装されている [\Cake\Http\Client](https://book.cakephp.org/3/ja/core-libraries/httpclient.html "\Cake\Http\Client") を使ってリモートAPIと通信したところ、HTTPリクエストのプロトコルバージョンが HTTP/1.0 で、リモートAPIの仕様(HTTP/1.1 以上)を満たしておらず `426 Upgrade Required` が返却される事象に遭遇しました。
本事象に対する検証を行っていく中で、CakePHP3の実装が自分の認識と違っている部分があったため備忘として記事に残しておきます。

# 動作環境

```bash:os
$ cat /etc/redhad-release
CentOS Linux release 7.7.1908 (Core)
```

```bash:libcurl
$ yum list installed | grep libcurl
libcurl.x86_64                        7.29.0-54.el7             @CentOS 
```

```bash:PHP
$ php -v
PHP 7.3.27 (cli) (built: Feb  2 2021 10:32:50) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.3.27, Copyright (c) 1998-2018 Zend Technologies
```

```bash:cURL拡張モジュール
$ php -m | grep curl
curl

$ php -r "echo json_encode(curl_version(), JSON_PRETTY_PRINT),PHP_EOL;"
{
    "version_number": 466176,
    "age": 3,
    "features": 558781,
    "ssl_version_number": 0,
    "version": "7.29.0",
    "host": "x86_64-redhat-linux-gnu",
    "ssl_version": "NSS\/3.36",
    "libz_version": "1.2.7",
    "protocols": [
        "dict",
        "file",
        "ftp",
        "ftps",
        "gopher",
        "http",
        "https",
        "imap",
        "imaps",
        "ldap",
        "ldaps",
        "pop3",
        "pop3s",
        "rtsp",
        "scp",
        "sftp",
        "smtp",
        "smtps",
        "telnet",
        "tftp"
    ],
    "ares": "",
    "ares_num": 0,
    "libidn": "1.28",
    "iconv_ver_num": 0,
    "libssh_version": "libssh2\/1.4.3"
}
```

```bash:CakePHP
$ bin/cake --version
3.7.5
```

# 調査内容

最初に、プロトコルバージョンを指定しなかった場合は HTTP/1.0 となる状況を再現します。実際のコードではもっと色々やっていますが、関係ない部分は割愛しています。

```php
use Cake\Http\Client;
use Cake\Http\Client\Request;

$http = new Client();

$repuest = new Request('http://www.example.com/', 'GET');
$options = [
  CURLOPT_VERBOSE => true  // 調査のために詳細情報を出力します
];
$http->send($request, $options);
```

```text:CURLOPT_VERBOSE
* About to connect() to www.example.com port 80 (#1)
*   Trying 93.184.216.34...
* Connected to www.example.com (93.184.216.34) port 80 (#1)
> GET / HTTP/1.0  ← HTTP/1.0でリクエストしていることが分かります
Host: www.example.com
Accept: */*
Content-Length: 0
Connection: close
User-Agent: CakePHP

* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Age: 394679
< Cache-Control: max-age=604800
< Content-Type: text/html; charset=UTF-8
< Date: Tue, 08 Jun 2021 05:39:44 GMT
< Etag: "3147526947+ident"
< Expires: Tue, 15 Jun 2021 05:39:44 GMT
< Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
< Server: ECS (sab/5693)
< Vary: Accept-Encoding
< X-Cache: HIT
< Content-Length: 1256
< Connection: close
< 
* Closing connection 1
```

プロトコルバージョンに HTTP/1.1 を指定する方法を探すため、CakePHPの公式ドキュメントを読んでいたら [\Cake\Http\Client\Request::version()](https://api.cakephp.org/3.7/class-Cake.Http.Client.Request.html#_version "\Cake\Http\Client\Request::version()") という関数が実装されていることが分かりました。
ちなみに `\Cake\Http\Client\Request` はリクエストを送信するために呼び出している [\Cake\Http\Client::send()](https://api.cakephp.org/3.7/class-Cake.Http.Client.html#_send "\Cake\Http\Client::send()") の第一引数に渡されるクラスです。
なお、当時の状況をそのまま記載する関係上ここでは上記関数を使用していますが、現在は非推奨となっているため、CakePHPのバージョンが `3.3.0` 以上の場合はご注意ください。

```php
use Cake\Http\Client;
use Cake\Http\Client\Request;

$http = new Client();

$repuest = new Request('http://www.example.com/', 'GET');
+ $request = $request->version('1.1');
$options = [
  CURLOPT_VERBOSE => true  // 調査のために詳細情報を出力します
];
$http->send($request, $options);
```

```text:CURLOPT_VERBOSE
* About to connect() to www.example.com port 80 (#1)
*   Trying 93.184.216.34...
* Connected to www.example.com (93.184.216.34) port 80 (#1)
> GET / HTTP/1.0  ← 変わっていない・・・
Host: www.example.com
Accept: */*
Content-Length: 0
Connection: close
User-Agent: CakePHP

* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Accept-Ranges: bytes
< Age: 402912
< Cache-Control: max-age=604800
< Content-Type: text/html; charset=UTF-8
< Date: Tue, 08 Jun 2021 07:03:48 GMT
< Etag: "3147526947"
< Expires: Tue, 15 Jun 2021 07:03:48 GMT
< Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
< Server: ECS (sab/5707)
< Vary: Accept-Encoding
< X-Cache: HIT
< Content-Length: 1256
< Connection: close
< 
* Closing connection 1
```
結果として、この方法ではうまくいきませんでした。
調査に立ち返り `\Cake\Http\Client` とその関連クラスの内部実装を確認することにします。

`\Cake\Http\Client` は リクエスト送信用アダプタである`\Cake\Http\Client\AdapterInterface` に依存しており、cURL拡張モジュールがロードされていてアダプタの指定がない場合は `\Cake\Http\Client\Adapter\Curl` が使用されます。
また、先ほど登場した `\Cake\Http\Client\Request` は最終的にこのCurlクラスに渡され、cURLを実行するための各種オプションを構築する用途で利用されているようです。

- [\Cake\Http\Client](https://github.com/cakephp/cakephp/blob/3.x/src/Http/Client.php "\Cake\Http\Client")
- [\Cake\Http\Client\AdapterInterface](https://github.com/cakephp/cakephp/blob/3.x/src/Http/Client/AdapterInterface.php "\Cake\Http\Client\AdapterInterface")
- [\Cake\Http\Client\Request](https://github.com/cakephp/cakephp/blob/3.x/src/Http/Client/Request.php "\Cake\Http\Client\Request")

リクエストの送信はcURLで行われていることが分かったので、プロトコルバージョンに関するオプションが設定されている箇所がないか探したところ `\Cake\Http\Client\Adapter\Curl` で `CURLOPT_HTTP_VERSION` に `\Cake\Http\Client\Request:getProtocolVersion()` で取得した値を設定していることが分かりました。

```php:\Cake\Http\Client\Adapter\Curl
<?php
namespace Cake\Http\Client\Adapter;

use Cake\Http\Client\AdapterInterface;
use Cake\Http\Client\Request;
use Cake\Http\Client\Response;
use Cake\Http\Exception\HttpException;

class Curl implements AdapterInterface
{
    ... 省略 ...

    /**
     * Convert client options into curl options.
     *
     * @param \Cake\Http\Client\Request $request The request.
     * @param array $options The client options
     * @return array
     */
    public function buildOptions(Request $request, array $options)
    {
        $headers = [];
        foreach ($request->getHeaders() as $key => $values) {
            $headers[] = $key . ': ' . implode(', ', $values);
        }

        $out = [
            CURLOPT_URL => (string)$request->getUri(),
            CURLOPT_HTTP_VERSION => $request->getProtocolVersion(),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_HTTPHEADER => $headers
        ];
        switch ($request->getMethod()) {
            case Request::METHOD_GET:
                $out[CURLOPT_HTTPGET] = true;
                break;

            case Request::METHOD_POST:
                $out[CURLOPT_POST] = true;
                break;

            default:
                $out[CURLOPT_POST] = true;
                $out[CURLOPT_CUSTOMREQUEST] = $request->getMethod();
                break;
        }

        $body = $request->getBody();
        if ($body) {
            $body->rewind();
            $out[CURLOPT_POSTFIELDS] = $body->getContents();
        }

        if (empty($options['ssl_cafile'])) {
            $options['ssl_cafile'] = CORE_PATH . 'config' . DIRECTORY_SEPARATOR . 'cacert.pem';
        }
        if (!empty($options['ssl_verify_host'])) {
            // Value of 1 or true is deprecated. Only 2 or 0 should be used now.
            $options['ssl_verify_host'] = 2;
        }
        $optionMap = [
            'timeout' => CURLOPT_TIMEOUT,
            'ssl_verify_peer' => CURLOPT_SSL_VERIFYPEER,
            'ssl_verify_host' => CURLOPT_SSL_VERIFYHOST,
            'ssl_cafile' => CURLOPT_CAINFO,
            'ssl_local_cert' => CURLOPT_SSLCERT,
            'ssl_passphrase' => CURLOPT_SSLCERTPASSWD,
        ];
        foreach ($optionMap as $option => $curlOpt) {
            if (isset($options[$option])) {
                $out[$curlOpt] = $options[$option];
            }
        }
        if (isset($options['proxy']['proxy'])) {
            $out[CURLOPT_PROXY] = $options['proxy']['proxy'];
        }
        if (isset($options['proxy']['username'])) {
            $password = !empty($options['proxy']['password']) ? $options['proxy']['password'] : '';
            $out[CURLOPT_PROXYUSERPWD] = $options['proxy']['username'] . ':' . $password;
        }
        if (isset($options['curl']) && is_array($options['curl'])) {
            // Can't use array_merge() because keys will be re-ordered.
            foreach ($options['curl'] as $key => $value) {
                $out[$key] = $value;
            }
        }

        return $out;
    }

    ... 省略 ...
}
```

ここで `\Cake\Http\Client\Request:getProtocolVersion()` ですが `\Cake\Http\Client\Request` が使用している `\Zend\Diactoros\MessageTrait` に実装されています。

- [\Zend\Diactoros\MessageTrait](https://github.com/zendframework/zend-diactoros/blob/master/src/MessageTrait.php "\Zend\Diactoros\MessageTrait")

```php:\Zend\Diactoros\MessageTrait
<?php
namespace Zend\Diactoros;

use InvalidArgumentException;
use Psr\Http\Message\StreamInterface;

use function array_map;
use function array_merge;
use function get_class;
use function gettype;
use function implode;
use function is_array;
use function is_object;
use function is_resource;
use function is_string;
use function preg_match;
use function sprintf;
use function strtolower;

trait MessageTrait
{
    ... 省略 ...

    /**
     * @var string
     */
    private $protocol = '1.1';

    ... 省略 ...

    /**
     * Retrieves the HTTP protocol version as a string.
     *
     * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
     *
     * @return string HTTP protocol version.
     */
    public function getProtocolVersion()
    {
        return $this->protocol;
    }

    ... 省略 ...
}
```

`$protocol = '1.1'` となっているため、CakePHP3のデフォルトでは `HTTP/1.1` での通信を意図しているように読み取れます。
ただし `CURLOPT_HTTP_VERSION` に設定できる値に `'1.1'` は想定されておらず、本記事の環境で定義できる値は下表の通りとなっています。

| 定数名 | 値(型) | 振る舞い |
| --- | --- | --- |
| CURL_HTTP_VERSION_1_0 | 1(int) | HTTP/1.0 を使用する |
| CURL_HTTP_VERSION_1_1 | 2(int) | HTTP/1.1 を使用する |
| CURL_HTTP_VERSION_NONE | 0(int) | 使用するバージョンを決めるのは cURL にまかせる |

>CURL_HTTP_VERSION_NONE (デフォルト。 使用するバージョンを決めるのは cURL にまかせる)、 CURL_HTTP_VERSION_1_0 (HTTP/1.0 を使用する)、 あるいは CURL_HTTP_VERSION_1_1 (HTTP/1.1 を使用する) CURL_HTTP_VERSION_2_0 (HTTP 2 の使用を試みる), CURL_HTTP_VERSION_2 (CURL_HTTP_VERSION_2_0 のエイリアス), CURL_HTTP_VERSION_2TLS (TLS (https) の場合のみ HTTP 2 の使用を試みる) または CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE (HTTP 1.1 へのアップグレードを行わず、HTTP/2 を使って TLS でないリクエストを発行する) のいずれかです。

[cURL拡張モジュール](https://github.com/php/php-src/blob/master/ext/curl/interface.c "cURL拡張モジュール")の実装ではオプションが `CURLOPT_HTTP_VERSION` の場合は設定された値がlong型にキャストされるようなので、`\Cake\Http\Client\Adapter\Curl` で設定した `'1.1'` が `1` となった結果、CakePHP3では HTTP/1.1 で通信している認識であったが、実際は HTTP/1.0 での通信となったと考えています。

従って HTTP/1.1 で通信したい場合、プロトコルバージョンの指定は一例として以下のようなコードである必要があると考えます。
繰り返しになりますが `\Cake\Http\Client\Request::version()` は現在は非推奨となっているため、CakePHPのバージョンが `3.3.0` 以上の場合はご注意ください。
また、引数に型宣言がされていないのでエラーにはなりませんが、引数の型としては `string|null` が想定されている点に留意してください。

```php
use Cake\Http\Client;
use Cake\Http\Client\Request;

$http = new Client();

$repuest = new Request('http://www.example.com/', 'GET');
- $request = $request->version('1.1');
+ $request = $request->version(CURL_HTTP_VERSION_1_1);
$options = [
  CURLOPT_VERBOSE => true  // 調査のために詳細情報を出力します
];
$http->send($request, $options);
```

```text:CURLOPT_VERBOSE
* About to connect() to www.example.com port 80 (#0)
*   Trying 93.184.216.34...
* Connected to www.example.com (93.184.216.34) port 80 (#0)
> GET / HTTP/1.1
Host: www.example.com
Accept: */*
Content-Length: 0
Connection: close
User-Agent: CakePHP

< HTTP/1.1 200 OK
< Age: 455145
< Cache-Control: max-age=604800
< Content-Type: text/html; charset=UTF-8
< Date: Wed, 09 Jun 2021 05:13:02 GMT
< Etag: "3147526947+ident"
< Expires: Wed, 16 Jun 2021 05:13:02 GMT
< Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
< Server: ECS (sab/5798)
< Vary: Accept-Encoding
< X-Cache: HIT
< Content-Length: 1256
< Connection: close
< 
* Closing connection 0
```

# おわりに

今回の調査で `\Cake\Http\Client` を使用してHTTP通信を行う場合、デフォルトプロトコルバージョンは HTTP/1.1 と見せかけて HTTP/1.0 となってしまうということを発見できたと同時に、初めからプロトコルバージョンを明示的に指定しておくべきだったと反省しました。

実はこのやり方がCakePHP3のスタンダードから外れており、そのために今回の事象が発生したという可能性もあると考えています。
リモートAPIとの通信方法でもっと良いやり方を知っている方がいらっしゃいましたら教えていただけると嬉しいです。

また、CakePHP4では本事象がどのようになっているのかについては時間を見つけて調べてみようと思います。

---

文中のリンクの最終確認日は全て `2021/06/09(本記事執筆時点)` です。