Skip to content

Commit

Permalink
Better error handling (#6)
Browse files Browse the repository at this point in the history
* feat: add test page showing modals, flash messages and more
* feat: better error handling
* feat: add modal and error modal
* refactor: refactoring
  • Loading branch information
tbreuss authored Feb 2, 2022
1 parent 5fd7ba6 commit cee20da
Show file tree
Hide file tree
Showing 15 changed files with 625 additions and 132 deletions.
14 changes: 13 additions & 1 deletion backend/config/middleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

declare(strict_types = 1);

use App\Handler\JsonErrorRenderer;
use App\Handler\ShutdownHandler;
use App\Middleware\CorsMiddleware;
use App\Middleware\ValidationExceptionMiddleware;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Factory\ServerRequestCreatorFactory;

return function (App $app) {
$settings = $app->getContainer()->get('settings');
Expand All @@ -21,13 +24,22 @@
$app->addRoutingMiddleware();

// Add Slims built-in error middleware
$app->addErrorMiddleware(
$errorMiddleware = $app->addErrorMiddleware(
$settings['error']['display_error_details'],
$settings['error']['log_errors'],
$settings['error']['log_error_details'],
$logger
);

// Add shutdown handler to catch notices and warnings
$errorHandler = $errorMiddleware->getErrorHandler('default');
$request = ServerRequestCreatorFactory::create()->createServerRequestFromGlobals();
$shutdownHandler = new ShutdownHandler($request, $errorHandler, $settings['error']['display_error_details']);
register_shutdown_function($shutdownHandler);

// Add own JSON error handler to display more information
$errorHandler->registerErrorRenderer('application/json', JsonErrorRenderer::class);

// Add cors middleware
$app->add(CorsMiddleware::class);
};
1 change: 1 addition & 0 deletions backend/config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
$group->get('/organizations/{id}', \App\Action\Organization\OrganizationDetailAction::class)->setName('organizations.detail');
$group->get('/organizations', \App\Action\Organization\OrganizationListAction::class)->setName('organizations.list');
$group->get('/ping', \App\Action\PingAction::class)->setName('ping');
$group->get('/error/{type}', \App\Action\ErrorAction::class)->setName('error'); // showing different errors in frontend
});

// protected routes
Expand Down
58 changes: 58 additions & 0 deletions backend/src/Action/ErrorAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Action;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpForbiddenException;
use Slim\Exception\HttpGoneException;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Exception\HttpNotImplementedException;
use Slim\Exception\HttpUnauthorizedException;

final class ErrorAction
{
public function __invoke(
ServerRequestInterface $request,
ResponseInterface $response,
array $args
): ResponseInterface {
$type = (int)$args['type'];

switch ($type) {
case 400:
throw new HttpBadRequestException($request);
case 401:
throw new HttpUnauthorizedException($request);
case 403:
throw new HttpForbiddenException($request);
case 404:
default:
throw new HttpNotFoundException($request);
case 405:
throw new HttpMethodNotAllowedException($request);
case 410:
throw new HttpGoneException($request);
case 500:
throw new HttpInternalServerErrorException($request);
case 501:
throw new HttpNotImplementedException($request);
case 600:
trigger_error('Run-time notices', E_USER_NOTICE);
break;
case 601:
trigger_error('Run-time warnings (non-fatal errors)', E_USER_WARNING);
break;
case 602:
trigger_error('Fatal run-time error', E_USER_ERROR);
break;
}

return $response->withHeader('Content-Type', 'application/json');
}
}
47 changes: 47 additions & 0 deletions backend/src/Handler/JsonErrorRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Handler;

use Throwable;

