Skip to content

Instantly share code, notes, and snippets.

@webdevilopers
Last active March 23, 2023 13:57
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
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);
}
}
@webdevilopers
Copy link
Author

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.

@j4r3kb
Copy link

j4r3kb commented Jan 14, 2023

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.

@webdevilopers
Copy link
Author

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.

@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