Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

4.x Error Middleware #2398

Merged
merged 25 commits into from Mar 12, 2018
Merged

4.x Error Middleware #2398

merged 25 commits into from Mar 12, 2018

Conversation

l0gicgate
Copy link
Member

@l0gicgate l0gicgate commented Feb 14, 2018

Preface

The original PR #2222 was closed and some of the concepts and code will be implemented in this PR.

Description

This PR eliminates all native error handlers and replaces it with ErrorMiddleware which handles all errors and provides multiple different renderers (HTML, XML, JSON, Plain Text) to enhance error display.

Implementation

use Slim\App;
use Slim\Middleware\ErrorMiddleware;
use Slim\Middleware\RoutingMiddleware;

$app = new App();

/*
 * The routing middleware should be added earlier than the ErrorMiddleware
 * Otherwise exceptions thrown from it will not be handled by the middleware
 */
$routingMiddleware = new RoutingMiddleware($app->getRouter());
$app->add($routingMiddleware);

/*
 * The constructor of `ErrorMiddleware` takes in 4 parameters
 * @param CallableResolverInterface $callableResolver -> Callable Resolver Interface of your choice
 * @param bool $displayErrorDetails -> Should be set to false in production
 * @param bool $logErrors -> Parameter is passed to the default ErrorHandler
 * @param bool $logErrorDetails -> Display error details in error log
 * which can be replaced by a callable of your choice.
 * Note: This middleware should be added last. It will not handle any exceptions/errors
 * for middleware added after it.
 */
$callableResolver = $app->getCallableResolver();
$errorMiddleware = new ErrorMiddleware($callableResolver, true, true, true);
$app->add($errorMiddleware);

...
$app->run();

Adding custom handlers for different named exceptions

...
$callableResolver = $app->getCallableResolver();
$errorMiddleware = new ErrorMiddleware($callableResolver, true, true, true);

$handler = function ($req, $res, $exception, $displayErrorDetails) {
    return $res->withJson(['error' => 'Caught MyNamedException']);
}
$errorMiddleware->setErrorHandler(MyNamedException::class, $handler);
$app->add($errorMiddleware);

Error Logging

If you would like to pipe in custom error logging to the default ErrorHandler that ships with Slim you can simply extend it and stub the logError() method.

namespace MyApp\Handlers;

use Slim\Handlers\ErrorHandler;

class MyErrorHandler extends ErrorHandler {
    public function logError($error)
    {
        // Insert custom error logging function.
    }
}
use MyApp\Handlers\MyErrorHandler;
use Slim\App;
use Slim\Middleware\ErrorMiddleware;

$app = new App();
$callableResolver = $app->getCallableResolver();

$myErrorHandler = new MyErrorHandler(true); // Constructor parameter is $logErrors (bool)
$errorMiddleware = new ErrorMiddleware($callableResolver, true, true, true);
$errorMiddleware->setDefaultErrorHandler($myErrorHandler);
$app->add($errorMiddleware);

...
$app->run();

Error Handling/Rendering

The rendering is finally decoupled from the handling. Everything still works the way it previously did. It will still detect the content-type and render things appropriately with the help of ErrorRenderers. The core ErrorHandler extends the AbstractErrorHandler class which has been completely refactored. By default it will call the appropriate ErrorRenderer for the supported content types. Someone can now provide their custom error renderer by extending the AbstractErrorHandler class and overloading the protected renderer variable from the parent.

class MyCustomErrorRenderer extends \Slim\Handlers\AbstractErrorRenderer
{
    public function render()
    {
        return 'My awesome format';
    }
}

class MyCustomErrorHandler extends \Slim\Handlers\ErrorHandler
{
    protected $renderer = MyCustomErrorRenderer::class;
}

HTTP Exceptions

I just think it makes sense to throw HTTP based exceptions within the application. These exceptions works with nicely with the proposed native renderers. They can each have a description and title attribute as well to provide a bit more insight when the native HTML renderer is invoked.

