Etiqueta: api

  • Cómo testear una aplicación que depende de una API sin conexión a Internet

    Venís tirando código como un campeón.

    Las integraciones con las APIs de Twitter y Google no esconden ningún secreto.

    Sólo te falta probar el último feature.

    Le das a correr y…

    Timeout.

    Lo qué??

    Revisás el código de arriba abajo… ¿Cómo es posible?

    Si hace 5 minutos funcionaba de 10…

    Levantás la vista y ves como el router te sonríe con sorna:

    Como diciéndote: A ver cuándo vas a aprender quién manda acá.

    ¿Otra vez se cayó Internet?

    ¿De nuevo vas a tener que llamar al proveedor para que lo reparen de una buena vez?

    Y… otra no queda… si hay que testear la API… se necesita conectividad…

    ¿O no?

    Y de pronto… muy bajito en la distancia se escucha la voz del maestro Yoda susurrando «Tests escribir debes».

    Exacto. Si querés tener un entorno sólido de desarrollo tenés que aislarte de las dependencias de terceros.

    Cómo escribir tests que no dependan de terceros

    Las soluciones son múltiples y mucho dependerá del contexto pero todas siguen la misma lógica: en vez de depender de un servicio poco confiable (O al menos, uno que está fuera de tu control), usá un doble del cual tengas control total.

    Usar un doble de test a nivel unitario

    Empecemos por un ejemplo simple a nivel de test unitario.

    Imaginemos que tenés un código que usa una librería como esta y es algo del estilo de:

    <?php
    
    declare(strict_types=1);
    
    namespace App;
    use Exception;
    use GuzzleHttpExceptionGuzzleException;
    use NowehTwitterApiClient;
    
    readonly class TwitterMonitor
    {
        public function __construct(private Client $client)
        {
        }
    
        /**
         * @return array<Mention>
         * @throws Exception
         * @throws GuzzleException
         */
        public function getRecentMentions(string $uid): array
        {
            $mentions = $this->client
                ->timeline()
                ->getRecentMentions($uid)
                ->performRequest()
            ;
    
            foreach ($mentions->data as $mention) {
                $return[] = new Mention($mention);
            }
    
            return $return;
        }
    }

    Está claro que, si $client->performRequest() falla, por ejemplo por falta de conectividad, todo lo que sigue va a fallar.

    Algo que podrías hacer en tu test es usar un mock. Sería algo así como:

    <?php
    
    declare(strict_types=1);
    
    require_once __DIR__ . '/../vendor/autoload.php';
    
    use App\Mention;
    use App\TwitterMonitor;
    use GuzzleHttp\Exception\GuzzleException;
    use Noweh\TwitterApi\Timeline;
    use PHPUnit\Framework\Attributes\Test;
    use PHPUnit\Framework\TestCase;
    use Noweh\TwitterApi\Client;
    
    class TwitterMonitorShould extends TestCase
    {
        const string UID = "1346889436626259968";
    
        private static function a2stdClass(array $array): stdClass
        {
            return json_decode(json_encode($array));
        }
    
        /**
         * @throws Exception|GuzzleException
         */
        #[Test]
        public function returnUsersLastMentions(): void
        {
            $timeline = $this
                ->getMockBuilder(Timeline::class)
                ->disableOriginalConstructor()
                ->getMock();
    
            $timeline
                ->expects($this->once())
                ->method('getRecentMentions')
                ->with(self::UID)
                ->willReturn($timeline);
    
            $mentionsData = self::a2stdClass([
                'data' => [
                    [
                        "author_id" => "2244994945",
                        "created_at" => "Wed Jan 06 18:40:40 +0000 2021",
                        "id" => "1346889436626259968",
                        "text" => "Learn how to use the user Tweet timeline and user mention timeline endpoints in the X API v2 to explore Tweet\\u2026 https:\\/\\/t.co\\/56a0vZUx7i",
                        "username" => "XDevelopers"
                    ]
                ]
            ]);
            $timeline
                ->expects($this->once())
                ->method('performRequest')
                ->willReturn($mentionsData);
    
            $client = $this
                ->getMockBuilder(Client::class)
                ->disableOriginalConstructor()
                ->getMock();
    
            $client
                ->expects($this->once())
                ->method('timeline')
                ->willReturn($timeline);
    
            $userMonitor = new TwitterMonitor($client);
    
            $this->assertEquals([new Mention($mentionsData->data[0]),], $userMonitor->getRecentMentions(self::UID));
        }
    }

    De esta forma, cuando ejecutes tus tests con phpUnit la llamada a Twitter no se realizará, si no que será el doble quien responda con la respuesta pre-armada.

    Usar un servicio mockeado

    Esta técnica puede ser algo más compleja de implementar pero probablemente sea mejor si lo que buscás es hacer algún tipo de test de caja negra.

    La idea aquí es «engañar» a tu aplicación para que crea que está hablando con el servicio real cuando, en realidad, está interactuando con uno local.

    Si no querés montarte tu propio servicio, podés usar una herramienta como mockoon, la idea es que puedas hacerle peticiones como si estuvieras yendo contra el servicio real.

    Es importante comprender que lo que podés testear es aquello que controlás, para todo lo demás tenés que asumir que el comportamiento esperado es el real. En otras palabras: dado que los terceros hacen su parte como se espera, tu aplicación debe comportarse como se espera, en caso de que no se cumpla la premisa no estás obligado a dar garantías.

    Obviamente, te conviene dar garantías de que tu aplicación nunca va a generar daños pero esa es otra historia.

    Veamos cómo sería en este caso.

    Ante todo, tenemos que averiguar a qué URL estamos haciendo los requests. Esta parte no es muy compleja: se trata de seguir la cadena de llamadas hacia atrás:

    Empezamos por Client::timeline() donde nos encontramos con que la respuesta es un objeto Timeline, el cual será el que eventualmente ejecutará el método performRequest.

    Entramos un poco más y nos encontramos con:

    if ($this->auth_mode === 0) { // Bearer Token
                    // Inject the Bearer token header
                    $client = new Client(['base_uri' => self::API_BASE_URI]);
                    $headers['Authorization'] = 'Bearer ' . $this->bearer_token;
                } elseif ($this->auth_mode === 1) { // OAuth 1.0a User Context
                    // Insert Oauth1 middleware
                    $stack = HandlerStack::create();
                    $middleware = new Oauth1([
                        'consumer_key' => $this->consumer_key,
                        'consumer_secret' => $this->consumer_secret,
                        'token' => $this->access_token,
                        'token_secret' => $this->access_token_secret,
                    ]);
                    $stack->push($middleware);
                    $client = new Client([
                        'base_uri' => self::API_BASE_URI,
                        'handler' => $stack,
                        'auth' => 'oauth'
                    ]);
                } else { // OAuth 2.0 Authorization Code Flow
                    throw new RuntimeException('OAuth 2.0 Authorization Code Flow had not been implemented & also requires user interaction.');
                }

    Es decir: la pieza clave aquí es la constante AbstractController::API_BASE_URI, cuya definición no es otra que https://api.twitter.com/2/… qué sorpresa, ¿no?

    Vamos a usar este dato para configurar nuestro proxy de modo de que la aplicación jamás se entere de que está hablando con un doble y no con el verdadero Twitter.

    Descargar la especificación de OpenAPI de la documentación de Twitter es un buen punto de partida.

    El siguiente paso es importarla en Mockoon:

    Con esto estarás listo para tener un servidor mockeado para empezar a jugar

    Por ejemplo, podrías cambiar el contenido de la respuesta que generó Mockoon para que retorne:

    "data": [
        {
          "author_id": "22123122345",
          "created_at": "Wed Oct 23 18:10:20 +0000 2024",
          "id": "2346889456626258962",
          "text": "Check out this article by @mchojrin!",
          "username": "PHP4Ever"
        }
      ],

    Y probar tu aplicación vía CLI o web, depende del caso a ver si está levantando esta respuesta.

    Bueno… en realidad todavía vas a tener un pequeño inconveniente en este caso en particular: la librería tiene la URL de Twitter hardcodeada y vos necesitás que Mockoon atrape todos esos requests.

    Hay varias soluciones que podés tomar para esto, las nombro solamente para no irme demasiado del tema de este post, en todo caso, escribiré algún otro detallando:

    • Cambiar el código de la librería para que apunte, en lugar de a twitter.com a localhost:3000 (La mala)
    • Montar un proxy en tu computadora que redirija el tráfico destinado a twitter.com hacia localhost:3000 (La fea)
    • Derivar una clase de la que te propone la librería y hacer que la URL base sea un parámetro del constructor (La buena)

    Más allá de cuál sea la solución que tomes, con este esquema vas a poder testear End-to-End sin depender de la conectividad.

    El lado oscuro de los dobles

    Si bien estas técnicas son muy útiles para trabajar tranquilo, dependen de un hecho fundamental: las respuestas hechas a mano deben coincidir (en estructura al menos) con aquellas devueltas por el servicio real.

    Si bien esto puede parecer obvio, es importante recordarlo porque muchas veces la propia documentación de la API contra la que estás trabajando está desactualizada y, en ese caso… nada más cierto que la realidad misma.

  • Cómo manejar las excepciones en API Rest con Symfony

    Cómo manejar las excepciones en API Rest con Symfony

    Desarrollaste tu primer servicio web usando Symfony.

    Lo probaste por acá y por allá y funciona todo… salvo cuando no es así.

    Justo hubo un caso de esos muy extraños en que el registro que buscabas no tiene exactamente todos los campos solicitados… cosas que pasan en el desarrollo de software.

    Nada es grave, ¿no? Al final sólo se trata de enviar al cliente una respuesta adecuada para que sea él quien se encargue de resolver el problema y acompañarla del código de error HTTP correspondiente.

    A lo sumo, agregar una capa de Logging para poder analizar el tema cuando sea posible.

    La idea es bastante simple pero la pregunta es cómo hacerlo correctamente dentro aprovechando al máximo las capacidades del framework Symfony.

    La forma simple

    Para hacer algo rápido no se requiere mucho, basta con atrapar la excepción dentro del controlador en cuestión, generar una respuesta JSON, asignarle el código HTTP que corresponda y enviarla.

    Algo así:

    #[Route('/dangerous', name: 'dangerous_action', methods: ['GET'])]
        public function dangerousAction(Request $request): JsonResponse
        {
            try {
                $this->exceptionThrowerMethod();
            } catch (\Exception $exception) {
                
                $response = new JsonResponse([
                    'message' => $exception->getMessage(),
                    'data' => [],
                    'errors' => []
                ]);
                
                $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
                
                return $response;
            }
        }

    El problema es que esto habrá que replicarlo en cada acción (o en cada controlador al menos).

    Usando algunos elementos de Symfony es posible lograr una solución mucho más elegante y escalable.

    La forma correcta

    Si la API en cuestión cuenta con múltiples end-points es conveniente estandarizar el formato de las respuestas que se enviarán, sean estas exitosas o fallidas.

    El mejor modo de lograr esta homogeneidad es, como siempre que se trata de software, centralizar la funcionalidad común en un único componente.

    En este caso vale la pena contar con una clase como esta:

    <?php
    
    namespace App\Responses;
    
    class APIResponse extends \Symfony\Component\HttpFoundation\JsonResponse
    {
        /**
         * ApiResponse constructor.
         *
         * @param string $message
         * @param mixed  $data
         * @param array  $errors
         * @param int    $status
         * @param array  $headers
         * @param bool   $json
         */
        public function __construct(string $message, $data = null, array $errors = [], int $status = 200, array $headers = [], bool $json = false)
        {
            parent::__construct([
                'message' => $message,
                'data' => $data,
                'errors' => $errors,
            ], $status, $headers, $json);
        }
    }

    Con esto cuentas con lo necesario para estandarizar las respuestas a las peticiones de la API… pero el problema que se menciona en la sección anterior persiste: ¿cómo utilizar esta clase para responder a las excepciones?

    La respuesta: usando el sistema de eventos de Symfony.

    Cada excepción que no es atrapada explícitamente termina generando un evento de tipo kernel.exception, el cual puede ser atrapado por un EventListener como este:

    <?php
    
    namespace App\EventListener;
    
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
    use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
    
    class ExceptionListener
    {
        public function onKernelException(GetResponseForExceptionEvent $event)
        {
            $exception = $event->getException();
            $request   = $event->getRequest();
            
            $response = $this->createApiResponse($exception);
            $event->setResponse($response);
        }
        
        private function createApiResponse(\Exception $exception)
        {
            $statusCode = $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : Response::HTTP_INTERNAL_SERVER_ERROR;
            $errors     = [];
    
            return new APIResponse($exception->getMessage(), null, $errors, $statusCode);
        }
    }

    Con esta clase ya tienes definida la acción que debe tomarse ante una excepción y, combinándola con la clase definida en el paso anterior te aseguras de que la respuesta siempre tenga el mismo formato (Algo que los consumidores de tu API te agradecerán seguramente).

    Sólo falta un último paso: conectar esta clase con el EventDispatcher.

    Registrar un EventListener en Symfony

    Para lograr esto necesitas hacer un par de ajustes a la configuración de tu aplicación.

    Al final del archivo config/services.yaml debes agregar:

    App\EventListener\ExceptionListener:
            tags:
                - { name: kernel.event_listener, event: kernel.exception }

    Y ya está.

    Con esta configuración Symfony sabrá conectar el método onKernelException de tu clase App\EventListener\ExceptionListener.

    Si te quedan dudas de cómo es hace Symfony para establecer esta relación puedes leer este artículo.

    Para seguir investigando

    Si estás desarrollando o, mejor aún, pensando en desarrollar una API REST usando Symfony te sugiero que le des una mirada a API-Platform, seguro que te puede ahorrar muchos dolores de cabeza.