Introduce mediawiki.ForeignApi
authorBartosz Dziewoński <matma.rex@gmail.com>
Fri, 14 Aug 2015 23:17:49 +0000 (01:17 +0200)
committerBartosz Dziewoński <matma.rex@gmail.com>
Fri, 21 Aug 2015 17:00:01 +0000 (17:00 +0000)
mw.ForeignApi is an extension of mw.Api, automatically handling
everything required to communicate with another MediaWiki wiki via
cross-origin requests (CORS).

Authentication-related MediaWiki extensions may extend it further to
ensure that the user authenticated on the current wiki will be
automatically authenticated on the foreign one. A CentralAuth
implementation is provided in I0fd05ef8b9c9db0fdb59c6cb248f364259f80456.

Bug: T66636
Change-Id: Ic20b9682d28633baa87d22e6e9fb71ce507da58d

autoload.php
docs/hooks.txt
includes/resourceloader/ResourceLoaderForeignApiModule.php [new file with mode: 0644]
maintenance/jsduck/categories.json
resources/Resources.php
resources/src/mediawiki.api/mediawiki.ForeignApi.js [new file with mode: 0644]
tests/qunit/QUnitTestResources.php
tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js [new file with mode: 0644]

index 3b123c4..6bc7238 100644 (file)
@@ -1012,6 +1012,7 @@ $wgAutoloadLocalClasses = array(
        'ResourceLoaderEditToolbarModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderEditToolbarModule.php',
        'ResourceLoaderFileModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderFileModule.php',
        'ResourceLoaderFilePath' => __DIR__ . '/includes/resourceloader/ResourceLoaderFilePath.php',
+       'ResourceLoaderForeignApiModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderForeignApiModule.php',
        'ResourceLoaderImage' => __DIR__ . '/includes/resourceloader/ResourceLoaderImage.php',
        'ResourceLoaderImageModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderImageModule.php',
        'ResourceLoaderJqueryMsgModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderJqueryMsgModule.php',
index bf55997..5e2269a 100644 (file)
@@ -2448,6 +2448,13 @@ $user: The user having their password expiration reset
 $oldSessionID: old session id
 $newSessionID: new session id
 
+'ResourceLoaderForeignApiModules': Called from ResourceLoaderForeignApiModule.
+Use this to add dependencies to 'mediawiki.ForeignApi' module when you wish
+to override its behavior. See the module docs for more information.
+&$dependencies: string[] List of modules that 'mediawiki.ForeignApi' should
+depend on
+$context: ResourceLoaderContext|null
+
 'ResourceLoaderGetConfigVars': Called at the end of
 ResourceLoaderStartUpModule::getConfigSettings(). Use this to export static
 configuration variables to JavaScript. Things that depend on the current page
diff --git a/includes/resourceloader/ResourceLoaderForeignApiModule.php b/includes/resourceloader/ResourceLoaderForeignApiModule.php
new file mode 100644 (file)
index 0000000..7ed0831
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+/**
+ * ResourceLoader module for mediawiki.ForeignApi that has dynamically
+ * generated dependencies, via a hook usable by extensions.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoader module for mediawiki.ForeignApi and its generated data
+ */
+class ResourceLoaderForeignApiModule extends ResourceLoaderFileModule {
+       public function getDependencies( ResourceLoaderContext $context = null ) {
+               $dependencies = $this->dependencies;
+               Hooks::run( 'ResourceLoaderForeignApiModules', array( &$dependencies, $context ) );
+               return $dependencies;
+       }
+}
index 07e72bf..d547b7b 100644 (file)
@@ -40,7 +40,7 @@
                        },
                        {
                                "name": "API",
-                               "classes": ["mw.Api*"]
+                               "classes": ["mw.Api*", "mw.ForeignApi*"]
                        },
                        {
                                "name": "Language",
index 5ac00fe..9e73415 100644 (file)
@@ -981,6 +981,18 @@ return array(
                        'oojs-ui',
                ),
        ),
