diff --git a/.travis.yml b/.travis.yml index 04b1391..f4a3f3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,15 @@ language: php php: - - '5.4' - '5.5' - '5.6' - '7.0' + - '7.1' - hhvm - nightly before_script: - composer install + +script: + - vendor/bin/phpcs + - vendor/bin/phpunit diff --git a/README.md b/README.md index e61122e..aeec53d 100644 --- a/README.md +++ b/README.md @@ -12,22 +12,14 @@ Via [composer](https://getcomposer.org): `$ composer require "phpcurl/curlhttp"` ##Usage - -It is really that easy. - ```php setOptions([ - CURLOPT_FOLLOWLOCATION => false, // Any arbitrary curl options you want -]); - $response = $http->post('http://example.com/?a=b', 'my post data', ['User-Agent: My php crawler']); -// Supported: get(), post(), head(), post(), put(), delete(), custom methods +// Supported: get(), post(), head(), post(), put(), delete() $body = $response->getBody(); // Response body, string diff --git a/composer.json b/composer.json index 94372b2..5be7e62 100644 --- a/composer.json +++ b/composer.json @@ -24,12 +24,13 @@ } ], "require": { - "php": ">=5.3.0", - "phpcurl/curlwrapper": "^1" + "php": ">=5.5", + "phpcurl/curlwrapper": "^2.1" }, "require-dev": { - "phpunit/phpunit": "4.*", - "weew/php-http-server": "^1.0.0" + "phpunit/phpunit": "^4.0 || ^5.0", + "squizlabs/php_codesniffer": "^2.0", + "symfony/process": "^2" }, "autoload": { "psr-4": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..1747b77 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,7 @@ + + + src + test + vendor/* + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2b4a063..1c138ae 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,6 @@ curl = $curl ?: new Curl(); + } + + /** + * @inheritdoc + */ + public function execute($url, array $options) + { + $options[CURLOPT_RETURNTRANSFER] = true; + $options[CURLOPT_HEADER] = true; + + $this->curl->init($url); + $this->curl->setOptArray($options); + $response = $this->curl->exec(); + if (false === $response) { + throw NoResponse::fromCurl($this->curl); + } + $info = $this->curl->getInfo(); + return HttpResponse::fromCurl($response, $info); + } +} diff --git a/src/ExecutorInterface.php b/src/ExecutorInterface.php new file mode 100644 index 0000000..d99bbd0 --- /dev/null +++ b/src/ExecutorInterface.php @@ -0,0 +1,14 @@ +executor = $executor ?: new Executor(); + } /** * HTTP GET - * @param string $url Goes to curl_init() - * @param array $headers Same as CURLOPT_HEADER + * @param string $url Goes to curl_init() + * @param array $headers Same as CURLOPT_HEADER * @return HttpResponse */ - public function get($url, array $headers = null) + public function get($url, array $headers = []) { - $opt = array(); - if ($headers) { - $opt[CURLOPT_HTTPHEADER] = $headers; - } - return $this->exec($url, $opt); + return $this->executor->execute( + $url, + [ + CURLOPT_HTTPHEADER => $headers, + ] + ); } /** * HTTP HEAD (implemented using CURLOPT_NOBODY) - * @param string $url Goes to curl_init() - * @param array $headers Same as CURLOPT_HEADER + * @param string $url Goes to curl_init() + * @param array $headers Same as CURLOPT_HEADER * @return HttpResponse */ - public function head($url, array $headers = null) + public function head($url, array $headers = []) { - $opt[CURLOPT_NOBODY] = true; - if ($headers !== null) { - $opt[CURLOPT_HTTPHEADER] = $headers; - } - return $this->exec($url, $opt); + return $this->executor->execute( + $url, + [ + CURLOPT_NOBODY => true, + CURLOPT_HTTPHEADER => $headers, + ] + ); } /** * HTTP POST - * @param string $url Goes to curl_init() - * @param string|array $data Same as CURLOPT_POSTFIELDS - * @param array $headers Same as CURLOPT_HEADER + * @param string $url Goes to curl_init() + * @param string|array $data Same as CURLOPT_POSTFIELDS + * @param array $headers Same as CURLOPT_HEADER * @return HttpResponse */ - public function post($url, $data = null, array $headers = null) + public function post($url, $data = '', array $headers = []) { - $opt[CURLOPT_POST] = true; - if ($data !== null) { - $opt[CURLOPT_POSTFIELDS] = $data; - } - if ($headers !== null) { - $opt[CURLOPT_HTTPHEADER] = $headers; - } - return $this->exec($url, $opt); + return $this->executor->execute( + $url, + [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $data, + ] + ); } /** * HTTP PUT - * @param string $url Goes to curl_init() - * @param string|array $data Same as CURLOPT_POSTFIELDS - * @param array $headers Same as CURLOPT_HEADER + * @param string $url Goes to curl_init() + * @param string|array $data Same as CURLOPT_POSTFIELDS + * @param array $headers Same as CURLOPT_HEADER * @return HttpResponse */ - public function put($url, $data = null, array $headers = null) + public function put($url, $data = '', array $headers = []) { - return $this->request('PUT', $url, $data, $headers); + return $this->executor->execute( + $url, + [ + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $data, + ] + ); } /** * HTTP DELETE - * @param string $url Goes to curl_init() - * @param array $headers Same as CURLOPT_HEADER - * @return HttpResponse - */ - public function delete($url, array $headers = null) - { - return $this->request('DELETE', $url, null, $headers); - } - - /** - * Custom HTTP method - * @param string $method Goes to CURLOPT_CUSTOMREQUEST - * @param string $url Goes to curl_init() - * @param string|array $data Goes to CURLOPT_POSTFIELDS - * @param array $headers Goes to CURLOPT_HEADER - * @return HttpResponse - */ - public function request($method, $url, $data = null, array $headers = null) - { - $opt[CURLOPT_CUSTOMREQUEST] = $method; - if ($headers !== null) { - $opt[CURLOPT_HTTPHEADER] = $headers; - } - if ($data !== null) { - $opt[CURLOPT_POSTFIELDS] = $data; - } - return $this->exec($url, $opt); - } - - /** - * Set additional CURL options to pass with each request - * @param array $userOptions Format is the same as curl_setopt_array(). - * Pass an empty array to clear. - */ - public function setOptions(array $userOptions) - { - $this->userOptions = $userOptions; - } - - /** - * Init curl with $url, set $options, execute, return response - * @param string $url Goes to curl_init() - * @param array $options Goes to curl_setopt() - * @param Curl $curl + * @param string $url Goes to curl_init() + * @param array $headers Same as CURLOPT_HEADER * @return HttpResponse */ - public function exec($url, array $options, Curl $curl = null) + public function delete($url, array $headers = []) { - $options[CURLOPT_RETURNTRANSFER] = true; - $options[CURLOPT_HEADER] = true; - - $curl = $curl ?: new Curl(); - $curl->init($url); - $curl->setOptArray(array_replace_recursive( - $this->userOptions, - $options - )); - - $response = $curl->exec($this->attempts, self::USE_EXCEPTIONS); - - $info = $curl->getInfo(); - $code = $info['http_code']; - $headerSize = $info['header_size']; - $headers = substr($response, 0, $headerSize); - $headersArray = preg_split("/\r\n/", $headers, -1, PREG_SPLIT_NO_EMPTY); - $body = substr($response, $headerSize); - return new HttpResponse($code, $headersArray, $body); + return $this->executor->execute( + $url, + [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => $headers, + ] + ); } } diff --git a/src/HttpClientInterface.php b/src/HttpClientInterface.php new file mode 100644 index 0000000..7f99d1a --- /dev/null +++ b/src/HttpClientInterface.php @@ -0,0 +1,47 @@ +status = $status; - $this->body = $body; + $this->status = (int) $status; + $this->body = (string) $body; $this->headers = $headers; } + /** + * @param string $response + * @param array $info + * @return HttpResponse + */ + public static function fromCurl($response, array $info) + { + $headerSize = $info['header_size']; + $headers = substr($response, 0, $headerSize); + return new self( + $info['http_code'], + preg_split("/\r\n/", $headers, -1, PREG_SPLIT_NO_EMPTY), + substr($response, $headerSize) + ); + } + /** * @return int */ diff --git a/src/NoResponse.php b/src/NoResponse.php new file mode 100644 index 0000000..33ee5ce --- /dev/null +++ b/src/NoResponse.php @@ -0,0 +1,12 @@ +error(), $curl->errno()); + } +} diff --git a/src/RetryingExecutor.php b/src/RetryingExecutor.php new file mode 100644 index 0000000..215f39f --- /dev/null +++ b/src/RetryingExecutor.php @@ -0,0 +1,43 @@ +retries = $retries; + $this->executor = $executor; + } + + /** + * @inheritdoc + */ + public function execute($url, array $options) + { + for ($attempt = 0; $attempt <= $this->retries; $attempt++) { + try { + return $this->executor->execute($url, $options); + } catch (NoResponse $exception) { + } + } + throw new NoResponse("No response after $attempt attempts", 0, $exception); + } +} diff --git a/test/FunctionalTest.php b/test/FunctionalTest.php new file mode 100644 index 0000000..5cfa32a --- /dev/null +++ b/test/FunctionalTest.php @@ -0,0 +1,184 @@ +start(); + sleep(1); + } + + public static function tearDownAfterClass() + { + if (isset(self::$server)) { + self::$server->stop(); + } + } + + protected function setUp() + { + $this->client = new HttpClient(); + } + + public function testGet() + { + $response = $this->client->get('http://localhost:8080/yo', ['Foo: Bar']); + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals( + [ + 'uri' => '/yo', + 'headers' => [ + 'Foo' => 'Bar', + 'Host' => 'localhost:8080', + 'Accept' => '*/*', + ], + 'method' => 'GET', + 'post' => [], + 'raw_post' => null, + ], + $this->getRequestData($response) + ); + } + + public function testHead() + { + $response = $this->client->head('http://localhost:8080/yo', ['Foo: Bar']); + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals( + [ + 'uri' => '/yo', + 'headers' => [ + 'Foo' => 'Bar', + 'Host' => 'localhost:8080', + 'Accept' => '*/*', + ], + 'method' => 'HEAD', + 'post' => [], + 'raw_post' => null, + ], + $this->getRequestData($response) + ); + } + + public function testPost() + { + $response = $this->client->post('http://localhost:8080/yo', 'foo=bar', ['Foo: Bar']); + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals( + [ + 'uri' => '/yo', + 'headers' => [ + 'Foo' => 'Bar', + 'Host' => 'localhost:8080', + 'Accept' => '*/*', + 'Content-Length' => '7', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'method' => 'POST', + 'post' => [ + 'foo' => 'bar', + ], + 'raw_post' => 'foo=bar', + ], + $this->getRequestData($response) + ); + } + + public function testPut() + { + $response = $this->client->put('http://localhost:8080/yo', 'boo', ['Foo: Bar']); + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals( + [ + 'uri' => '/yo', + 'headers' => [ + 'Foo' => 'Bar', + 'Host' => 'localhost:8080', + 'Accept' => '*/*', + 'Content-Length' => '3', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'method' => 'PUT', + 'post' => [], + 'raw_post' => 'boo', + ], + $this->getRequestData($response) + ); + } + + public function testDelete() + { + $response = $this->client->delete('http://localhost:8080/yo', ['Foo: Bar']); + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals( + [ + 'uri' => '/yo', + 'headers' => [ + 'Foo' => 'Bar', + 'Host' => 'localhost:8080', + 'Accept' => '*/*', + ], + 'method' => 'DELETE', + 'post' => [], + 'raw_post' => null, + ], + $this->getRequestData($response) + ); + } + + /** + * @expectedException \PHPCurl\CurlHttp\NoResponse + */ + public function testException() + { + $response = $this->client->delete('http://localhost:0', ['Foo: Bar']); + $this->assertEquals(200, $response->getStatus()); + $this->assertEquals( + [ + 'uri' => '/yo', + 'headers' => [ + 'Foo' => 'Bar', + 'Host' => 'localhost:8080', + 'Accept' => '*/*', + ], + 'method' => 'DELETE', + 'post' => [], + 'raw_post' => null, + ], + $this->getRequestData($response) + ); + } + + /** + * @param HttpResponse $response + * @return array + */ + private function getRequestData(HttpResponse $response) + { + $prefix = 'Request-Data: '; + foreach ($response->getHeaders() as $header) { + if (0 === strpos($header, $prefix)) { + return unserialize(substr($header, strlen($prefix))); + } + } + throw new \LogicException(); + } +} diff --git a/test/HttpClientFunctionalTest.php b/test/HttpClientFunctionalTest.php deleted file mode 100644 index b2da960..0000000 --- a/test/HttpClientFunctionalTest.php +++ /dev/null @@ -1,106 +0,0 @@ -server = new HttpServer('localhost', 8080, __DIR__.'/helper/server.php'); - $this->server->disableOutput(); - $this->server->start(); - - $this->client = new HttpClient(); - } - - public function tearDown() - { - $this->server->stop(); - } - - public function testGet() - { - $response = $this->client->get('http://localhost:8080/yo', ['Foo: Bar']); - $server = $this->getRequestData($response); - $this->assertEquals('/yo', $server['uri']); - $this->assertEquals('Bar', $server['headers']['Foo']); - $this->assertEquals('GET', $server['method']); - $this->assertEquals(null, $server['raw_post']); - } - - public function testHead() - { - $response = $this->client->head('http://localhost:8080/yo', ['Foo: Bar']); - $server = $this->getRequestData($response); - $this->assertEquals('/yo', $server['uri']); - $this->assertEquals('Bar', $server['headers']['Foo']); - $this->assertEquals('HEAD', $server['method']); - $this->assertEquals(null, $server['raw_post']); - } - - public function testPost() - { - $response = $this->client->post('http://localhost:8080/yo', 'boo', ['Foo: Bar']); - $server = $this->getRequestData($response); - $this->assertEquals('/yo', $server['uri']); - $this->assertEquals('Bar', $server['headers']['Foo']); - $this->assertEquals('POST', $server['method']); - $this->assertEquals('boo', $server['raw_post']); - } - - public function testPut() - { - $response = $this->client->put('http://localhost:8080/yo', 'boo', ['Foo: Bar']); - $server = $this->getRequestData($response); - $this->assertEquals('/yo', $server['uri']); - $this->assertEquals('Bar', $server['headers']['Foo']); - $this->assertEquals('PUT', $server['method']); - $this->assertEquals('boo', $server['raw_post']); - } - - public function testDelete() - { - $response = $this->client->delete('http://localhost:8080/yo', ['Foo: Bar']); - $server = $this->getRequestData($response); - $this->assertEquals('/yo', $server['uri']); - $this->assertEquals('Bar', $server['headers']['Foo']); - $this->assertEquals('DELETE', $server['method']); - $this->assertEquals(null, $server['raw_post']); - } - - public function testCustom() - { - $response = $this->client->request('OPTIONS', 'http://localhost:8080/yo', 'boo', ['Foo: Bar']); - $server = $this->getRequestData($response); - $this->assertEquals('/yo', $server['uri']); - $this->assertEquals('Bar', $server['headers']['Foo']); - $this->assertEquals('OPTIONS', $server['method']); - $this->assertEquals('boo', $server['raw_post']); - } - - /** - * @param HttpResponse $response - * @return array - */ - private function getRequestData(HttpResponse $response) - { - $prefix = 'Request-Data: '; - foreach ($response->getHeaders() as $header) { - if (0 === strpos($header, $prefix)) { - return unserialize(substr($header, strlen($prefix))); - } - } - } - -} diff --git a/test/HttpClientTest.php b/test/HttpClientTest.php index cfa6b81..d733648 100644 --- a/test/HttpClientTest.php +++ b/test/HttpClientTest.php @@ -1,50 +1,117 @@ executor = $this->getMockForAbstractClass(ExecutorInterface::class); + $this->http = new HttpClient($this->executor); + } + + public function testGet() + { + $response = new HttpResponse(200, [], 'ok'); + $this->executor->expects($this->once()) + ->method('execute') + ->with( + 'http://example.com', + [ + CURLOPT_HTTPHEADER => ['Foo: Bar'] + ] + ) + ->willReturn($response); + $this->assertEquals( + $response, + $this->http->get('http://example.com', ['Foo: Bar']) + ); + } + + public function testHead() { - $curl = $this->getMockBuilder('PHPCurl\\CurlWrapper\\Curl') - ->disableOriginalConstructor() - ->setMethods(array('init', 'exec', 'setOptArray', 'getInfo', '__destruct')) - ->getMock(); - - $curl->expects($this->once()) - ->method('init') - ->with('http://example.com'); - - $curl->expects($this->once()) - ->method('exec') - ->willReturn("Age: 42\r\n\r\nHey"); - - $curl->expects($this->once()) - ->method('getInfo') - ->willReturn(array( - 'http_code' => 200, - 'header_size' => 11 - )); - - $curl->expects($this->once()) - ->method('setOptArray') - ->with(array( - CURLOPT_BINARYTRANSFER => true, - CURLOPT_NOBODY => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, - )); - - $client = new HttpClient(); - $client->setOptions(array( - CURLOPT_BINARYTRANSFER => true, - )); - - $response = $client->exec('http://example.com', array(CURLOPT_NOBODY => true), $curl); - - $this->assertEquals(200, $response->getStatus()); - $this->assertEquals(array('Age: 42'), $response->getHeaders()); - $this->assertEquals('Hey', $response->getBody()); + $response = new HttpResponse(200, [], 'ok'); + $this->executor->expects($this->once()) + ->method('execute') + ->with( + 'http://example.com', + [ + CURLOPT_HTTPHEADER => ['Foo: Bar'], + CURLOPT_NOBODY => true, + ] + ) + ->willReturn($response); + $this->assertEquals( + $response, + $this->http->head('http://example.com', ['Foo: Bar']) + ); } + public function testPost() + { + $response = new HttpResponse(200, [], 'ok'); + $this->executor->expects($this->once()) + ->method('execute') + ->with( + 'http://example.com', + [ + CURLOPT_HTTPHEADER => ['Foo: Bar'], + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => 'foo', + ] + ) + ->willReturn($response); + $this->assertEquals( + $response, + $this->http->post('http://example.com', 'foo', ['Foo: Bar']) + ); + } + + public function testPut() + { + $response = new HttpResponse(200, [], 'ok'); + $this->executor->expects($this->once()) + ->method('execute') + ->with( + 'http://example.com', + [ + CURLOPT_HTTPHEADER => ['Foo: Bar'], + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_POSTFIELDS => 'foo', + ] + ) + ->willReturn($response); + $this->assertEquals( + $response, + $this->http->put('http://example.com', 'foo', ['Foo: Bar']) + ); + } + + public function testDelete() + { + $response = new HttpResponse(200, [], 'ok'); + $this->executor->expects($this->once()) + ->method('execute') + ->with( + 'http://example.com', + [ + CURLOPT_HTTPHEADER => ['Foo: Bar'], + CURLOPT_CUSTOMREQUEST => 'DELETE', + ] + ) + ->willReturn($response); + $this->assertEquals( + $response, + $this->http->delete('http://example.com', ['Foo: Bar']) + ); + } } diff --git a/test/RetryingExecutorTest.php b/test/RetryingExecutorTest.php new file mode 100644 index 0000000..ad2026d --- /dev/null +++ b/test/RetryingExecutorTest.php @@ -0,0 +1,76 @@ +getMockForAbstractClass(ExecutorInterface::class)); + } + + public function successfulCases() + { + return [ + [1, 1], + [1, 0], + [10, 0], + [10, 1], + [10, 9], + ]; + } + + public function unsuccessfulCases() + { + return [ + [1], + [10], + ]; + } + + /** + * @dataProvider successfulCases + * @param $retries + * @param $fails + */ + public function testSuccessfulResponse($retries, $fails) + { + $url = 'https://example.com'; + $options = [1 => true]; + $executor = $this->getMockForAbstractClass(ExecutorInterface::class); + for ($i = 0; $i < $fails; $i++) { + $executor->expects($this->at($i)) + ->method('execute') + ->with($url, $options) + ->willThrowException(new NoResponse()); + } + $response = new HttpResponse(200, [], 'ok'); + $executor->expects($this->at($fails)) + ->method('execute') + ->with($url, $options) + ->willReturn($response); + $re = new RetryingExecutor($retries, $executor); + $this->assertEquals($response, $re->execute($url, $options)); + } + + /** + * @dataProvider unsuccessfulCases + * @param $retries + * @expectedException \PHPCurl\CurlHttp\NoResponse + * @expectedExceptionMessageRegExp /No response after \d+ attempts/ + */ + public function testNoResponse($retries) + { + $url = 'https://example.com'; + $options = [1 => true]; + $executor = $this->getMockForAbstractClass(ExecutorInterface::class); + $executor->method('execute') + ->with($url, $options) + ->willThrowException(new NoResponse()); + $re = new RetryingExecutor($retries, $executor); + $re->execute($url, $options); + } +}