Browse Source

Merge pull request #12209 from cakephp/3.7-http-client-curl

3.7 - Add curl based Http\Client Adapter.
Mark Story 7 years ago
parent
commit
3dec653e6e

+ 1 - 0
composer.json

@@ -38,6 +38,7 @@
     },
     "suggest": {
         "ext-openssl": "To use Security::encrypt() or have secure CSRF token generation.",
+        "ext-curl": "To enable more efficient network calls in Http\\Client.",
         "lib-ICU": "The intl PHP library, to use Text::transliterate() or Text::slug()"
     },
     "require-dev": {

+ 23 - 5
src/Http/Client.php

@@ -16,6 +16,9 @@ namespace Cake\Http;
 use Cake\Core\App;
 use Cake\Core\Exception\Exception;
 use Cake\Core\InstanceConfigTrait;
+use Cake\Http\Client\AdapterInterface;
+use Cake\Http\Client\Adapter\Curl;
+use Cake\Http\Client\Adapter\Stream;
 use Cake\Http\Client\Request;
 use Cake\Http\Cookie\CookieCollection;
 use Cake\Http\Cookie\CookieInterface;
@@ -104,7 +107,7 @@ class Client
      * @var array
      */
     protected $_defaultConfig = [
-        'adapter' => 'Cake\Http\Client\Adapter\Stream',
+        'adapter' => null,
         'host' => null,
         'port' => null,
         'scheme' => 'http',
@@ -127,10 +130,9 @@ class Client
     protected $_cookies;
 
     /**
-     * Adapter for sending requests. Defaults to
-     * Cake\Http\Client\Adapter\Stream
+     * Adapter for sending requests.
      *
-     * @var \Cake\Http\Client\Adapter\Stream
+     * @var \Cake\Http\Client\AdapterInterface
      */
     protected $_adapter;
 
@@ -154,6 +156,9 @@ class Client
      * - ssl_verify_host - Verify that the certificate and hostname match.
      *   Defaults to true.
      * - redirect - Number of redirects to follow. Defaults to false.
+     * - adapter - The adapter class name or instance. Defaults to
+     *   \Cake\Http\Client\Adapter\Curl if `curl` extension is loaded else
+     *   \Cake\Http\Client\Adapter\Stream.
      *
      * @param array $config Config options for scoped clients.
      */
@@ -162,10 +167,23 @@ class Client
         $this->setConfig($config);
 
         $adapter = $this->_config['adapter'];
-        $this->setConfig('adapter', null);
+        if ($adapter === null) {
+            $adapter = Curl::class;
+
+            if (!extension_loaded('curl')) {
+                $adapter = Stream::class;
+            }
+        } else {
+            $this->setConfig('adapter', null);
+        }
+
         if (is_string($adapter)) {
             $adapter = new $adapter();
         }
+
+        if (!$adapter instanceof AdapterInterface) {
+            throw new InvalidArgumentException('Adapter must be an instance of Cake\Http\Client\AdapterInterface');
+        }
         $this->_adapter = $adapter;
 
         if (!empty($this->_config['cookieJar'])) {

+ 161 - 0
src/Http/Client/Adapter/Curl.php

@@ -0,0 +1,161 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.7.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+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;
+
+/**
+ * Implements sending Cake\Http\Client\Request via ext/curl.
+ *
+ * In addition to the standard options documented in Cake\Http\Client,
+ * this adapter supports all available curl options. Additional curl options
+ * can be set via the `curl` option key when making requests or configuring
+ * a client.
+ */
+class Curl implements AdapterInterface
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function send(Request $request, array $options)
+    {
+        $ch = curl_init();
+        $options = $this->buildOptions($request, $options);
+        curl_setopt_array($ch, $options);
+
+        $body = $this->exec($ch);
+        if ($body === false) {
+            $errorCode = curl_errno($ch);
+            $error = curl_error($ch);
+            curl_close($ch);
+
+            $status = 500;
+            if ($error === 28) {
+                $status = 504;
+            }
+            throw new HttpException("cURL Error ({$errorCode}) {$error}", $status);
+        }
+
+        $responses = $this->createResponse($ch, $body);
+        curl_close($ch);
+
+        return $responses;
+    }
+
+    /**
+     * 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['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;
+    }
+
+    /**
+     * Convert the raw curl response into an Http\Client\Response
+     *
+     * @param resource $handle Curl handle
+     * @param string $responseData string The response data from curl_exec
+     * @return \Cake\Http\Client\Response
+     */
+    protected function createResponse($handle, $responseData)
+    {
+        $meta = curl_getinfo($handle);
+        $headers = trim(substr($responseData, 0, $meta['header_size']));
+        $body = substr($responseData, $meta['header_size']);
+        $response = new Response(explode("\r\n", $headers), $body);
+
+        return [$response];
+    }
+
+    /**
+     * Execute the curl handle.
+     *
+     * @param resource $ch Curl Resource handle
+     * @return string
+     */
+    protected function exec($ch)
+    {
+        return curl_exec($ch);
+    }
+}

+ 3 - 6
src/Http/Client/Adapter/Stream.php

@@ -14,6 +14,7 @@
 namespace Cake\Http\Client\Adapter;
 
 use Cake\Core\Exception\Exception;
+use Cake\Http\Client\AdapterInterface;
 use Cake\Http\Client\Request;
 use Cake\Http\Client\Response;
 use Cake\Http\Exception\HttpException;
@@ -24,7 +25,7 @@ use Cake\Http\Exception\HttpException;
  *
  * This approach and implementation is partly inspired by Aura.Http
  */
-class Stream
+class Stream implements AdapterInterface
 {
 
     /**
@@ -63,11 +64,7 @@ class Stream
     protected $_connectionErrors = [];
 
     /**
-     * Send a request and get a response back.
-     *
-     * @param \Cake\Http\Client\Request $request The request object to send.
-     * @param array $options Array of options for the stream.
-     * @return array Array of populated Response objects
+     * {@inheritDoc}
      */
     public function send(Request $request, array $options)
     {

+ 28 - 0
src/Http/Client/AdapterInterface.php

@@ -0,0 +1,28 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.7.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http\Client;
+
+use Cake\Http\Client\Request;
+
+interface AdapterInterface
+{
+    /**
+     * Send a request and get a response back.
+     *
+     * @param \Cake\Http\Client\Request $request The request object to send.
+     * @param array $options Array of options for the stream.
+     * @return \Cake\Http\Client\Response[] Array of populated Response objects
+     */
+    public function send(Request $request, array $options);
+}

+ 307 - 0
tests/TestCase/Http/Client/Adapter/CurlTest.php

@@ -0,0 +1,307 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.7.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http\Client\Adapter;
+
+use Cake\Http\Client\Adapter\Curl;
+use Cake\Http\Client\Request;
+use Cake\Http\Client\Response;
+use Cake\TestSuite\TestCase;
+
+/**
+ * HTTP curl adapter test.
+ */
+class CurlTest extends TestCase
+{
+
+    public function setUp()
+    {
+        parent::setUp();
+        $this->skipIf(!function_exists('curl_init'), 'Skipping as ext/curl is not installed.');
+
+        $this->curl = new Curl();
+        $this->caFile = CORE_PATH . 'config' . DIRECTORY_SEPARATOR . 'cacert.pem';
+    }
+
+    /**
+     * Test the send method
+     *
+     * @return void
+     */
+    public function testSendLive()
+    {
+        $request = new Request('http://localhost', 'GET', [
+            'User-Agent' => 'CakePHP TestSuite',
+            'Cookie' => 'testing=value'
+        ]);
+        try {
+            $responses = $this->curl->send($request, []);
+        } catch (\Cake\Core\Exception\Exception $e) {
+            $this->markTestSkipped('Could not connect to localhost, skipping');
+        }
+        $this->assertCount(1, $responses);
+
+        $response = $responses[0];
+        $this->assertInstanceOf(Response::class, $response);
+        $this->assertNotEmpty($response->getHeaders());
+        $this->assertNotEmpty($response->getBody()->getContents());
+    }
+
+    /**
+     * Test the send method
+     *
+     * @return void
+     */
+    public function testSendLiveResponseCheck()
+    {
+        $request = new Request('https://api.cakephp.org/3.0/', 'GET', [
+            'User-Agent' => 'CakePHP TestSuite',
+        ]);
+        try {
+            $responses = $this->curl->send($request, []);
+        } catch (\Cake\Core\Exception\Exception $e) {
+            $this->markTestSkipped('Could not connect to book.cakephp.org, skipping');
+        }
+        $this->assertCount(1, $responses);
+
+        $response = $responses[0];
+        $this->assertInstanceOf(Response::class, $response);
+        $this->assertTrue($response->hasHeader('Date'));
+        $this->assertTrue($response->hasHeader('Content-type'));
+        $this->assertContains('<html', $response->getBody()->getContents());
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsGet()
+    {
+        $options = [
+            'timeout' => 5
+        ];
+        $request = new Request(
+            'http://localhost/things',
+            'GET',
+            ['Cookie' => 'testing=value']
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Cookie: testing=value',
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_TIMEOUT => 5,
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsPost()
+    {
+        $options = [];
+        $request = new Request(
+            'http://localhost/things',
+            'POST',
+            ['Cookie' => 'testing=value'],
+            ['name' => 'cakephp', 'yes' => 1]
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Cookie: testing=value',
+                'Connection: close',
+                'User-Agent: CakePHP',
+                'Content-Type: application/x-www-form-urlencoded',
+            ],
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => 'name=cakephp&yes=1',
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsPut()
+    {
+        $options = [];
+        $request = new Request(
+            'http://localhost/things',
+            'PUT',
+            ['Cookie' => 'testing=value']
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Cookie: testing=value',
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_POST => true,
+            CURLOPT_CUSTOMREQUEST => 'PUT',
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsJsonPost()
+    {
+        $options = [];
+        $content = json_encode(['a' => 1, 'b' => 2]);
+        $request = new Request(
+            'http://localhost/things',
+            'POST',
+            ['Content-type' => 'application/json'],
+            $content
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Content-type: application/json',
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => $content,
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsSsl()
+    {
+        $options = [
+            'ssl_verify_host' => true,
+            'ssl_verify_peer' => true,
+            'ssl_verify_peer_name' => true,
+            // These options do nothing in curl.
+            'ssl_verify_depth' => 9000,
+            'ssl_allow_self_signed' => false,
+        ];
+        $request = new Request('http://localhost/things', 'GET');
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_SSL_VERIFYPEER => true,
+            CURLOPT_SSL_VERIFYHOST => 2,
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsProxy()
+    {
+        $options = [
+            'proxy' => [
+                'proxy' => '127.0.0.1:8080'
+            ]
+        ];
+        $request = new Request('http://localhost/things', 'GET');
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_CAINFO => $this->caFile,
+            CURLOPT_PROXY => '127.0.0.1:8080',
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsCurlOptions()
+    {
+        $options = [
+            'curl' => [
+                CURLOPT_USERAGENT => 'Super-secret'
+            ]
+        ];
+        $request = new Request('http://localhost/things', 'GET');
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_CAINFO => $this->caFile,
+            CURLOPT_USERAGENT => 'Super-secret'
+        ];
+        $this->assertSame($expected, $result);
+    }
+}

+ 14 - 0
tests/TestCase/Http/ClientTest.php

@@ -19,6 +19,7 @@ use Cake\Http\Client\Response;
 use Cake\Http\Cookie\Cookie;
 use Cake\Http\Cookie\CookieCollection;
 use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
 
 /**
  * HTTP client test.
@@ -60,6 +61,19 @@ class ClientTest extends TestCase
     }
 
     /**
+     * testAdapterInstanceCheck
+     *
+     * @return void
+     */
+    public function testAdapterInstanceCheck()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Adapter must be an instance of Cake\Http\Client\AdapterInterface');
+
+        new Client(['adapter' => 'stdClass']);
+    }
+
+    /**
      * Data provider for buildUrl() tests
      *
      * @return array