Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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);
}
}
@webdevilopers

This comment has been minimized.

Copy link
Owner Author

@webdevilopers webdevilopers commented Apr 21, 2020

When #symfony throws an exception #monolog logs it and for instance sends an email. However this does not happen when you catch the exception and return a 500 response from a controller action manually. Is there a way to keep the original behaviour without logging manually?

Feel free to join the discussion:

@webdevilopers

This comment has been minimized.

Copy link
Owner Author

@webdevilopers webdevilopers commented Apr 21, 2020

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;
@webdevilopers

This comment has been minimized.

Copy link
Owner Author

@webdevilopers webdevilopers commented Apr 21, 2020

Trying to move the ErrorResponseBuilder to an event listener instead:

final class ExceptionListener
{
    public function onKernelException(ExceptionEvent $event)
    {
        // You get the exception object from the received event
        $exception = $event->getThrowable();

        $response = new Response();
        $response->setContent('Will I still monolog?!');
        $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);

        $event->setResponse($response);
        // No monolog any more :(
    }
}

Do we have to manually inject the logger and fire it @weaverryan?

@webdevilopers

This comment has been minimized.

Copy link
Owner Author

@webdevilopers webdevilopers commented Apr 21, 2020

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.

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