Skip to content

Commit

Permalink
Allow top-level error handler within dispatcher (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
Firehed authored May 21, 2018
1 parent 43a7332 commit b71f841
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 11 deletions.
31 changes: 29 additions & 2 deletions src/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@

use BadMethodCallException;
use DomainException;
use Firehed\API\Interfaces\ErrorHandlerInterface;
use Firehed\Common\ClassMapper;
use Firehed\Input\Containers\ParsedInput;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
use OutOfBoundsException;
use UnexpectedValueException;

Expand Down Expand Up @@ -64,6 +67,19 @@ public function setContainer(ContainerInterface $container = null): self
return $this;
}

/**
* Provide a default error handler. This will be used in the event that an
* endpoint does not define its own handler.
*
* @param ErrorHandlerInterface $handler
* @return self
*/
public function setErrorHandler(ErrorHandlerInterface $handler): self
{
$this->error_handler = $handler;
return $this;
}

/**
* Inject the request
*
Expand Down Expand Up @@ -135,8 +151,19 @@ public function dispatch(): ResponseInterface
->validate($endpoint);

$response = $endpoint->execute($safe_input);
} catch (\Throwable $e) {
$response = $endpoint->handleException($e);
} catch (Throwable $e) {
try {
$response = $endpoint->handleException($e);
} catch (Throwable $e) {
// If an application-wide handler has been defined, use the
// response that it generates. If not, just rethrow the
// exception for the system default (if defined) to handle.
if ($this->error_handler && $this->request instanceof ServerRequestInterface) {
$response = $this->error_handler->handle($this->request, $e);
} else {
throw $e;
}
}
}
return $this->executeResponseMiddleware($response);
}
Expand Down
5 changes: 5 additions & 0 deletions src/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
use Psr\Log\LoggerInterface;
use Throwable;

/**
* This will be deprecated in the next minor release and removed in the next
* major release. Instead, prefer to provide an ErrorHandlerInterface to the
* Dispatcher.
*/
class ErrorHandler
{
/** @var LoggerInterface */
Expand Down
18 changes: 18 additions & 0 deletions src/Interfaces/ErrorHandlerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);

namespace Firehed\API\Interfaces;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;

interface ErrorHandlerInterface
{
/**
* Handle an exception for the given request. It is RECOMMENDED that
* implementations use the $request's accept header to determine how best
* to format the response.
*/
public function handle(ServerRequestInterface $request, Throwable $t): ResponseInterface;
}
127 changes: 118 additions & 9 deletions tests/DispatcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
namespace Firehed\API;

use Exception;
use Firehed\API\Interfaces\EndpointInterface;
use Firehed\API\Interfaces\ErrorHandlerInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Firehed\API\Interfaces\EndpointInterface;
use Throwable;

/**
* @coversDefaultClass Firehed\API\Dispatcher
Expand Down Expand Up @@ -335,7 +338,7 @@ public function testErrorInResponseHandler()
"The exception thrown from the error handler's failure should ".
"have made it through"
);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->assertSame(
$error,
$e,
Expand Down Expand Up @@ -450,7 +453,7 @@ public function testFailedAuthenticationReachesErrorHandler()
}

/** @covers ::dispatch */
public function testFailedEndpointExecutionReachesErrorHandler()
public function testFailedEndpointExecutionReachesEndpointErrorHandler()
{
$e = new Exception('This should reach the error handler');
$endpoint = $this->getMockEndpoint();
Expand Down Expand Up @@ -487,6 +490,102 @@ public function testInvalidTypeResponseFromEndpointReachesErrorHandler()
$this->executeMockRequestOnEndpoint($endpoint);
}

/**
* @covers ::dispatch
* @covers ::setErrorHandler
*/
public function testExceptionsReachDefaultErrorHandlerWhenSet()
{
$e = new Exception('This should reach the main error handler');
$res = $this->createMock(ResponseInterface::class);
$cb = function ($req, $ex) use ($e, $res) {
$this->assertSame($e, $ex, 'A different exception reached the handler');

return $res;
};

$handler = $this->createMock(ErrorHandlerInterface::class);
$handler->expects($this->once())
->method('handle')
->will($this->returnCallback($cb));

$dispatcher = new Dispatcher();
$this->assertSame(
$dispatcher,
$dispatcher->setErrorHandler($handler),
'setErrorHandler should return $this'
);

$endpoint = $this->getMockEndpoint();
$endpoint->method('execute')
->will($this->throwException($e));
$endpoint->expects($this->once())
->method('handleException')
->with($e)
->will($this->throwException($e));
$this->executeMockRequestOnEndpoint($endpoint, $dispatcher, ServerRequestInterface::class);
}

