From 2f30ff7a8613f87ba23961c3c638dba5f7213efd Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bartosz=20Dziewo=C5=84ski?= Date: Sat, 15 Aug 2015 01:17:49 +0200 Subject: [PATCH] Introduce mediawiki.ForeignApi 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 | 1 + docs/hooks.txt | 7 ++ .../ResourceLoaderForeignApiModule.php | 33 ++++++ maintenance/jsduck/categories.json | 2 +- resources/Resources.php | 12 ++ .../src/mediawiki.api/mediawiki.ForeignApi.js | 109 ++++++++++++++++++ tests/qunit/QUnitTestResources.php | 2 + .../mediawiki.ForeignApi.test.js | 39 +++++++ 8 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 includes/resourceloader/ResourceLoaderForeignApiModule.php create mode 100644 resources/src/mediawiki.api/mediawiki.ForeignApi.js create mode 100644 tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js diff --git a/autoload.php b/autoload.php index 3b123c431b..6bc7238ce0 100644 --- a/autoload.php +++ b/autoload.php @@ -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', diff --git a/docs/hooks.txt b/docs/hooks.txt index bf5599778e..5e2269abb7 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -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 index 0000000000..7ed08317eb --- /dev/null +++ b/includes/resourceloader/ResourceLoaderForeignApiModule.php @@ -0,0 +1,33 @@ +dependencies; + Hooks::run( 'ResourceLoaderForeignApiModules', array( &$dependencies, $context ) ); + return $dependencies; + } +} diff --git a/maintenance/jsduck/categories.json b/maintenance/jsduck/categories.json index 07e72bf85d..d547b7bde6 100644 --- a/maintenance/jsduck/categories.json +++ b/maintenance/jsduck/categories.json @@ -40,7 +40,7 @@ }, { "name": "API", - "classes": ["mw.Api*"] + "classes": ["mw.Api*", "mw.ForeignApi*"] }, { "name": "Language", diff --git a/resources/Resources.php b/resources/Resources.php index 5ac00fe444..9e73415139 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -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 index 0000000000..c835345d1b --- /dev/null +++ b/resources/src/mediawiki.api/mediawiki.ForeignApi.js @@ -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 + * 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 ) ); diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index bcfdead56e..60b2802cd8 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -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 index 0000000000..9d0fdf54b0 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js @@ -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 ) ); -- 2.20.1