+       'mediawiki.ForeignApi' => array(
+               'class' => 'ResourceLoaderForeignApiModule',
+               // Additional dependencies generated dynamically
+               'dependencies' => 'mediawiki.ForeignApi.core',
+       ),
+       'mediawiki.ForeignApi.core' => array(
+               'scripts' => 'resources/src/mediawiki.api/mediawiki.ForeignApi.js',
+               'dependencies' => array(
+                       'mediawiki.api',
+                       'oojs',
+               ),
+       ),
        'mediawiki.helplink' => array(
                'position' => 'top',
                'styles' => array(
diff --git a/resources/src/mediawiki.api/mediawiki.ForeignApi.js b/resources/src/mediawiki.api/mediawiki.ForeignApi.js
new file mode 100644 (file)
index 0000000..c835345
--- /dev/null
@@ -0,0 +1,109 @@
+( function ( mw, $ ) {
+
+       /**
+        * Create an object like mw.Api, but automatically handling everything required to communicate
+        * with another MediaWiki wiki via cross-origin requests (CORS).
+        *
+        * The foreign wiki must be configured to accept requests from the current wiki. See
+        * <https://www.mediawiki.org/wiki/Manual:$wgCrossSiteAJAXdomains> for details.
+        *
+        *     var api = new mw.ForeignApi( 'https://commons.wikimedia.org/w/api.php' );
+        *     api.get( {
+        *         action: 'query',
+        *         meta: 'userinfo'
+        *     } ).done( function ( data ) {
+        *         console.log( data );
+        *     } );
+        *
+        * To ensure that the user at the foreign wiki is logged in, pass the `assert: 'user'` parameter
+        * to #get/#post (since MW 1.23): if they are not, the API request will fail. (Note that this
+        * doesn't guarantee that it's the same user.)
+        *
+        * Authentication-related MediaWiki extensions may extend this class to ensure that the user
+        * authenticated on the current wiki will be automatically authenticated on the foreign one. These
+        * extension modules should be registered using the ResourceLoaderForeignApiModules hook. See
+        * CentralAuth for a practical example. The general pattern to extend and override the name is:
+        *
+        *     function MyForeignApi() {};
+        *     OO.inheritClass( MyForeignApi, mw.ForeignApi );
+        *     mw.ForeignApi = MyForeignApi;
+        *
+        * @class mw.ForeignApi
+        * @extends mw.Api
+        * @since 1.26
+        *
+        * @constructor
+        * @param {string|mw.Uri} url URL pointing to another wiki's `api.php` endpoint.
+        * @param {Object} [options] See mw.Api.
+        *
+        * @author Bartosz Dziewoński
+        * @author Jon Robson
+        */
+       function CoreForeignApi( url, options ) {
+               if ( !url || $.isPlainObject( url ) ) {
+                       throw new Error( 'mw.ForeignApi() requires a `url` parameter' );
+               }
+
+               this.apiUrl = String( url );
+
+               options = $.extend( /*deep=*/ true,
+                       {
+                               ajax: {
+                                       url: this.apiUrl,
+                                       xhrFields: {
+                                               withCredentials: true
+                                       }
+                               },
+                               parameters: {
+                                       // Add 'origin' query parameter to all requests.
+                                       origin: this.getOrigin()
+                               }
+                       },
+                       options
+               );
+
+               // Call parent constructor
+               CoreForeignApi.parent.call( this, options );
+       }
+
+       OO.inheritClass( CoreForeignApi, mw.Api );
+
+       /**
+        * Return the origin to use for API requests, in the required format (protocol, host and port, if
+        * any).
+        *
+        * @protected
+        * @return {string}
+        */
+       CoreForeignApi.prototype.getOrigin = function () {
+               var origin = window.location.protocol + '//' + window.location.hostname;
+               if ( window.location.port ) {
+                       origin += ':' + window.location.port;
+               }
+               return origin;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       CoreForeignApi.prototype.ajax = function ( parameters, ajaxOptions ) {
+               var url, origin, newAjaxOptions;
+
+               // 'origin' query parameter must be part of the request URI, and not just POST request body
+               if ( ajaxOptions.type !== 'GET' ) {
+                       url = ( ajaxOptions && ajaxOptions.url ) || this.defaults.ajax.url;
+                       origin = ( parameters && parameters.origin ) || this.defaults.parameters.origin;
+                       url += ( url.indexOf( '?' ) !== -1 ? '&' : '?' ) +
+                               'origin=' + encodeURIComponent( origin );
+                       newAjaxOptions = $.extend( {}, ajaxOptions, { url: url } );
+               } else {
+                       newAjaxOptions = ajaxOptions;
+               }
+
+               return CoreForeignApi.parent.prototype.ajax.call( this, parameters, newAjaxOptions );
+       };
+
+       // Expose
+       mw.ForeignApi = CoreForeignApi;
+
+}( mediaWiki, jQuery ) );
index bcfdead..60b2802 100644 (file)
@@ -83,6 +83,7 @@ return array(
                        'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js',
                        'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js',
                        'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js',
+                       'tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js',
                        'tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js',
@@ -111,6 +112,7 @@ return array(
                        'mediawiki.api.parse',
                        'mediawiki.api.upload',
                        'mediawiki.api.watch',
+                       'mediawiki.ForeignApi.core',
                        'mediawiki.jqueryMsg',
                        'mediawiki.messagePoster',
                        'mediawiki.RegExp',
diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js
new file mode 100644 (file)
index 0000000..9d0fdf5
--- /dev/null
@@ -0,0 +1,39 @@
+( function ( mw ) {
+       QUnit.module( 'mediawiki.ForeignApi', QUnit.newMwEnvironment( {
+               setup: function () {
+                       this.server = this.sandbox.useFakeServer();
+                       this.server.respondImmediately = true;
+                       this.clock = this.sandbox.useFakeTimers();
+               },
+               teardown: function () {
+                       // https://github.com/jquery/jquery/issues/2453
+                       this.clock.tick();
+               }
+       } ) );
+
+       QUnit.test( 'origin is included in GET requests', function ( assert ) {
+               QUnit.expect( 1 );
+               var api = new mw.ForeignApi( '//localhost:4242/w/api.php' );
+
+               this.server.respond( function ( request ) {
+                       assert.ok( request.url.match( /origin=/ ), 'origin is included in GET requests' );
+                       request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+               } );
+
+               api.get( {} );
+       } );
+
+       QUnit.test( 'origin is included in POST requests', function ( assert ) {
+               QUnit.expect( 2 );
+               var api = new mw.ForeignApi( '//localhost:4242/w/api.php' );
+
+               this.server.respond( function ( request ) {
+                       assert.ok( request.requestBody.match( /origin=/ ), 'origin is included in POST request body' );
+                       assert.ok( request.url.match( /origin=/ ), 'origin is included in POST request URL, too' );
+                       request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+               } );
+
+               api.post( {} );
+       } );
+
+}( mediaWiki ) );