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

Conversation

Projects
None yet
7 participants
@l0gicgate
Copy link
Contributor

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

This comment has been minimized.

Copy link

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

@asheliahut
Copy link

left a comment

Just some thoughts.

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

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

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

@@ -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)) {

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

should stay ===

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

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

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())) {

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

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())) {

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

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

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

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

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.

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

either-> one of the following

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

This comment has been minimized.

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

This comment has been minimized.

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

This comment has been minimized.

Copy link
@asheliahut

asheliahut Feb 15, 2018

either should be "one of the following"

@l0gicgate

This comment has been minimized.

Copy link
Contributor Author

commented Feb 15, 2018

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;

This comment has been minimized.

Copy link
@odan

odan Feb 17, 2018

Contributor

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

*
* @return ErrorRendererInterface
*
* @throws \RuntimeException

This comment has been minimized.

Copy link
@odan

odan Feb 17, 2018

Contributor

Add use RuntimeException on top to remove the backslash.

{
$renderer = $this->renderer;
if ((

This comment has been minimized.

Copy link
@odan

odan Feb 17, 2018

Contributor

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;

This comment has been minimized.

Copy link
@odan

odan Feb 17, 2018

Contributor

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

This comment has been minimized.

Copy link
@odan

odan Feb 17, 2018

Contributor

What for values can be passed here?

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 17, 2018

Author Contributor

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

@odan

This comment has been minimized.

Copy link
Contributor

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

This comment has been minimized.

Copy link
Contributor

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);

This comment has been minimized.

Copy link
@designermonkey

designermonkey Feb 17, 2018

Member

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.

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 17, 2018

Author Contributor

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

@l0gicgate

This comment has been minimized.

Copy link
Contributor Author

commented Feb 17, 2018

@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

This comment has been minimized.

Copy link

commented Feb 17, 2018

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.)

@l0gicgate

This comment has been minimized.

Copy link
Contributor Author

commented Feb 17, 2018

@designermonkey I refactored the ErrorRendererInterface after our discussion in this commit I however did not split the methods for displaying/not displaying error details.

@akrabat akrabat added this to the 4.0 milestone Feb 18, 2018

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

This comment has been minimized.

Copy link
@edudobay

edudobay Feb 18, 2018

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?)

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 19, 2018

Author Contributor

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

This comment has been minimized.

Copy link
@odan

odan Feb 19, 2018

Contributor

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.

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 19, 2018

Member

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.

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 19, 2018

Author Contributor

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.

This comment has been minimized.

Copy link
@odan

odan Feb 19, 2018

Contributor

@l0gicgate Sounds good

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 19, 2018

Author Contributor

@odan I've implemented $logErrorDetails in this commit

@akrabat

This comment has been minimized.

Copy link
Member

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

This comment has been minimized.

Copy link
Contributor Author

commented Feb 19, 2018

@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

@@ -106,6 +87,7 @@ public function __construct(array $settings = [], ContainerInterface $container
* Get container
*
* @return ContainerInterface|null
* @codeCoverageIgnore

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

Why are we ignoring code coverage for methods in App?

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

Removed and added tests for those methods.

*/
protected $notFoundHandler;
protected $request;

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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.

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

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

@akrabat
Copy link
Member

left a comment

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
*/

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

Done.

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

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

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();

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

Done.

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

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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.

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

Done.

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

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

Done.

<?php
namespace Slim\Exception;
class HttpNotAllowedException extends HttpException

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

Rename to HttpMethodNotAllowedException.

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

Done.

$response = $this->response;
$body = $this->renderer->renderWithBody($this->exception, $this->displayErrorDetails);
if ($this->exception instanceof HttpNotAllowedException) {

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

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

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

Would rather have an interface and rename AbstractErrorHandler to ErrorHandler

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

Done.

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

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

Response not required.

This comment has been minimized.

Copy link
@l0gicgate

l0gicgate Feb 26, 2018

Author Contributor

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?

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 26, 2018

Member

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

This comment has been minimized.

Copy link
@akrabat

akrabat Feb 25, 2018

Member

I think we just need render which returns a response.

@akrabat akrabat merged commit 8cefb1c into slimphp:4.x Mar 12, 2018

2 checks passed

continuous-integration/travis-ci/pr The Travis CI build passed
Details
coverage/coveralls Coverage increased (+1.1%) to 96.055%
Details

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 l0gicgate:4.x-ErrorMiddleware branch Jul 13, 2018

@l0gicgate l0gicgate referenced this pull request Apr 25, 2019

Merged

Slim 4 Alpha Release #2665

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.