Symfony HttpClient, what else?

Symfony HttpClient, what else?

Have you heard of Symfony HttpClient? This new component will be released at the end of May with Symfony 4.3. How about experimenting with it by launching "compose require symfony/http-client dev-master"? That is what I propose you do at this conference. We will review its features and essential characteristics. Enough to replace Guzzle et al.? Undoubtedly as far as I am concerned :)

6baa34bc1e5c347b1003f6abe8691de1?s=128

Nicolas Grekas

March 29, 2019
Tweet

Transcript

  1. 2.

    @nicolasgrekas { "name": "symfony/http-client", "provide": { "php-http/client-implementation": "*", "psr/http-client-implementation": "1.0",

    "symfony/http-client-contracts-implementation": "1.1" }, "require": { "php": "^7.1.3", "psr/log": "^1.0", "symfony/http-client-contracts": "^1.1.3", "symfony/polyfill-php73": "^1.11" }, ... HttpClient is a standalone package
  2. 4.

    @nicolasgrekas Consume JSON APIs in a snap $symfonyVersions = $response->toArray();

    dump($symfonyVersions); array:23 [ "lts" => "3.4.23" "latest" => "4.2.4" "dev" => "4.3.0-DEV" "2.0" => "2.0.25" "2.1" => "2.1.13" "2.2" => "2.2.11" …
  3. 11.

    @nicolasgrekas namespace Symfony\Contracts\HttpClient; interface ResponseInterface { public function getStatusCode(): int;

    public function getHeaders(): array; public function getContent(): string; public function toArray(): array; public function cancel(): void; public function getInfo(): array; }
  4. 12.

    Symfony HttpClient 80% of use cases now covered (in 20%

    of the symfony/contracts abstraction) #Symfony_Live @nicolasgrekas
  5. 13.

    @nicolasgrekas What about PSR-18? namespace Symfony\Component\HttpClient; class Psr18Client implements \Psr\Http\Client\ClientInterface

    { public function __construct( \Symfony\Contracts\HttpClient\HttpClientInterface, \Psr\Http\Message\ResponseFactoryInterface, \Psr\Http\Message\StreamFactoryInterface )
  6. 14.

    @nicolasgrekas composer require nyholm/psr7 $client = new Psr18Client(); $url =

    'https://symfony.com/versions.json'; $request = $client->createRequest('GET', $url); $response = $client->sendRequest($request); $symfonyVersions = json_decode($response ->getBody()->getContents(), true); Dual PSR-17 + 18
  7. 15.

    @nicolasgrekas Also for HTTPlug ^1.0|^2.0 namespace Symfony\Component\HttpClient; class HttplugClient implements

    \Http\Client\HttpClient { public function __construct( \Symfony\Contracts\HttpClient\HttpClientInterface, \Http\Message\ResponseFactory, \Http\Message\StreamFactory )
  8. 18.

    @nicolasgrekas Options to the rescue 'auth_basic' => null, // array|string

    - an array containing the username as first value, and optionally the // password as the second one; or string like username:password - enabling HTTP Basic // authentication (RFC 7617) 'auth_bearer' => null, // string - a token enabling HTTP Bearer authorization (RFC 6750) 'query' => [], // string[] - associative array of query string values to merge with the request's URL 'headers' => [], // iterable|string[]|string[][] - headers names provided as keys or as part of values 'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string // smaller than the amount requested as argument; the empty string signals EOF; when // an array is passed, it is meant as a form payload of field names and values 'json' => null, // array|\JsonSerializable - when set, implementations MUST set the "body" option to // the JSON-encoded value and set the "content-type" headers to a JSON-compatible // value it is they are not defined - typically "application/json" 'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that // MUST be available via $response->getInfo('user_data') - not used internally 'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower or equal to 0 means // redirects should not be followed; "Authorization" and "Cookie" headers MUST // NOT follow except for the initial host name 'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0 'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2 'buffer' => true, // bool - whether the content of the response should be buffered or not 'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort // the request; it MUST be called on DNS resolution, on arrival of headers and on // completion; it SHOULD be called on upload/download of data and at least 1/s 'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution 'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored 'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached 'timeout' => null, // float - the inactivity timeout - defaults to ini_get('default_socket_timeout') 'bindto' => '0', // string - the interface or the local socket to bind to 'verify_peer' => true, // see https://php.net/context.ssl for the following options 'verify_host' => true, 'cafile' => null, 'capath' => null, 'local_cert' => null, 'local_pk' => null, 'passphrase' => null, 'ciphers' => null, 'peer_fingerprint' => null, 'capture_peer_cert_chain' => false, 'extra' => [], // array - additional options that can be ignored if unsupported, unlike regular options Options are in the contracts: Writing decoupled decorators is easy Options are in the contracts: the transport is covered by the abstraction
  9. 19.

    @nicolasgrekas • timeout – control inactivity periods • proxy –

    get through an HTTP proxy • on_progress – display a progress bar / abort a request • base_uri – resolve relative URLs / build a scoped client • resolve – protect webhooks against calls to internal endpoints • max_redirects – disable or limit redirects Showcase of some options
  10. 20.

    @nicolasgrekas The resiliency of the Web relies on redirects, you’re

    part of it Robust and failsafe by default /** * Gets the HTTP headers of the response. * * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes * * @throws TransportExceptionInterface When a network error occurs * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the * "max_redirects" option has been reached * @throws ClientExceptionInterface On a 4xx when $throw is true * @throws ServerExceptionInterface On a 5xx when $throw is true */ public function getHeaders(bool $throw = true): array; But what happens when the redirection limit is reached? or on 4xx / 5xx? Yours to decide:
  11. 22.

    @nicolasgrekas Streameable downloads $url = 'http://.../ubuntu-18.04.1-desktop-amd64.iso'; $response = $client->request('GET', $url,

    [ 'on_progress' => 'dump', // let's spam the console for fun 'buffer' => false, // skip buffering the response in memory ]); // Responses are lazy! This waits only for the headers if (200 !== $response->getStatusCode()) { throw new \Exception('...'); } $h = fopen('./ubuntu.iso', 'w'); foreach ($client->stream($response) as $chunk) { fwrite($h, $chunk->getContent()); }
  12. 23.

    @nicolasgrekas Responses are lazy & requests are concurrent for ($i

    = 0; $i < 379; ++$i) { $uri = "https://http2.akamai.com/demo/tile-$i.png"; $responses[] = $client->request('GET', $uri); } foreach ($responses as $response) { // block until completion of $response // but monitor all other responses meanwhile $response->getContent(); }
  13. 24.

    @nicolasgrekas Asynchronous requests for ($i = 0; $i < 379;

    ++$i) { $uri = "https://http2.akamai.com/demo/tile-$i.png"; $responses[] = $client->request('GET', $uri); } foreach ($client->stream($responses) as $response => $chunk) { if ($chunk->isLast()) { // a $response completed } else { // a $response's got network activity or timeout } } 379 requests completed in 0,4 s!
  14. 25.

    @nicolasgrekas dump($request->getInfo('debug')) * Found bundle for host http2.akamai.com: 0x55f8aaa0f8c0 [can

    multiplex] * MAX_CONCURRENT_STREAMS reached, skip (100) * Conn: 2 (0x55f8aaa261d0) Receive pipe weight: (-1/0), penalized: FALSE * Multiplexed connection found! * Found connection 2, with requests in the pipe (86) * Re-using existing connection! (#2) with host http2.akamai.com * Using Stream ID: c7 (easy handle 0x55f8ac0b7740) > GET /demo/tile-248.png HTTP/2 Host: http2.akamai.com User-Agent: Symfony HttpClient/Curl Accept-Encoding: deflate, gzip < HTTP/2 200 < content-length: 761 < content-type: image/png < ... < * Connection #2 to host http2.akamai.com left intact
  15. 27.

    @nicolasgrekas ResponseInterface::getInfo() /** * This method SHOULD NOT throw any

    ExceptionInterface and SHOULD be non-blocking. * The returned info is "live": it can be empty and can change from one call to * another, as the request/response progresses. * * The following info MUST be returned: * - response_headers - an array modelled after the special $http_response_header variable * - redirect_count - the number of redirects followed while executing the request * - redirect_url - the resolved location of redirect responses, null otherwise * - start_time - the time when the request was sent or 0.0 when it's pending * - http_method - the HTTP verb of the last request * - http_code - the last response code or 0 when it is not known yet * - error - the error message when the transfer was aborted, null otherwise * - user_data - the value of the "user_data" request option, null if not set * - url - the last effective URL of the request * * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. * * Other info SHOULD be named after curl_getinfo()'s associative return value. * * @return array|mixed|null An array of all available info, or one of them when $type is * provided, or null when an unsupported type is requested */
  16. 29.

    @nicolasgrekas NativeHttpClient & CurlHttpClient Both provide: • 100% of the

    contracts • secure redirects • extended (time) info • transparent HTTP compression and (de)chunking • automatic HTTP proxy configuration via env vars • I18n Domain Names
  17. 30.

    @nicolasgrekas NativeHttpClient • The most portable • Based on the

    HTTP stream wrapper • With fixed redirection logic • Blocking until response headers arrive • Multiplexing response bodies • Abstracts all http+ssl+socket stream context options
  18. 31.

    @nicolasgrekas CurlHttpClient • Requires ext-curl • With fixed redirection logic

    • Fully multiplexing response headers and bodies • Leverages HTTP/2 and PUSH when available • Abstracts most/all CURL(M)OPT_*/CURLINFO_* related to HTTP
  19. 32.

    @nicolasgrekas 4 decorators as of now • ScopingHttpClient – Auto-configure

    the options based on the request URL • MockHttpClient – A client that doesn't make actual HTTP requests • CachingHttpClient – Adds caching on top of an HTTP client • Psr18Client – You already know about it with TraceableHttpClient and record & replay coming
  20. 33.

    Symfony HttpClient Yours to combine (here are a few ideas

    that leverage some battle tested packages) #Symfony_Live @nicolasgrekas
  21. 34.

    @nicolasgrekas FrameworkBundle / Autowiring framework: http_client: max_host_connections: 4 default_options: #

    ... scoped_clients: github_client: base_uri: https://api.github.com headers: Authorization: token abc123 # creates the HttpClientInterface $githubClient autowiring alias # and the HttpClientInterface $scopingHttpClient one
  22. 36.

    @nicolasgrekas BrowserKit\HttpBrowser use Symfony\Component\BrowserKit\HttpBrowser; use Symfony\Component\HttpClient\HttpClient; $client = HttpClient::create(); $browser

    = new HttpBrowser($client); $browser->request('GET', 'https://example.com/'); $browser->clickLink('Log In'); $browser->submitForm('Sign In', ['username' => 'me', 'password' => 'pass']); $browser->clickLink('Subscriptions') ->filter('table tr:nth-child(2) td:nth-child(2)') ->each(function ($node) { echo trim($node->text())."\n"; });
  23. 38.

    Thanks for the ideas fopen(), Composer, Guzzle, Buzz, HTTPlug, Cake

    3, Axios, window.fetch() and all reviewers!
  24. 40.

    @nicolasgrekas for ($i = 0; $i < ...; ++$i) {

    $pendingResponses[$i] = $client->request('GET', "/some{$i}.html", [ 'user_data' => [$i, function ($response) use ($client): array { // extract CSS/JS files from $response->getContent() // $client->request() them and return the list of responses }], ]); } Made for HTTP/2 PUSH while ($pendingResponses) { foreach ($client->stream($pendingResponses) as $response => $chunk) { if ($chunk->isLast()) { [$responseId, $processResponse] = $response->getInfo('user_data'); unset($pendingResponses[$responseId]); if ($moreResponses = $processResponse($response)) { $pendingResponses += $moreResponses; break; } } } } Bonus slide: feed the stream while streaming