Skip to content
Permalink
Browse files

feature #30413 [HttpClient][Contracts] introduce component and relate…

…d contracts (nicolas-grekas)

This PR was squashed before being merged into the 4.3-dev branch (closes #30413).

Discussion
----------

[HttpClient][Contracts] introduce component and related contracts

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #28628
| License       | MIT
| Doc PR        | -

This PR introduces new `HttpClient` contracts and
component. It makes no compromises between DX, performance, and design.
Its surface should be very simple to use, while still flexible enough
to cover most advanced use cases thanks to streaming+laziness.

Common existing HTTP clients for PHP rely on PSR-7, which is complex
and orthogonal to the way Symfony is designed. More reasons we need
this in core are the [package principles](https://en.wikipedia.org/wiki/Package_principles): if we want to be able to keep our
BC+deprecation promises, we have to build on more stable and more
abstract dependencies than Symfony itself. And we need an HTTP client
for e.g. Symfony Mailer or #27738.

The existing state-of-the-art puts a quite high bar in terms of features we must
support if we want any adoption. The code in this PR aims at implementing an
even better HTTP client for PHP than existing ones, with more (useful) features
and a better architecture. What a pitch :)

Two full implementations are provided:
 - `NativeHttpClient` is based on the native "http" stream wrapper.
   It's the most portable one but relies on a blocking `fopen()`.
 - `CurlHttpClient` relies on the curl extension. It supports full
   concurrency and HTTP/2, including server push.

Here are some examples that work with both clients.

For simple cases, all the methods on responses are synchronous:

```php
$client = new NativeHttpClient();

$response = $client->get('https://google.com');

$statusCode = $response->getStatusCode();
$headers = $response->getHeaders();
$content = $response->getContent();
```

By default, clients follow redirects. On `3xx`, `4xx` or `5xx`, the `getHeaders()` and `getContent()` methods throw an exception, unless their `$throw` argument is set to `false`.
This is part of the "failsafe" design of the component. Another example of this
failsafe property is that broken dechunk or gzip streams always trigger an exception,
unlike most other HTTP clients who can silently ignore the situations.

An array of options allows adjusting the behavior when sending requests.
They are documented in `HttpClientInterface`.

When several responses are 1) first requested in batch, 2) then accessed
via any of their public methods, requests are done concurrently while
waiting for one.

For more advanced use cases, when streaming is needed:

Streaming the request body is possible via the "body" request option.
Streaming the response content is done via client's `stream()` method:

```php
$client = new CurlHttpClient();

$response = $client->request('GET', 'http://...');

$output = fopen('output.file', 'w');

foreach ($client->stream($response) as $chunk) {
    fwrite($output, $chunk->getContent());
}
```

The `stream()` method also works with multiple responses:

```php
$client = new CurlHttpClient();
$pool = [];

for ($i = 0; $i < 379; ++$i) {
    $uri = "https://http2.akamai.com/demo/tile-$i.png";
    $pool[] = $client->get($uri);
}

$chunks = $client->stream($pool);

foreach ($chunks as $response => $chunk) {
    // $chunk is a ChunkInterface object
    if ($chunk->isLast()) {
        $content = $response->getContent();
    }
}
```

The `stream()` method accepts a second `$timeout` argument: responses that
are *inactive* for longer than the timeout will emit an empty chunk to signal
it. Providing `0` as timeout allows monitoring responses in a non-blocking way.

Implemented:
 - flexible contracts for HTTP clients
 - `fopen()` + `curl`-based clients with close feature parity
 - gzip compression enabled when possible
 - streaming multiple responses concurrently
 - `base_uri` option for scoped clients
 - progress callback with detailed info and able to cancel the request
 - more flexible options for precise behavior control
 - flexible timeout management allowing e.g. server sent events
 - public key pinning
 - auto proxy configuration via env vars
 - transparent IDN support
 - `HttpClient::create()` factory
 - extensive error handling, e.g. on broken dechunk/gzip streams
 - time stats, primary_ip and other info inspired from `curl_getinfo()`
 - transparent HTTP/2-push support with authority validation
 - `Psr18Client` for integration with libs relying on PSR-18
 - free from memory leaks by avoiding circular references
 - fixed handling of redirects when using the `fopen`-based client
 - DNS cache pre-population with `resolve` option

