diff --git a/composer.json b/composer.json index 7a18daddb..3f8c0bef2 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "squizlabs/php_codesniffer": "~1.4", "zendframework/zendframework1": "~1.12", "satooshi/php-coveralls": "~1.0", - "guzzlehttp/guzzle": "^3.8" + "guzzlehttp/guzzle": "^3.8 || ^6.2" }, "suggest": { "minimalcode/search": "Query builder compatible with Solarium, allows simplified solr-query handling" diff --git a/library/Solarium/Core/Client/Adapter/Guzzle.php b/library/Solarium/Core/Client/Adapter/Guzzle.php new file mode 100644 index 000000000..85cab896a --- /dev/null +++ b/library/Solarium/Core/Client/Adapter/Guzzle.php @@ -0,0 +1,174 @@ + + * @license http://github.com/basdenooijer/solarium/raw/master/COPYING + * + * @link http://www.solarium-project.org/ + */ + +namespace Solarium\Core\Client\Adapter; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\RequestOptions; +use Solarium\Core\Configurable; +use Solarium\Core\Client\Request; +use Solarium\Core\Client\Response; +use Solarium\Core\Client\Endpoint; +use Solarium\Exception\HttpException; + +/** + * Guzzle HTTP adapter. + */ +class Guzzle extends Configurable implements AdapterInterface +{ + /** + * The Guzzle HTTP client instance. + * + * @var GuzzleClient + */ + private $guzzleClient; + + /** + * Execute a Solr request using the cURL Http. + * + * @param Request $request The incoming Solr request. + * @param Endpoint $endpoint The configured Solr endpoint. + * + * @return Response + * + * @throws HttpException Thrown if solr request connot be made. + * + * @codingStandardsIgnoreStart AdapterInterface does not declare type-hints + */ + public function execute($request, $endpoint) + { + //@codingStandardsIgnoreEnd + $requestOptions = [ + RequestOptions::HEADERS => $this->getRequestHeaders($request), + RequestOptions::BODY => $this->getRequestBody($request), + RequestOptions::TIMEOUT => $endpoint->getTimeout(), + ]; + + // Try endpoint authentication first, fallback to request for backwards compatibility + $authData = $endpoint->getAuthentication(); + if (empty($authData['username'])) { + $authData = $request->getAuthentication(); + } + + if (!empty($authData['username']) && !empty($authData['password'])) { + $requestOptions[RequestOptions::AUTH] = [$authData['username'], $authData['password']]; + } + + try { + $guzzleResponse = $this->getGuzzleClient()->request( + $request->getMethod(), + $endpoint->getBaseUri() . $request->getUri(), + $requestOptions + ); + + $responseHeaders = [ + "HTTP/{$guzzleResponse->getProtocolVersion()} {$guzzleResponse->getStatusCode()} " + . $guzzleResponse->getReasonPhrase(), + ]; + + foreach ($guzzleResponse->getHeaders() as $key => $value) { + $responseHeaders[] = "{$key}: " . implode(', ', $value); + } + + return new Response((string)$guzzleResponse->getBody(), $responseHeaders); + } catch (\GuzzleHttp\Exception\RequestException $e) { + $error = $e->getMessage(); + throw new HttpException("HTTP request failed, {$error}"); + } + } + + /** + * Gets the Guzzle HTTP client instance. + * + * @return GuzzleClient + */ + public function getGuzzleClient() + { + if ($this->guzzleClient === null) { + $this->guzzleClient = new GuzzleClient($this->options); + } + + return $this->guzzleClient; + } + + /** + * Helper method to create a request body suitable for a guzzle 3 request. + * + * @param Request $request The incoming solarium request. + * + * @return null|resource|string + */ + private function getRequestBody(Request $request) + { + if ($request->getMethod() !== Request::METHOD_POST) { + return null; + } + + if ($request->getFileUpload()) { + return fopen($request->getFileUpload(), 'r'); + } + + return $request->getRawData(); + } + + /** + * Helper method to extract headers from the incoming solarium request and put them in a format + * suitable for a guzzle 3 request. + * + * @param Request $request The incoming solarium request. + * + * @return array + */ + private function getRequestHeaders(Request $request) + { + $headers = []; + foreach ($request->getHeaders() as $headerLine) { + list($header, $value) = explode(':', $headerLine); + if ($header = trim($header)) { + $headers[$header] = trim($value); + } + } + + if (!isset($headers['Content-Type'])) { + if ($request->getMethod() == Request::METHOD_GET) { + $headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8'; + } else { + $headers['Content-Type'] = 'application/xml; charset=utf-8'; + } + } + + return $headers; + } +} diff --git a/tests/Solarium/Tests/Core/Client/Adapter/Guzzle3Test.php b/tests/Solarium/Tests/Core/Client/Adapter/Guzzle3Test.php index 9bc45b371..0133fc33f 100644 --- a/tests/Solarium/Tests/Core/Client/Adapter/Guzzle3Test.php +++ b/tests/Solarium/Tests/Core/Client/Adapter/Guzzle3Test.php @@ -58,6 +58,10 @@ final class Guzzle3Test extends \PHPUnit_Framework_TestCase */ public function setUp() { + if (!class_exists('\\Guzzle\\Http\\Client')) { + $this->markTestSkipped('Guzzle 3 not installed'); + } + $this->adapter = new GuzzleAdapter(); } diff --git a/tests/Solarium/Tests/Core/Client/Adapter/GuzzleTest.php b/tests/Solarium/Tests/Core/Client/Adapter/GuzzleTest.php new file mode 100644 index 000000000..dbc526cdf --- /dev/null +++ b/tests/Solarium/Tests/Core/Client/Adapter/GuzzleTest.php @@ -0,0 +1,319 @@ + + * @license http://github.com/basdenooijer/solarium/raw/master/COPYING + * + * @link http://www.solarium-project.org/ + */ + +namespace Solarium\Tests\Core\Client\Adapter; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; +use Solarium\Core\Client\Adapter\Guzzle as GuzzleAdapter; +use Solarium\Core\Client\Endpoint; +use Solarium\Core\Client\Request; +use Solarium\Core\Exception; + +/** + * @coversDefaultClass \Solarium\Core\Client\Adapter\Guzzle + * @covers :: + * @covers ::getGuzzleClient + */ +final class GuzzleAdapterTest extends \PHPUnit_Framework_TestCase +{ + /** + * Prepare each test. + * + * @return void + */ + public function setUp() + { + if (!class_exists('\\GuzzleHttp\\Client')) { + $this->markTestSkipped('Guzzle 6 not installed'); + } + } + + /** + * Verify basic behavior of execute() + * + * @test + * @covers ::execute + * + * @return void + */ + public function executeGet() + { + $guzzleResponse = $this->getValidResponse(); + $mockHandler = new MockHandler([$guzzleResponse]); + + $container = []; + $history = Middleware::history($container); + + $stack = HandlerStack::create($mockHandler); + $stack->push($history); + + $adapter = new GuzzleAdapter(['handler' => $stack]); + + $request = new Request(); + $request->setMethod(Request::METHOD_GET); + $request->addHeader('X-PHPUnit: request value'); + + $endpoint = new Endpoint(); + $endpoint->setTimeout(10); + + $response = $adapter->execute($request, $endpoint); + $this->assertSame('OK', $response->getStatusMessage()); + $this->assertSame('200', $response->getStatusCode()); + $this->assertSame( + [ + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + 'X-PHPUnit: response value', + ], + $response->getHeaders() + ); + $this->assertSame((string)$guzzleResponse->getBody(), $response->getBody()); + + $this->assertCount(1, $container); + $this->assertSame('GET', $container[0]['request']->getMethod()); + $this->assertSame('request value', $container[0]['request']->getHeaderline('X-PHPUnit')); + } + + /** + * Verify execute() with request containing file + * + * @test + * @covers ::execute + * + * @return void + */ + public function executePostWithFile() + { + $guzzleResponse = $this->getValidResponse(); + $mockHandler = new MockHandler([$guzzleResponse]); + + $container = []; + $history = Middleware::history($container); + + $stack = HandlerStack::create($mockHandler); + $stack->push($history); + + $adapter = new GuzzleAdapter(['handler' => $stack]); + + $request = new Request(); + $request->setMethod(Request::METHOD_POST); + $request->addHeader('X-PHPUnit: request value'); + $request->setFileUpload(__FILE__); + + $endpoint = new Endpoint(); + $endpoint->setTimeout(10); + + $response = $adapter->execute($request, $endpoint); + $this->assertSame('OK', $response->getStatusMessage()); + $this->assertSame('200', $response->getStatusCode()); + $this->assertSame( + [ + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + 'X-PHPUnit: response value', + ], + $response->getHeaders() + ); + $this->assertSame((string)$guzzleResponse->getBody(), $response->getBody()); + + $this->assertCount(1, $container); + $this->assertSame('POST', $container[0]['request']->getMethod()); + $this->assertSame('request value', $container[0]['request']->getHeaderline('X-PHPUnit')); + $this->assertSame(file_get_contents(__FILE__), (string)$container[0]['request']->getBody()); + } + + /** + * Verify execute() with request containing raw body + * + * @test + * @covers ::execute + * + * @return void + */ + public function executePostWithRawBody() + { + $guzzleResponse = $this->getValidResponse(); + $mockHandler = new MockHandler([$guzzleResponse]); + + $container = []; + $history = Middleware::history($container); + + $stack = HandlerStack::create($mockHandler); + $stack->push($history); + + $adapter = new GuzzleAdapter(['handler' => $stack]); + + $request = new Request(); + $request->setMethod(Request::METHOD_POST); + $request->addHeader('X-PHPUnit: request value'); + $xml = 'some data'; + $request->setRawData($xml); + + $endpoint = new Endpoint(); + $endpoint->setTimeout(10); + + $response = $adapter->execute($request, $endpoint); + $this->assertSame('OK', $response->getStatusMessage()); + $this->assertSame('200', $response->getStatusCode()); + $this->assertSame( + [ + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + 'X-PHPUnit: response value', + ], + $response->getHeaders() + ); + $this->assertSame((string)$guzzleResponse->getBody(), $response->getBody()); + + $this->assertCount(1, $container); + $this->assertSame('POST', $container[0]['request']->getMethod()); + $this->assertSame('request value', $container[0]['request']->getHeaderline('X-PHPUnit')); + $this->assertSame('application/xml; charset=utf-8', $container[0]['request']->getHeaderline('Content-Type')); + $this->assertSame($xml, (string)$container[0]['request']->getBody()); + } + + /** + * Verify execute() with GET request containing Authentication. + * + * @test + * @covers ::execute + * + * @return void + */ + public function executeGetWithAuthentication() + { + $guzzleResponse = $this->getValidResponse(); + $mockHandler = new MockHandler([$guzzleResponse]); + + $container = []; + $history = Middleware::history($container); + + $stack = HandlerStack::create($mockHandler); + $stack->push($history); + + $adapter = new GuzzleAdapter(['handler' => $stack]); + + $request = new Request(); + $request->setMethod(Request::METHOD_GET); + $request->addHeader('X-PHPUnit: request value'); + $request->setAuthentication('username', 's3cr3t'); + + $endpoint = new Endpoint(); + $endpoint->setTimeout(10); + + $response = $adapter->execute($request, $endpoint); + $this->assertSame('OK', $response->getStatusMessage()); + $this->assertSame('200', $response->getStatusCode()); + $this->assertSame( + [ + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + 'X-PHPUnit: response value', + ], + $response->getHeaders() + ); + $this->assertSame((string)$guzzleResponse->getBody(), $response->getBody()); + + $this->assertCount(1, $container); + $this->assertSame('GET', $container[0]['request']->getMethod()); + $this->assertSame('request value', $container[0]['request']->getHeaderline('X-PHPUnit')); + $this->assertSame( + 'Basic ' . base64_encode('username:s3cr3t'), + $container[0]['request']->getHeaderLine('Authorization') + ); + } + + /** + * Verify execute() with GET when guzzle throws an exception. + * + * @test + * @covers ::execute + * @expectedException \Solarium\Exception\HttpException + * @expectedExceptionMessage HTTP request failed + * + * @return void + */ + public function executeRequestException() + { + $adapter = new GuzzleAdapter(); + + $request = new Request(); + $request->setMethod(Request::METHOD_GET); + + $endpoint = new Endpoint( + [ + 'scheme' => 'silly', //invalid protocol + ] + ); + $endpoint->setTimeout(10); + + $adapter->execute($request, $endpoint); + } + + /** + * Helper method to create a valid Guzzle response. + * + * @return Response + */ + private function getValidResponse() + { + $body = json_encode( + [ + 'response' => [ + 'numFound' => 10, + 'start' => 0, + 'docs' => [ + [ + 'id' => '58339e95d5200', + 'author' => 'Gambardella, Matthew', + 'title' => "XML Developer's Guide", + 'genre' => 'Computer', + 'price' => 44.95, + 'published' => 970372800, + 'description' => 'An in-depth look at creating applications with XML.', + ], + ], + ], + ] + ); + + $headers = ['Content-Type' => 'application/json', 'X-PHPUnit' => 'response value']; + return new Response(200, $headers, $body); + } +}