Controladors nets a Symfony (I): gestió d’excepcions

Introducció

Suposem que tenim el següent endpoint d’una API:

Url: GET /llibres/{uuid}

  • Resposta correcta
    • Codi HTTP: 200
    • Contingut:
        {
            "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5",
            "titol": "Curial e Güelfa",
            "autor": "Anònim"
        }
      
  • Resposta incorrecta: UUID amb un format incorrecte:
    • Codi HTTP: 400
    • Contingut:
        {
            "error": "LlibreId provided format \"c59620eb-c0ab-4a0c-8354-5a20faf537e5\" is not a valid UUID"
        }
      
  • Resposta incorrecta: llibre inexistent:
    • Codi HTTP: 404
    • Contingut:
        {
            "error": "LlibreDTO with LlibreId \"c59620eb-c0ab-4a0c-8354-5a20faf537e5\" not found"
        }
      

A Symfony, si fem servir el patró query bus, podríem tenir el següent controlador per a implementar l’endpoint especificat:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Llibre\GetLlibreDTOByIdQuery;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
use rubenrubiob\Domain\Exception\Repository\Llibre\LlibreDTONotFound;
use rubenrubiob\Domain\Exception\ValueObject\Llibre\LlibreIdFormatIsNotValid;
use rubenrubiob\Domain\Exception\ValueObject\Llibre\LlibreIdIsEmpty;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

final readonly class GetLlibreController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    public function __invoke(string $llibreId): Response
    {
        try {
            /** @var LlibreDTO $llibreDTO */
            $llibreDTO = $this->queryBus->__invoke(
                new GetLlibreDTOByIdQuery(
                    $llibreId
                )
            );
        } catch (LlibreIdFormatIsNotValid|LlibreIdIsEmpty $e) {
            return new JsonResponse(
                [
                    'error' => $e->getMessage(),
                ],
                Response::HTTP_BAD_REQUEST,
            );
        } catch (LlibreDTONotFound $e) {
            return new JsonResponse(
                [
                    'error' => $e->getMessage(),
                ],
                Response::HTTP_NOT_FOUND,
            );
        }

        return new JsonResponse(
            [
                'id' => $llibreDTO->llibreId->toString(),
                'titol' => $llibreDTO->llibreTitol->toString(),
                'autor' => $llibreDTO->autorNom->toString(),
            ],
            Response::HTTP_OK,
        );
    }
}

Amb aquesta implementació complim l’especificació, però veiem que el controlador té molta lògica: la gestió del format de la resposta i dels codis HTTP d’error.

En aquest post, veurem com podem delegar la gestió de les excepcions al framework, per així simplificar els nostres controladors i centralitzar el formatatge dels errors.

Esdeveniments del Kernel de Symfony

El flux d’execució web consisteix a rebre una petició i retornar una resposta. El component HttpKernel de Symfony és prou flexible per a gestionar aquest flux per a frameworks sencers, microframeworks o un CMS com Drupal.

El Kernel de Symfony és enfocat a esdeveniments, de manera que tota la lògica de gestió de peticions està abstreta, alhora que proporciona flexibilitat. Això permet al desenvolupador executar codi propi en punts concrets del flux de gestió d’una petició. Aquests punts es poden veure a la imatge següent1:

Font: documentació de Symfony

Així doncs, podem delegar la gestió de les excepcions a un Listener o Subscriber del Kernel que gestioni les excepcions. En el nostre cas, optem per un Subscriber. Per a decidir-nos per un o altre, a la documentació de Symfony hi tenen una comparativa.

ExceptionResponseSubscriber: primera versió

Podem afegir EventSubscriber que escolti l’esdeveniment kernel.exception, que és el punt en què Symfony gestiona les excepcions no capturades. La implementació és la següent:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Subscriber;

use rubenrubiob\Domain\Exception\Repository\Llibre\LlibreDTONotFound;
use rubenrubiob\Domain\Exception\ValueObject\Llibre\LlibreIdFormatIsNotValid;
use rubenrubiob\Domain\Exception\ValueObject\Llibre\LlibreIdIsEmpty;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;

use function array_key_exists;

final readonly class ExceptionResponseSubscriber implements EventSubscriberInterface
{
    private const EXCEPTION_RESPONSE_HTTP_CODE_MAP = [
        LlibreDTONotFound::class => Response::HTTP_NOT_FOUND,
        LlibreIdFormatIsNotValid::class => Response::HTTP_BAD_REQUEST,
        LlibreIdIsEmpty::class => Response::HTTP_BAD_REQUEST,
    ];

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::EXCEPTION => ['__invoke']];
    }

    public function __invoke(ExceptionEvent $event): void
    {
        $throwable = $event->getThrowable();

        $response = new JsonResponse(
            [
                'error' => $throwable->getMessage(),
            ],
            $this->httpCode($throwable),
            [
                'Content-Type' => 'application/json',
            ]
        );

        $event->setResponse($response);
    }

    private function httpCode(Throwable $throwable): int
    {
        $throwableClass = $throwable::class;
        if (array_key_exists($throwableClass, self:: EXCEPTION_RESPONSE_HTTP_CODE_MAP)) {
            return self:: EXCEPTION_RESPONSE_HTTP_CODE_MAP[$throwableClass];
        }

        return Response::HTTP_INTERNAL_SERVER_ERROR;
    }
}

Per a cadascuna de les excepcions del nostre codi, l’afegim a la constant EXCEPTION_RESPONSE_HTTP_CODE_MAP, on mapegem l’excepció al codi HTTP que ha de retornar. Si no tenim l’excepció controlada, retornem un error 500.

En el Subscriber també hi formatem la resposta d’error, que és molt bàsica: simplement mostrem el missatge de l’excepció2.