Help wanted (can be done after merge):
 - `FrameworkBundle` integration: autowireable alias + semantic configuration for default options
 - add `TraceableHttpClient` and integrate with the profiler
 - logger integration
 - add a mock client

More ideas:
 - record/replay like CsaGuzzleBundle
 - use raw sockets instead of the HTTP stream wrapper
 - `cookie_jar` option
 - HTTP/HSTS cache
 - using the symfony CLI binary to test ssl-related options, HTTP/2-push, etc.
 - add "auto" mode to the "buffer" option, based on the content-type? or array of content-types to buffer
 - *etc.*

Commits
-------

fc83120 [HttpClient] Add Psr18Client - aka a PSR-18 adapter
8610668 [HttpClient] introduce the component
d2d63a2 [Contracts] introduce HttpClient contracts
  • Loading branch information...
fabpot committed Mar 7, 2019
2 parents 81faf42 + fc83120 commit 790854989e4b11462dc37cfd272d4cf3a542f73d
Showing with 4,758 additions and 2 deletions.
  1. +4 −1 composer.json
  2. +7 −0 src/Symfony/Component/HttpClient/CHANGELOG.md
  3. +79 −0 src/Symfony/Component/HttpClient/Chunk/DataChunk.php
  4. +106 −0 src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php
  5. +28 −0 src/Symfony/Component/HttpClient/Chunk/FirstChunk.php
  6. +28 −0 src/Symfony/Component/HttpClient/Chunk/LastChunk.php
  7. +374 −0 src/Symfony/Component/HttpClient/CurlHttpClient.php
  8. +26 −0 src/Symfony/Component/HttpClient/Exception/ClientException.php
  9. +38 −0 src/Symfony/Component/HttpClient/Exception/HttpExceptionTrait.php
  10. +23 −0 src/Symfony/Component/HttpClient/Exception/InvalidArgumentException.php
  11. +26 −0 src/Symfony/Component/HttpClient/Exception/RedirectionException.php
  12. +26 −0 src/Symfony/Component/HttpClient/Exception/ServerException.php
  13. +23 −0 src/Symfony/Component/HttpClient/Exception/TransportException.php
  14. +39 −0 src/Symfony/Component/HttpClient/HttpClient.php
  15. +457 −0 src/Symfony/Component/HttpClient/HttpClientTrait.php
  16. +299 −0 src/Symfony/Component/HttpClient/HttpOptions.php
  17. +19 −0 src/Symfony/Component/HttpClient/LICENSE
  18. +420 −0 src/Symfony/Component/HttpClient/NativeHttpClient.php
  19. +109 −0 src/Symfony/Component/HttpClient/Psr18Client.php
  20. +17 −0 src/Symfony/Component/HttpClient/README.md
  21. +298 −0 src/Symfony/Component/HttpClient/Response/CurlResponse.php
  22. +305 −0 src/Symfony/Component/HttpClient/Response/NativeResponse.php
  23. +56 −0 src/Symfony/Component/HttpClient/Response/ResponseStream.php
  24. +299 −0 src/Symfony/Component/HttpClient/Response/ResponseTrait.php
  25. +27 −0 src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php
  26. +29 −0 src/Symfony/Component/HttpClient/Tests/HttpClientTest.php
  27. +166 −0 src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php
  28. +24 −0 src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php
  29. +77 −0 src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php
  30. +42 −0 src/Symfony/Component/HttpClient/composer.json
  31. +30 −0 src/Symfony/Component/HttpClient/phpunit.xml.dist
  32. +8 −0 src/Symfony/Component/VarDumper/Caster/SymfonyCaster.php
  33. +4 −0 src/Symfony/Component/VarDumper/Cloner/AbstractCloner.php
  34. +5 −0 src/Symfony/Contracts/CHANGELOG.md
  35. +66 −0 src/Symfony/Contracts/HttpClient/ChunkInterface.php
  36. +23 −0 src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php
  37. +23 −0 src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php
  38. +23 −0 src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php
  39. +23 −0 src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php
  40. +23 −0 src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php
  41. +87 −0 src/Symfony/Contracts/HttpClient/HttpClientInterface.php
  42. +88 −0 src/Symfony/Contracts/HttpClient/ResponseInterface.php
  43. +26 −0 src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php
  44. +124 −0 src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php
  45. +677 −0 src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php
  46. +54 −0 src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php
  47. +3 −1 src/Symfony/Contracts/composer.json