The base class HttpSpecializedException extends Exception and comes with the following sub classes :

  • HttpBadRequestException
  • HttpForbiddenException
  • HttpInternalServerErrorException
  • HttpNotAllowedException
  • HttpNotFoundException
  • HttpNotImplementedException
  • HttpUnauthorizedException

The developer can extend the HttpSpecializedException class if they need any other response codes that we decide not to provide with the base repository. Example if you wanted a 504 gateway timeout exception that behaves like the native ones you would do the following:
I

class HttpForbiddenException extends HttpSpecializedException
{
    protected $code = 504;
    protected $message = 'Gateway Timeout.';
    protected $title = '504 Gateway Timeout';
    protected $description = 'Timed out before receiving response from the upstream server.';
}

Status

Work in progress

  • Remove Existing Error Handling
  • Implement Error Middleware
  • Implement supporting tests
  • [X ] Discussion and Code Review.

@coveralls
Copy link

coveralls commented Feb 14, 2018

Coverage Status

Coverage increased (+1.1%) to 96.055% when pulling 8cefb1c on l0gicgate:4.x-ErrorMiddleware into bd6ef95 on slimphp:4.x.

@l0gicgate l0gicgate changed the title 4.x Error Middleware 4.x Error Handling Middleware Feb 14, 2018
@l0gicgate l0gicgate changed the title 4.x Error Handling Middleware 4.x Error Middleware Feb 14, 2018
Copy link

@asheliahut asheliahut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some thoughts.

