From f68a46a81b4d7d4493e754cae3c713c8cc581238 Mon Sep 17 00:00:00 2001 From: Jan Britz Date: Tue, 28 Jan 2025 16:55:42 +0100 Subject: [PATCH] feat: use `Guzzle` and handle `RequestError`s --- classes/api/api.php | 50 ++-- classes/api/connector.php | 222 ---------------- classes/api/http_response_container.php | 92 ------- classes/api/package_api.php | 236 ++++++++---------- classes/api/qpy_http_client.php | 101 +++++++- classes/api/utils.php | 44 ++++ classes/exception/error_code.php | 41 +++ .../options_form_validation_error.php | 53 ++++ classes/exception/request_error.php | 65 +++++ lang/en/qtype_questionpy.php | 6 +- tests/api/qpy_http_client_test.php | 169 +++++++++++++ tests/http_response_container_test.php | 77 ------ 12 files changed, 609 insertions(+), 547 deletions(-) delete mode 100644 classes/api/connector.php delete mode 100644 classes/api/http_response_container.php create mode 100644 classes/api/utils.php create mode 100644 classes/exception/error_code.php create mode 100644 classes/exception/options_form_validation_error.php create mode 100644 classes/exception/request_error.php create mode 100644 tests/api/qpy_http_client_test.php delete mode 100644 tests/http_response_container_test.php diff --git a/classes/api/api.php b/classes/api/api.php index e8d6d672..f313e55c 100644 --- a/classes/api/api.php +++ b/classes/api/api.php @@ -16,8 +16,10 @@ namespace qtype_questionpy\api; +use GuzzleHttp\Exception\GuzzleException; use moodle_exception; use qtype_questionpy\array_converter\array_converter; +use qtype_questionpy\exception\request_error; use qtype_questionpy\package\package_raw; use qtype_questionpy\package\package_versions_info; use stored_file; @@ -46,13 +48,15 @@ public function __construct( * Retrieves QuestionPy packages from the application server. * * @return package_versions_info[] + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ - public function get_packages(): array { - $connector = connector::default(); - $response = $connector->get('/packages'); - $response->assert_2xx(); - $packages = $response->get_data(); + public static function get_packages(): array { + $client = new qpy_http_client(); + $response = $client->get('/packages'); + + $packages = json_decode($response->getBody()->getContents(), associative: true); $result = []; foreach ($packages as $package) { @@ -83,13 +87,14 @@ public function package(string $hash, ?stored_file $file = null): package_api { * * @param string $hash * @return package_raw + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public function get_package_info(string $hash): package_raw { - $connector = connector::default(); - $response = $connector->get("/packages/$hash"); - $response->assert_2xx(); - return array_converter::from_array(package_raw::class, $response->get_data()); + $client = new qpy_http_client(); + $response = $client->get("/packages/$hash"); + return utils::convert_response_to_class($response, package_raw::class); } /** @@ -97,33 +102,34 @@ public function get_package_info(string $hash): package_raw { * * @param stored_file $file * @return package_raw + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public static function extract_package_info(stored_file $file): package_raw { - $connector = connector::default(); - - $filestorage = get_file_storage(); - $filepath = $filestorage->get_file_system()->get_local_path_from_storedfile($file, true); + $client = new qpy_http_client(); + $fd = $file->get_content_file_handle(); - $data = [ - 'package' => curl_file_create($filepath), + $options['multipart'][] = [ + 'name' => 'package', + 'contents' => $fd, ]; - $response = $connector->post('/package-extract-info', $data); - $response->assert_2xx(); - return array_converter::from_array(package_raw::class, $response->get_data()); + $response = $client->post('/package-extract-info', $options); + return utils::convert_response_to_class($response, package_raw::class); } /** * Get the status and information from the server. * * @return status + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public static function get_server_status(): status { - $connector = connector::default(); - $response = $connector->get('/status'); - $response->assert_2xx(); - return array_converter::from_array(status::class, $response->get_data()); + $client = new qpy_http_client(); + $response = $client->get('/status'); + return utils::convert_response_to_class($response, status::class); } } diff --git a/classes/api/connector.php b/classes/api/connector.php deleted file mode 100644 index ff627bfc..00000000 --- a/classes/api/connector.php +++ /dev/null @@ -1,222 +0,0 @@ -. - -namespace qtype_questionpy\api; - -use moodle_exception; - -/** - * Helper class for cURL. - * - * @package qtype_questionpy - * @copyright 2022 Jan Britz, TU Berlin, innoCampus - www.questionpy.org - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class connector { - /** - * @var string server url - */ - private $serverurl; - - /** - * @var int cURL timeout - */ - private $timeout; - - /** - * @var false|resource cURL handle - */ - private $curlhandle; - - /** - * Constructs connector class. - * - * @param string $serverurl - * @param int $timeout - * @throws moodle_exception - */ - public function __construct(string $serverurl, int $timeout = 30) { - // Sanitize url. - $this->serverurl = rtrim($serverurl, '/'); - - $this->timeout = $timeout; - - // Initialize curl handle. - $this->curlhandle = curl_init(); - - if (!$this->curlhandle) { - throw new moodle_exception( - 'curl_init_error', - 'qtype_questionpy', - '', - curl_errno($this->curlhandle), - curl_error($this->curlhandle) - ); - } - - $this->set_opts([ - CURLOPT_VERBOSE => false, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => $this->timeout, - ]); - } - - /** - * Destructs connector class. - */ - public function __destruct() { - curl_close($this->curlhandle); - } - - /** - * Creates a new connector with current server url. - * - * @throws moodle_exception - */ - public static function default(): self { - // Get server configs. - $serverurl = get_config('qtype_questionpy', 'server_url'); - $timeout = get_config('qtype_questionpy', 'server_timeout'); - return new connector($serverurl, $timeout); - } - - /** - * Set an option for cURL transfer. - * - * @param int $option - * @param mixed $value - * @throws moodle_exception - */ - private function set_opt(int $option, $value): void { - $success = curl_setopt($this->curlhandle, $option, $value); - - if (!$success) { - throw new moodle_exception( - 'curl_set_opt_error', - 'qtype_questionpy', - '', - curl_errno($this->curlhandle), - curl_error($this->curlhandle) - ); - } - } - - /** - * Set multiple options for cURL transfer. - * - * @param array $options cURL options and values - * @throws moodle_exception - */ - private function set_opts(array $options): void { - $success = curl_setopt_array($this->curlhandle, $options); - - if (!$success) { - throw new moodle_exception( - 'curl_set_opt_error', - 'qtype_questionpy', - '', - curl_errno($this->curlhandle), - curl_error($this->curlhandle) - ); - } - } - - /** - * Concatenates host url with path and sets the resulting url. - * - * @param string $path - * @throws moodle_exception - */ - private function set_url(string $path = ''): void { - $url = $this->serverurl . '/' . ltrim($path, '/'); - $this->set_opt(CURLOPT_URL, $url); - } - - /** - * Perform cURL session. - * - * @return http_response_container data received from server - * @throws moodle_exception - */ - private function exec(): http_response_container { - $data = curl_exec($this->curlhandle); - - // Check for cURL failure. - if ($data === false) { - throw new moodle_exception( - 'curl_exec_error', - 'qtype_questionpy', - '', - curl_errno($this->curlhandle), - curl_error($this->curlhandle) - ); - } - - // Create response. - $responsecode = curl_getinfo($this->curlhandle, CURLINFO_RESPONSE_CODE); - return new http_response_container($responsecode, $data); - } - - /** - * Performs a GET request to the given path on the application server. - * - * @param string $path - * @return http_response_container data received from server - * @throws moodle_exception - */ - public function get(string $path = ''): http_response_container { - // Set url to the endpoint. - $this->set_url($path); - - // Setup GET request. - $this->set_opt(CURLOPT_HTTPGET, true); - - // Execute GET request. - return $this->exec(); - } - - /** - * Performs a POST request to the given path on the application server. - * If `$data` is a string, the Content-Type will be set to application/json. - * If `$data` is an array, the Content-Type will be set to multipart/form-data. - * - * @param string $path - * @param string|array|null $data - * @return http_response_container data received from server - * @throws moodle_exception - */ - public function post(string $path = '', $data = null): http_response_container { - // Set url to the endpoint. - $this->set_url($path); - - // Setup POST request. - $this->set_opt(CURLOPT_POST, true); - - // Set data and content type if data is available. - if (!is_null($data)) { - $contenttype = is_string($data) ? 'application/json' : 'multipart/form-data'; - $this->set_opts([ - CURLOPT_POSTFIELDS => $data, - CURLOPT_HTTPHEADER => ["Content-Type: $contenttype"], - ]); - } - - // Execute POST request. - return $this->exec(); - } -} diff --git a/classes/api/http_response_container.php b/classes/api/http_response_container.php deleted file mode 100644 index fc85426f..00000000 --- a/classes/api/http_response_container.php +++ /dev/null @@ -1,92 +0,0 @@ -. - -namespace qtype_questionpy\api; - -use moodle_exception; - -/** - * Represents an HTTP cURL response. - * - * @package qtype_questionpy - * @copyright 2022 Jan Britz, TU Berlin, innoCampus - www.questionpy.org - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class http_response_container { - /** - * @var int response code - */ - public $code; - - /** - * @var string data string - */ - private $data; - - /** - * @var array|null cached array of data - */ - private $json; - - /** - * Constructs a response object. - * - * @param int $code response code - * @param string $data - */ - public function __construct(int $code, string $data = '') { - $this->code = $code; - $this->data = $data; - $this->json = null; - } - - /** - * Returns data as string or parsed associative array. - * - * @param bool $json if true, parse string to associative array - * @return string|array - * @throws moodle_exception - */ - public function get_data(bool $json = true) { - if (!$json) { - return $this->data; - } - - // Check if data is already cached. - if (!is_null($this->json)) { - return $this->json; - } - - // Parse data. - $this->json = json_decode($this->data, true); - if (is_null($this->json)) { - throw new moodle_exception('json_parsing_error', 'qtype_questionpy', ''); - } - - return $this->json; - } - - /** - * Throws a {@see moodle_exception} if the response status code is not 2xx (successful). - * - * @throws moodle_exception if the response status code is not 2xx (successful). - */ - public function assert_2xx(): void { - if ($this->code < 200 || $this->code >= 300) { - throw new moodle_exception('server_bad_status', 'qtype_questionpy', '', $this->code); - } - } -} diff --git a/classes/api/package_api.php b/classes/api/package_api.php index c0727ecc..5a19f724 100644 --- a/classes/api/package_api.php +++ b/classes/api/package_api.php @@ -21,7 +21,8 @@ use GuzzleHttp\Exception\GuzzleException; use moodle_exception; use Psr\Http\Message\ResponseInterface; -use qtype_questionpy\array_converter\array_converter; +use qtype_questionpy\exception\error_code; +use qtype_questionpy\exception\request_error; use stored_file; /** @@ -58,15 +59,14 @@ public function __construct( * * @param string|null $questionstate current question state * @return question_edit_form_response + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public function get_question_edit_form(?string $questionstate): question_edit_form_response { - $parts = $this->create_request_parts([ - 'main' => '{}', - ], $questionstate); - - $response = $this->post_and_maybe_retry('/options', $parts); - return array_converter::from_array(question_edit_form_response::class, $response->get_data()); + $options['multipart'] = $this->transform_to_multipart([], $questionstate); + $response = $this->post_and_maybe_retry('/options', $options); + return utils::convert_response_to_class($response, question_edit_form_response::class); } /** @@ -75,17 +75,21 @@ public function get_question_edit_form(?string $questionstate): question_edit_fo * @param string|null $currentstate current state string if the question already exists, null otherwise * @param object $formdata data from the question edit form * @return question_response + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public function create_question(?string $currentstate, object $formdata): question_response { - $parts = $this->create_request_parts([ - 'form_data' => $formdata, - // TODO: Send an actual context. - 'context' => 1, - ], $currentstate); - - $response = $this->post_and_maybe_retry('/question', $parts); - return array_converter::from_array(question_response::class, $response->get_data()); + $options['multipart'] = $this->transform_to_multipart( + [ + 'form_data' => $formdata, + // TODO: Send an actual context. + 'context' => 1, + ], + $currentstate, + ); + $response = $this->post_and_maybe_retry('/question', $options); + return utils::convert_response_to_class($response, question_response::class); } /** @@ -95,15 +99,14 @@ public function create_question(?string $currentstate, object $formdata): questi * @param int $variant variant which should be started (`1` for questions with only one variant) * @return attempt_started the attempt's state and metadata. Note that the attempt state never changes after the * attempt has been started. + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public function start_attempt(string $questionstate, int $variant): attempt_started { - $parts = $this->create_request_parts([ - 'variant' => $variant, - ], $questionstate); - - $response = $this->post_and_maybe_retry('/attempt/start', $parts); - return array_converter::from_array(attempt_started::class, $response->get_data()); + $options['multipart'] = $this->transform_to_multipart(['variant' => $variant], $questionstate); + $response = $this->post_and_maybe_retry('/attempt/start', $options); + return utils::convert_response_to_class($response, attempt_started::class); } /** @@ -114,22 +117,22 @@ public function start_attempt(string $questionstate, int $variant): attempt_star * @param string|null $scoringstate the last scoring state if this attempt has already been scored * @param array|null $response data currently entered by the student * @return attempt the attempt's metadata. The state is not returned since it never changes. + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public function view_attempt(string $questionstate, string $attemptstate, ?string $scoringstate = null, ?array $response = null): attempt { - $main = ['attempt_state' => $attemptstate]; - // Cast to object so empty responses are serialized as JSON objects, not arrays. - if ($response !== null) { - $main['response'] = (object)$response; - } - - if ($scoringstate) { - $main['scoring_state'] = $scoringstate; - } - $parts = $this->create_request_parts($main, $questionstate); - $httpresponse = $this->post_and_maybe_retry('/attempt/view', $parts); - return array_converter::from_array(attempt::class, $httpresponse->get_data()); + $options['multipart'] = $this->transform_to_multipart( + [ + 'attempt_state' => $attemptstate, + 'scoring_state' => $scoringstate, + 'response' => $response, + ], + $questionstate, + ); + $httpresponse = $this->post_and_maybe_retry('/attempt/view', $options); + return utils::convert_response_to_class($httpresponse, attempt::class); } /** @@ -140,72 +143,23 @@ public function view_attempt(string $questionstate, string $attemptstate, ?strin * @param string|null $scoringstate the last scoring state if this attempt had been scored before * @param array $response data submitted by the student * @return attempt_scored the attempt's metadata. The state is not returned since it never changes. + * @throws GuzzleException + * @throws request_error * @throws moodle_exception */ public function score_attempt(string $questionstate, string $attemptstate, ?string $scoringstate, array $response): attempt_scored { - $main = [ - 'attempt_state' => $attemptstate, - // Cast to object so empty responses are serialized as JSON objects, not arrays. - 'response' => (object)$response, - 'generate_hint' => false, - ]; - - if ($scoringstate) { - $main['scoring_state'] = $scoringstate; - } - - $parts = $this->create_request_parts($main, $questionstate); - $httpresponse = $this->post_and_maybe_retry('/attempt/score', $parts); - return array_converter::from_array(attempt_scored::class, $httpresponse->get_data()); - } - - /** - * Send a POST request and retry if the server doesn't have the package file cached, but we have it available. - * - * @param string $uri can be absolute or relative to the base url - * @param array $options request options as per - * {@link https://docs.guzzlephp.org/en/stable/request-options.html Guzzle docs} - * @param bool $allowretry if set to false, retry won't be attempted if the package file isn't cached, instead - * throwing a {@see coding_exception} - * @return ResponseInterface - * @throws coding_exception if the request is unsuccessful for any other reason - * @see post_and_maybe_retry - */ - private function guzzle_post_and_maybe_retry(string $uri, array $options = [], bool $allowretry = true): ResponseInterface { - try { - return $this->client->post($uri, $options); - } catch (BadResponseException $e) { - if (!$allowretry || !$this->file || $e->getResponse()->getStatusCode() != 404) { - throw $e; - } - - $json = json_decode($e->getResponse()->getBody(), associative: true); - if (JSON_ERROR_NONE !== json_last_error()) { - // Not valid JSON, so the problem probably isn't a missing package file. - throw $e; - } - - if ($json['what'] ?? null !== 'PACKAGE') { - throw $e; - } - - // Add file to parts and resend. - - $fd = $this->file->get_content_file_handle(); - try { - $options['multipart'][] = [ - 'name' => 'package', - 'contents' => $fd, - ]; - - return $this->guzzle_post_and_maybe_retry($uri, $options, allowretry: false); - } finally { - @fclose($fd); - } - } catch (GuzzleException $e) { - throw new coding_exception('Request to QPy server failed: ' . $e->getMessage()); - } + $options['multipart'] = $this->transform_to_multipart( + [ + 'attempt_state' => $attemptstate, + 'scoring_state' => $scoringstate, + 'response' => $response, + 'generate_hint' => false, + ], + $questionstate + ); + $httpresponse = $this->post_and_maybe_retry('/attempt/score', $options); + return utils::convert_response_to_class($httpresponse, attempt_scored::class); } /** @@ -219,17 +173,20 @@ private function guzzle_post_and_maybe_retry(string $uri, array $options = [], b * @param string $path path of the static file in the package * @param string $targetpath path where the file should be downloaded to. Anything here will be overwritten. * @return string|null the mime type as reported by the server or null if the file wasn't found + * @throws GuzzleException + * @throws request_error * @throws coding_exception */ public function download_static_file(string $namespace, string $shortname, string $kind, string $path, string $targetpath): ?string { try { - $res = $this->guzzle_post_and_maybe_retry( - "/packages/$this->hash/file/$namespace/$shortname/$kind/$path", + $res = $this->post_and_maybe_retry( + "/file/$namespace/$shortname/$kind/$path", ['sink' => $targetpath] ); } catch (BadResponseException $e) { if ($e->getResponse()->getStatusCode() == 404) { + // The static file was not found. return null; } @@ -248,51 +205,70 @@ public function download_static_file(string $namespace, string $shortname, strin } /** - * Creates the multipart parts array. + * Send a POST request and retry if the server doesn't have the package file cached, but we have it available. * - * @param array $main main JSON part - * @param string|null $questionstate optional question state - * @return array + * @param string $uri can be absolute or relative to the base url + * @param array $options request options as per + * {@link https://docs.guzzlephp.org/en/stable/request-options.html Guzzle docs} + * @param bool $allowretry if set to false, retry won't be attempted if the package file isn't cached, instead + * throwing a {@see coding_exception} + * @return ResponseInterface + * @throws GuzzleException + * @throws request_error */ - private function create_request_parts(array $main, ?string $questionstate): array { - $parts = []; + private function post_and_maybe_retry(string $uri, array $options = [], bool $allowretry = true): ResponseInterface { + $uri = "/packages/$this->hash/" . ltrim($uri, '/'); - if ($questionstate !== null) { - $parts['question_state'] = $questionstate; - } + try { + return $this->client->post($uri, $options); + } catch (request_error $e) { + if (!$allowretry || !$this->file || $e->requesterrorcode !== error_code::package_not_found) { + throw $e; + } - $parts['main'] = json_encode($main); - return $parts; + $fd = $this->file->get_content_file_handle(); + try { + $options['multipart'][] = [ + 'name' => 'package', + 'contents' => $fd, + ]; + return $this->post_and_maybe_retry($uri, $options, allowretry: false); + } finally { + @fclose($fd); + } + } } /** - * Send a POST request and retry if the server doesn't have the package file cached, but we have it available. + * Creates the multipart parts array. * - * @param string $subpath path relative to `/packages/hash...` - * @param array $parts array of multipart parts - * @return http_response_container - * @throws moodle_exception - * @see guzzle_post_and_maybe_retry + * @param array $main main JSON part + * @param string|null $questionstate optional question state + * @return array */ - private function post_and_maybe_retry(string $subpath, array $parts): http_response_container { - $connector = connector::default(); - $path = "/packages/$this->hash/" . ltrim($subpath, '/'); - - $response = $connector->post($path, $parts); - if ($this->file && $response->code == 404) { - $json = $response->get_data(); - if ($json['what'] === 'PACKAGE') { - // Add file to parts and resend. - $fs = get_file_storage(); - $filepath = $fs->get_file_system()->get_local_path_from_storedfile($this->file, true); - - $parts['package'] = curl_file_create($filepath, 'application/zip'); + private function transform_to_multipart(array $main, ?string $questionstate): array { + if (!is_null($questionstate)) { + $multipart[] = [ + 'name' => 'question_state', + 'contents' => $questionstate, + ]; + } - $response = $connector->post($path, $parts); + $transformed = []; + foreach ($main as $key => $value) { + if (is_null($value)) { + continue; } + + // Cast arrays to objects so that empty arrays get serialized to JSON objects, not arrays. + $transformed[$key] = is_array($value) ? (object) $value : $value; } - $response->assert_2xx(); - return $response; + $multipart[] = [ + 'name' => 'main', + 'contents' => json_encode((object) $transformed), + ]; + + return $multipart; } } diff --git a/classes/api/qpy_http_client.php b/classes/api/qpy_http_client.php index 7dc30fb1..39bf566c 100644 --- a/classes/api/qpy_http_client.php +++ b/classes/api/qpy_http_client.php @@ -18,7 +18,16 @@ use core\http_client; use dml_exception; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\RequestOptions; use GuzzleHttp\HandlerStack; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; +use qtype_questionpy\exception\error_code; +use qtype_questionpy\exception\options_form_validation_error; +use qtype_questionpy\exception\request_error; /** * Guzzle http client configured with Moodle's standards ({@see http_client}) and QPy-specific ones. @@ -37,11 +46,47 @@ class qpy_http_client extends http_client { * @throws dml_exception */ public function __construct(array $config = []) { - $config['base_uri'] = rtrim(get_config('qtype_questionpy', 'server_url'), '/') . '/'; - $config['timeout'] = get_config('qtype_questionpy', 'server_timeout'); + $config['base_uri'] ??= rtrim(get_config('qtype_questionpy', 'server_url'), '/') . '/'; + $config[RequestOptions::TIMEOUT] ??= get_config('qtype_questionpy', 'server_timeout'); parent::__construct($config); } + // phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod.Found + /** + * Create and send an HTTP GET request. + * + * Use an absolute path to override the base path of the client, or a relative path to append to the base path of the client. + * The URL can contain the query string as well. + * + * @param UriInterface|string $uri + * @param array $options + * @return ResponseInterface + * @throws GuzzleException + * @throws request_error + * @throws options_form_validation_error + */ + public function get($uri, array $options = []): ResponseInterface { + return parent::get($uri, $options); + } + + /** + * Create and send an HTTP POST request. + * + * Use an absolute path to override the base path of the client, or a relative path to append to the base path of the client. + * The URL can contain the query string as well. + * + * @param UriInterface|string $uri + * @param array $options + * @return ResponseInterface + * @throws GuzzleException + * @throws request_error + * @throws options_form_validation_error + */ + public function post($uri, array $options = []): ResponseInterface { + return parent::post($uri, $options); + } + // phpcs:enable + /** * Get the handler stack according to the settings/options from client. * @@ -54,6 +99,58 @@ protected function get_handlers(array $settings): HandlerStack { to ensure their QPy server isn't in this list otherwise. There may be ways to granularly allow the server_url, but this will do for now. */ $handlerstack->remove('moodle_check_initial_request'); + $handlerstack->before('http_errors', $this->exception_middleware(), 'qpy_exception_middleware'); return $handlerstack; } + + /** + * This middleware transforms responses with a 4xx or 5xx status code to our custom {@see request_error}. + * + * @return callable middleware callback + */ + private static function exception_middleware(): callable { + return function (callable $handler) { + return function (RequestInterface $request, array $options) use ($handler) { + return $handler($request, $options)->then( + function (ResponseInterface $response) { + return $response; + }, + function (GuzzleException $exception) { + if (!($exception instanceof RequestException) || !$exception->hasResponse()) { + // TODO: also handle other guzzle exceptions like `ConnectException`? + throw $exception; + } + + $body = $exception->getResponse()->getBody()->getContents(); + $data = json_decode($body, associative: true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + throw $exception; + } + + $errorcode = error_code::tryFrom($data['error_code'] ?? ''); + if ($errorcode === null) { + throw $exception; + } + + if ($errorcode === error_code::invalid_options_form) { + throw new options_form_validation_error( + $exception, + $errorcode, + $data['temporary'] ?? false, + $data['reason'] ?? null, + $data['errors'] ?? [], + ); + } + + throw new request_error( + $exception, + $errorcode, + $data['temporary'] ?? false, + $data['reason'] ?? null, + ); + }, + ); + }; + }; + } } diff --git a/classes/api/utils.php b/classes/api/utils.php new file mode 100644 index 00000000..c76a8c23 --- /dev/null +++ b/classes/api/utils.php @@ -0,0 +1,44 @@ +. + +namespace qtype_questionpy\api; + +use moodle_exception; +use Psr\Http\Message\ResponseInterface; +use qtype_questionpy\array_converter\array_converter; + +/** + * Utility class for handling various data transformations and modifications. + * + * @package qtype_questionpy + * @author Jan Britz + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class utils { + /** + * Converts the body of a {@see ResponseInterface} to the given class. + * + * @param ResponseInterface $response + * @param string $class + * @return object an instance of `$class` + * @throws moodle_exception + * @see array_converter::from_array + */ + public static function convert_response_to_class(ResponseInterface $response, string $class): object { + return array_converter::from_array($class, json_decode($response->getBody()->getContents(), associative: true)); + } +} diff --git a/classes/exception/error_code.php b/classes/exception/error_code.php new file mode 100644 index 00000000..2687501a --- /dev/null +++ b/classes/exception/error_code.php @@ -0,0 +1,41 @@ +. + +namespace qtype_questionpy\exception; + +// phpcs:ignore moodle.Commenting.InlineComment.DocBlock +/** + * Possible request error codes. + * + * @package qtype_questionpy + * @author Jan Britz + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +enum error_code: string { + case queue_waiting_timeout = 'QUEUE_WAITING_TIMEOUT'; + case worker_timeout = 'WORKER_TIMEOUT'; + case out_of_memory = 'OUT_OF_MEMORY'; + case invalid_attempt_state = 'INVALID_ATTEMPT_STATE'; + case invalid_question_state = 'INVALID_QUESTION_STATE'; + case invalid_package = 'INVALID_PACKAGE'; + case invalid_request = 'INVALID_REQUEST'; + case invalid_options_form = 'INVALID_OPTIONS_FORM'; + case package_error = 'PACKAGE_ERROR'; + case package_not_found = 'PACKAGE_NOT_FOUND'; + case callback_api_error = 'CALLBACK_API_ERROR'; + case server_error = 'SERVER_ERROR'; +} diff --git a/classes/exception/options_form_validation_error.php b/classes/exception/options_form_validation_error.php new file mode 100644 index 00000000..0529695c --- /dev/null +++ b/classes/exception/options_form_validation_error.php @@ -0,0 +1,53 @@ +. + +namespace qtype_questionpy\exception; + +use GuzzleHttp\Exception\RequestException; + +/** + * Creates a throwable options form validation error. + * + * @package qtype_questionpy + * @author Jan Britz + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class options_form_validation_error extends request_error { + /** + * Creates a throwable options form validation error. + * + * @param RequestException $origin + * @param error_code $requesterrorcode + * @param bool $temporary + * @param string|null $reason + * @param array $errors string to string mapping of field names to the error message + */ + public function __construct( + /** @var RequestException */ + public RequestException $origin, + /** @var error_code */ + public error_code $requesterrorcode, + /** @var bool */ + public bool $temporary, + /** @var string|null */ + public ?string $reason, + /** @var array */ + public array $errors, + ) { + parent::__construct($origin, $requesterrorcode, $temporary, $reason); + } +} diff --git a/classes/exception/request_error.php b/classes/exception/request_error.php new file mode 100644 index 00000000..1f405733 --- /dev/null +++ b/classes/exception/request_error.php @@ -0,0 +1,65 @@ +. + +namespace qtype_questionpy\exception; + +use GuzzleHttp\Exception\RequestException; +use moodle_exception; + +/** + * Represents a request error from the application server. + * + * @package qtype_questionpy + * @author Jan Britz + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class request_error extends moodle_exception { + /** + * Creates a throwable request error. + * + * @param RequestException $origin + * @param error_code $requesterrorcode + * @param bool $temporary + * @param string|null $reason + */ + public function __construct( + /** @var RequestException */ + public RequestException $origin, + /** @var error_code */ + public error_code $requesterrorcode, + /** @var bool */ + public bool $temporary, + /** @var string|null */ + public ?string $reason, + ) { + $request = $this->origin->getRequest(); + $response = $this->origin->getResponse(); + + parent::__construct( + 'request_error', + 'qtype_questionpy', + a: [ + 'requestmethod' => $request->getMethod(), + 'errorcode' => $this->requesterrorcode->value, + 'statuscode' => $response->getStatusCode(), + 'reasonphrase' => $response->getReasonPhrase(), + 'uri' => $request->getUri()->getPath(), + ], + debuginfo: $this->reason, + ); + } +} diff --git a/lang/en/qtype_questionpy.php b/lang/en/qtype_questionpy.php index ab9da1dc..385eb68a 100644 --- a/lang/en/qtype_questionpy.php +++ b/lang/en/qtype_questionpy.php @@ -49,6 +49,8 @@ $string['question_package_upload'] = 'Upload your own'; $string['remove_packages_button'] = 'Remove Packages'; $string['render_error_section'] = 'An error occurred'; +$string['request_error'] = 'The {$a->requestmethod} request to "{$a->uri}" failed with the error code "{$a->errorcode}" and' + . ' status code {$a->statuscode} ({$a->reasonphrase}).'; $string['same_version_different_hash_error'] = 'A package with the same version but different hash already exists.'; $string['search_all_header'] = 'All ({$a})'; $string['search_bar'] = 'Search...'; @@ -64,8 +66,8 @@ $string['select_package'] = 'Select'; $string['select_package_element_aria'] = 'Choose version.'; $string['selection_custom_package_header'] = 'Custom Package'; -$string['selection_custom_package_text'] = 'This package version was uploaded by a user and might not appear in the package" - . " search.'; +$string['selection_custom_package_text'] = 'This package version was uploaded by a user and might not appear in the package' + . ' search.'; $string['selection_no_icon'] = 'Could not load the icon.'; $string['selection_package_no_longer_in_database_header'] = 'Discontinued'; $string['selection_package_no_longer_in_database_text'] = 'This package version is no longer available through the package search.'; diff --git a/tests/api/qpy_http_client_test.php b/tests/api/qpy_http_client_test.php new file mode 100644 index 00000000..d035888d --- /dev/null +++ b/tests/api/qpy_http_client_test.php @@ -0,0 +1,169 @@ +. + +namespace qtype_questionpy\api; + +use dml_exception; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\RequestOptions; +use qtype_questionpy\exception\error_code; +use qtype_questionpy\exception\options_form_validation_error; +use qtype_questionpy\exception\request_error; + +/** + * Tests {@see qpy_http_client}. + * + * @covers \qtype_questionpy\api\qpy_http_client + * + * @package qtype_questionpy + * @author Jan Britz + * @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class qpy_http_client_test extends \advanced_testcase { + /** + * Tests that the middleware transforms {@see RequestException}s to {@see request_error}s. + * + * @param error_code $code + * @param bool $temporary + * @param string|null $reason + * @dataProvider valid_error_code_provider + * @throws GuzzleException + * @throws dml_exception + */ + public function test_middleware_should_throw_request_error(error_code $code, bool $temporary, ?string $reason): void { + $response = new Response(422, [], json_encode([ + 'error_code' => $code->value, + 'temporary' => $temporary, + 'reason' => $reason, + ])); + $mock = new MockHandler([$response]); + $client = new qpy_http_client(['mock' => HandlerStack::create($mock)]); + + try { + $client->get('/'); + } catch (request_error $error) { + $this->assertSame($code, $error->requesterrorcode); + $this->assertSame($temporary, $error->temporary); + $this->assertSame($reason, $error->reason); + $this->assertInstanceof(GuzzleException::class, $error->origin); + $this->assertSame($response, $error->origin->getResponse()); + return; + } + $this->fail('Expected "request_error" to be thrown.'); + } + + /** + * Tests that the middleware transforms {@see RequestException}s with a body containing the + * {@see error_code::invalid_options_form} to {@see options_form_validation_error}s. + * + * @return void + * @throws GuzzleException + * @throws dml_exception + * @throws options_form_validation_error + * @throws request_error + */ + public function test_middleware_should_throw_options_form_validation_error(): void { + $errors = ['my_hidden' => 'Required.']; + $mock = new MockHandler([new Response(422, [], json_encode([ + 'error_code' => error_code::invalid_options_form->value, + 'temporary' => true, + 'reason' => 'test', + 'errors' => $errors, + ]))]); + $client = new qpy_http_client(['mock' => HandlerStack::create($mock)]); + + try { + $client->get('/'); + } catch (options_form_validation_error $error) { + $this->assertEquals($errors, $error->errors); + return; + } + $this->fail('Expected "options_form_validation_error" to be thrown.'); + } + + /** + * Tests that the middleware respects the {@see RequestOptions::HTTP_ERRORS}-option. + * + * @return void + * @throws GuzzleException + * @throws dml_exception + * @throws options_form_validation_error + * @throws request_error + */ + public function test_middleware_should_not_throw_if_appropriate_option_is_set(): void { + $mock = new MockHandler([new Response(422, [], json_encode([ + 'error_code' => error_code::invalid_request->value, + 'temporary' => true, + 'reason' => 'test', + ]))]); + $client = new qpy_http_client(['mock' => HandlerStack::create($mock)]); + + $client->get('/', [RequestOptions::HTTP_ERRORS => false]); + } + + /** + * Tests that the middleware throws the standard {@see GuzzleException} if there is an unknown body. + * + * @param string|null $body + * @dataProvider unknown_body_provider + * @throws GuzzleException + * @throws options_form_validation_error + * @throws dml_exception + */ + public function test_middleware_should_throw_guzzle_exception_if_unknown_body(?string $body): void { + $mock = new MockHandler([new Response(422, [], $body)]); + $client = new qpy_http_client(['mock' => HandlerStack::create($mock)]); + $this->expectException(GuzzleException::class); + $client->get('/'); + } + + + /** + * Provides every {@see error_code}, alternating temporary-flag and a reason-string (or null). + * + * @return array + */ + public static function valid_error_code_provider(): array { + return array_map( + function (error_code $code, int $index) { + $temporary = $index % 2 === 0; + return [$code, $temporary, $temporary ? null : 'reason']; + }, + error_code::cases(), + array_keys(error_code::cases()), + ); + } + + /** + * Provides request bodies which cannot be transformed into {@see request_error}s. + * + * @return array + */ + public static function unknown_body_provider(): array { + return [ + [null], // No request body. + ['{'], // Invalid json body. + ['string'], // Not an array. + ['{}'], // No error code. + ['{"error_code": "unknown"}'], // Unknown error code. + ]; + } +} diff --git a/tests/http_response_container_test.php b/tests/http_response_container_test.php deleted file mode 100644 index 7025a8ae..00000000 --- a/tests/http_response_container_test.php +++ /dev/null @@ -1,77 +0,0 @@ -. - -namespace qtype_questionpy; - -use moodle_exception; -use qtype_questionpy\api\http_response_container; - -/** - * Unit tests for the questionpy question type class. - * - * @package qtype_questionpy - * @copyright 2022 Jan Britz, TU Berlin, innoCampus - www.questionpy.org - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -final class http_response_container_test extends \advanced_testcase { - /** - * Tests the function get_data with json. - * - * @covers \http_response_container::get_data - * @return void - * @throws moodle_exception - */ - public function test_get_data_with_json(): void { - $code = 200; - $data = '{"test": "data"}'; - $dataarray = json_decode($data, true); - - $response = new http_response_container($code, $data); - - // Check if the data is string. - $responsedata = $response->get_data(false); - self::assertIsString($responsedata); - self::assertEquals($data, $responsedata); - - // Check if the data is array. - $responsedata = $response->get_data(); - self::assertIsArray($responsedata); - self::assertEquals($dataarray, $responsedata); - } - - /** - * Tests the function get_data with a string. - * - * @covers \http_response_container::get_data - * @return void - * @throws moodle_exception - */ - public function test_get_data_not_json(): void { - $code = 200; - $data = 'This is not a json.'; - - $response = new http_response_container($code, $data); - - // Check if the data is string. - $responsedata = $response->get_data(false); - self::assertIsString($responsedata); - self::assertEquals($data, $responsedata); - - // Check if parsing the data as json throws an exception. - self::expectException(moodle_exception::class); - $response->get_data(); - } -}