MDL-49329 admin: Introduce new \core\update\api client class

The purpose of this class is to provide a general client for all APIs
available at https://download.moodle.org/api/ (e.g. available updates,
plugin info, plugins list etc). Currently, fetching data from this API
is done separately at several places. This leads to code duplication and
harder maintenance (I know it well).

Additionally, the existing client was implemented as
tool_installaddon_pluginfo_client in the admin/tool/installaddon/ scope.
I will soon need to use the same functionality in the
core_plugin_manager and it would hurt my karma if the core was depending
on a class provided by a admin tool plugin (even if it is standard one).

So, there is new \core\update\api client implementing the version 1.3 of
the pluginfo API. There is a TODO note left for remaining services.
This commit is contained in:
David Mudrák 2015-09-30 01:59:52 +02:00
parent 7eb87eff65
commit 48900324b3
6 changed files with 493 additions and 231 deletions

View File

@ -252,8 +252,6 @@ class tool_installaddon_installer {
* @param bool $confirmed
*/
public function handle_remote_request(tool_installaddon_renderer $output, $request, $confirmed = false) {
global $CFG;
require_once(dirname(__FILE__).'/pluginfo_client.php');
if (is_null($request)) {
return;
@ -296,18 +294,12 @@ class tool_installaddon_installer {
// Fetch the plugin info. The essential information is the URL to download the ZIP
// and the MD5 hash of the ZIP, obtained via HTTPS.
$client = tool_installaddon_pluginfo_client::instance();
$client = \core\update\api::client();
$pluginfo = $client->get_plugin_info($data->component, $data->version);
try {
$pluginfo = $client->get_pluginfo($data->component, $data->version);
} catch (tool_installaddon_pluginfo_exception $e) {
if (debugging()) {
throw $e;
} else {
echo $output->remote_request_pluginfo_exception($data, $e, $this->index_url());
exit();
}
if (empty($pluginfo) or empty($pluginfo->version)) {
echo $output->remote_request_pluginfo_failure($data, $this->index_url());
exit();
}
// Fetch the ZIP with the plugin version
@ -316,7 +308,7 @@ class tool_installaddon_installer {
$zipfilename = 'downloaded.zip';
try {
$this->download_file($pluginfo->downloadurl, $sourcedir.'/'.$zipfilename);
$this->download_file($pluginfo->version->downloadurl, $sourcedir.'/'.$zipfilename);
} catch (tool_installaddon_installer_exception $e) {
if (debugging()) {
@ -328,7 +320,7 @@ class tool_installaddon_installer {
}
// Check the MD5 checksum
$md5expected = $pluginfo->downloadmd5;
$md5expected = $pluginfo->version->downloadmd5;
$md5actual = md5_file($sourcedir.'/'.$zipfilename);
if ($md5expected !== $md5actual) {
$e = new tool_installaddon_installer_exception('err_zip_md5', array('expected' => $md5expected, 'actual' => $md5actual));

View File

@ -1,209 +0,0 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Provides tool_installaddon_pluginfo_client and related classes
*
* @package tool_installaddon
* @subpackage classes
* @copyright 2013 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Implements a client for https://download.moodle.org/api/x.y/pluginfo.php service
*
* @copyright 2013 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class tool_installaddon_pluginfo_client {
/**
* Factory method returning an instance of this class.
*
* @return tool_installaddon_pluginfo_client
*/
public static function instance() {
return new static();
}
/**
* Return the information about the plugin
*
* @throws tool_installaddon_pluginfo_exception
* @param string $component
* @param string $version
* @return stdClass the pluginfo structure
*/
public function get_pluginfo($component, $version) {
$response = $this->call_service($component, $version);
$response = $this->decode_response($response);
$this->validate_response($response);
return $response->pluginfo;
}
// End of external API /////////////////////////////////////////////////
/**
* @see self::instance()
*/
protected function __construct() {
}
/**
* Calls the pluginfo.php service and returns the raw response
*
* @param string $component
* @param string $version
* @return string
*/
protected function call_service($component, $version) {
global $CFG;
require_once($CFG->libdir.'/filelib.php');
$curl = new curl(array('proxy' => true));
$response = $curl->get(
$this->service_request_url(),
$this->service_request_params($component, $version),
$this->service_request_options());
$curlerrno = $curl->get_errno();
$curlinfo = $curl->get_info();
if (!empty($curlerrno)) {
throw new tool_installaddon_pluginfo_exception('err_curl_exec', array(
'url' => $curlinfo['url'], 'errno' => $curlerrno, 'error' => $curl->error));
} else if ($curlinfo['http_code'] != 200) {
throw new tool_installaddon_pluginfo_exception('err_curl_http_code', array(
'url' => $curlinfo['url'], 'http_code' => $curlinfo['http_code']));
} else if (isset($curlinfo['ssl_verify_result']) and $curlinfo['ssl_verify_result'] != 0) {
throw new tool_installaddon_pluginfo_exception('err_curl_ssl_verify', array(
'url' => $curlinfo['url'], 'ssl_verify_result' => $curlinfo['ssl_verify_result']));
}
return $response;
}
/**
* Return URL to the pluginfo.php service
*
* @return moodle_url
*/
protected function service_request_url() {
global $CFG;
if (!empty($CFG->config_php_settings['alternativepluginfoserviceurl'])) {
$url = $CFG->config_php_settings['alternativepluginfoserviceurl'];
} else {
$url = 'https://download.moodle.org/api/1.2/pluginfo.php';
}
return new moodle_url($url);
}
/**
* Return list of pluginfo service parameters
*
* @param string $component
* @param string $version
* @return array
*/
protected function service_request_params($component, $version) {
$params = array();
$params['format'] = 'json';
$params['plugin'] = $component.'@'.$version;
return $params;
}
/**
* Return cURL options for the service request
*
* @return array of (string)param => (string)value
*/
protected function service_request_options() {
global $CFG;
$options = array(
'CURLOPT_SSL_VERIFYHOST' => 2, // this is the default in {@link curl} class but just in case
'CURLOPT_SSL_VERIFYPEER' => true,
);
return $options;
}
/**
* Decode the raw service response
*
* @param string $raw
* @return stdClass
*/
protected function decode_response($raw) {
return json_decode($raw);
}
/**
* Validate decoded service response
*
* @param stdClass $response
*/
protected function validate_response($response) {
if (empty($response)) {
throw new tool_installaddon_pluginfo_exception('err_response_empty');
}
if (empty($response->status) or $response->status !== 'OK') {
throw new tool_installaddon_pluginfo_exception('err_response_status', $response->status);
}
if (empty($response->apiver) or $response->apiver !== '1.2') {
throw new tool_installaddon_pluginfo_exception('err_response_api_version', $response->apiver);
}
if (empty($response->pluginfo->component) or empty($response->pluginfo->downloadurl)
or empty($response->pluginfo->downloadmd5)) {
throw new tool_installaddon_pluginfo_exception('err_response_pluginfo');
}
}
}
/**
* General exception thrown by {@link tool_installaddon_pluginfo_client} class
*
* @copyright 2013 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class tool_installaddon_pluginfo_exception extends moodle_exception {
/**
* @param string $errorcode exception description identifier
* @param mixed $debuginfo debugging data to display
*/
public function __construct($errorcode, $a=null, $debuginfo=null) {
parent::__construct($errorcode, 'tool_installaddon', '', $a, print_r($debuginfo, true));
}
}

View File

@ -194,18 +194,13 @@ class tool_installaddon_renderer extends plugin_renderer_base {
}
/**
* Inform the user about pluginfo service call exception
*
* This implementation does not actually use the passed exception. Custom renderers might want to
* display additional data obtained via {@link get_exception_info()}. Also note, this method is called
* in non-debugging mode only. If debugging is allowed at the site, default exception handler is triggered.
* Inform the user about pluginfo service call failure
*
* @param stdClass $data decoded request data
* @param tool_installaddon_pluginfo_exception $e thrown exception
* @param moodle_url $continueurl
* @return string
*/
public function remote_request_pluginfo_exception(stdClass $data, tool_installaddon_pluginfo_exception $e, moodle_url $continueurl) {
public function remote_request_pluginfo_failure(stdClass $data, moodle_url $continueurl) {
$out = $this->output->header();
$out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon'));

261
lib/classes/update/api.php Normal file
View File

@ -0,0 +1,261 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* The class \core\update\api is defined here.
*
* @package core
* @copyright 2015 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core\update;
use curl;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/filelib.php');
/**
* General purpose client for https://download.moodle.org/api/
*
* The API provides proxy access to public information about plugins available
* in the Moodle Plugins directory. It is used when we are checking for
* updates, resolving missing dependecies or installing a plugin. This client
* can be used to:
*
* - obtain information about particular plugin version
* - locate the most suitable plugin version for the given Moodle branch
*
* TODO:
*
* - Convert \core\update\checker to use this client too, so that we have a
* single access point for all the API services.
* - Implement client method for pluglist.php even if it is not actually
* used by the Moodle core.
*
* @copyright 2015 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class api {
/** The root of the standard API provider */
const APIROOT = 'https://download.moodle.org/api';
/** The API version to be used by this client */
const APIVER = '1.3';
/**
* Factory method returning an instance of the class.
*
* @return \core\update\api client instance
*/
public static function client() {
return new static();
}
/**
* Constructor is protected, use the factory method.
*/
protected function __construct() {
}
/**
* Returns info about the particular plugin version in the plugins directory.
*
* Uses pluginfo.php end-point to find the given plugin version in the
* Moodle plugins directory. This is typically used to handle the
* installation request coming from the plugins directory (aka clicking the
* "Install" button there).
*
* If a plugin with the given component name is found, data about the
* plugin are returned as an object. The ->version property of the object
* contains the information about the requested plugin version. The
* ->version property is false if the requested version of the plugin was
* not found (yet the plugin itself is known).
*
* @param string $component frankenstyle name of the plugin
* @param int $version plugin version as declared via $plugin->version in its version.php
* @return stdClass|bool
*/
public function get_plugin_info($component, $version) {
$params = array(
'plugin' => $component.'@'.$version,
'format' => 'json',
);
return $this->call_pluginfo_service($params);
}
/**
* Locate the given plugin in the plugin directory.
*
* Uses pluginfo.php end-point to find a plugin with the given component
* name, that suits best for the given Moodle core branch. Minimal required
* plugin version can be specified. This is typically used for resolving
* dependencies.
*
* False is returned on error, or if there is no plugin with such component
* name found in the plugins directory via the API.
*
* If a plugin with the given component name is found, data about the
* plugin are returned as an object. The ->version property of the object
* contains the information about the particular plugin version that
* matches best the given critera. The ->version property is false if no
* suitable version of the plugin was found (yet the plugin itself is
* known).
*
* @param string $component frankenstyle name of the plugin
* @param string|int $reqversion minimal required version of the plugin, defaults to ANY_VERSION
* @param int $branch moodle core branch such as 29, 30, 31 etc, defaults to $CFG->branch
* @return stdClass|bool false or data object
*/
public function find_plugin($component, $reqversion=ANY_VERSION, $branch=null) {
global $CFG;
$params = array(
'plugin' => $component,
'format' => 'json',
);
if ($reqversion === ANY_VERSION) {
$params['minversion'] = 0;
} else {
$params['minversion'] = $reqversion;
}
if ($branch === null) {
$branch = $CFG->branch;
}
$params['branch'] = $this->convert_branch_numbering_format($branch);
return $this->call_pluginfo_service($params);
}
/**
* Calls the pluginfo.php end-point with given parameters.
*
* @param array $params
* @return stdClass|bool false or data object
*/
protected function call_pluginfo_service(array $params) {
$serviceurl = $this->get_serviceurl_pluginfo();
$response = $this->call_service($serviceurl, $params);
if ($response) {
if ($response->info['http_code'] == 404) {
// There is no such plugin found in the plugins directory.
return false;
} else if ($response->info['http_code'] == 200 and isset($response->data->status)
and $response->data->status === 'OK' and isset($response->data->pluginfo)) {
return $response->data->pluginfo;
} else {
debugging('cURL: Unexpected response '.array_shift($response->response), DEBUG_DEVELOPER);
return false;
}
}
return false;
}
/**
* Calls the given end-point service with the given parameters.
*
* Returns false on cURL error and/or SSL verification failure. Otherwise
* an object with the response, cURL info and HTTP status message is
* returned.
*
* @param string $serviceurl
* @param array $params
* @return stdClass|bool
*/
protected function call_service($serviceurl, array $params=array()) {
$response = (object)array(
'data' => null,
'info' => null,
'status' => null,
);
$curl = new curl();
$response->data = json_decode($curl->get($serviceurl, $params, array(
'CURLOPT_SSL_VERIFYHOST' => 2,
'CURLOPT_SSL_VERIFYPEER' => true,
)));
$curlerrno = $curl->get_errno();
if (!empty($curlerrno)) {
debugging('cURL: Error '.$curlerrno.' when calling '.$serviceurl, DEBUG_DEVELOPER);
return false;
}
$response->info = $curl->get_info();
if (isset($response->info['ssl_verify_result']) and $response->info['ssl_verify_result'] != 0) {
debugging('cURL/SSL: Unable to verify remote service response when calling '.$serviceurl, DEBUG_DEVELOPER);
return false;
}
// The first response header with the HTTP status code and reason phrase.
$response->status = array_shift($curl->response);
return $response;
}
/**
* Converts the given branch from XY format to the X.Y format
*
* The syntax of $CFG->branch uses the XY format that suits the Moodle docs
* versioning and stable branches numbering scheme. The API at
* download.moodle.org uses the X.Y numbering scheme.
*
* @param int $branch moodle branch in the XY format (e.g. 29, 30, 31 etc)
* @return string moodle branch in the X.Y format (e.g. 2.9, 3.0, 3.1 etc)
*/
protected function convert_branch_numbering_format($branch) {
$branch = (string)$branch;
if (strpos($branch, '.') === false) {
$branch = substr($branch, 0, -1).'.'.substr($branch, -1);
}
return $branch;
}
/**
* Returns URL of the pluginfo.php API end-point.
*
* @return string
*/
protected function get_serviceurl_pluginfo() {
global $CFG;
if (!empty($CFG->config_php_settings['alternativepluginfoserviceurl'])) {
return $CFG->config_php_settings['alternativepluginfoserviceurl'];
} else {
return self::APIROOT.'/'.self::APIVER.'/pluginfo.php';
}
}
}

View File

@ -0,0 +1,129 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* @package core
* @subpackage fixtures
* @category test
* @copyright 2015 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core\update;
defined('MOODLE_INTERNAL') || die();
/**
* Testable variant of \core\update\api class.
*
* Provides access to some protected methods we want to explicitly test and
* bypass the actual cURL calls by providing fake responses.
*
* @copyright 2015 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class testable_api extends api {
/**
* Provides access to the parent protected method.
*
* @param int $branch
* @return string
*/
public function convert_branch_numbering_format($branch) {
return parent::convert_branch_numbering_format($branch);
}
/**
* Returns fake URL of the pluginfo.php API end-point.
*
* @return string
*/
protected function get_serviceurl_pluginfo() {
return 'http://testab.le/api/pluginfo.php';
}
/**
* Mimics the call to the given end-point service with the given parameters.
*
* This simulates a hypothetical plugins directory with a single plugin
* 'foo_bar' available (with a single release).
*
* @param string $serviceurl
* @param array $params
* @return stdClass|bool
*/
protected function call_service($serviceurl, array $params=array()) {
$response = (object)array(
'data' => null,
'info' => null,
'status' => null,
);
if ($serviceurl === 'http://testab.le/api/pluginfo.php') {
if (strpos($params['plugin'], 'foo_bar@') === 0) {
$response->data = (object)array(
'status' => 'OK',
'pluginfo' => (object)array(
'component' => 'foo_bar',
'version' => false,
),
);
$response->info = array(
'http_code' => 200,
);
$response->status = '200 OK';
if (substr($params['plugin'], -11) === '@2015093000') {
$response->data->pluginfo->version = (object)array(
'downloadurl' => 'http://mood.le/plugins/foo_bar/2015093000.zip',
);
}
} else if ($params['plugin'] === 'foo_bar' and isset($params['branch']) and isset($params['minversion'])) {
$response->data = (object)array(
'status' => 'OK',
'pluginfo' => (object)array(
'component' => 'foo_bar',
'version' => false,
),
);
$response->info = array(
'http_code' => 200,
);
$response->status = '200 OK';
if ($params['minversion'] <= 2015093000) {
$response->data->pluginfo->version = (object)array(
'downloadurl' => 'http://mood.le/plugins/foo_bar/2015093000.zip',
);
}
} else {
$response->info = array(
'http_code' => 404,
);
$response->status = '404 Not Found (unknown plugin)';
}
return $response;
} else {
return 'This should not happen';
}
}
}

View File

@ -0,0 +1,94 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Provides core_update_checker_testcase class.
*
* @package core
* @category test
* @copyright 2015 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__.'/fixtures/testable_update_api.php');
/**
* Tests for \core\update\api client.
*
* @copyright 2015 David Mudrak <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_update_api_testcase extends advanced_testcase {
/**
* Make sure the $CFG->branch is mapped correctly to the format used by the API.
*/
public function test_convert_branch_numbering_format() {
$client = \core\update\testable_api::client();
$this->assertSame('2.9', $client->convert_branch_numbering_format(29));
$this->assertSame('3.0', $client->convert_branch_numbering_format('30'));
$this->assertSame('3.1', $client->convert_branch_numbering_format(3.1));
$this->assertSame('3.1', $client->convert_branch_numbering_format('3.1'));
$this->assertSame('10.1', $client->convert_branch_numbering_format(101));
$this->assertSame('10.2', $client->convert_branch_numbering_format('102'));
}
/**
* Getting info about particular plugin version.
*/
public function test_get_plugin_info() {
$client = \core\update\testable_api::client();
// The plugin is not found in the plugins directory.
$this->assertFalse($client->get_plugin_info('non_existing', 2015093000));
// The plugin is known but there is no such version.
$info = $client->get_plugin_info('foo_bar', 2014010100);
$this->assertFalse($info->version);
// Both plugin and the version are available.
$info = $client->get_plugin_info('foo_bar', 2015093000);
$this->assertNotNull($info->version->downloadurl);
}
/**
* Getting info about the most suitable plugin version for us.
*/
public function test_find_plugin() {
$client = \core\update\testable_api::client();
// The plugin is not found in the plugins directory.
$this->assertFalse($client->find_plugin('non_existing'));
// The plugin is known but there is no sufficient version.
$info = $client->find_plugin('foo_bar', 2015093001);
$this->assertFalse($info->version);
// Both plugin and the version are available.
$info = $client->find_plugin('foo_bar', 2015093000);
$this->assertNotNull($info->version->downloadurl);
$info = $client->find_plugin('foo_bar', ANY_VERSION);
$this->assertNotNull($info->version->downloadurl);
}
}