Slim/App.php Outdated
@@ -555,51 +434,29 @@ public function group($pattern, $callable)
public function run()
{
// create request
$request = Request::createFromGlobals($_SERVER);
if (is_null($this->request)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null === $this->request is better here no overhead of function call.

Slim/App.php Outdated
@@ -682,22 +541,14 @@ public function __invoke(ServerRequestInterface $request, ResponseInterface $res
$router = $this->getRouter();

// If routing hasn't been done, then do it now so we can dispatch
if (null === $routeInfo) {
if (is_null($routeInfo)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should stay ===

$html = '<p>The application could not run because of the following error:</p>';
$html .= '<h2>Details</h2>';
$html .= $this->renderExceptionFragment($e);
} else {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can just return before the else and have this html set as a second return lowering cyclomatic complexity by 1.

{
$html = sprintf('<div><strong>Type:</strong> %s</div>', get_class($exception));

if (($code = $exception->getCode())) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if someone passes an exception code of 0 this will never execute.

$html .= sprintf('<div><strong>Code:</strong> %s</div>', $code);
}

if (($message = $exception->getMessage())) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if someone sets a message of '0' it also will fail to run.

*/
protected function logError($error)
{
error_log($error);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this error log should also be able to take in an outside error logging, as in be able to take in a logger middleware of some kind.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I agree. A separate PR after this could allow for handling a PSR3 logger.

/**
* Default Slim application error handler
*
* It outputs the error message and diagnostic information in either

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either-> one of the following

*/
if (method_exists($exception, 'getRequest')) {
$request = $exception->getRequest();
if (!is_null($request)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

=== null

* exact state of the request at the time of the exception can be
* accessed by the end user if necessary
*/
if (!is_null($exception)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!=== null

/**
* Default Slim application error renderer
*
* It outputs the error message and diagnostic information in either JSON, XML,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either should be "one of the following"

@l0gicgate
Copy link
Member Author

Thank you @asheliahut for the code review. I'm aware this is a large PR and I really appreciate you going through everything. I have made the suggested changes besides the suggestion you made for the AbstractErrorHandler::logError() method, If people want to implement logging they can simply extend the class and replace the ::logError() method. That's the main reason why I've left it abstract.

{
$e = $this->exception;

$text = 'Slim Application Error:' . PHP_EOL;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use \n instead of PHP_EOL to make integration tests compatible with windows.

*
* @return ErrorRendererInterface
*
* @throws \RuntimeException
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add use RuntimeException on top to remove the backslash.

{
$renderer = $this->renderer;

if ((
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There'd be plenty of room to put this statement in one line.
if (($renderer !== null && !class_exists($renderer)) {

*/
protected function formatResponse()
{
$e = $this->exception;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This extra variable ($e) is not really necessary.

* Get callable to handle scenarios where an error
* occurs when processing the current request.
*
* @param string $type
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What for values can be passed here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand your question. Can you clarify?

@odan
Copy link

odan commented Feb 17, 2018

Hi!

I have some questions / ideas about this PR and the error handling in general.

  1. If Slim 4 will provide a new Error middleware like in this PR, why do we still need the setErrorHandler() and setDefaultErrorHandler methods in the App class? There should be only one style how to add middleware to the stack and not multiple different methods. This is important, because I want to decide in which order I place my middleware handlers.

  2. I don't like the PHP_EOL magic constant because the response will be different on windows/linux. The Response should be exactly the same for all platforms.

@odan
Copy link

odan commented Feb 17, 2018

The name of the middleware ErrorMiddleware implies that it handles PHP errors. But in PHP there is a small difference between Exceptions and Errors. If this middleware handles "only" Exceptions and no "Errors" then the it should be renamed to "ExceptionMiddleware".

The question is how to handle PHP errors (extended from Throwable)? Are you planning a new separate Middleware (e.g. ErrorMiddleware) or a generic "ThrowableMiddleware" for Exceptions and Errors together?

* @param \Exception|\Throwable $exception
* @param bool $displayErrorDetails
*/
public function __construct($exception, $displayErrorDetails);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interfaces should not have a constructor as you are limiting the ability to define implementation detail vs defining expected behaviour.

Instead, your methods render and renderWithBody should accept the exception instance.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing that out, I will refactor the interface accordingly!

@l0gicgate
Copy link
Member Author

@odan

  • I am going to replace all instances of PHP_EOL to \n. Thanks for pointing that out.
  • The App class does not contain setErrorHandler() and setDefaultErrorHandler() anymore. The first commit on this PR removes all that.
  • Definitely open for proper naming suggestion for the middleware's name. It does handle both Exception and Throwble by default.

Thank you for reviewing this PR!

@edudobay
Copy link
Contributor

Hi! As I started to point out at #2393, I believe that there should be a separate writeToErrorLog (or similar) setting, that doesn’t need to subordinate to displayErrorDetails. A couple reasons for that:

  • Newcomers might be confused when they turn of display of error details and things stop going to the error log.
  • Anyways we might need to keep a log of errors even though they were displayed to the client (maybe the client was an API so we couldn’t really see the output).
  • Writing to a log and rendering the error to the client seem like separate concerns to me. These could even be dealt with by separate classes; but anyway it seems to me that it would be more logical to have separate settings for separate (independent) behaviors.

If you agree with these concerns, you can refer to #2393 for some remarks I’ve already made and I’ll be happy to help with the implementation. (I already have a draft but it is written for Slim 3.)

protected function writeToErrorLog()
{
$renderer = new PlainTextErrorRenderer();
$error = $renderer->render($this->exception, $this->displayErrorDetails);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the details should always be displayed in the error log? To me it doesn’t make sense to log a generic "Application error" without details.

(Maybe we are starting to cross boundaries and violating SRP as @odan pointed out?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do have a point. I can modify this so it’s always displaying the error details when writing to log if everyone is in favor of this option!

@odan @designermonkey @akrabat @asheliahut @geggleto

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a production server I have to log all errors (into a file), but for security reasons I am not allowed to show the users any error details. Therefore, it is still necessary to be able to configure both options (displayErrorDetails and logging) independently of each other.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For more advanced requirements, I expect that the user should be able to easily replace Slim's error handling with their own.

I don't want this component to turn into a massive thing as we're Slim! However, we should be reasonably flexible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So how about I decouple $displayErrorDetails from the logging and add another parameter $logErrorDetails which can be enabled or disabled. So for instance you may not want to display the error details to the end user but you may want to have them displayed in your log.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@l0gicgate Sounds good

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@odan I've implemented $logErrorDetails in this commit

@akrabat
Copy link
Member

akrabat commented Feb 19, 2018

/*
 * The routing middleware should always be added first
 * before the error handling middleware is added
 */

Why? I want the error handling middleware to be the outermost middleware as there will be other middleware before routing that I want the error handler to pick up.

@l0gicgate
Copy link
Member Author

@akrabat what I meant by that is ErrorMiddleware should be the last one to be added. In the example RoutingMiddleware should be added first, but I don't mean that it should be added first in your whole application.

@akrabat akrabat added the Slim 4 label Feb 25, 2018
Slim/App.php Outdated
@@ -106,6 +87,7 @@ public function __construct(array $settings = [], ContainerInterface $container
* Get container
*
* @return ContainerInterface|null
* @codeCoverageIgnore
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we ignoring code coverage for methods in App?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed and added tests for those methods.

Slim/App.php Outdated
*/
protected $notFoundHandler;
protected $request;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not want $request or $response as properties on App. They were intentionally removed from Slim 3's DIC because they are immutable and people use them as if they aren't.

This also means that I don't want set/getRequest() and set/getResponse().

Also, run() and process() are separate for a reason: it allows someone to inject their own request and response when running Silm.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed and modified run() signature to take in an optional $request parameter for test purposes.

Copy link
Member

@akrabat akrabat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A number of bits and bobs in here to look at @l0gicgate.

If you'd like me to raise a PR against this one addressing them, let me know.

{
/**
* @var bool
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a gap between each property. It just looks nicer. This applies to all files.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
{
$this->request = $request;
$this->response = $response;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not store response. Each renderer should return a response that they have created.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -60,6 +65,21 @@ public function performRouting(ServerRequestInterface $request)

// add route to the request's attributes
$request = $request->withAttribute('route', $route);
} elseif ($routeInfo[0] === Dispatcher::NOT_FOUND) {
$exception = new HttpNotFoundException();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call $exception->setRequest($request); and then throw directly here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

$exception = new HttpNotFoundException();
} elseif ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
$exception = new HttpNotAllowedException();
$exception->setAllowedMethods($routeInfo[1]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call $exception->setRequest($request); and then throw directly here.

I know it's a duplication from above, but will be clearer to read and remove the test for not null below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

/**
* Retrieve request object from exception and replace current request object if not null
*/
if (method_exists($exception, 'getRequest')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test for an instance of HttpException. That way we can be sure that the Request object in getRequest() is a PSR7 Request object.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

<?php
namespace Slim\Exception;

class HttpNotAllowedException extends HttpException
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename to HttpMethodNotAllowedException.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

$response = $this->response;
$body = $this->renderer->renderWithBody($this->exception, $this->displayErrorDetails);

if ($this->exception instanceof HttpNotAllowedException) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the exception should return a response, this can go into the exception itself.

* It outputs the error message and diagnostic information in one of the following formats:
* JSON, XML, Plain Text or HTML based on the Accept header.
*/
class ErrorHandler extends AbstractErrorHandler
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would rather have an interface and rename AbstractErrorHandler to ErrorHandler

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

*/
public function __invoke(
ServerRequestInterface $request,
ResponseInterface $response,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response not required.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, what if during the middleware progression a user did attach headers to the response, example if a CORS middleware appended headers, wouldn't we want to have the response object that reflects the correct state at the time the time the exception was thrown?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does it come from given that the error handler is called from the catch() of the Middleware and that we won't have $response passed into the middleware in PSR-15?

* @package Slim
* @since 4.0.0
*/
interface ErrorRendererInterface
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we just need render which returns a response.

@akrabat akrabat merged commit 8cefb1c into slimphp:4.x Mar 12, 2018
akrabat added a commit that referenced this pull request Mar 12, 2018
l0gicgate added a commit to l0gicgate/Slim that referenced this pull request Mar 13, 2018
l0gicgate added a commit to l0gicgate/Slim that referenced this pull request May 15, 2018
l0gicgate added a commit to l0gicgate/Slim that referenced this pull request May 17, 2018
@l0gicgate l0gicgate deleted the 4.x-ErrorMiddleware branch July 13, 2018 18:02
@l0gicgate l0gicgate mentioned this pull request Apr 25, 2019
@l0gicgate l0gicgate mentioned this pull request Aug 1, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants