From ba9ac6f94bc1ec545a0e7a5a76a521fb5aae579e Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Tue, 27 Aug 2019 14:49:19 +0200 Subject: [PATCH] [FEATURE] Provide implementation for PSR-18 HTTP Client The implementation of the PSR-18 ClientInterface is provided as an adapter to the existing GuzzleHTTP Client. Therefore existing configuraton settings will be reused. As our current Guzzle wrapper (RequestFactory->request) has support for passing custom guzzle per-request options, we do not deprecate this method but add the PSR-18 implementation as a more generic alternative. Once GuzzleHTTP supports PSR-18 natively we can (and will) drop our adapter and point to Guzzles native implementation in our dependency injection configuration. Therefore, this adapter is marked as internal and extensions are being instructed to depend on the PSR-18 interfaces only. composer require psr/http-client:^1.0 Releases: master Resolves: #89216 Change-Id: I0f2c81916a2f5e4b40abd6f0b146440ef155cf00 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61567 Tested-by: TYPO3com Tested-by: Benni Mack Tested-by: Anja Leichsenring Reviewed-by: Benni Mack Reviewed-by: Anja Leichsenring --- composer.json | 2 + composer.lock | 51 +++++- typo3/sysext/core/Classes/Http/Client.php | 73 ++++++++ .../Classes/Http/Client/ClientException.php | 32 ++++ .../Http/Client/GuzzleClientFactory.php | 47 +++++ .../Classes/Http/Client/NetworkException.php | 42 +++++ .../Classes/Http/Client/RequestException.php | 42 +++++ .../core/Classes/Http/RequestFactory.php | 27 +-- typo3/sysext/core/Configuration/Services.yaml | 10 ++ ...e-89216-PSR-18HTTPClientImplementation.rst | 109 ++++++++++++ .../core/Tests/Unit/Http/ClientTest.php | 166 ++++++++++++++++++ typo3/sysext/core/composer.json | 2 + 12 files changed, 577 insertions(+), 26 deletions(-) create mode 100644 typo3/sysext/core/Classes/Http/Client.php create mode 100644 typo3/sysext/core/Classes/Http/Client/ClientException.php create mode 100644 typo3/sysext/core/Classes/Http/Client/GuzzleClientFactory.php create mode 100644 typo3/sysext/core/Classes/Http/Client/NetworkException.php create mode 100644 typo3/sysext/core/Classes/Http/Client/RequestException.php create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-89216-PSR-18HTTPClientImplementation.rst create mode 100644 typo3/sysext/core/Tests/Unit/Http/ClientTest.php diff --git a/composer.json b/composer.json index 2488a68c7a27..332d8b1cf09d 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "phpdocumentor/reflection-docblock": "^4.3", "psr/container": "^1.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "~1.0", "psr/http-server-middleware": "^1.0", @@ -95,6 +96,7 @@ "phpdocumentor/reflection-docblock": ">= 4.3.2" }, "provide": { + "psr/http-client-implementation": "1.0", "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, diff --git a/composer.lock b/composer.lock index 255273f0bbea..262571df67b2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f2fe7c52b1352fc018455fec38f1eedc", + "content-hash": "2e106005a5c77c6ef0ee2fc67b6be5c0", "packages": [ { "name": "cogpowered/finediff", @@ -1050,6 +1050,55 @@ ], "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "496a823ef742b632934724bf769560c2a5c7c44e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/496a823ef742b632934724bf769560c2a5c7c44e", + "reference": "496a823ef742b632934724bf769560c2a5c7c44e", + "shasum": "" + }, + "require": { + "php": "^7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "time": "2018-10-30T23:29:13+00:00" + }, { "name": "psr/http-factory", "version": "1.0.1", diff --git a/typo3/sysext/core/Classes/Http/Client.php b/typo3/sysext/core/Classes/Http/Client.php new file mode 100644 index 000000000000..1ade6f884fe2 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Client.php @@ -0,0 +1,73 @@ +guzzle = $guzzle; + } + + /** + * Sends a PSR-7 request and returns a PSR-7 response. + * + * @param RequestInterface $request + * @return ResponseInterface + * @throws ClientExceptionInterface If an error happens while processing the request. + * @throws NetworkExceptionInterface If the request cannot be sent due to a network failure of any kind + * @throws RequestExceptionInterface If the request message is not a well-formed HTTP request + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + try { + return $this->guzzle->send($request, [ + RequestOptions::HTTP_ERRORS => false, + RequestOptions::ALLOW_REDIRECTS => false, + ]); + } catch (ConnectException $e) { + throw new Client\NetworkException($e->getMessage(), 1566909446, $e->getRequest(), $e); + } catch (RequestException $e) { + throw new Client\RequestException($e->getMessage(), 1566909447, $e->getRequest(), $e); + } catch (GuzzleException $e) { + throw new Client\ClientException($e->getMessage(), 1566909448, $e); + } + } +} diff --git a/typo3/sysext/core/Classes/Http/Client/ClientException.php b/typo3/sysext/core/Classes/Http/Client/ClientException.php new file mode 100644 index 000000000000..49abec13b22e --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Client/ClientException.php @@ -0,0 +1,32 @@ +push($handler); + } + $httpOptions['handler'] = $stack; + } + + return GeneralUtility::makeInstance(Client::class, $httpOptions); + } +} diff --git a/typo3/sysext/core/Classes/Http/Client/NetworkException.php b/typo3/sysext/core/Classes/Http/Client/NetworkException.php new file mode 100644 index 000000000000..c84ccea1c12f --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Client/NetworkException.php @@ -0,0 +1,42 @@ +code = $code; + } + + public function getRequest(): RequestInterface + { + parent::getRequest(); + } +} diff --git a/typo3/sysext/core/Classes/Http/Client/RequestException.php b/typo3/sysext/core/Classes/Http/Client/RequestException.php new file mode 100644 index 000000000000..fe794bd80087 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/Client/RequestException.php @@ -0,0 +1,42 @@ +code = $code; + } + + public function getRequest(): RequestInterface + { + parent::getRequest(); + } +} diff --git a/typo3/sysext/core/Classes/Http/RequestFactory.php b/typo3/sysext/core/Classes/Http/RequestFactory.php index 93bbf5d2cbef..13717695e377 100644 --- a/typo3/sysext/core/Classes/Http/RequestFactory.php +++ b/typo3/sysext/core/Classes/Http/RequestFactory.php @@ -15,14 +15,11 @@ * The TYPO3 project - inspiring people to share! */ -use GuzzleHttp\Client; -use GuzzleHttp\ClientInterface; -use GuzzleHttp\HandlerStack; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; /** * Class RequestFactory to create Request objects @@ -52,27 +49,7 @@ public function createRequest(string $method, $uri): RequestInterface */ public function request(string $uri, string $method = 'GET', array $options = []): ResponseInterface { - $client = $this->getClient(); + $client = GuzzleClientFactory::getClient(); return $client->request($method, $uri, $options); } - - /** - * Creates the client to do requests - * @return ClientInterface - */ - protected function getClient(): ClientInterface - { - $httpOptions = $GLOBALS['TYPO3_CONF_VARS']['HTTP']; - $httpOptions['verify'] = filter_var($httpOptions['verify'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $httpOptions['verify']; - - if (isset($GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler']) && is_array($GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler'])) { - $stack = HandlerStack::create(); - foreach ($GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler'] ?? [] as $handler) { - $stack->push($handler); - } - $httpOptions['handler'] = $stack; - } - - return GeneralUtility::makeInstance(Client::class, $httpOptions); - } } diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml index 30baf16ec2cc..c2e1f5b91499 100644 --- a/typo3/sysext/core/Configuration/Services.yaml +++ b/typo3/sysext/core/Configuration/Services.yaml @@ -77,6 +77,9 @@ services: Psr\EventDispatcher\EventDispatcherInterface: alias: TYPO3\CMS\Core\EventDispatcher\EventDispatcher public: true + Psr\Http\Client\ClientInterface: + alias: TYPO3\CMS\Core\Http\Client + public: true Psr\Http\Message\RequestFactoryInterface: alias: TYPO3\CMS\Core\Http\RequestFactory public: true @@ -95,3 +98,10 @@ services: Psr\Http\Message\UriFactoryInterface: alias: TYPO3\CMS\Core\Http\UriFactory public: true + GuzzleHttp\ClientInterface: + alias: GuzzleHttp\Client + public: true + + # External dependencies + GuzzleHttp\Client: + factory: ['TYPO3\CMS\Core\Http\Client\GuzzleClientFactory', 'getClient'] diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-89216-PSR-18HTTPClientImplementation.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-89216-PSR-18HTTPClientImplementation.rst new file mode 100644 index 000000000000..d0868c0d2a3b --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-89216-PSR-18HTTPClientImplementation.rst @@ -0,0 +1,109 @@ +.. include:: ../../Includes.txt + +=================================================== +Feature: #89216 - PSR-18 HTTP Client Implementation +=================================================== + +See :issue:`89216` + +Description +=========== + +Support for PSR-18_ HTTP Client has been added. + +PSR-18 HTTP Client is intended to be used by PSR-15_ request handlers in order to perform HTTP +requests based on PSR-7_ message objects without relying on a specific HTTP client implementation. + +PSR-18 consists of a client interfaces and three exception interfaces: + +- :php:`Psr\Http\Client\ClientInterface` +- :php:`Psr\Http\Client\ClientExceptionInterface` +- :php:`Psr\Http\Client\NetworkExceptionInterface` +- :php:`Psr\Http\Client\RequestExceptionInterface` + +Request handlers shall use dependency injection to retrieve the concrete implementation +of the PSR-18 HTTP client interface :php:`Psr\Http\Client\ClientInterface`. + + +Impact +====== + +The PSR-18 HTTP Client interface is provided by `psr/http-client` and may be used as +dependency for services in order to perform HTTP requests using PSR-7 request objects. +PSR-7 request objects can be created with the PSR-17_ Request Factory interface. + +Note: This does not replace the currently available Guzzle wrapper +:php:`TYPO\CMS\Core\Http\RequestFactory->request()`, but is available as a framework +agnostic, more generic alternative. The PSR-18 interface does not allow to pass request +specific guzzle options. But global options defined in :php:`$GLOBALS['TYPO3_CONF_VARS']['HTTP']` +are taken into account as GuzzleHTTP is used as backend for this PSR-18 implementation. +The concrete implementations is internal and will be replaced by a native guzzle PSR-18 +implementation once it is available. + +Example usage +------------- + +A middleware might need to request an external service in order to transform the response +into a new response. The PSR-18 HTTP client interface is used to perform the external +HTTP request. The PSR-17 Request Factory Interface is used to create the HTTP request that +the PSR-18 HTTP Client expects. The PSR-7 Response Factory is then used to create a new +response to be returned to the user. All off these interface implementations are injected +as constructor dependencies: + +.. code-block:: php + + use Psr\Http\Client\ClientInterface; + use Psr\Http\Message\RequestFactoryInterface; + use Psr\Http\Message\ResponseFactoryInterface; + use Psr\Http\Message\ResponseInterface; + use Psr\Http\Message\ServerRequestInterface; + use Psr\Http\Server\MiddlewareInterface; + use Psr\Http\Server\RequestHandlerInterface; + + class ExampleMiddleware implements MiddlewareInterface + { + /** @var ResponseFactory */ + private $responseFactory; + + /** @var RequestFactory */ + private $requestFactory; + + /** @var ClientInterface */ + private $client; + + public function __construct( + ResponseFactoryInterface $responseFactory, + RequestFactoryInterface $requestFactory, + ClientInterface $client + ) { + $this->responseFactory = $responseFactory; + $this->requestFactory = $requestFactory; + $this->client = $client; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($request->getRequestTarget() === '/example') { + $req = $this->requestFactory->createRequest('GET', 'https://api.external.app/endpoint.json') + // Perform HTTP request + $res = $this->client->sendRequest($req); + // Process data + $data = [ + 'content' => json_decode((string)$res->getBody()); + ]; + $response = $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'application/json; charset=utf-8'); + $response->getBody()->write(json_encode($data)); + return $response; + } + return $handler->handle($request); + } + } + + +.. _PSR-18: https://www.php-fig.org/psr/psr-18/ +.. _PSR-17: https://www.php-fig.org/psr/psr-17/ +.. _PSR-15: https://www.php-fig.org/psr/psr-15/ +.. _PSR-7: https://www.php-fig.org/psr/psr-7/ + +.. index:: PHP-API, ext:core diff --git a/typo3/sysext/core/Tests/Unit/Http/ClientTest.php b/typo3/sysext/core/Tests/Unit/Http/ClientTest.php new file mode 100644 index 000000000000..0dc97a1dd94b --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Http/ClientTest.php @@ -0,0 +1,166 @@ +prophesize(GuzzleClientInterface::class)->reveal()); + $this->assertInstanceOf(ClientInterface::class, $client); + } + + public function testSendRequest(): void + { + $transactions = []; + // Create a guzzle mock and queue two responses. + $mock = new GuzzleMockHandler([ + new GuzzleResponse(200, ['X-Foo' => 'Bar']), + new GuzzleResponse(202, ['X-Foo' => 'Baz']), + ]); + $handler = GuzzleHandlerStack::create($mock); + $handler->push(GuzzleMiddleware::history($transactions)); + $guzzleClient = new GuzzleClient(['handler' => $handler]); + + $client = new Client($guzzleClient); + + $request1 = new Request('https://example.com', 'GET', 'php://temp'); + $response1 = $client->sendRequest($request1); + $request2 = new Request('https://example.com/action', 'POST', 'php://temp'); + $response2 = $client->sendRequest($request2); + + $this->assertCount(2, $transactions); + + $this->assertSame('GET', $transactions[0]['request']->getMethod()); + $this->assertSame('https://example.com', $transactions[0]['request']->getUri()->__toString()); + $this->assertSame(200, $response1->getStatusCode()); + $this->assertSame('Bar', $response1->getHeaderLine('X-Foo')); + + $this->assertSame('POST', $transactions[1]['request']->getMethod()); + $this->assertSame('https://example.com/action', $transactions[1]['request']->getUri()->__toString()); + $this->assertSame(202, $response2->getStatusCode()); + $this->assertSame('Baz', $response2->getHeaderLine('X-Foo')); + } + + public function testRequestException(): void + { + $request = new Request('https://example.com', 'GET', 'php://temp'); + $exception = $this->prophesize(GuzzleRequestException::class); + $exception->getRequest()->willReturn($request); + $mock = new GuzzleMockHandler([ + $exception->reveal() + ]); + $handler = GuzzleHandlerStack::create($mock); + $guzzleClient = new GuzzleClient(['handler' => $handler]); + + $client = new Client($guzzleClient); + + $this->expectException(RequestExceptionInterface::class); + $client->sendRequest($request); + } + + public function testNetworkException(): void + { + $request = new Request('https://example.com', 'GET', 'php://temp'); + $exception = $this->prophesize(GuzzleConnectException::class); + $exception->getRequest()->willReturn($request); + $mock = new GuzzleMockHandler([ + $exception->reveal() + ]); + $handler = GuzzleHandlerStack::create($mock); + $guzzleClient = new GuzzleClient(['handler' => $handler]); + + $client = new Client($guzzleClient); + + $this->expectException(NetworkExceptionInterface::class); + $client->sendRequest($request); + } + + public function testGenericGuzzleException(): void + { + $request = new Request('https://example.com', 'GET', 'php://temp'); + $mock = new GuzzleMockHandler([ + new class extends \RuntimeException implements GuzzleExceptionInterface { + } + ]); + $handler = GuzzleHandlerStack::create($mock); + $guzzleClient = new GuzzleClient(['handler' => $handler]); + + $client = new Client($guzzleClient); + + $this->expectException(ClientExceptionInterface::class); + $client->sendRequest($request); + } + + public function testRedirectIsNotHandledRecursivelyButReturnedAsResponse(): void + { + $transactions = []; + $mock = new GuzzleMockHandler([ + new GuzzleResponse(303, ['Location' => 'https://example.com']), + ]); + $handler = GuzzleHandlerStack::create($mock); + $handler->push(GuzzleMiddleware::history($transactions)); + $guzzleClient = new GuzzleClient(['handler' => $handler]); + + $client = new Client($guzzleClient); + + $request = new Request('https://example.com', 'GET', 'php://temp'); + $response = $client->sendRequest($request); + + $this->assertCount(1, $transactions); + $this->assertSame(303, $response->getStatusCode()); + $this->assertSame('https://example.com', $response->getHeaderLine('Location')); + } + + public function testErrorResponsesDoNotThrowAnException(): void + { + $mock = new GuzzleMockHandler([ + new GuzzleResponse(404), + new GuzzleResponse(500), + ]); + $handler = GuzzleHandlerStack::create($mock); + $guzzleClient = new GuzzleClient(['handler' => $handler]); + + $client = new Client($guzzleClient); + + $request = new Request('https://example.com', 'GET', 'php://temp'); + $response1 = $client->sendRequest($request); + $response2 = $client->sendRequest($request); + + $this->assertSame(404, $response1->getStatusCode()); + $this->assertSame(500, $response2->getStatusCode()); + } +} diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json index 9ae259f8b633..a251d8465faa 100644 --- a/typo3/sysext/core/composer.json +++ b/typo3/sysext/core/composer.json @@ -28,6 +28,7 @@ "nikic/php-parser": "^4.2", "psr/container": "^1.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "~1.0", "psr/http-server-handler": "^1.0", @@ -80,6 +81,7 @@ "typo3/cms-sv": "*" }, "provide": { + "psr/http-client-implementation": "1.0", "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" },