From f19beb3279ac858bf4b57e5d8c881a4755dc6b55 Mon Sep 17 00:00:00 2001 From: Albert Gasset Date: Mon, 21 Jan 2019 17:00:17 +0100 Subject: [PATCH] MDL-64642 tool_mobile: Web service tool_mobile_call_external_functions --- admin/tool/mobile/classes/external.php | 116 +++++++++++++++ admin/tool/mobile/db/services.php | 9 ++ admin/tool/mobile/tests/externallib_test.php | 144 +++++++++++++++++++ admin/tool/mobile/upgrade.txt | 4 + admin/tool/mobile/version.php | 2 +- lib/externallib.php | 2 +- 6 files changed, 275 insertions(+), 2 deletions(-) diff --git a/admin/tool/mobile/classes/external.php b/admin/tool/mobile/classes/external.php index 6a4626477f5..17956350a89 100644 --- a/admin/tool/mobile/classes/external.php +++ b/admin/tool/mobile/classes/external.php @@ -26,6 +26,7 @@ namespace tool_mobile; defined('MOODLE_INTERNAL') || die(); require_once("$CFG->libdir/externallib.php"); +require_once("$CFG->dirroot/webservice/lib.php"); use external_api; use external_files; @@ -460,4 +461,119 @@ class external extends external_api { ) ); } + + /** + * Returns description of method parameters + * + * @return external_function_parameters + * @since Moodle 3.7 + */ + public static function call_external_functions_parameters() { + return new external_function_parameters([ + 'requests' => new external_multiple_structure( + new external_single_structure([ + 'function' => new external_value(PARAM_ALPHANUMEXT, 'Function name'), + 'arguments' => new external_value(PARAM_RAW, 'JSON-encoded object with named arguments', VALUE_DEFAULT, '{}'), + 'settingraw' => new external_value(PARAM_BOOL, 'Return raw text', VALUE_DEFAULT, false), + 'settingfilter' => new external_value(PARAM_BOOL, 'Filter text', VALUE_DEFAULT, false), + 'settingfileurl' => new external_value(PARAM_BOOL, 'Rewrite plugin file URLs', VALUE_DEFAULT, true), + 'settinglang' => new external_value(PARAM_LANG, 'Session language', VALUE_DEFAULT, ''), + ]) + ) + ]); + } + + /** + * Call multiple external functions and return all responses. + * + * @param array $requests List of requests. + * @return array Responses. + * @since Moodle 3.7 + */ + public static function call_external_functions($requests) { + global $SESSION; + + $params = self::validate_parameters(self::call_external_functions_parameters(), ['requests' => $requests]); + + // We need to check if the functions being called are included in the service of the current token. + // This function only works when using mobile services via REST (this is intended). + $webservicemanager = new \webservice; + $token = $webservicemanager->get_user_ws_token(required_param('wstoken', PARAM_ALPHANUM)); + + $settings = \external_settings::get_instance(); + $defaultlang = current_language(); + $responses = []; + + foreach ($params['requests'] as $request) { + // Some external functions modify _GET or $_POST data, we need to restore the original data after each call. + $originalget = fullclone($_GET); + $originalpost = fullclone($_POST); + + // Set external settings and language. + $settings->set_raw($request['settingraw']); + $settings->set_filter($request['settingfilter']); + $settings->set_fileurl($request['settingfileurl']); + $settings->set_lang($request['settinglang']); + $SESSION->lang = $request['settinglang'] ?: $defaultlang; + + // Parse arguments to an array, validation is done in external_api::call_external_function. + $args = @json_decode($request['arguments'], true); + if (!is_array($args)) { + $args = []; + } + + if ($webservicemanager->service_function_exists($request['function'], $token->externalserviceid)) { + $response = external_api::call_external_function($request['function'], $args, false); + } else { + // Function not included in the service, return an access exception. + $response = [ + 'error' => true, + 'exception' => [ + 'errorcode' => 'accessexception', + 'module' => 'webservice' + ] + ]; + if (debugging('', DEBUG_DEVELOPER)) { + $response['exception']['debuginfo'] = 'Access to the function is not allowed.'; + } + } + + if (isset($response['data'])) { + $response['data'] = json_encode($response['data']); + } + if (isset($response['exception'])) { + $response['exception'] = json_encode($response['exception']); + } + $responses[] = $response; + + // Restore original $_GET and $_POST. + $_GET = $originalget; + $_POST = $originalpost; + + if ($response['error']) { + // Do not process the remaining requests. + break; + } + } + + return ['responses' => $responses]; + } + + /** + * Returns description of method result value + * + * @return external_single_structure + * @since Moodle 3.7 + */ + public static function call_external_functions_returns() { + return new external_function_parameters([ + 'responses' => new external_multiple_structure( + new external_single_structure([ + 'error' => new external_value(PARAM_BOOL, 'Whether an exception was thrown.'), + 'data' => new external_value(PARAM_RAW, 'JSON-encoded response data', VALUE_OPTIONAL), + 'exception' => new external_value(PARAM_RAW, 'JSON-encoed exception info', VALUE_OPTIONAL), + ]) + ) + ]); + } } diff --git a/admin/tool/mobile/db/services.php b/admin/tool/mobile/db/services.php index 5e329c6ad86..0b50d6853cf 100644 --- a/admin/tool/mobile/db/services.php +++ b/admin/tool/mobile/db/services.php @@ -61,6 +61,7 @@ $functions = array( 'type' => 'write', 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE), ), + 'tool_mobile_get_content' => array( 'classname' => 'tool_mobile\external', 'methodname' => 'get_content', @@ -68,5 +69,13 @@ $functions = array( 'type' => 'read', 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE), ), + + 'tool_mobile_call_external_functions' => array( + 'classname' => 'tool_mobile\external', + 'methodname' => 'call_external_functions', + 'description' => 'Call multiple external functions and return all responses.', + 'type' => 'write', + 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE), + ), ); diff --git a/admin/tool/mobile/tests/externallib_test.php b/admin/tool/mobile/tests/externallib_test.php index 00db97ef5ca..4045ab14d26 100644 --- a/admin/tool/mobile/tests/externallib_test.php +++ b/admin/tool/mobile/tests/externallib_test.php @@ -30,6 +30,7 @@ global $CFG; require_once($CFG->dirroot . '/webservice/tests/helpers.php'); require_once($CFG->dirroot . '/admin/tool/mobile/tests/fixtures/output/mobile.php'); +require_once($CFG->dirroot . '/webservice/lib.php'); use tool_mobile\external; use tool_mobile\api; @@ -358,4 +359,147 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase { $this->expectException('moodle_exception'); $result = external::get_content('tool_blahblahblah', 'test_view'); } + + public function test_call_external_functions() { + global $SESSION; + + $this->resetAfterTest(true); + + $category = self::getDataGenerator()->create_category(array('name' => 'Category 1')); + $course = self::getDataGenerator()->create_course([ + 'category' => $category->id, + 'shortname' => 'c1', + 'summary' => 'Course summary' + . 'Kurso resumo' + . '@@PLUGINFILE@@/filename.txt' + . '', + 'summaryformat' => FORMAT_MOODLE + ]); + $user1 = self::getDataGenerator()->create_user(['username' => 'user1', 'lastaccess' => time()]); + $user2 = self::getDataGenerator()->create_user(['username' => 'user2', 'lastaccess' => time()]); + + self::setUser($user1); + + // Setup WS token. + $webservicemanager = new \webservice; + $service = $webservicemanager->get_external_service_by_shortname(MOODLE_OFFICIAL_MOBILE_SERVICE); + $token = external_generate_token_for_current_user($service); + $_POST['wstoken'] = $token->token; + + // Workaround for external_api::call_external_function requiring sesskey. + $_POST['sesskey'] = sesskey(); + + // Call some functions. + + $requests = [ + [ + 'function' => 'core_course_get_courses_by_field', + 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]) + ], + [ + 'function' => 'core_user_get_users_by_field', + 'arguments' => json_encode(['field' => 'id', 'values' => [$user1->id]]) + ], + [ + 'function' => 'core_user_get_user_preferences', + 'arguments' => json_encode(['name' => 'some_setting', 'userid' => $user2->id]) + ], + [ + 'function' => 'core_course_get_courses_by_field', + 'arguments' => json_encode(['field' => 'shortname', 'value' => $course->shortname]) + ], + ]; + $result = external::call_external_functions($requests); + + // We need to execute the return values cleaning process to simulate the web service server. + $result = external_api::clean_returnvalue(external::call_external_functions_returns(), $result); + + // Only 3 responses, the 4th request is not executed because the 3rd throws an exception. + $this->assertCount(3, $result['responses']); + + $this->assertFalse($result['responses'][0]['error']); + $coursedata = external_api::clean_returnvalue( + core_course_external::get_courses_by_field_returns(), + core_course_external::get_courses_by_field('id', $course->id)); + $this->assertEquals(json_encode($coursedata), $result['responses'][0]['data']); + + $this->assertFalse($result['responses'][1]['error']); + $userdata = external_api::clean_returnvalue( + core_user_external::get_users_by_field_returns(), + core_user_external::get_users_by_field('id', [$user1->id])); + $this->assertEquals(json_encode($userdata), $result['responses'][1]['data']); + + $this->assertTrue($result['responses'][2]['error']); + $exception = json_decode($result['responses'][2]['exception'], true); + $this->assertEquals('nopermissions', $exception['errorcode']); + + // Call a function not included in the external service. + + $_POST['wstoken'] = $token->token; + $functions = $webservicemanager->get_not_associated_external_functions($service->id); + $requests = [['function' => current($functions)->name]]; + $result = external::call_external_functions($requests); + + $this->assertTrue($result['responses'][0]['error']); + $exception = json_decode($result['responses'][0]['exception'], true); + $this->assertEquals('accessexception', $exception['errorcode']); + $this->assertEquals('webservice', $exception['module']); + + // Call a function with different external settings. + + filter_set_global_state('multilang', TEXTFILTER_ON); + $_POST['wstoken'] = $token->token; + $SESSION->lang = 'eo'; // Change default language, so we can test changing it to "en". + $requests = [ + [ + 'function' => 'core_course_get_courses_by_field', + 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]), + ], + [ + 'function' => 'core_course_get_courses_by_field', + 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]), + 'settingraw' => '1' + ], + [ + 'function' => 'core_course_get_courses_by_field', + 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]), + 'settingraw' => '1', + 'settingfileurl' => '0' + ], + [ + 'function' => 'core_course_get_courses_by_field', + 'arguments' => json_encode(['field' => 'id', 'value' => $course->id]), + 'settingfilter' => '1', + 'settinglang' => 'en' + ], + ]; + $result = external::call_external_functions($requests); + + $this->assertCount(4, $result['responses']); + + $context = \context_course::instance($course->id); + $pluginfile = 'webservice/pluginfile.php'; + + $this->assertFalse($result['responses'][0]['error']); + $data = json_decode($result['responses'][0]['data']); + $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null); + $expected = format_text($expected, $course->summaryformat, ['para' => false, 'filter' => false]); + $this->assertEquals($expected, $data->courses[0]->summary); + + $this->assertFalse($result['responses'][1]['error']); + $data = json_decode($result['responses'][1]['data']); + $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null); + $this->assertEquals($expected, $data->courses[0]->summary); + + $this->assertFalse($result['responses'][2]['error']); + $data = json_decode($result['responses'][2]['data']); + $this->assertEquals($course->summary, $data->courses[0]->summary); + + $this->assertFalse($result['responses'][3]['error']); + $data = json_decode($result['responses'][3]['data']); + $expected = file_rewrite_pluginfile_urls($course->summary, $pluginfile, $context->id, 'course', 'summary', null); + $SESSION->lang = 'en'; // We expect filtered text in english. + $expected = format_text($expected, $course->summaryformat, ['para' => false, 'filter' => true]); + $this->assertEquals($expected, $data->courses[0]->summary); + } } diff --git a/admin/tool/mobile/upgrade.txt b/admin/tool/mobile/upgrade.txt index 2149382be8d..ec720c3491a 100644 --- a/admin/tool/mobile/upgrade.txt +++ b/admin/tool/mobile/upgrade.txt @@ -1,6 +1,10 @@ This files describes changes in tool_mobile code. Information provided here is intended especially for developers. +=== 3.7 === + + * New external function tool_mobile::tool_mobile_call_external_function allows calling multiple external functions and returns all responses. + === 3.5 === * External function tool_mobile::tool_mobile_get_plugins_supporting_mobile now returns additional plugins information required by diff --git a/admin/tool/mobile/version.php b/admin/tool/mobile/version.php index bc7dc201005..766dfb272ab 100644 --- a/admin/tool/mobile/version.php +++ b/admin/tool/mobile/version.php @@ -23,7 +23,7 @@ */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2019021100; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2019021101; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2018112800; // Requires this Moodle version. $plugin->component = 'tool_mobile'; // Full name of the plugin (used for diagnostics). $plugin->dependencies = array( diff --git a/lib/externallib.php b/lib/externallib.php index 17961911a64..c958000820a 100644 --- a/lib/externallib.php +++ b/lib/externallib.php @@ -206,7 +206,7 @@ class external_api { } // Do not allow access to write or delete webservices as a public user. - if ($externalfunctioninfo->loginrequired) { + if ($externalfunctioninfo->loginrequired && !WS_SERVER) { if (defined('NO_MOODLE_COOKIES') && NO_MOODLE_COOKIES && !PHPUNIT_TEST) { throw new moodle_exception('servicerequireslogin', 'webservice'); }