En funció del format de la resposta, caldria afegir més lògica al Subscriber, per exemple si fem servir Problem Details o JSON:API3.

Si fem servir l’autoconfiguració per a definir els serveis de Symfony, el nostre Subscriber ja estarà automàticament configurat a l’aplicació.

Controlador simplificat

Amb l’ús de ExceptionResponseSubscriber, el controlador el podem simplificar esborrant tota la gestió d’errors:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Llibre\GetLlibreDTOByIdQuery;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

final readonly class GetLlibreController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    public function __invoke(string $llibreId): Response
    {
        /** @var LlibreDTO $llibreDTO */
        $llibreDTO = $this->queryBus->__invoke(
            new GetLlibreDTOByIdQuery(
                $llibreId
            )
        );

        return new JsonResponse(
            [
                'id' => $llibreDTO->llibreId->toString(),
                'titol' => $llibreDTO->llibreTitol->toString(),
                'autor' => $llibreDTO->autorNom->toString(),
            ],
            Response::HTTP_OK,
        );
    }
}

Delegant la gestió d’errors a un Subscriber, aconseguim simplificar molt la lògica del controlador: només fem la crida per a obtenir les dades i les formatem.

Ara bé, amb aquesta implementació del Subscriber, per a cada nova excepció que afegim al nostre codi i que vulguem que tingui un codi HTTP concret, hem d’afegir-la a la constant EXCEPTION_RESPONSE_HTTP_CODE_MAP. Aquesta opció no creix bé, ja que ens pot quedar un array enorme. A més a més, implica que cada desenvolupador ha de recordar afegir l’excepció.

Interfícies d’excepcions

Per a resoldre aquest problema, hi ha una alternativa: fer servir interfícies a les nostres excepcions de domini.

Per exemple, per a aquest cas, podem tenir dues interfícies diferents, una per a totes les excepcions de Value Objects incorrectes, i una altra per als NotFound:

<?php

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\ValueObject;

interface InvalidValueObject
{
}

<?php

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\Repository;

interface NotFound
{
}

Implementar aquestes excepcions al nostre domini no implica res més que afegir un implements, ja que no tenen cos. Així, doncs, podem veure com quedaria l’excepció LlibreDTONotFound:

<?php

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\Repository\Llibre;

use Exception;
use rubenrubiob\Domain\Exception\Repository\NotFound;
use rubenrubiob\Domain\ValueObject\Llibre\LlibreId;

use function sprintf;

final class LlibreDTONotFound extends Exception implements NotFound
{
    public static function withLlibreId(LlibreId $llibreId): self
    {
        return new self(
            sprintf(
                'LlibreDTO with LlibreId "%s" not found',
                $llibreId->toString(),
            )
        );
    }
}

ExceptionResponseSubscriber: segona versió

Amb les interfícies, ja podem refactoritzar el ExceptionResponseSubscriber:

<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Subscriber;

use rubenrubiob\Domain\Exception\Repository\NotFound;
use rubenrubiob\Domain\Exception\ValueObject\InvalidValueObject;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;

use function array_key_exists;
use function Safe\class_implements;

final readonly class ExceptionResponseSubscriber implements EventSubscriberInterface
{
    private const EXCEPTION_RESPONSE_HTTP_CODE_MAP = [
        NotFound::class => Response::HTTP_NOT_FOUND,
        InvalidValueObject::class => Response::HTTP_BAD_REQUEST,
    ];

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::EXCEPTION => '__invoke'];
    }

    public function __invoke(ExceptionEvent $event): void
    {
        $throwable = $event->getThrowable();

        $response = new JsonResponse(
            [
                'error' => $throwable->getMessage(),
            ],
            $this->httpCode($throwable),
            [
                'Content-Type' => 'application/json',
            ]
        );

        $event->setResponse($response);
    }

    private function httpCode(Throwable $throwable): int
    {
        /** @var class-string[] $interfaces */
        $interfaces = class_implements($throwable);

        foreach ($interfaces as $interface) {
            if (array_key_exists($interface, self::RESPONSE_CODE_MAP)) {
                return self::EXCEPTION_RESPONSE_HTTP_CODE_MAP[$interface];
            }
        }

        return Response::HTTP_INTERNAL_SERVER_ERROR;
    }
}

Amb només les dues interfícies, ens estalviem haver d’afegir cadascuna de les excepcions que implementem al nostre codi. Qualsevol excepció que sorgeixi d’un Value Object invàlid o d’un NotFound retornarà un codi de resposta HTTP adequat.

Conclusions

Fer servir les interfícies no és una solució definitiva, ja que pot haver-hi situacions en què vulguem tenir excepcions concretes. En qualsevol cas, es poden combinar ambdues opcions, tot i que, en aquest cas, segurament el millor seria extreure-ho a un servei que fes aquesta gestió.

Com sempre, el millor és anar evolucionant el codi en funció de les necessitats que ens hi anem trobant.

Resum

  • Hem vist el potencial de l’orientació a esdeveniments del Kernel de Symfony per a simplificar les nostres aplicacions.
  • Hem aconseguit refactoritzar un controlador per a extreure’n el control d’errors i delegar-lo a un Subscriber.
  • Hem vist com crear un Subscriber per a l’esdeveniment kernel.exception per a gestionar les excepcions d’una API de manera centralitzada.
  1. Es pot consultar la taula de tots els esdeveniments i els paràmetres que reben a la documentació oficial de Symfony

  2. Mostrar directament el missatge de l’excepció no és una bona opció, perquè acostumen a ser missatges escrits per i per a desenvolupadors. A més a més, poden contenir dades o informació sensible que mai no hauríem d’exposar públicament. 

  3. Per a veure una bona descripció de possibles alternatives, consulteu aquest article d’APIs you won’t hate