/**
* This is a sort of BC-prevention test: in v4, the Dispatcher will only
* accept a ServerRequestInterface instead of the base-level
* RequestInterface. The new error handler is typehinted as such. This
* checks that if the base class was provided to the dispatcher, it
* shouldn't attempt to use the error handler since it would just result in
* a TypeError. This will be removed in v4.
*
* @covers ::dispatch
*/
public function testExceptionsLeakWhenRequestIsBaseClass()
{
$e = new Exception('This should reach the top level');

$handler = $this->createMock(ErrorHandlerInterface::class);
$handler->expects($this->never())
->method('handle');

$dispatcher = new Dispatcher();
$dispatcher->setErrorHandler($handler);

$endpoint = $this->getMockEndpoint();
$endpoint->method('execute')
->will($this->throwException($e));
$endpoint->expects($this->once())
->method('handleException')
->with($e)
->will($this->throwException($e));
try {
$this->executeMockRequestOnEndpoint($endpoint, $dispatcher);
$this->fail('An exception should have been thrown');
} catch (Throwable $t) {
$this->assertSame($e, $t);
}
}

/** @covers ::dispatch */
public function testExceptionsLeakWhenNoErrorHandler()
{
$e = new Exception('This should reach the top level');

$endpoint = $this->getMockEndpoint();
$endpoint->method('execute')
->will($this->throwException($e));
// This is a quasi-v4 endpoint: one where the endpoint's exception
// handler just rethrows the exception. This should be the same as not
// choosing to have an endpoint handle exeptions directly in v4.
$endpoint->expects($this->once())
->method('handleException')
->with($e)
->will($this->throwException($e));

try {
$this->executeMockRequestOnEndpoint($endpoint);
$this->fail('An exception should have been thrown');
} catch (Throwable $t) {
$this->assertSame($e, $t, 'A different exception was thrown');
}
}

// ----(Helper methods)----------------------------------------------------

/**
Expand All @@ -510,12 +609,14 @@ private function checkResponse(ResponseInterface $response, int $expected_code)
* @param string $uri path component of URI
* @param string $method optional HTTP method
* @param array $query_data optional raw, unescaped query string data
* @param string $requestClass What RequestInterface to mock
* @return RequestInterface | \PHPUnit\Framework\MockObject\MockObject
*/
private function getMockRequestWithUriPath(
string $uri,
string $method = 'GET',
array $query_data = []
array $query_data = [],
string $requestClass = RequestInterface::class
): RequestInterface {
$mock_uri = $this->createMock(UriInterface::class);
$mock_uri->expects($this->any())
Expand All @@ -524,7 +625,8 @@ private function getMockRequestWithUriPath(
$mock_uri->method('getQuery')
->will($this->returnValue(http_build_query($query_data)));

$req = $this->createMock(RequestInterface::class);
/** @var RequestInterface | \PHPUnit\Framework\MockObject\MockObject */
$req = $this->createMock($requestClass);

$req->expects($this->any())
->method('getUri')
Expand Down Expand Up @@ -557,17 +659,24 @@ private function getMockEndpoint(): EndpointInterface
* Run the endpointwith an empty request
*
* @param EndpointInterface $endpoint the endpoint to test
* @param ?Dispatcher $dispatcher a configured dispatcher
* @return ResponseInterface the endpoint response
*/
private function executeMockRequestOnEndpoint(EndpointInterface $endpoint): ResponseInterface
{
$req = $this->getMockRequestWithUriPath('/container', 'GET');
private function executeMockRequestOnEndpoint(
EndpointInterface $endpoint,
Dispatcher $dispatcher = null,
string $requestClass = RequestInterface::class
): ResponseInterface {
$req = $this->getMockRequestWithUriPath('/container', 'GET', [], $requestClass);
$list = [
'GET' => [
'/container' => 'ClassThatDoesNotExist',
],
];
$response = (new Dispatcher())
if (!$dispatcher) {
$dispatcher = new Dispatcher();
}
$response = $dispatcher
->setContainer($this->getMockContainer(['ClassThatDoesNotExist' => $endpoint]))
->setEndpointList($list)
->setParserList($this->getDefaultParserList())
Expand Down

0 comments on commit b71f841

Please sign in to comment.