Add functionality for converting exceptions to HTTP responses

[MAILPOET-2900]
This commit is contained in:
Jan Jakeš
2020-05-07 18:39:35 +02:00
committed by Veljko V
parent d11dc98190
commit 3f78c82c51
7 changed files with 153 additions and 10 deletions

View File

@ -4,6 +4,7 @@ namespace MailPoet\API\JSON;
use MailPoet\API\JSON\Endpoint; use MailPoet\API\JSON\Endpoint;
use MailPoet\Config\AccessControl; use MailPoet\Config\AccessControl;
use MailPoet\Exception;
use MailPoet\Settings\SettingsController; use MailPoet\Settings\SettingsController;
use MailPoet\Subscription\Captcha; use MailPoet\Subscription\Captcha;
use MailPoet\Tracy\ApiPanel\ApiPanel; use MailPoet\Tracy\ApiPanel\ApiPanel;
@ -12,6 +13,7 @@ use MailPoet\Util\Helpers;
use MailPoet\Util\Security; use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions; use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Psr\Container\ContainerInterface; use MailPoetVendor\Psr\Container\ContainerInterface;
use Throwable;
use Tracy\Debugger; use Tracy\Debugger;
use Tracy\ILogger; use Tracy\ILogger;
@ -33,6 +35,9 @@ class API {
/** @var AccessControl */ /** @var AccessControl */
private $accessControl; private $accessControl;
/** @var ErrorHandler */
private $errorHandler;
/** @var WPFunctions */ /** @var WPFunctions */
private $wp; private $wp;
@ -44,11 +49,13 @@ class API {
public function __construct( public function __construct(
ContainerInterface $container, ContainerInterface $container,
AccessControl $accessControl, AccessControl $accessControl,
ErrorHandler $errorHandler,
SettingsController $settings, SettingsController $settings,
WPFunctions $wp WPFunctions $wp
) { ) {
$this->container = $container; $this->container = $container;
$this->accessControl = $accessControl; $this->accessControl = $accessControl;
$this->errorHandler = $errorHandler;
$this->settings = $settings; $this->settings = $settings;
$this->wp = $wp; $this->wp = $wp;
foreach ($this->availableApiVersions as $availableApiVersion) { foreach ($this->availableApiVersions as $availableApiVersion) {
@ -192,7 +199,9 @@ class API {
} }
$response = $endpoint->{$this->requestMethod}($this->requestData); $response = $endpoint->{$this->requestMethod}($this->requestData);
return $response; return $response;
} catch (\Exception $e) { } catch (Exception $e) {
return $this->errorHandler->convertToResponse($e);
} catch (Throwable $e) {
if (class_exists(Debugger::class) && Debugger::$logDirectory) { if (class_exists(Debugger::class) && Debugger::$logDirectory) {
Debugger::log($e, ILogger::EXCEPTION); Debugger::log($e, ILogger::EXCEPTION);
} }

View File

@ -0,0 +1,27 @@
<?php declare(strict_types = 1);
namespace MailPoet\API\JSON;
use MailPoet\Exception;
use MailPoet\HttpAwareException;
use MailPoet\WP\Functions as WPFunctions;
class ErrorHandler {
/** @var string[] */
private $defaultErrors;
public function __construct(WPFunctions $wp) {
$this->defaultErrors = [
Error::UNKNOWN => $wp->__('An unknown error occurred.', 'mailpoet'),
];
}
public function convertToResponse(\Throwable $e): ErrorResponse {
if ($e instanceof Exception) {
$errors = $e->getErrors() ?: $this->defaultErrors;
$statusCode = $e instanceof HttpAwareException ? $e->getHttpStatusCode() : Response::STATUS_UNKNOWN;
return new ErrorResponse($errors, [], $statusCode);
}
return new ErrorResponse($this->defaultErrors, [], Response::STATUS_UNKNOWN);
}
}

View File

@ -59,6 +59,7 @@ class ContainerConfigurator implements IContainerConfigurator {
->addArgument(new Reference(ContainerWrapper::class)) ->addArgument(new Reference(ContainerWrapper::class))
->setAutowired(true) ->setAutowired(true)
->setPublic(true); ->setPublic(true);
$container->autowire(\MailPoet\API\JSON\ErrorHandler::class)->setPublic(true);
$container->autowire(\MailPoet\API\MP\v1\API::class)->setPublic(true); $container->autowire(\MailPoet\API\MP\v1\API::class)->setPublic(true);
$container->autowire(\MailPoet\API\JSON\v1\Analytics::class)->setPublic(true); $container->autowire(\MailPoet\API\JSON\v1\Analytics::class)->setPublic(true);
$container->autowire(\MailPoet\API\JSON\v1\AutomatedLatestContent::class)->setPublic(true); $container->autowire(\MailPoet\API\JSON\v1\AutomatedLatestContent::class)->setPublic(true);

View File

@ -3,6 +3,14 @@
// phpcs:ignoreFile PSR1.Classes.ClassDeclaration // phpcs:ignoreFile PSR1.Classes.ClassDeclaration
namespace MailPoet; namespace MailPoet;
/**
* Provides information for converting exceptions to HTTP responses.
*/
interface HttpAwareException {
public function getHttpStatusCode(): int;
}
/** /**
* Frames all MailPoet exceptions ("$e instanceof MailPoet\Exception"). * Frames all MailPoet exceptions ("$e instanceof MailPoet\Exception").
*/ */
@ -41,35 +49,57 @@ abstract class Exception extends \Exception {
/** /**
* USE: Generic runtime error. When possible, use a more specific exception instead. * USE: Generic runtime error. When possible, use a more specific exception instead.
* API: 500 Server Error (not HTTP-aware)
*/ */
class RuntimeException extends Exception {} class RuntimeException extends Exception {}
/** /**
* USE: When wrong data VALUE is received. * USE: When wrong data VALUE is received.
* API: 400 Bad Request
*/ */
class UnexpectedValueException extends RuntimeException {} class UnexpectedValueException extends RuntimeException implements HttpAwareException {
public function getHttpStatusCode(): int {
return 400;
}
}
/** /**
* USE: When an action is forbidden for given actor (although generally valid). * USE: When an action is forbidden for given actor (although generally valid).
* API: 403 Forbidden
*/ */
class AccessDeniedException extends UnexpectedValueException {} class AccessDeniedException extends UnexpectedValueException implements HttpAwareException {
public function getHttpStatusCode(): int {
return 403;
}
}
/** /**
* USE: When the main resource we're interested in doesn't exist. * USE: When the main resource we're interested in doesn't exist.
* API: 404 Not Found
*/ */
class NotFoundException extends UnexpectedValueException {} class NotFoundException extends UnexpectedValueException implements HttpAwareException {
public function getHttpStatusCode(): int {
return 404;
}
}
/** /**
* USE: When the main action produces conflict (i.e. duplicate key). * USE: When the main action produces conflict (i.e. duplicate key).
* API: 409 Conflict
*/ */
class ConflictException extends UnexpectedValueException {} class ConflictException extends UnexpectedValueException implements HttpAwareException {
public function getHttpStatusCode(): int {
return 409;
}
}
/** /**
* USE: An application state that should not occur. Can be subclassed for feature-specific exceptions. * USE: An application state that should not occur. Can be subclassed for feature-specific exceptions.
* API: 500 Server Error (not HTTP-aware)
*/ */
class InvalidStateException extends RuntimeException {} class InvalidStateException extends RuntimeException {}

View File

@ -6,6 +6,7 @@ use Codeception\Stub;
use Codeception\Stub\Expected; use Codeception\Stub\Expected;
use MailPoet\API\JSON\API as JSONAPI; use MailPoet\API\JSON\API as JSONAPI;
use MailPoet\API\JSON\Endpoint; use MailPoet\API\JSON\Endpoint;
use MailPoet\API\JSON\ErrorHandler;
use MailPoet\API\JSON\Response; use MailPoet\API\JSON\Response;
use MailPoet\API\JSON\Response as APIResponse; use MailPoet\API\JSON\Response as APIResponse;
use MailPoet\API\JSON\SuccessResponse; use MailPoet\API\JSON\SuccessResponse;
@ -29,6 +30,9 @@ class APITest extends \MailPoetTest {
/** @var Container */ /** @var Container */
private $container; private $container;
/** @var ErrorHandler */
private $errorHandler;
/** @var SettingsController */ /** @var SettingsController */
private $settings; private $settings;
@ -48,10 +52,12 @@ class APITest extends \MailPoetTest {
$this->container->autowire(APITestNamespacedEndpointStubV1::class)->setPublic(true); $this->container->autowire(APITestNamespacedEndpointStubV1::class)->setPublic(true);
$this->container->autowire(APITestNamespacedEndpointStubV2::class)->setPublic(true); $this->container->autowire(APITestNamespacedEndpointStubV2::class)->setPublic(true);
$this->container->compile(); $this->container->compile();
$this->errorHandler = $this->container->get(ErrorHandler::class);
$this->settings = $this->container->get(SettingsController::class); $this->settings = $this->container->get(SettingsController::class);
$this->api = new \MailPoet\API\JSON\API( $this->api = new \MailPoet\API\JSON\API(
$this->container, $this->container,
$this->container->get(AccessControl::class), $this->container->get(AccessControl::class),
$this->errorHandler,
$this->settings, $this->settings,
new WPFunctions new WPFunctions
); );
@ -142,6 +148,25 @@ class APITest extends \MailPoetTest {
expect($response->getData()['data'])->equals($data['data']); expect($response->getData()['data'])->equals($data['data']);
} }
public function testItConvertsExceptionToErrorResponse() {
$namespace = [
'name' => 'MailPoet\API\JSON\v1',
'version' => 'v1',
];
$this->api->addEndpointNamespace($namespace['name'], $namespace['version']);
$data = [
'endpoint' => 'a_p_i_test_namespaced_endpoint_stub_v1',
'method' => 'testBadRequest',
'api_version' => 'v1',
'data' => ['test' => 'data'],
];
$this->api->setRequestData($data, Endpoint::TYPE_POST);
$response = $this->api->processRoute();
expect($response->errors)->equals([['error' => 'key', 'message' => 'value']]);
}
public function testItCallsAddedEndpointsForSpecificAPIVersion() { public function testItCallsAddedEndpointsForSpecificAPIVersion() {
$namespace = [ $namespace = [
'name' => 'MailPoet\API\JSON\v2', 'name' => 'MailPoet\API\JSON\v2',
@ -211,7 +236,7 @@ class APITest extends \MailPoetTest {
['validatePermission' => false] ['validatePermission' => false]
); );
$api = new JSONAPI($this->container, $accessControl, $this->settings, new WPFunctions); $api = new JSONAPI($this->container, $accessControl, $this->errorHandler, $this->settings, new WPFunctions);
$api->addEndpointNamespace($namespace['name'], $namespace['version']); $api->addEndpointNamespace($namespace['name'], $namespace['version']);
$api->setRequestData($data, Endpoint::TYPE_POST); $api->setRequestData($data, Endpoint::TYPE_POST);
$response = $api->processRoute(); $response = $api->processRoute();
@ -233,7 +258,7 @@ class APITest extends \MailPoetTest {
] ]
); );
$api = new JSONAPI($this->container, $accessControl, $this->settings, new WPFunctions); $api = new JSONAPI($this->container, $accessControl, $this->errorHandler, $this->settings, new WPFunctions);
expect($api->validatePermissions(null, $permissions))->false(); expect($api->validatePermissions(null, $permissions))->false();
$accessControl = Stub::make( $accessControl = Stub::make(
@ -245,7 +270,7 @@ class APITest extends \MailPoetTest {
}), }),
] ]
); );
$api = new JSONAPI($this->container, $accessControl, $this->settings, new WPFunctions); $api = new JSONAPI($this->container, $accessControl, $this->errorHandler, $this->settings, new WPFunctions);
expect($api->validatePermissions(null, $permissions))->true(); expect($api->validatePermissions(null, $permissions))->true();
} }
@ -267,7 +292,7 @@ class APITest extends \MailPoetTest {
] ]
); );
$api = new JSONAPI($this->container, $accessControl, $this->settings, new WPFunctions); $api = new JSONAPI($this->container, $accessControl, $this->errorHandler, $this->settings, new WPFunctions);
expect($api->validatePermissions('test', $permissions))->false(); expect($api->validatePermissions('test', $permissions))->false();
$accessControl = Stub::make( $accessControl = Stub::make(
@ -280,7 +305,7 @@ class APITest extends \MailPoetTest {
] ]
); );
$api = new JSONAPI($this->container, $accessControl, $this->settings, new WPFunctions); $api = new JSONAPI($this->container, $accessControl, $this->errorHandler, $this->settings, new WPFunctions);
expect($api->validatePermissions('test', $permissions))->true(); expect($api->validatePermissions('test', $permissions))->true();
} }