@@ -28,7 +28,7 @@
"psr/link": "^1.0",
"psr/log": "~1.0",
"psr/simple-cache": "^1.0",
"symfony/contracts": "^1.0.2",
"symfony/contracts": "^1.1",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-intl-icu": "~1.0",
"symfony/polyfill-intl-idn": "^1.10",
@@ -55,6 +55,7 @@
"symfony/finder": "self.version",
"symfony/form": "self.version",
"symfony/framework-bundle": "self.version",
"symfony/http-client": "self.version",
"symfony/http-foundation": "self.version",
"symfony/http-kernel": "self.version",
"symfony/inflector": "self.version",
@@ -101,8 +102,10 @@
"doctrine/reflection": "~1.0",
"doctrine/doctrine-bundle": "~1.4",
"monolog/monolog": "~1.11",
"nyholm/psr7": "^1.0",
"ocramius/proxy-manager": "~0.4|~1.0|~2.0",
"predis/predis": "~1.1",
"psr/http-client": "^1.0",
"egulias/email-validator": "~1.2,>=1.2.8|~2.0",
"symfony/phpunit-bridge": "~3.4|~4.0",
"symfony/security-acl": "~2.8|~3.0",
@@ -0,0 +1,7 @@
CHANGELOG
=========

4.3.0
-----

* added the component
@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class DataChunk implements ChunkInterface
{
private $offset;
private $content;
public function __construct(int $offset = 0, string $content = '')
{
$this->offset = $offset;
$this->content = $content;
}
/**
* {@inheritdoc}
*/
public function isTimeout(): bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function isFirst(): bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function isLast(): bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function getContent(): string
{
return $this->content;
}
/**
* {@inheritdoc}
*/
public function getOffset(): int
{
return $this->offset;
}
/**
* {@inheritdoc}
*/
public function getError(): ?string
{
return null;
}
}
@@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class ErrorChunk implements ChunkInterface
{
protected $didThrow;
private $offset;
private $errorMessage;
private $error;
/**
* @param bool &$didThrow Allows monitoring when the $error has been thrown or not
*/
public function __construct(bool &$didThrow, int $offset, \Throwable $error = null)
{
$didThrow = false;
$this->didThrow = &$didThrow;
$this->offset = $offset;
$this->error = $error;
$this->errorMessage = null !== $error ? $error->getMessage() : 'Reading from the response stream reached the inactivity timeout.';
}
/**
* {@inheritdoc}
*/
public function isTimeout(): bool
{
$this->didThrow = true;
if (null !== $this->error) {
throw new TransportException($this->errorMessage, 0, $this->error);
}
return true;
}
/**
* {@inheritdoc}
*/
public function isFirst(): bool
{
$this->didThrow = true;
throw new TransportException($this->errorMessage, 0, $this->error);
}
/**
* {@inheritdoc}
*/
public function isLast(): bool
{
$this->didThrow = true;
throw new TransportException($this->errorMessage, 0, $this->error);
}
/**
* {@inheritdoc}
*/
public function getContent(): string
{
$this->didThrow = true;
throw new TransportException($this->errorMessage, 0, $this->error);
}
/**
* {@inheritdoc}
*/
public function getOffset(): int
{
return $this->offset;
}
/**
* {@inheritdoc}
*/
public function getError(): ?string
{
return $this->errorMessage;
}
public function __destruct()
{
if (!$this->didThrow) {
$this->didThrow = true;
throw new TransportException($this->errorMessage, 0, $this->error);
}
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class FirstChunk extends DataChunk
{
/**
* {@inheritdoc}
*/
public function isFirst(): bool
{
return true;
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class LastChunk extends DataChunk
{
/**
* {@inheritdoc}
*/
public function isLast(): bool
{
return true;
}
}
Oops, something went wrong.

0 comments on commit 7908549

Please sign in to comment.
You can’t perform that action at this time.