From 031910f7fef4ac2c319848a22ea3ad707163aef3 Mon Sep 17 00:00:00 2001 From: Timo Tijhof Date: Thu, 7 Jun 2012 22:45:53 +0200 Subject: [PATCH] (bug 36783) Implement Promise interface in mediawiki.api module. - Deprecates 'ok'/'err' properties. They still work, but are no longer mandatory. Instead the Promise interface should used. - See unit tests for good examples of how it works. http://alpha.dev/mediawiki/core/wiki/Special:JavaScriptTest/qunit?module=mediawiki.api - Migrated submodule "mediawiki.api.parse" as example. Other mediawiki.api.* submodules will be migrated later. Change-Id: I4d8428124598093f220dd28a8cbf861686ab61a7 --- RELEASE-NOTES-1.20 | 1 + resources/mediawiki.api/mediawiki.api.js | 113 ++++++++++-------- .../mediawiki.api/mediawiki.api.parse.js | 45 ++++--- tests/qunit/QUnitTestResources.php | 10 +- tests/qunit/data/testrunner.js | 18 +++ .../mediawiki.api/mediawiki.api.parse.test.js | 18 +++ .../mediawiki.api/mediawiki.api.test.js | 59 +++++++++ 7 files changed, 195 insertions(+), 69 deletions(-) create mode 100644 tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js create mode 100644 tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js diff --git a/RELEASE-NOTES-1.20 b/RELEASE-NOTES-1.20 index f1ecf08443..3b1d03f552 100644 --- a/RELEASE-NOTES-1.20 +++ b/RELEASE-NOTES-1.20 @@ -92,6 +92,7 @@ upgrade PHP if you have not done so prior to upgrading MediaWiki. replacement, and so on. * (bug 34678) Added InternalParseBeforeSanitize hook which gets called during Parser's internalParse method just before the parser removes unwanted/dangerous HTML tags. +* (bug 36783) Implement jQuery Promise interface in mediawiki.api module. === Bug fixes in 1.20 === * (bug 30245) Use the correct way to construct a log page title. diff --git a/resources/mediawiki.api/mediawiki.api.js b/resources/mediawiki.api/mediawiki.api.js index 74306d5c68..88b92ee0c4 100644 --- a/resources/mediawiki.api/mediawiki.api.js +++ b/resources/mediawiki.api/mediawiki.api.js @@ -1,5 +1,6 @@ -/* mw.Api objects represent the API of a particular MediaWiki server. */ - +/** + * mw.Api objects represent the API of a particular MediaWiki server. + */ ( function( $, mw, undefined ) { /** @@ -21,14 +22,7 @@ ajax: { url: mw.util.wikiScript( 'api' ), - ok: function() {}, - - // caller can supply handlers for http transport error or api errors - err: function( code, result ) { - mw.log( 'mw.Api error: ' + code, 'debug' ); - }, - - timeout: 30000, // 30 seconds + timeout: 30 * 1000, // 30 seconds dataType: 'json' } @@ -73,32 +67,29 @@ mw.Api.prototype = { /** - * For api queries, in simple cases the caller just passes a success callback. - * In complex cases they pass an object with a success property as callback and - * probably other options. - * Normalize the argument so that it's always the latter case. + * Normalize the ajax options for compatibility and/or convenience methods. * - * @param {Object|Function} An object contaning one or more of options.ajax, + * @param {undefined|Object|Function} An object contaning one or more of options.ajax, * or just a success function (options.ajax.ok). * @return {Object} Normalized ajax options. */ - normalizeAjaxOptions: function( arg ) { - var opt = arg; + normalizeAjaxOptions: function ( arg ) { + // Arg argument is usually empty + // (before MW 1.20 it was often used to pass ok/err callbacks) + var opts = arg || {}; + // Options can also be a success callback handler if ( typeof arg === 'function' ) { - opt = { 'ok': arg }; + opts = { ok: arg }; } - if ( !opt.ok ) { - throw new Error( 'ajax options must include ok callback' ); - } - return opt; + return opts; }, /** * Perform API get request * * @param {Object} request parameters - * @param {Object|Function} ajax options, or just a success function - * @return {jqXHR} + * @param {Object|Function} [optional] ajax options + * @return {jQuery.Promise} */ get: function( parameters, ajaxOptions ) { ajaxOptions = this.normalizeAjaxOptions( ajaxOptions ); @@ -111,8 +102,8 @@ * @todo Post actions for nonlocal will need proxy * * @param {Object} request parameters - * @param {Object|Function} ajax options, or just a success function - * @return {jqXHR} + * @param {Object|Function} [optional] ajax options + * @return {jQuery.Promise} */ post: function( parameters, ajaxOptions ) { ajaxOptions = this.normalizeAjaxOptions( ajaxOptions ); @@ -125,10 +116,14 @@ * * @param {Object} request parameters * @param {Object} ajax options - * @return {jqXHR} + * @return {jQuery.Promise} + * - done: API response data as first argument + * - fail: errorcode as first arg, details (string or object) as second arg. */ ajax: function( parameters, ajaxOptions ) { - var token; + var token, + apiDeferred = $.Deferred(); + parameters = $.extend( {}, this.defaults.parameters, parameters ); ajaxOptions = $.extend( {}, this.defaults.ajax, ajaxOptions ); @@ -141,32 +136,52 @@ // So let's escape them here. See bug #28235 // This works because jQuery accepts data as a query string or as an Object ajaxOptions.data = $.param( parameters ).replace( /\./g, '%2E' ); + // If we extracted a token parameter, add it back in. if ( token ) { ajaxOptions.data += '&token=' + encodeURIComponent( token ); } - ajaxOptions.error = function( xhr, textStatus, exception ) { - ajaxOptions.err( 'http', { - xhr: xhr, - textStatus: textStatus, - exception: exception + + // Backwards compatibility: Before MediaWiki 1.20, + // callbacks were done with the 'ok' and 'err' property in ajaxOptions. + if ( ajaxOptions.ok ) { + apiDeferred.done( ajaxOptions.ok ); + delete ajaxOptions.ok; + } + if ( ajaxOptions.err ) { + apiDeferred.fail( ajaxOptions.err ); + delete ajaxOptions.err; + } + + // Make the AJAX request + $.ajax( ajaxOptions ) + // If AJAX fails, reject API call with error code 'http' + // and details in second argument. + .fail( function ( xhr, textStatus, exception ) { + apiDeferred.reject( 'http', { + xhr: xhr, + textStatus: textStatus, + exception: exception + } ); + } ) + // AJAX success just means "200 OK" response, also check API error codes + .done( function ( result ) { + if ( result === undefined || result === null || result === '' ) { + apiDeferred.reject( 'ok-but-empty', + 'OK response but empty result (check HTTP headers?)' + ); + } else if ( result.error ) { + var code = result.error.code === undefined ? 'unknown' : result.error.code; + apiDeferred.reject( code, result ); + } else { + apiDeferred.resolve( result ); + } } ); - }; - - // Success just means 200 OK; also check for output and API errors - ajaxOptions.success = function( result ) { - if ( result === undefined || result === null || result === '' ) { - ajaxOptions.err( 'ok-but-empty', - 'OK response but empty result (check HTTP headers?)' ); - } else if ( result.error ) { - var code = result.error.code === undefined ? 'unknown' : result.error.code; - ajaxOptions.err( code, result ); - } else { - ajaxOptions.ok( result ); - } - }; - - return $.ajax( ajaxOptions ); + + // Return the Promise + return apiDeferred.promise().fail( function ( code, details ) { + mw.log( 'mw.Api error: ', code, details ); + }); } }; diff --git a/resources/mediawiki.api/mediawiki.api.parse.js b/resources/mediawiki.api/mediawiki.api.parse.js index 1cc68f29cf..e784ef7581 100644 --- a/resources/mediawiki.api/mediawiki.api.parse.js +++ b/resources/mediawiki.api/mediawiki.api.parse.js @@ -1,31 +1,42 @@ /** - * Additional mw.Api methods to assist with API calls related to parsing wikitext. + * mw.Api methods for parsing wikitext. */ - -( function( $, mw ) { +( function ( mw, $ ) { $.extend( mw.Api.prototype, { /** * Convinience method for 'action=parse'. Parses wikitext into HTML. * * @param wikiText {String} - * @param success {Function} callback to which to pass success HTML - * @param err {Function} callback if error (optional) - * @return {jqXHR} + * @param ok {Function} [optional] deprecated (success callback) + * @param err {Function} [optional] deprecated (error callback) + * @return {jQuery.Promise} */ - parse: function( wikiText, success, err ) { - var params = { - text: wikiText, - action: 'parse' - }, - ok = function( data ) { + parse: function( wikiText, ok, err ) { + var apiDeferred = $.Deferred(); + + // Backwards compatibility (< MW 1.20) + if ( ok ) { + apiDeferred.done( ok ); + } + if ( err ) { + apiDeferred.fail( err ); + } + + this.get( { + action: 'parse', + text: wikiText + } ) + .done( function ( data ) { if ( data.parse && data.parse.text && data.parse.text['*'] ) { - success( data.parse.text['*'] ); + apiDeferred.resolve( data.parse.text['*'] ); } - }; - return this.get( params, { ok: ok, err: err } ); - } + } ) + .fail( apiDeferred.reject ); + // Return the promise + return apiDeferred.promise(); + } } ); -} )( jQuery, mediaWiki ); +} )( mediaWiki, jQuery ); diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index adfd111214..1cd085ffee 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -19,14 +19,16 @@ return array( 'tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js', 'tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js', 'tests/qunit/suites/resources/jquery/jquery.textSelection.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js', 'tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js', - 'tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js', ), 'dependencies' => array( @@ -44,13 +46,15 @@ return array( 'jquery.tablesorter', 'jquery.textSelection', 'mediawiki', + 'mediawiki.api', + 'mediawiki.api.parse', + 'mediawiki.jqueryMsg', 'mediawiki.Title', 'mediawiki.Uri', 'mediawiki.user', 'mediawiki.util', 'mediawiki.special.recentchanges', - 'mediawiki.jqueryMsg', - 'mediawiki.language' + 'mediawiki.language', ), 'position' => 'top', ) diff --git a/tests/qunit/data/testrunner.js b/tests/qunit/data/testrunner.js index f3176abf72..848384b05e 100644 --- a/tests/qunit/data/testrunner.js +++ b/tests/qunit/data/testrunner.js @@ -147,6 +147,24 @@ QUnit.newMwEnvironment = ( function () { }; }() ); +// $.when stops as soon as one fails, which makes sense in most +// practical scenarios, but not in a unit test where we really do +// need to wait until all of them are finished. +QUnit.whenPromisesComplete = function () { + var altPromises = []; + + $.each( arguments, function ( i, arg ) { + var alt = $.Deferred(); + altPromises.push( alt ); + + // Whether this one fails or not, forwards it to + // the 'done' (resolve) callback of the alternative promise. + arg.always( alt.resolve ); + }); + + return $.when.apply( $, altPromises ); +}; + /** * Add-on assertion helpers */ diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js new file mode 100644 index 0000000000..246b74a05e --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js @@ -0,0 +1,18 @@ +QUnit.module( 'mediawiki.api.parse', QUnit.newMwEnvironment() ); + +QUnit.asyncTest( 'Simple', function ( assert ) { + var api; + QUnit.expect( 1 ); + + api = new mw.Api(); + + api.parse( "'''Hello world'''" ) + .done( function ( html ) { + // Html also contains "NewPP report", so only check the first part + assert.equal( html.substr( 0, 26 ), '

