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.

mchojrin

Por mchojrin

Ayudo a desarrolladores PHP a acceder mercados y clientes más sofisticados y exigentes

¿Te quedó alguna duda? Publica aca tu pregunta

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.