From bfa2f3b57e324cb6996e454cab4da7ccb844fd25 Mon Sep 17 00:00:00 2001 From: ferishili Date: Mon, 16 Dec 2024 14:54:55 +0100 Subject: [PATCH] improved opencast api exceptions and handlers, This PR fixes #56 --- classes/api/handler/api_handler_stack.php | 126 ++++++++++++++++++ classes/api/middleware/api_middlewares.php | 100 ++++++++++++++ .../opencast_api_http_errors_exception.php | 55 ++++++++ .../opencast_api_response_exception.php | 61 +++++++++ classes/local/api.php | 5 +- lang/en/tool_opencast.php | 20 ++- 6 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 classes/api/handler/api_handler_stack.php create mode 100644 classes/api/middleware/api_middlewares.php create mode 100644 classes/exception/opencast_api_http_errors_exception.php create mode 100644 classes/exception/opencast_api_response_exception.php diff --git a/classes/api/handler/api_handler_stack.php b/classes/api/handler/api_handler_stack.php new file mode 100644 index 0000000..c5c9555 --- /dev/null +++ b/classes/api/handler/api_handler_stack.php @@ -0,0 +1,126 @@ +. + +/** + * API Handler Stack class for Opencast API services. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_opencast\api\handler; + +use GuzzleHttp\HandlerStack; +use tool_opencast\api\middleware\api_middlewares; + +/** + * API Handler Stack class for Opencast API services. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class api_handler_stack { + + /** @var HandlerStack $handlerstack */ + private HandlerStack $handlerstack; + + /** + * Constructor of class api_handler_stack. + */ + public function __construct() { + // Get the default Guzzle Handlers. + $this->handlerstack = HandlerStack::create(); + $this->register_custom_handlers(); + } + + /** + * Registers custom handlers to the Guzzle HandlerStack. + * + * This method initially removes the default 'http_errors' handler and adds a custom handler + * for handling HTTP errors using the 'api_middlewares::http_errors()' middleware. + * + * @return void + */ + private function register_custom_handlers() { + // As for http errors, we use a custom handler. + $this->handlerstack->remove('http_errors'); + $this->handlerstack->unshift(api_middlewares::http_errors(), 'tool_opencast_http_errors'); + } + + /** + * Adds a new handler to the handler stack. + * + * This function adds a new middleware handler to the existing handler stack. + * The handler can be added either at the beginning or end of the stack. + * + * @param callable $middleware The middleware function to be added to the stack. + * @param string $name The name of the middleware for identification. + * @param bool $first Optional. If true, adds the middleware to the beginning of the stack. Default is true. + * + * @return bool Returns true if the handler was successfully added to the stack. + * + * @throws moodle_exception If the handler stack is empty or the handler cannot be added. + */ + public function add_handler_to_stack(callable $middleware, string $name, bool $first = true): bool { + if (!empty($this->handlerstack)) { + if ($first) { + $this->handlerstack->unshift($middleware, $name); + } else { + $this->handlerstack->push($middleware, $name); + } + return true; + } + throw new moodle_exception('exception_code_unabletoaddhandler', 'tool_opencast'); + } + + /** + * Removes a handler from the handler stack. + * + * This function attempts to remove a handler with the specified name from the handler stack. + * If the handler is found and successfully removed, it returns true. Otherwise, it returns false. + * + * @param string $name The name of the handler to be removed from the stack. + * + * @return bool Returns true if the handler was successfully removed, false otherwise. + */ + public function remove_handler_from_stack($name): bool { + $isremoved = false; + try { + if ($this->handlerstack && $this->handlerstack->findByName($name) !== false) { + $this->handlerstack->remove($name); + $isremoved = true; + } + } catch (\Throwable $th) { + return false; + } + return $isremoved; + } + + /** + * Retrieves the current handler stack. + * + * This method returns the HandlerStack object that contains all the registered middleware handlers. + * + * @return HandlerStack The current handler stack containing all registered middleware handlers. + */ + public function get_handler_stack() { + return $this->handlerstack; + } +} diff --git a/classes/api/middleware/api_middlewares.php b/classes/api/middleware/api_middlewares.php new file mode 100644 index 0000000..d79c3e2 --- /dev/null +++ b/classes/api/middleware/api_middlewares.php @@ -0,0 +1,100 @@ +. + +/** + * API Middlewares for Opencast API client. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_opencast\api\middleware; + +use GuzzleHttp\Exception\ConnectException; +use Psr\Http\Message\ResponseInterface; +use tool_opencast\exception\opencast_api_http_errors_exception; + +/** + * API Middlewares for Opencast API client. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class api_middlewares { + /** + * Middleware that throws exceptions for both 4xx and 5xx error as well as the cURL errors. + * + * @return callable(callable): callable Returns a function that accepts the next handler. + */ + public static function http_errors(): callable { + return static function (callable $handler): callable { + return static function ($request, array $options) use ($handler) { + // To get the 4xx and 5xx http errors, we need to check if the "http_errors" option is set. + $onfulfilled = empty($options['http_errors']) ? null : + static function (ResponseInterface $response) use ($request) { + $code = $response->getStatusCode(); + if ($code < 400) { + return $response; + } + $exceptionstringkey = \sprintf("exception_request_%s", $code); + if (!get_string_manager()->string_exists($exceptionstringkey, 'tool_opencast')) { + $exceptionstringkey = 'exception_request_generic'; + } + throw new opencast_api_http_errors_exception($exceptionstringkey, $code); + }; + + // This on rejected function would only get invoked if there is a connection error, mostly to catch cURL errors. + $onrejected = static function (\RuntimeException|string $reason) { + // No reason of any kind, we directly throw generic exception message. + if (empty($reason)) { + throw new opencast_api_http_errors_exception('exception_request_generic', 500); + } + + // As default we assume the generic exception messages and code. + $reasonstring = get_string('exception_connect_generic', 'tool_opencast'); + $code = 500; + + // When the exception is of type ConnectException, we extract the reason string and code. + if ($reason instanceof ConnectException) { + $reasonstring = $reason->getMessage(); + $code = $reason->getCode(); + } else if (is_string($reason)) { + // Otherwise, if the reason is a string, we take that as the reason. + $reasonstring = $reason; + } + + // In case the error is cURL, we try to make it more human readable. + if (preg_match('/cURL error (\d+):/', $reasonstring, $matches)) { + $curlerrornum = (int) $matches[1]; + $reasonstring = curl_strerror($curlerrornum); + } + + // At the end, we append the reason string to the "exception_connect" string! + $exceptionmessage = get_string('exception_connect', 'tool_opencast', $reasonstring); + // Throw the exception with message replacement, as we already got the message text. + throw new opencast_api_http_errors_exception($exceptionmessage, $code, true); + }; + + // Finally, we pass the above callable closures as promise fulfillment and rejection handlers. + return $handler($request, $options)->then($onfulfilled, $onrejected); + }; + }; + } +} diff --git a/classes/exception/opencast_api_http_errors_exception.php b/classes/exception/opencast_api_http_errors_exception.php new file mode 100644 index 0000000..264e71c --- /dev/null +++ b/classes/exception/opencast_api_http_errors_exception.php @@ -0,0 +1,55 @@ +. + +/** + * Opencast API HTTP Errors Exception. + * This is the exception mostly to be used in middlewares to find and replace the error message. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_opencast\exception; + +use moodle_exception; + +/** + * Opencast API HTTP Errors Exception. + * This is the exception mostly to be used in middlewares to find and replace the error message. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class opencast_api_http_errors_exception extends moodle_exception { + /** + * Constructor of class opencast_api_http_errors_exception. + * + * @param string $errorkey the error string key + * @param int $errorcodenum the error code + * @param bool $replacemessage the flag to determine whether to replace the errorkey with message. + */ + public function __construct(string $errorkey, int $errorcodenum, bool $replacemessage = false) { + $this->code = $errorcodenum; + parent::__construct($errorkey, 'tool_opencast'); + if ($replacemessage) { + $this->message = $errorkey; + } + } +} diff --git a/classes/exception/opencast_api_response_exception.php b/classes/exception/opencast_api_response_exception.php new file mode 100644 index 0000000..7604bdc --- /dev/null +++ b/classes/exception/opencast_api_response_exception.php @@ -0,0 +1,61 @@ +. + +/** + * Opencast API Response Exception. + * This should be used to throw exception when a response is made, in order to digest the response from Opencast API + * and to decide the best error message. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_opencast\exception; + +use moodle_exception; + +/** + * Opencast API Response Exception. + * This should be used to throw exception when a response is made, in order to digest the response from Opencast API + * and to decide the best error message. + * + * @package tool_opencast + * @copyright 2024 Farbod Zamani Boroujeni, ELAN e.V. + * @author Farbod Zamani Boroujeni + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class opencast_api_response_exception extends moodle_exception { + /** + * Constructor of class opencast_api_response_exception. + * + * @param array $response the response array that must contain the following: + * - reason: the reason for the exception + * - code: the exception/error code + * @param bool $replacemessage the flag to determine whether to replace the reason with message. + */ + public function __construct(array $response, bool $replacemessage = true) { + $reason = !empty($response['reason']) ? $response['reason'] : null; + $errorkey = !empty($reason) ? $reason : 'exception_request_generic'; + $this->code = isset($response['code']) ? $response['code'] : 500; + parent::__construct($errorkey, 'tool_opencast'); + // In case, the reason has already been set by middleware exception, we should show it as error message. + if (!empty($reason) && $replacemessage) { + $this->message = $reason; + } + } +} diff --git a/classes/local/api.php b/classes/local/api.php index acf1e70..4d9982f 100644 --- a/classes/local/api.php +++ b/classes/local/api.php @@ -27,6 +27,7 @@ namespace tool_opencast\local; use local_chunkupload\local\chunkupload_file; +use tool_opencast\api\handler\api_handler_stack; use tool_opencast\empty_configuration_exception; defined('MOODLE_INTERNAL') || die; @@ -279,6 +280,8 @@ public function __construct($instanceid = null, 'timeout' => (intval($this->timeout) / 1000), 'connect_timeout' => (intval($this->connecttimeout) / 1000), ]; + $apihandlerstack = new api_handler_stack(); + $config['handler'] = $apihandlerstack->get_handler_stack(); $this->opencastapi = new \OpencastApi\Opencast($config, [], $enableingest); $this->opencastrestclient = new \OpencastApi\Rest\OcRestClient($config); } @@ -620,7 +623,7 @@ public function connection_test_credentials() { // If the credentials are invalid, return a corresponding http code. if (!$userinfo) { - return 400; // Bad Request. + return !empty($response['code']) ? $response['code'] : 400; // Bad Request. } // If the connection fails or the Opencast instance could not be found, return the http code. diff --git a/lang/en/tool_opencast.php b/lang/en/tool_opencast.php index 30d25ea..4f38048 100644 --- a/lang/en/tool_opencast.php +++ b/lang/en/tool_opencast.php @@ -26,8 +26,8 @@ defined('MOODLE_INTERNAL') || die(); $string['addinstance'] = 'Add instance'; -$string['apicreadentialstestfailedshort'] = 'Opencast API User Credentials test failed with http code: {$a}'; $string['apicreadentialstestfailedlong'] = 'The given Username or Password for the Opencast API is not valid.
Please use valid Username and Password in order to avoid fatal error during tasks which use this setting.'; +$string['apicreadentialstestfailedshort'] = 'Opencast API User Credentials test failed with http code: {$a}'; $string['apicreadentialstestsuccessfulshort'] = 'Opencast API User Credentials test successful.'; $string['apipassword'] = 'Password of Opencast API user'; $string['apipassworddesc'] = 'Configure the password of the Opencast user who is used to do the Opencast API calls.'; @@ -53,6 +53,24 @@ The deletion will be performed after you click on \'Save changes\' on the main settings page.'; $string['demoservernotification'] = 'The Opencast API tool is currently configured to connect to the public Opencast demo server. You can use this Opencast server for evaluating this plugin.
Do not use it for any production purposes. Please setup your own Opencast server instead.'; $string['errornumdefaultinstances'] = 'There must be exactly one default Opencast instance.'; +$string['exception_code_unabletoaddhandler'] = 'There was an error loading the opencast api middleware, must be fixed by a developer.'; +$string['exception_connect'] = 'Opencast API call failed: {$a}'; +$string['exception_connect_generic'] = 'Opencast is unreachable due to a connection error.'; +$string['exception_request_400'] = 'Unexpected Opencast API response error: (400) Bad Request!'; +$string['exception_request_401'] = 'Unexpected Opencast API response error: (401) Unauthorized!'; +$string['exception_request_403'] = 'Unexpected Opencast API response error: (403) Forbidden!'; +$string['exception_request_404'] = 'Unexpected Opencast API response error: (404) Not found!'; +$string['exception_request_405'] = 'Unexpected Opencast API response error: (405) Method not allowed!'; +$string['exception_request_408'] = 'Unexpected Opencast API response error: (408) Request Timeout!'; +$string['exception_request_409'] = 'Unexpected Opencast API response error: (409) Conflict!'; +$string['exception_request_410'] = 'Unexpected Opencast API response error: (410) Gone!'; +$string['exception_request_422'] = 'Unexpected Opencast API response error: (422) Unprocessable Conten!'; +$string['exception_request_500'] = 'Unexpected Opencast API response error: (500) Internal Server Error!'; +$string['exception_request_501'] = 'Unexpected Opencast API response error: (501) Not Implemented!'; +$string['exception_request_502'] = 'Unexpected Opencast API response error: (502) Bad Gateway!'; +$string['exception_request_503'] = 'Unexpected Opencast API response error: (503) Service Unavailable!'; +$string['exception_request_generic'] = 'An error occurred while trying to reach Opencast Server. Please try again later.'; +$string['exception_request_ingest_endpoint_notfound'] = 'The ingest endpoint is not available, this has to be fix by the system administrator.'; $string['isdefault'] = 'Default'; $string['isvisible'] = 'Is visible to teachers'; $string['lticonsumerkey'] = 'Consumer key';