Aller à la documentation
PHP

Clients HTTP

cURL par défaut, n'importe quel client PSR-18 si vous préférez utiliser le vôtre, et un faux pour les tests.

Le client par défaut

Sans httpClient:, les requêtes passent par OpenEmail\Http\CurlHttpClient, qui n'a besoin que de l'extension curl. Il garde un handle cURL par client : une deuxième requête réutilise donc la connexion ouverte par la première, et la page Configuration liste ses options pour les proxys, les autorités de certification et les réglages cURL supplémentaires.

Quoi qui transporte les octets, le client lui-même fait tout le reste : l'identifiant, les clés d'idempotence, les réessais et leur temporisation, le délai de chaque tentative et l'exception de chaque échec. Un client HTTP ne fait qu'envoyer une requête et rendre la réponse.

N'importe quel client PSR-18

OpenEmail\Http\Psr18HttpClient enveloppe n'importe quel client qui implémente PSR-18, comme Guzzle ou Symfony HttpClient : les requêtes passent donc par le client que votre application configure déjà, avec son middleware, sa journalisation et ses réglages de proxy.

guzzle.php
use GuzzleHttp\Client;use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail; $client = new OpenEmail(httpClient: new Psr18HttpClient(new Client())); $client->me->ping();
symfony_http_client.php
use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail;use Symfony\Component\HttpClient\HttpClient;use Symfony\Component\HttpClient\Psr18Client; $http = new Psr18Client(HttpClient::create(['timeout' => 30, 'max_redirects' => 0])); $client = new OpenEmail(httpClient: new Psr18HttpClient($http)); $client->me->ping();

PSR-18 construit ses requêtes avec des fabriques PSR-17. L'adaptateur utilise le client lui-même quand il est aussi une fabrique, comme l'est Psr18Client de Symfony, puis php-http/discovery quand il est installé, puis nyholm/psr7 ou celles de Guzzle, et lève InvalidArgumentException en nommant ce qu'il faut installer quand il n'en trouve aucune. Passez requestFactory: et streamFactory: pour les choisir vous-même. Le Psr18Client de Symfony a aussi besoin des interfaces PSR-18 et d'une implémentation PSR-17 : installez-le donc avec composer require symfony/http-client psr/http-client nyholm/psr7.

factories.php
use GuzzleHttp\Client;use Nyholm\Psr7\Factory\Psr17Factory;use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail; $factory = new Psr17Factory(); $client = new OpenEmail(httpClient: new Psr18HttpClient(new Client(), requestFactory: $factory, streamFactory: $factory)); $client->me->ping();
AspectCe qui se passe
DélaisGuzzle reçoit le timeout: du client à chaque requête. PSR-18 n'offre aucun moyen de passer un délai à un autre client : définissez-en donc un sur le client que vous enveloppez, comme le fait HttpClient::create(['timeout' => 30, 'max_redirects' => 0]) ci-dessus, sinon une requête qui ne répond jamais peut attendre indéfiniment.
Statuts d'erreurUn client PSR-18 renvoie les réponses 4xx et 5xx au lieu de lever une exception, et l'adaptateur demande à Guzzle d'en faire autant : chaque refus devient donc toujours la bonne ApiException.
Pas de réponseUne ClientExceptionInterface venant du client enveloppé devient une NetworkException qui garde son message mais pas l'exception, car celle-ci contient la requête et son en-tête Authorization. isTimeout() vaut true quand le message indique que la requête a expiré, et pour tout client autre que Guzzle le message précise que c'était le délai propre au client enveloppé.
RedirectionsGuzzle reçoit l'instruction de ne pas les suivre, et l'exemple Symfony fixe max_redirects à 0 : une redirection devient donc une ApiException, comme avec le client cURL par défaut.

Votre propre client

httpClient: accepte tout ce qui implémente OpenEmail\Http\HttpClient, une interface à une seule méthode : send(HttpRequest $request): HttpResponse. Renvoyez la réponse pour chaque statut, 4xx et 5xx compris, et ne levez une exception que lorsqu'aucune réponse n'est arrivée.

ClasseCe qu'il contient
HttpRequestmethod, url, headers sous forme de tableau de noms et de valeurs, body sous forme de chaîne ou null, et timeout en secondes, ou null pour aucun. header($name) lit un en-tête quelle que soit sa casse. var_dump(), print_r() et json_encode() affichent son en-tête Authorization sous la forme [redacted], mais var_export() et le dump() de Symfony affichent headers tel quel : ne journalisez donc jamais une requête avec ces deux-là.
HttpResponseSe construit avec new HttpResponse($status, $headers, $body). Il met les noms d'en-têtes en minuscules, et header($name) en lit un quelle que soit sa casse.
HttpClientExceptionLevez-la depuis send quand aucune réponse n'est arrivée. new HttpClientException($message, timeout: true) signale un délai dépassé.

Tester sans réseau

Un faux client enregistre ce qui serait parti et répond avec ce dont le test a besoin : rien ne quitte donc la machine, et le test peut vérifier exactement ce qui a été envoyé.

fake_http_client.php
use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse;use OpenEmail\OpenEmail; $fake = new class implements HttpClient {    public array $requests = [];     public function send(HttpRequest $request): HttpResponse    {        $this->requests[] = $request;         return new HttpResponse(            200,            ['content-type' => 'application/json'],            json_encode(['id' => 'msg_test', 'status' => 'sent', 'replayed' => false], JSON_THROW_ON_ERROR),        );    }}; $testClient = new OpenEmail(apiKey: 'oe_test_fake', httpClient: $fake, maxRetries: 0, disableUpdateNotice: true); $sent = $testClient->emails->send([    'from' => '[email protected]',    'to' => '[email protected]',    'subject' => 'Hi',    'text' => 'Hello',]); $request = $fake->requests[0]; echo $sent['status'], ' ', $request->method, ' ', $request->url, ' ', $request->header('Idempotency-Key'), PHP_EOL;var_dump($request);
  • Renvoyez un statut hors de 2xx avec l'enveloppe d'erreur de l'API comme corps, par exemple {"error": {"type": "validation_error", "code": "invalid_parameter", "message": "..."}}, pour obtenir la sous-classe d'ApiException correspondante.
  • Levez new HttpClientException('timed out', timeout: true) depuis send pour obtenir une NetworkException dont isTimeout() vaut true. Toute autre Exception, comme une RuntimeException, devient une NetworkException dont isTimeout() vaut false.
  • Une LogicException et toute Error, comme une TypeError, comptent comme des bugs du faux client. Elles sont relevées telles quelles et jamais réessayées.

Construisez un client de test avec maxRetries: 0 quand vous scénarisez des échecs. Sinon, un statut réessayable ou un échec réseau sur un appel qui peut être répété sans risque est tenté trois fois, avec de vraies attentes entre les tentatives.