class JsonErrorRenderer extends \Slim\Error\AbstractErrorRenderer
{
/**
* @param Throwable $exception
* @param bool $displayErrorDetails
* @return string
*/
public function __invoke(Throwable $exception, bool $displayErrorDetails): string
{
$error = [
'message' => $this->getErrorTitle($exception),
'description' => $this->getErrorDescription($exception)
];

if ($displayErrorDetails) {
$error['exception'] = [];
do {
$error['exception'][] = $this->formatExceptionFragment($exception);
} while ($exception = $exception->getPrevious());
}

return (string) json_encode($error, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}

/**
* @param Throwable $exception
* @return array<string|int>
*/
private function formatExceptionFragment(Throwable $exception): array
{
return [
'type' => get_class($exception),
'code' => $exception->getCode(),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
];
}
}
86 changes: 86 additions & 0 deletions backend/src/Handler/ShutdownHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace App\Handler;

use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\ResponseEmitter;
use Slim\Handlers\ErrorHandler;

class ShutdownHandler
{
/**
* @var Request
*/
private $request;

/**
* @var HttpErrorHandler
*/
private $errorHandler;

/**
* @var bool
*/
private $displayErrorDetails;

/**
* ShutdownHandler constructor.
*
* @param Request $request
* @param HttpErrorHandler $errorHandler
* @param bool $displayErrorDetails
*/
public function __construct(Request $request, ErrorHandler $errorHandler, bool $displayErrorDetails)
{
$this->request = $request;
$this->errorHandler = $errorHandler;
$this->displayErrorDetails = $displayErrorDetails;
}

public function __invoke()
{
$error = error_get_last();
if ($error) {
$errorFile = $error['file'];
$errorLine = $error['line'];
$errorMessage = $error['message'];
$errorType = $error['type'];
$message = 'An error while processing your request. Please try again later.';

if ($this->displayErrorDetails) {
switch ($errorType) {
case E_USER_ERROR:
$message = "FATAL ERROR: {$errorMessage}. ";
$message .= " on line {$errorLine} in file {$errorFile}.";
break;

case E_USER_WARNING:
$message = "WARNING: {$errorMessage}";
break;

case E_USER_NOTICE:
$message = "NOTICE: {$errorMessage}";
break;

default:
$message = "ERROR: {$errorMessage}";
$message .= " on line {$errorLine} in file {$errorFile}.";
break;
}
}

$exception = new HttpInternalServerErrorException($this->request, $message);
$response = $this->errorHandler->__invoke($this->request, $exception, $this->displayErrorDetails, false, false);

if (ob_get_length()) {
ob_clean();
}

$responseEmitter = new ResponseEmitter();
$responseEmitter->emit($response);
}
}
}
31 changes: 31 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ section {
left: 0;
right: 0;
top: -3rem;
z-index:1;
}

.alert {
Expand All @@ -231,3 +232,33 @@ section {
.closebtn:hover {
color: black;
}

dialog h3 {
margin-top: -1rem;
}

dialog div.buttons {
margin-top: 2.25rem;
text-align: right;
}

dialog div.buttons a {
margin-left: 1rem;
}

dialog.error article {
background-color: #cc3333;
min-width: 75%;
}

dialog.error article h3, dialog.error article p {
color: #edf0f3;
}

dialog.error article .close {
opacity: 1;
}

dialog.error small {
color: rgba(255, 255, 255, 0.75)
}
101 changes: 101 additions & 0 deletions frontend/src/components/ErrorModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import m from 'mithril'

/**
@typedef {{
// Title render function.
// Using a function allows us to keep content
// updated after the modal is opened.
title(): any
// Body render function
body?(): any
redraw?: boolean
onclick?(id: string, event: any): void,
onclose?(): void
}} Options
*/

const animationDuration = 0;

/** @type {Options} */
let options = {
title: () => 'Modal'
}

let isOpen = false

/**
* @param {Options} opts
*/
export function openErrorModal(opts) {
// Deep copy the supplied opts
isOpen = true
options = { ...opts }
// Redraw by default unless caller suppressed
if (options.redraw == null || options.redraw === true) {
m.redraw()
}
}

/** Calls redraw by default unless called with `false` */
export function closeErrorModal(redraw = true) {
isOpen = false
// Redraw by default unless caller suppressed
if (redraw) {
m.redraw()
}
}

export function errorModalIsOpen() {
return isOpen
}

let modelRef = undefined;

/** Modal component */
export const ErrorModal = {
oncreate() {
const html = document.documentElement
html.classList.add('error-modal-is-opening')
setTimeout(() => {
html.classList.remove('error-modal-is-opening')
html.classList.add('error-modal-is-open')
}, animationDuration)
},
onbeforeremove() {
const html = document.documentElement
html.classList.remove('error-modal-is-open', 'error-modal-is-opening')
html.classList.add('error-modal-is-closing')
return new Promise(r => {
setTimeout(r, animationDuration)
})
},
onremove() {
document.documentElement.classList.remove('error-modal-is-closing')
},
view() {
return m('dialog.error', {
open: true,
oncreate: ({ dom }) => {
modelRef = dom;
},
onclick: (e) => {
e.redraw = false
if (e.target === modelRef) {
closeErrorModal()
}
}
},
m('article',
m('a.close', {
href: '#', ariaLabel: 'Close', onclick: (e) => {
isOpen = !isOpen
e.preventDefault()
options.onclose && options.onclose()
}
}, ''),
options.title(),
options.body != null && options.body(),
)
)
}
}
Loading

0 comments on commit cee20da

Please sign in to comment.