View File

@ -4,6 +4,7 @@ namespace MailPoet\API\JSON\v1;
use MailPoet\API\JSON\Endpoint as APIEndpoint; use MailPoet\API\JSON\Endpoint as APIEndpoint;
use MailPoet\Config\AccessControl; use MailPoet\Config\AccessControl;
use MailPoet\UnexpectedValueException;
class APITestNamespacedEndpointStubV1 extends APIEndpoint { class APITestNamespacedEndpointStubV1 extends APIEndpoint {
public $permissions = [ public $permissions = [
@ -18,6 +19,10 @@ class APITestNamespacedEndpointStubV1 extends APIEndpoint {
return $this->successResponse($data); return $this->successResponse($data);
} }
public function testBadRequest($data) {
throw UnexpectedValueException::create()->withErrors(['key' => 'value']);
}
public function restricted($data) { public function restricted($data) {
return $this->successResponse($data); return $this->successResponse($data);
} }

View File

@ -0,0 +1,46 @@
<?php declare(strict_types = 1);
namespace MailPoet\API\JSON;
use MailPoet\AccessDeniedException;
use MailPoet\ConflictException;
use MailPoet\Exception;
use MailPoet\InvalidStateException;
use MailPoet\NotFoundException;
use MailPoet\RuntimeException;
use MailPoet\UnexpectedValueException;
use MailPoet\WP\Functions as WPFunctions;
class ErrorHandlerTest extends \MailPoetUnitTest {
public function testItCovertsToBadRequest() {
$this->runErrorHandlerTest(new UnexpectedValueException(), 400);
}
public function testItCovertsToForbidden() {
$this->runErrorHandlerTest(new AccessDeniedException(), 403);
}
public function testItCovertsToNotFound() {
$this->runErrorHandlerTest(new NotFoundException(), 404);
}
public function testItCovertsToConflict() {
$this->runErrorHandlerTest(new ConflictException(), 409);
}
public function testItCovertsToServerError() {
$this->runErrorHandlerTest(new RuntimeException(), 500);
$this->runErrorHandlerTest(new InvalidStateException(), 500);
}
private function runErrorHandlerTest(Exception $exception, int $expectedCode) {
$errorHandler = new ErrorHandler(new WPFunctions());
$response = $errorHandler->convertToResponse($exception->withErrors([
'key' => 'value',
]));
expect($response)->isInstanceOf(ErrorResponse::class);
expect($response->status)->equals($expectedCode);
expect($response->errors)->equals([['error' => 'key', 'message' => 'value']]);
}
}