diff --git a/src/classes/site.ts b/src/classes/site.ts index b9068bd13..8f01c4ad6 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -26,7 +26,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; -import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreUtilsProvider, PromiseDefer } from '@providers/utils/utils'; import { CoreConstants } from '@core/constants'; import { CoreConfigConstants } from '../configconstants'; import { Md5 } from 'ts-md5/dist/md5'; @@ -113,6 +113,17 @@ export interface CoreSiteWSPreSets { * @type {string} */ typeExpected?: string; + + /** + * Wehther a pending request in the queue matching the same function and arguments can be reused instead of adding + * a new request to the queue. Defaults to true for read requests. + */ + reusePending?: boolean; + + /** + * Whether the request will be be sent immediately as a single request. Defaults to false. + */ + skipQueue?: boolean; } /** @@ -144,6 +155,18 @@ export interface LocalMobileResponse { coreSupported?: boolean; } +/** + * Info of a request waiting in the queue. + */ +interface RequestQueueItem { + cacheId: string; + method: string; + data: any; + preSets: CoreSiteWSPreSets; + wsPreSets: CoreWSPreSets; + deferred: PromiseDefer; +} + /** * Class that represents a site (combination of site + user). * It will have all the site data and provide utility functions regarding a site. @@ -151,6 +174,11 @@ export interface LocalMobileResponse { * the tables are created in all the sites, not just the current one. */ export class CoreSite { + static REQUEST_QUEUE_DELAY = 50; // Maximum number of miliseconds to wait before processing the queue. + static REQUEST_QUEUE_LIMIT = 10; // Maximum number of requests allowed in the queue. + // @todo Set REQUEST_QUEUE_FORCE_WS to false before the release. + static REQUEST_QUEUE_FORCE_WS = true; // Use "tool_mobile_call_external_functions" even for calling a single function. + // List of injected services. This class isn't injectable, so it cannot use DI. protected appProvider: CoreAppProvider; protected dbProvider: CoreDbProvider; @@ -186,6 +214,8 @@ export class CoreSite { protected lastAutoLogin = 0; protected offlineDisabled = false; protected ongoingRequests: { [cacheId: string]: Promise } = {}; + protected requestQueue: RequestQueueItem[] = []; + protected requestQueueTimeout = null; /** * Create a site. @@ -217,6 +247,7 @@ export class CoreSite { this.wsProvider = injector.get(CoreWSProvider); this.logger = logger.getInstance('CoreWSProvider'); + this.setInfo(infos); this.calculateOfflineDisabled(); if (this.id) { @@ -349,6 +380,14 @@ export class CoreSite { */ setInfo(infos: any): void { this.infos = infos; + + // Index function by name to speed up wsAvailable method. + if (infos && infos.functions) { + infos.functionsByName = {}; + infos.functions.forEach((func) => { + infos.functionsByName[func.name] = func; + }); + } } /** @@ -442,7 +481,8 @@ export class CoreSite { // The get_site_info WS call won't be cached. const preSets = { getFromCache: false, - saveToCache: false + saveToCache: false, + skipQueue: true }; // Reset clean Unicode to check if it's supported again. @@ -467,6 +507,9 @@ export class CoreSite { if (typeof preSets.saveToCache == 'undefined') { preSets.saveToCache = true; } + if (typeof preSets.reusePending == 'undefined') { + preSets.reusePending = true; + } return this.request(method, data, preSets); } @@ -564,10 +607,9 @@ export class CoreSite { const originalData = data; - // Convert the values to string before starting the cache process. - try { - data = this.wsProvider.convertValuesToString(data, wsPreSets.cleanUnicode); - } catch (e) { + // Convert arguments to strings before starting the cache process. + data = this.wsProvider.convertValuesToString(data, wsPreSets.cleanUnicode); + if (data == null) { // Empty cleaned text found. return Promise.reject(this.utils.createFakeWSError('core.unicodenotsupportedcleanerror', true)); } @@ -584,7 +626,7 @@ export class CoreSite { const promise = this.getFromCache(method, data, preSets, false, originalData).catch(() => { // Do not pass those options to the core WS factory. - return this.wsProvider.call(method, data, wsPreSets).then((response) => { + return this.callOrEnqueueRequest(method, data, preSets, wsPreSets).then((response) => { if (preSets.saveToCache) { this.saveToCache(method, data, response, preSets); } @@ -699,6 +741,146 @@ export class CoreSite { }); } + /** + * Adds a request to the queue or calls it immediately when not using the queue. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreSiteWSPreSets} preSets Extra options related to the site. + * @param {CoreWSPreSets} wsPreSets Extra options related to the WS call. + * @returns {Promise} Promise resolved with the response when the WS is called. + */ + protected callOrEnqueueRequest(method: string, data: any, preSets: CoreSiteWSPreSets, wsPreSets: CoreWSPreSets): Promise { + if (preSets.skipQueue || !this.wsAvailable('tool_mobile_call_external_functions')) { + return this.wsProvider.call(method, data, wsPreSets); + } + + const cacheId = this.getCacheId(method, data); + + // Check if there is an identical request waiting in the queue (read requests only by default). + if (preSets.reusePending) { + const request = this.requestQueue.find((request) => request.cacheId == cacheId); + if (request) { + return request.deferred.promise; + } + } + + const request: RequestQueueItem = { + cacheId, + method, + data, + preSets, + wsPreSets, + deferred: {} + }; + + request.deferred.promise = new Promise((resolve, reject): void => { + request.deferred.resolve = resolve; + request.deferred.reject = reject; + }); + + this.requestQueue.push(request); + + if (this.requestQueue.length >= CoreSite.REQUEST_QUEUE_LIMIT) { + this.processRequestQueue(); + } else if (!this.requestQueueTimeout) { + this.requestQueueTimeout = setTimeout(this.processRequestQueue.bind(this), CoreSite.REQUEST_QUEUE_DELAY); + } + + return request.deferred.promise; + } + + /** + * Call the enqueued web service requests. + */ + protected processRequestQueue(): void { + this.logger.debug(`Processing request queue (${this.requestQueue.length} requests)`); + + // Clear timeout if set. + if (this.requestQueueTimeout) { + clearTimeout(this.requestQueueTimeout); + this.requestQueueTimeout = null; + } + + // Extract all requests from the queue. + const requests = this.requestQueue; + this.requestQueue = []; + + if (requests.length == 1 && !CoreSite.REQUEST_QUEUE_FORCE_WS) { + // Only one request, do a regular web service call. + this.wsProvider.call(requests[0].method, requests[0].data, requests[0].wsPreSets).then((data) => { + requests[0].deferred.resolve(data); + }).catch((error) => { + requests[0].deferred.reject(error); + }); + + return; + } + + const data = { + requests: requests.map((request) => { + const args = {}; + const settings = {}; + + // Separate WS settings from function arguments. + Object.keys(request.data).forEach((key) => { + let value = request.data[key]; + const match = /^moodlews(setting.*)$/.exec(key); + if (match) { + if (match[1] == 'settingfilter' || match[1] == 'settingfileurl') { + // Undo special treatment of these settings in CoreWSProvider.convertValuesToString. + value = (value == 'true' ? '1' : '0'); + } + settings[match[1]] = value; + } else { + args[key] = value; + } + }); + + return { + function: request.method, + arguments: JSON.stringify(args), + ...settings + }; + }) + }; + + const wsPresets: CoreWSPreSets = { + siteUrl: this.siteUrl, + wsToken: this.token, + }; + + this.wsProvider.call('tool_mobile_call_external_functions', data, wsPresets).then((data) => { + if (!data || !data.responses) { + return Promise.reject(null); + } + + requests.forEach((request, i) => { + const response = data.responses[i]; + + if (!response) { + // Request not executed, enqueue again. + this.callOrEnqueueRequest(request.method, request.data, request.preSets, request.wsPreSets); + } else if (response.error) { + request.deferred.reject(this.textUtils.parseJSON(response.exception)); + } else { + let responseData = this.textUtils.parseJSON(response.data); + // Match the behaviour of CoreWSProvider.call when no response is expected. + if (!responseData && (typeof wsPresets.responseExpected == 'undefined' || wsPresets.responseExpected)) { + responseData = {}; + } + request.deferred.resolve(responseData); + } + }); + + }).catch((error) => { + // Error not specific to a single request, reject all promises. + requests.forEach((request) => { + request.deferred.reject(error); + }); + }); + } + /** * Check if a WS is available in this site. * @@ -711,11 +893,8 @@ export class CoreSite { return false; } - for (let i = 0; i < this.infos.functions.length; i++) { - const func = this.infos.functions[i]; - if (func.name == method) { - return true; - } + if (this.infos.functionsByName[method]) { + return true; } // Let's try again with the compatibility prefix. diff --git a/src/core/user/providers/user.ts b/src/core/user/providers/user.ts index 928fb7bff..dd87758d8 100644 --- a/src/core/user/providers/user.ts +++ b/src/core/user/providers/user.ts @@ -250,8 +250,8 @@ export class CoreUserProvider { this.logger.debug(`Get user with ID '${userId}'`); wsName = 'core_user_get_users_by_field'; data = { - 'field': 'id', - 'values[0]': userId + field: 'id', + values: [userId] }; } diff --git a/src/providers/ws.ts b/src/providers/ws.ts index bc2cd80fc..10eb3821e 100644 --- a/src/providers/ws.ts +++ b/src/providers/ws.ts @@ -284,32 +284,55 @@ export class CoreWSProvider { /** * Converts an objects values to strings where appropriate. - * Arrays (associative or otherwise) will be maintained. + * Arrays (associative or otherwise) will be maintained, null values will be removed. * * @param {object} data The data that needs all the non-object values set to strings. * @param {boolean} [stripUnicode] If Unicode long chars need to be stripped. - * @return {object} The cleaned object, with multilevel array and objects preserved. + * @return {object} The cleaned object or null if some strings becomes empty after stripping Unicode. */ - convertValuesToString(data: object, stripUnicode?: boolean): object { - let result; - if (!Array.isArray(data) && typeof data == 'object') { - result = {}; - } else { - result = []; - } + convertValuesToString(data: any, stripUnicode?: boolean): any { + const result: any = Array.isArray(data) ? [] : {}; - for (const el in data) { - if (typeof data[el] == 'object') { - result[el] = this.convertValuesToString(data[el], stripUnicode); - } else { - if (typeof data[el] == 'string') { - result[el] = stripUnicode ? this.textUtils.stripUnicode(data[el]) : data[el]; - if (stripUnicode && data[el] != result[el] && result[el].trim().length == 0) { - throw new Error(); - } - } else { - result[el] = data[el] + ''; + for (const key in data) { + let value = data[key]; + + if (value == null) { + // Skip null or undefined value. + continue; + } else if (typeof value == 'object') { + // Object or array. + value = this.convertValuesToString(value, stripUnicode); + if (value == null) { + return null; } + } else if (typeof value == 'string') { + if (stripUnicode) { + const stripped = this.textUtils.stripUnicode(value); + if (stripped != value && stripped.trim().length == 0) { + return null; + } + value = stripped; + } + } else if (typeof value == 'boolean') { + /* Moodle does not allow "true" or "false" in WS parameters, only in POST parameters. + We've been using "true" and "false" for WS settings "filter" and "fileurl", + we keep it this way to avoid changing cache keys. */ + if (key == 'moodlewssettingfilter' || key == 'moodlewssettingfileurl') { + value = value ? 'true' : 'false'; + } else { + value = value ? '1' : '0'; + } + } else if (typeof value == 'number') { + value = String(value); + } else { + // Unknown type. + continue; + } + + if (Array.isArray(result)) { + result.push(value); + } else { + result[key] = value; } } @@ -525,9 +548,9 @@ export class CoreWSProvider { } // Perform the post request. - let promise = this.http.post(siteUrl, ajaxData, options).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + const promise = this.http.post(siteUrl, ajaxData, options).timeout(CoreConstants.WS_TIMEOUT).toPromise(); - promise = promise.then((data: any) => { + return promise.then((data: any) => { // Some moodle web services return null. // If the responseExpected value is set to false, we create a blank object if the response is null. if (!data && !preSets.responseExpected) { @@ -608,10 +631,6 @@ export class CoreWSProvider { return Promise.reject(this.createFakeWSError('core.serverconnection', true)); }); - - promise = this.setPromiseHttp(promise, 'post', preSets.siteUrl, ajaxData); - - return promise; } /** @@ -692,9 +711,8 @@ export class CoreWSProvider { preSets.responseExpected = true; } - try { - data = this.convertValuesToString(data, preSets.cleanUnicode); - } catch (e) { + data = this.convertValuesToString(data || {}, preSets.cleanUnicode); + if (data == null) { // Empty cleaned text found. errorResponse.message = this.translate.instant('core.unicodenotsupportedcleanerror');