-
-
Save webdevilopers/602ec1790d1c1ff9179f4b24f925c981 to your computer and use it in GitHub Desktop.
<?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); | |
} | |
} |
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;
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?
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.
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
Feel free to join the discussion: