From 1e5c7e54eeaea6feae9838b01d24b7330f50f6c6 Mon Sep 17 00:00:00 2001 From: Franz Liedke Date: Fri, 9 Aug 2019 20:35:42 +0200 Subject: [PATCH] Implement new error handling stack This separates the error registry (mapping exception types to status codes) from actual handling (the middleware) as well as error formatting (Whoops, pretty error pages or JSON-API?) and reporting (log? Sentry?). The components can be reused in different places (e.g. the API client and the error handler middleware both need the registry to understand all the exceptions Flarum knows how to handle), while still allowing to change only the parts that need to change (the API stack always uses the JSON-API formatter, and the forum stack switches between Whoops and pretty error pages based on debug mode). Finally, this paves the way for some planned features and extensibility: - A console error handler can build on top of the registry. - Extensions can register new exceptions and how to handle them. - Extensions can change how we report exceptions (e.g. Sentry). - We can build more pretty error pages, even different ones for exceptions having the same status code. --- src/Foundation/ErrorHandling/Formatter.php | 20 +++++ src/Foundation/ErrorHandling/HandledError.php | 67 ++++++++++++++++ .../ErrorHandling/JsonApiRenderer.php | 33 ++++++++ src/Foundation/ErrorHandling/LogReporter.php | 32 ++++++++ src/Foundation/ErrorHandling/Registry.php | 73 +++++++++++++++++ src/Foundation/ErrorHandling/Reporter.php | 17 ++++ src/Foundation/ErrorHandling/ViewRenderer.php | 78 +++++++++++++++++++ .../ErrorHandling/WhoopsRenderer.php | 25 ++++++ src/Foundation/KnownError.php | 17 ++++ src/Http/Middleware/HandleErrors.php | 64 +++++++++++++++ 10 files changed, 426 insertions(+) create mode 100644 src/Foundation/ErrorHandling/Formatter.php create mode 100644 src/Foundation/ErrorHandling/HandledError.php create mode 100644 src/Foundation/ErrorHandling/JsonApiRenderer.php create mode 100644 src/Foundation/ErrorHandling/LogReporter.php create mode 100644 src/Foundation/ErrorHandling/Registry.php create mode 100644 src/Foundation/ErrorHandling/Reporter.php create mode 100644 src/Foundation/ErrorHandling/ViewRenderer.php create mode 100644 src/Foundation/ErrorHandling/WhoopsRenderer.php create mode 100644 src/Foundation/KnownError.php create mode 100644 src/Http/Middleware/HandleErrors.php diff --git a/src/Foundation/ErrorHandling/Formatter.php b/src/Foundation/ErrorHandling/Formatter.php new file mode 100644 index 000000000..ec7eb4756 --- /dev/null +++ b/src/Foundation/ErrorHandling/Formatter.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +interface Formatter +{ + public function format(HandledError $error, Request $request): Response; +} diff --git a/src/Foundation/ErrorHandling/HandledError.php b/src/Foundation/ErrorHandling/HandledError.php new file mode 100644 index 000000000..738ba6da5 --- /dev/null +++ b/src/Foundation/ErrorHandling/HandledError.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +use Throwable; + +class HandledError +{ + private $error; + private $type; + private $statusCode; + + private $details = []; + + public static function unknown(Throwable $error) + { + return new static($error, 'unknown', 500); + } + + public function __construct(Throwable $error, $type, $statusCode) + { + $this->error = $error; + $this->type = $type; + $this->statusCode = $statusCode; + } + + public function withDetails(array $details): self + { + $this->details = $details; + + return $this; + } + + public function getError(): Throwable + { + return $this->error; + } + + public function getType(): string + { + return $this->type; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function shouldBeReported(): bool + { + return $this->type === 'unknown'; + } + + public function getDetails(): array + { + return $this->details; + } +} diff --git a/src/Foundation/ErrorHandling/JsonApiRenderer.php b/src/Foundation/ErrorHandling/JsonApiRenderer.php new file mode 100644 index 000000000..948f8ae8f --- /dev/null +++ b/src/Foundation/ErrorHandling/JsonApiRenderer.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +use Flarum\Api\JsonApiResponse; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Tobscure\JsonApi\Document; + +class JsonApiRenderer implements Formatter +{ + public function format(HandledError $error, Request $request): Response + { + $document = new Document; + $document->setErrors([ + [ + 'status' => (string) $error->getStatusCode(), + 'code' => $error->getType(), + ], + ]); + + return new JsonApiResponse($document, $error->getStatusCode()); + } +} diff --git a/src/Foundation/ErrorHandling/LogReporter.php b/src/Foundation/ErrorHandling/LogReporter.php new file mode 100644 index 000000000..edff300d1 --- /dev/null +++ b/src/Foundation/ErrorHandling/LogReporter.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +use Psr\Log\LoggerInterface; + +class LogReporter implements Reporter +{ + /** + * @var LoggerInterface + */ + protected $logger; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + public function report(HandledError $error) + { + $this->logger->error($error->getError()); + } +} diff --git a/src/Foundation/ErrorHandling/Registry.php b/src/Foundation/ErrorHandling/Registry.php new file mode 100644 index 000000000..45ed5c4ec --- /dev/null +++ b/src/Foundation/ErrorHandling/Registry.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +use Flarum\Foundation\KnownError; +use Throwable; + +class Registry +{ + private $statusMap; + private $classMap; + private $handlerMap; + + public function __construct(array $statusMap, array $classMap, array $handlerMap) + { + $this->statusMap = $statusMap; + $this->classMap = $classMap; + $this->handlerMap = $handlerMap; + } + + public function handle(Throwable $error): HandledError + { + return $this->handleKnownTypes($error) + ?? $this->handleCustomTypes($error) + ?? HandledError::unknown($error); + } + + private function handleKnownTypes(Throwable $error): ?HandledError + { + $errorType = null; + + if ($error instanceof KnownError) { + $errorType = $error->getType(); + } else { + $errorClass = get_class($error); + if (isset($this->classMap[$errorClass])) { + $errorType = $this->classMap[$errorClass]; + } + } + + if ($errorType) { + return new HandledError( + $error, + $errorType, + $this->statusMap[$errorType] ?? 500 + ); + } + + return null; + } + + private function handleCustomTypes(Throwable $error): ?HandledError + { + $errorClass = get_class($error); + + if (isset($this->handlerMap[$errorClass])) { + $handler = new $this->handlerMap[$errorClass]; + + return $handler->handle($error); + } + + return null; + } +} diff --git a/src/Foundation/ErrorHandling/Reporter.php b/src/Foundation/ErrorHandling/Reporter.php new file mode 100644 index 000000000..2e52d289b --- /dev/null +++ b/src/Foundation/ErrorHandling/Reporter.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +interface Reporter +{ + public function report(HandledError $error); +} diff --git a/src/Foundation/ErrorHandling/ViewRenderer.php b/src/Foundation/ErrorHandling/ViewRenderer.php new file mode 100644 index 000000000..4ac50ffd7 --- /dev/null +++ b/src/Foundation/ErrorHandling/ViewRenderer.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +use Flarum\Settings\SettingsRepositoryInterface; +use Illuminate\Contracts\View\Factory as ViewFactory; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Symfony\Component\Translation\TranslatorInterface; +use Zend\Diactoros\Response\HtmlResponse; + +class ViewRenderer implements Formatter +{ + /** + * @var ViewFactory + */ + protected $view; + + /** + * @var TranslatorInterface + */ + protected $translator; + + /** + * @var SettingsRepositoryInterface + */ + protected $settings; + + public function __construct(ViewFactory $view, TranslatorInterface $translator, SettingsRepositoryInterface $settings) + { + $this->view = $view; + $this->translator = $translator; + $this->settings = $settings; + } + + public function format(HandledError $error, Request $request): Response + { + $view = $this->view->make($this->determineView($error)) + ->with('error', $error->getError()) + ->with('message', $this->getMessage($error)); + + return new HtmlResponse($view->render(), $error->getStatusCode()); + } + + private function determineView(HandledError $error): string + { + $view = [ + 'route_not_found' => '404', + 'csrf_token_mismatch' => '419', + ][$error->getType()] ?? 'default'; + + return "flarum.forum::error.$view"; + } + + private function getMessage(HandledError $error) + { + return $this->getTranslationIfExists($error->getStatusCode()) + ?? $this->getTranslationIfExists('unknown') + ?? 'An error occurred while trying to load this page.'; + } + + private function getTranslationIfExists(string $errorType) + { + $key = "core.views.error.${errorType}_message"; + $translation = $this->translator->trans($key, ['{forum}' => $this->settings->get('forum_title')]); + + return $translation === $key ? null : $translation; + } +} diff --git a/src/Foundation/ErrorHandling/WhoopsRenderer.php b/src/Foundation/ErrorHandling/WhoopsRenderer.php new file mode 100644 index 000000000..7be647558 --- /dev/null +++ b/src/Foundation/ErrorHandling/WhoopsRenderer.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation\ErrorHandling; + +use Franzl\Middleware\Whoops\WhoopsRunner; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +class WhoopsRenderer implements Formatter +{ + public function format(HandledError $error, Request $request): Response + { + return WhoopsRunner::handle($error->getError(), $request) + ->withStatus($error->getStatusCode()); + } +} diff --git a/src/Foundation/KnownError.php b/src/Foundation/KnownError.php new file mode 100644 index 000000000..bb7e4591a --- /dev/null +++ b/src/Foundation/KnownError.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Foundation; + +interface KnownError +{ + public function getType(): string; +} diff --git a/src/Http/Middleware/HandleErrors.php b/src/Http/Middleware/HandleErrors.php new file mode 100644 index 000000000..ff077f105 --- /dev/null +++ b/src/Http/Middleware/HandleErrors.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http\Middleware; + +use Flarum\Foundation\ErrorHandling\Formatter; +use Flarum\Foundation\ErrorHandling\Registry; +use Flarum\Foundation\ErrorHandling\Reporter; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Server\MiddlewareInterface as Middleware; +use Psr\Http\Server\RequestHandlerInterface as Handler; +use Throwable; + +class HandleErrors implements Middleware +{ + /** + * @var Registry + */ + protected $registry; + + /** + * @var Formatter + */ + protected $formatter; + + /** + * @var Reporter + */ + protected $reporter; + + public function __construct(Registry $registry, Formatter $formatter, Reporter $reporter) + { + $this->registry = $registry; + $this->formatter = $formatter; + $this->reporter = $reporter; + } + + /** + * Catch all errors that happen during further middleware execution. + */ + public function process(Request $request, Handler $handler): Response + { + try { + return $handler->handle($request); + } catch (Throwable $e) { + $error = $this->registry->handle($e); + + if ($error->shouldBeReported()) { + $this->reporter->report($error); + } + + return $this->formatter->format($error, $request); + } + } +}