Hello world\n

', + 'Wikitext to html parsing works.' + ); + + QUnit.start(); + }); +}); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js new file mode 100644 index 0000000000..79bd730654 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js @@ -0,0 +1,59 @@ +QUnit.module( 'mediawiki.api', QUnit.newMwEnvironment() ); + +QUnit.asyncTest( 'Basic functionality', function ( assert ) { + var api, d1, d2, d3; + QUnit.expect( 3 ); + + api = new mw.Api(); + + d1 = api.get( {} ) + .done( function ( data ) { + assert.deepEqual( data, [], 'If request succeeds without errors, resolve deferred' ); + }); + + d2 = api.get({ + action: 'doesntexist' + }) + .fail( function ( errorCode, details ) { + assert.equal( errorCode, 'unknown_action', 'API error (e.g. "unknown_action") should reject the deferred' ); + }); + + d3 = api.post( {} ) + .done( function ( data ) { + assert.deepEqual( data, [], 'Simple POST request' ); + }); + + // After all are completed, continue the test suite. + QUnit.whenPromisesComplete( d1, d2, d3 ).always( function () { + QUnit.start(); + }); +}); + +QUnit.asyncTest( 'Deprecated callback methods', function ( assert ) { + var api, d1, d2, d3; + QUnit.expect( 3 ); + + api = new mw.Api(); + + d1 = api.get( {}, function () { + assert.ok( true, 'Function argument treated as success callback.' ); + }); + + d2 = api.get( {}, { + ok: function ( data ) { + assert.ok( true, '"ok" property treated as success callback.' ); + } + }); + + d3 = api.get({ + action: 'doesntexist' + }, { + err: function ( data ) { + assert.ok( true, '"err" property treated as error callback.' ); + } + }); + + QUnit.whenPromisesComplete( d1, d2, d3 ).always( function () { + QUnit.start(); + }); +}); -- 2.20.1