Commit ba9ac6f9 authored by Benjamin Franzke's avatar Benjamin Franzke Committed by Anja Leichsenring
Browse files

[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: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Anja Leichsenring's avatarAnja Leichsenring <aleichsenring@ab-softlab.de>
parent d9db0403
......@@ -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"
},
......
......@@ -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",
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Http;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use GuzzleHttp\ClientInterface as GuzzleClientInterface;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Client\RequestExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* PSR-18 adapter for Guzzle\ClientInterface
*
* Will be removed once GuzzleHTTP implements PSR-18.
*
* @internal
*/
class Client implements ClientInterface
{
/**
* @var GuzzleClientInterface
*/
private $guzzle;
public function __construct(GuzzleClientInterface $guzzle)
{
$this->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);
}
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Http\Client;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Client\ClientExceptionInterface;
use RuntimeException;
/**
* @internal
*/
class ClientException extends RuntimeException implements ClientExceptionInterface, GuzzleException
{
public function __construct(string $message, int $code, GuzzleException $previous)
{
parent::__construct($message, $code, $previous);
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Http\Client;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\HandlerStack;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal
*/
class GuzzleClientFactory
{
/**
* Creates the client to do requests
* @return ClientInterface
*/
public static 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);
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Http\Client;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use GuzzleHttp\Exception\ConnectException as GuzzleConnectException;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
/**
* @internal
*/
class NetworkException extends GuzzleConnectException implements NetworkExceptionInterface
{
public function __construct(
string $message,
int $code,
RequestInterface $request,
GuzzleConnectException $previous
) {
parent::__construct($message, $request, $previous);
$this->code = $code;
}
public function getRequest(): RequestInterface
{
parent::getRequest();
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Http\Client;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use GuzzleHttp\Exception\RequestException as GuzzleRequestException;
use Psr\Http\Client\RequestExceptionInterface;
use Psr\Http\Message\RequestInterface;
/**
* @internal
*/
class RequestException extends GuzzleRequestException implements RequestExceptionInterface
{
public function __construct(
string $message,
int $code,
RequestInterface $request,
GuzzleRequestException $previous
) {
parent::__construct($message, $request, null, $previous);
$this->code = $code;
}
public function getRequest(): RequestInterface
{
parent::getRequest();
}
}
......@@ -15,14 +15,11 @@ namespace TYPO3\CMS\Core\Http;
* 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 @@ class RequestFactory implements RequestFactoryInterface
*/
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);
}
}
......@@ -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']
.. 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
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Tests\Unit\Http;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\ClientInterface as GuzzleClientInterface;
use GuzzleHttp\Exception\ConnectException as GuzzleConnectException;
use GuzzleHttp\Exception\GuzzleException as GuzzleExceptionInterface;
use GuzzleHttp\Exception\RequestException as GuzzleRequestException;
use GuzzleHttp\Handler\MockHandler as GuzzleMockHandler;
use GuzzleHttp\HandlerStack as GuzzleHandlerStack;
use GuzzleHttp\Middleware as GuzzleMiddleware;
use GuzzleHttp\Psr7\Response as GuzzleResponse;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Client\RequestExceptionInterface;
use TYPO3\CMS\Core\Http\Client;
use TYPO3\CMS\Core\Http\Request;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
/**
* Testcase for \TYPO3\CMS\Core\Http\Client
*/
class ClientTest extends UnitTestCase
{
public function testImplementsPsr18ClientInterface(): void
{
$client = new Client($this->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]);