Last active
March 23, 2023 13:57
Star
You must be signed in to star a gist
API friendly error handling with Symfony Messenger and Event Listener
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Acme\Common\Infrastructure\Symfony\Messenger; | |
use Prooph\EventStore\Exception\ConcurrencyException; | |
use Symfony\Component\HttpFoundation\JsonResponse; | |
use Symfony\Component\HttpFoundation\Response; | |
use Symfony\Component\Messenger\Exception\HandlerFailedException; | |
use Symfony\Component\Messenger\Exception\RuntimeException; | |
use Symfony\Component\Messenger\Exception\ValidationFailedException; | |
use Symfony\Component\Validator\ConstraintViolation; | |
use Symfony\Contracts\Translation\TranslatorInterface; | |
/** | |
* @todo Move to symfony messenger middleware or use it as plugin | |
* with no need to inject into controllers. | |
* Returns a 400 status for client failures with errors public to the client. | |
* Returns a 500 otherwise. | |
*/ | |
final class ErrorResponseBuilder | |
{ | |
/** @var TranslatorInterface */ | |
private $translator; | |
private $translationDomain = 'exception'; | |
private $locale = 'de'; | |
public function __construct(TranslatorInterface $translator) | |
{ | |
$this->translator = $translator; | |
} | |
public function handleException(RuntimeException $exception): JsonResponse | |
{ | |
if ($exception instanceof ValidationFailedException) { | |
// Handle domain specific exceptions - public to the client | |
$errors = []; | |
/** @var ConstraintViolation $violation */ | |
foreach ($exception->getViolations() as $violation) { | |
$errors[] = [ | |
'message' => $violation->getMessage(), | |
'path' => $violation->getPropertyPath() | |
]; | |
} | |
return $this->badRequest($errors); | |
} | |
if ($exception instanceof HandlerFailedException) { | |
// Handle domain specific exceptions - public to the client | |
$errors = []; | |
if ($exception->getPrevious() instanceof \DomainException) { | |
$errors[] = ['message' => $this->translator->trans( | |
$exception->getPrevious()->getMessage(), [], $this->translationDomain, $this->locale | |
)]; | |
return $this->badRequest($errors); | |
} | |
// Handle individual server errors if relevant to the client | |
switch (get_class($exception->getPrevious())) { | |
case ConcurrencyException::class: | |
return $this->badRequest(['message' => 'Duplicate entry']); | |
break; | |
} | |
} | |
// Let any other exception fail hard | |
return new JsonResponse(null, Response::HTTP_INTERNAL_SERVER_ERROR); | |
} | |
private function badRequest(array $errors): JsonResponse | |
{ | |
return new JsonResponse(['errors' => $errors], Response::HTTP_BAD_REQUEST); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Acme\Common\Infrastructure\Symfony\EventListener; | |
use Monolog\Logger; | |
use Prooph\EventStore\Exception\ConcurrencyException; | |
use Symfony\Component\HttpFoundation\JsonResponse; | |
use Symfony\Component\HttpFoundation\Response; | |
use Symfony\Component\HttpKernel\Event\ExceptionEvent; | |
use Symfony\Component\Messenger\Exception\HandlerFailedException; | |
use Symfony\Component\Messenger\Exception\RuntimeException; | |
use Symfony\Component\Messenger\Exception\ValidationFailedException; | |
use Symfony\Component\Validator\ConstraintViolation; | |
use Symfony\Contracts\Translation\TranslatorInterface; | |
final class ExceptionListener | |
{ | |
/** @var TranslatorInterface */ | |
private $translator; | |
private $translationDomain = 'exception'; | |
private $locale = 'de'; | |
public function __construct(TranslatorInterface $translator) | |
{ | |
$this->translator = $translator; | |
} | |
public function onKernelException(ExceptionEvent $event) | |
{ | |
// You get the exception object from the received event | |
$exception = $event->getThrowable(); | |
if (!$exception instanceof RuntimeException) { | |
return; | |
} | |
$response = new JsonResponse(); | |
$response->setStatusCode(Response::HTTP_BAD_REQUEST); | |
if ($exception instanceof ValidationFailedException) { | |
// Handle domain specific exceptions - public to the client | |
$errors = []; | |
/** @var ConstraintViolation $violation */ | |
foreach ($exception->getViolations() as $violation) { | |
$errors[] = [ | |
'message' => $violation->getMessage(), | |
'path' => $violation->getPropertyPath() | |
]; | |
} | |
$response->setContent(json_encode(['errors' => $errors])); | |
$event->setResponse($response); | |
return; | |
} | |
if ($exception instanceof HandlerFailedException) { | |
// Handle domain specific exceptions - public to the client | |
$errors = []; | |
if ($exception->getPrevious() instanceof \DomainException) { | |
$errors[] = ['message' => $this->translator->trans( | |
$exception->getPrevious()->getMessage(), [], $this->translationDomain, $this->locale | |
)]; | |
$response->setContent(json_encode(['errors' => $errors])); | |
$event->setResponse($response); | |
return; | |
} | |
// Handle individual server errors if relevant to the client | |
switch (get_class($exception->getPrevious())) { | |
case ConcurrencyException::class: | |
$response->setContent(json_encode(['errors' => ['message' => 'Duplicate entry']])); | |
break; | |
} | |
$event->setResponse($response); | |
return; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Acme\PersonnelManagement\Infrastructure\Symfony\AppBundle\Controller; | |
use Rewotec\Common\Infrastructure\Symfony\Messenger\ErrorResponseBuilder; | |
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | |
use Symfony\Component\Messenger\Exception\RuntimeException; | |
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; | |
final class FooController | |
{ | |
/** @var AuthorizationCheckerInterface */ | |
private $authorizationChecker; | |
/** @var MessageBusInterface */ | |
private $commandBus; | |
/** @var ErrorResponseBuilder */ | |
private $errorResponseBuilder; | |
public function __construct( | |
AuthorizationCheckerInterface $authorizationChecker, | |
MessageBusInterface $commandBus, | |
ErrorResponseBuilder $errorResponseBuilder | |
) | |
{ | |
$this->authorizationChecker = $authorizationChecker; | |
$this->commandBus = $commandBus; | |
$this->errorResponseBuilder = $errorResponseBuilder; | |
} | |
public function newAction(Request $request): Response | |
{ | |
if (!$this->authorizationChecker->isGranted('ROLE_USER')) { | |
throw new AccessDeniedHttpException(); | |
} | |
$command = new CreateFoo(json_decode($request->getContent(), true)); | |
/* | |
try { | |
$this->commandBus->dispatch($command); | |
return new JsonResponse(null, Response::HTTP_CREATED); | |
} catch (RuntimeException $exception) { | |
return $this->errorResponseBuilder->handleException($exception); | |
} | |
*/ | |
// Replacing the old error response builder with a custom event listener | |
$this->commandBus->dispatch($command); | |
return new JsonResponse(null, Response::HTTP_CREATED); | |
} | |
} |
One possibility would be to throw the not-handled exception. Instead of:
// Let any other exception fail hard return new JsonResponse(null, Response::HTTP_INTERNAL_SERVER_ERROR);
This:
// Let any other exception fail hard throw $exception;
throwing any other exception in case of API app is not safe IMO - don't show all the details of the exception to the client.
Why should it not be safe? The 500 will not show anything to the user. Only 400 messages are converted to human-readable format e.g. JSON.
Thanks, I'll reuse your code
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Final solution works fine with the custom event exception listener. It catches and transforms "bad requests" to public client messages. Otherwise 500 is thrown as usual and monolog is still involved.