Clientes HTTP
cURL de forma predeterminada, cualquier cliente PSR-18 si prefieres usar el tuyo, y uno falso para las pruebas.
El predeterminado
Sin httpClient:, las solicitudes pasan por OpenEmail\Http\CurlHttpClient, que no necesita más que la extensión curl. Mantiene un handle de cURL por cliente, así que una segunda solicitud reutiliza la conexión que abrió la primera, y la página de configuración enumera sus opciones para proxies, autoridades de certificación y ajustes adicionales de cURL.
Sea lo que sea lo que mueva los bytes, el propio cliente hace todo lo demás: la credencial, las claves de idempotencia, los reintentos y su espera, el tiempo de espera de cada intento y la excepción de cada fallo. Un cliente HTTP solo envía una solicitud y devuelve la respuesta.
Cualquier cliente PSR-18
OpenEmail\Http\Psr18HttpClient envuelve cualquier cliente que implemente PSR-18, como Guzzle o Symfony HttpClient, así que las solicitudes pasan por el cliente que tu aplicación ya configura, con su middleware, sus registros y su configuración de proxy.
use GuzzleHttp\Client;use OpenEmail\Http\Psr18HttpClient;use OpenEmail\OpenEmail; $client = new OpenEmail(httpClient: new Psr18HttpClient(new Client())); $client->me->ping();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 construye sus solicitudes con factorías PSR-17. El adaptador usa el propio cliente cuando también es una factoría, como lo es Psr18Client de Symfony, después php-http/discovery cuando está instalado, después nyholm/psr7 o las de Guzzle, y lanza InvalidArgumentException indicando qué instalar cuando no encuentra ninguna. Pasa requestFactory: y streamFactory: para elegirlas tú. El Psr18Client de Symfony también necesita las interfaces de PSR-18 y una implementación de PSR-17, así que instálalo con composer require symfony/http-client psr/http-client nyholm/psr7.
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();| Aspecto | Qué ocurre |
|---|---|
| Tiempos de espera | Guzzle recibe el timeout: del cliente en cada solicitud. PSR-18 no tiene forma de pasar un tiempo de espera a ningún otro cliente, así que define uno en el cliente que envuelves, como hace HttpClient::create(['timeout' => 30, 'max_redirects' => 0]) más arriba, o una solicitud que nunca responde puede esperar para siempre. |
| Status de error | Un cliente PSR-18 devuelve las respuestas 4xx y 5xx en lugar de lanzar una excepción, y el adaptador le indica a Guzzle que haga lo mismo, así que cada rechazo sigue convirtiéndose en la ApiException correcta. |
| Sin respuesta | Una ClientExceptionInterface del cliente envuelto se convierte en una NetworkException que conserva su mensaje pero no la excepción, que contiene la solicitud y su cabecera Authorization. isTimeout() es true cuando el mensaje dice que la solicitud agotó el tiempo de espera, y con cualquier cliente que no sea Guzzle el mensaje indica que fue el tiempo de espera propio del cliente envuelto. |
| Redirecciones | A Guzzle se le indica que no las siga, y el ejemplo de Symfony pone max_redirects a 0, así que una redirección se convierte en una ApiException, igual que con el cliente cURL predeterminado. |
Tu propio cliente
httpClient: acepta cualquier cosa que implemente OpenEmail\Http\HttpClient, una interfaz con un solo método: send(HttpRequest $request): HttpResponse. Devuelve la respuesta para cualquier status, 4xx y 5xx incluidos, y lanza una excepción solo cuando no llegó ninguna respuesta.
| Clase | Lo que lleva |
|---|---|
| HttpRequest | method, url, headers como un array de nombre y valor, body como cadena o null, y timeout en segundos, o null si no hay. header($name) lee una cabecera sin distinguir mayúsculas. var_dump(), print_r() y json_encode() muestran su cabecera Authorization como [redacted], pero var_export() y el dump() de Symfony muestran headers tal cual, así que nunca registres una solicitud con esos dos. |
| HttpResponse | Se construye como new HttpResponse($status, $headers, $body). Pasa a minúsculas los nombres de las cabeceras, y header($name) lee una sin distinguir mayúsculas. |
| HttpClientException | Lánzala desde send cuando no llegó ninguna respuesta. new HttpClientException($message, timeout: true) marca un tiempo de espera agotado. |
Pruebas sin red
Un cliente falso registra lo que habría salido y responde con lo que necesite la prueba, así que nada sale de la máquina y la prueba puede comprobar exactamente lo que se envió.
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);- Devuelve un status fuera de 2xx con el sobre de error de la API como cuerpo, por ejemplo
{"error": {"type": "validation_error", "code": "invalid_parameter", "message": "..."}}, para obtener la subclase deApiExceptioncorrespondiente. - Lanza
new HttpClientException('timed out', timeout: true)desdesendpara obtener unaNetworkExceptioncuyoisTimeout()es true. Cualquier otraException, como unaRuntimeException, se convierte en unaNetworkExceptioncuyoisTimeout()es false. - Una
LogicExceptiony cualquierError, como unTypeError, cuentan como errores de programación del cliente falso. Se lanzan sin cambios y nunca se reintentan.
Construye un cliente de prueba con maxRetries: 0 cuando simules fallos. Si no, un status reintentable o un fallo de red en una llamada que se puede repetir sin riesgo se intenta tres veces, con esperas reales entre medias.