Skip to content

Instantly share code, notes, and snippets.

@webdevilopers
Last active March 23, 2023 13:57
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save webdevilopers/602ec1790d1c1ff9179f4b24f925c981 to your computer and use it in GitHub Desktop.
Save webdevilopers/602ec1790d1c1ff9179f4b24f925c981 to your computer and use it in GitHub Desktop.
API friendly error handling with Symfony Messenger and Event Listener
<?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);
}
}
<?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;
}
}
}
<?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);
}
}
@wwsh
Copy link

wwsh commented Mar 23, 2023

Thanks, I'll reuse your code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment