Skip to content

Commit

Permalink
feature #30413 [HttpClient][Contracts] introduce component and relate…
Browse files Browse the repository at this point in the history
…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 7908549
Show file tree
Hide file tree
Showing 47 changed files with 4,758 additions and 2 deletions.
5 changes: 4 additions & 1 deletion composer.json
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/Symfony/Component/HttpClient/CHANGELOG.md
@@ -0,0 +1,7 @@
CHANGELOG
=========

4.3.0
-----

* added the component
79 changes: 79 additions & 0 deletions src/Symfony/Component/HttpClient/Chunk/DataChunk.php
@@ -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;
}
}
106 changes: 106 additions & 0 deletions src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php
@@ -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);
}
}
}
28 changes: 28 additions & 0 deletions src/Symfony/Component/HttpClient/Chunk/FirstChunk.php
@@ -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;
}
}
28 changes: 28 additions & 0 deletions src/Symfony/Component/HttpClient/Chunk/LastChunk.php
@@ -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;
}
}

0 comments on commit 7908549

Please sign in to comment.