(bug 36783) Implement Promise interface in mediawiki.api module.
authorTimo Tijhof <ttijhof@wikimedia.org>
Thu, 7 Jun 2012 20:45:53 +0000 (22:45 +0200)
committerTimo Tijhof <ttijhof@wikimedia.org>
Sun, 8 Jul 2012 18:56:28 +0000 (20:56 +0200)
- 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
resources/mediawiki.api/mediawiki.api.js
resources/mediawiki.api/mediawiki.api.parse.js
tests/qunit/QUnitTestResources.php
tests/qunit/data/testrunner.js
tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js [new file with mode: 0644]
tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js [new file with mode: 0644]

index f1ecf08..3b1d03f 100644 (file)
@@ -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.
index 74306d5..88b92ee 100644 (file)
@@ -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 ) {
 
        /**
                        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'
                        }
        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 );
                 * @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 );
                 *
                 * @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 );
 
                        // 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 );
+                       });
                }
 
        };
index 1cc68f2..e784ef7 100644 (file)
@@ -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 );
index adfd111..1cd085f 100644 (file)
@@ -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',
        )
index f3176ab..848384b 100644 (file)
@@ -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 (file)
index 0000000..246b74a
--- /dev/null
@@ -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 ), '<p><b>Hello world</b>\n</p>',
+                               '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 (file)
index 0000000..79bd730
--- /dev/null
@@ -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();
+       });
+});