Merge "Add tests for WikiMap and WikiReference"
[lhc/web/wiklou.git] / resources / src / mediawiki.api / mediawiki.api.js
1 ( function ( mw, $ ) {
2
3 /**
4 * @class mw.Api
5 */
6
7 /**
8 * @property {Object} defaultOptions Default options for #ajax calls. Can be overridden by passing
9 * `options` to mw.Api constructor.
10 * @property {Object} defaultOptions.parameters Default query parameters for API requests.
11 * @property {Object} defaultOptions.ajax Default options for jQuery#ajax.
12 * @private
13 */
14 var defaultOptions = {
15 parameters: {
16 action: 'query',
17 format: 'json'
18 },
19 ajax: {
20 url: mw.util.wikiScript( 'api' ),
21 timeout: 30 * 1000, // 30 seconds
22 dataType: 'json'
23 }
24 },
25
26 // Keyed by ajax url and symbolic name for the individual request
27 promises = {};
28
29 // Pre-populate with fake ajax promises to save http requests for tokens
30 // we already have on the page via the user.tokens module (bug 34733).
31 promises[ defaultOptions.ajax.url ] = {};
32 $.each( mw.user.tokens.get(), function ( key, value ) {
33 // This requires #getToken to use the same key as user.tokens.
34 // Format: token-type + "Token" (eg. editToken, patrolToken, watchToken).
35 promises[ defaultOptions.ajax.url ][ key ] = $.Deferred()
36 .resolve( value )
37 .promise( { abort: function () {} } );
38 } );
39
40 /**
41 * Constructor to create an object to interact with the API of a particular MediaWiki server.
42 * mw.Api objects represent the API of a particular MediaWiki server.
43 *
44 * var api = new mw.Api();
45 * api.get( {
46 * action: 'query',
47 * meta: 'userinfo'
48 * } ).done( function ( data ) {
49 * console.log( data );
50 * } );
51 *
52 * Since MW 1.25, multiple values for a parameter can be specified using an array:
53 *
54 * var api = new mw.Api();
55 * api.get( {
56 * action: 'query',
57 * meta: [ 'userinfo', 'siteinfo' ] // same effect as 'userinfo|siteinfo'
58 * } ).done( function ( data ) {
59 * console.log( data );
60 * } );
61 *
62 * Since MW 1.26, boolean values for a parameter can be specified directly. If the value is
63 * `false` or `undefined`, the parameter will be omitted from the request, as required by the API.
64 *
65 * @constructor
66 * @param {Object} [options] See #defaultOptions documentation above. Can also be overridden for
67 * each individual request by passing them to #get or #post (or directly #ajax) later on.
68 */
69 mw.Api = function ( options ) {
70 // TODO: Share API objects with exact same config.
71 options = options || {};
72
73 // Force a string if we got a mw.Uri object
74 if ( options.ajax && options.ajax.url !== undefined ) {
75 options.ajax.url = String( options.ajax.url );
76 }
77
78 options.parameters = $.extend( {}, defaultOptions.parameters, options.parameters );
79 options.ajax = $.extend( {}, defaultOptions.ajax, options.ajax );
80
81 this.defaults = options;
82 };
83
84 mw.Api.prototype = {
85
86 /**
87 * Perform API get request
88 *
89 * @param {Object} parameters
90 * @param {Object} [ajaxOptions]
91 * @return {jQuery.Promise}
92 */
93 get: function ( parameters, ajaxOptions ) {
94 ajaxOptions = ajaxOptions || {};
95 ajaxOptions.type = 'GET';
96 return this.ajax( parameters, ajaxOptions );
97 },
98
99 /**
100 * Perform API post request
101 *
102 * TODO: Post actions for non-local hostnames will need proxy.
103 *
104 * @param {Object} parameters
105 * @param {Object} [ajaxOptions]
106 * @return {jQuery.Promise}
107 */
108 post: function ( parameters, ajaxOptions ) {
109 ajaxOptions = ajaxOptions || {};
110 ajaxOptions.type = 'POST';
111 return this.ajax( parameters, ajaxOptions );
112 },
113
114 /**
115 * Massage parameters from the nice format we accept into a format suitable for the API.
116 *
117 * @private
118 * @param {Object} parameters (modified in-place)
119 */
120 preprocessParameters: function ( parameters ) {
121 var key;
122 // Handle common MediaWiki API idioms for passing parameters
123 for ( key in parameters ) {
124 // Multiple values are pipe-separated
125 if ( $.isArray( parameters[ key ] ) ) {
126 parameters[ key ] = parameters[ key ].join( '|' );
127 }
128 // Boolean values are only false when not given at all
129 if ( parameters[ key ] === false || parameters[ key ] === undefined ) {
130 delete parameters[ key ];
131 }
132 }
133 },
134
135 /**
136 * Perform the API call.
137 *
138 * @param {Object} parameters
139 * @param {Object} [ajaxOptions]
140 * @return {jQuery.Promise} Done: API response data and the jqXHR object.
141 * Fail: Error code
142 */
143 ajax: function ( parameters, ajaxOptions ) {
144 var token,
145 apiDeferred = $.Deferred(),
146 xhr, key, formData;
147
148 parameters = $.extend( {}, this.defaults.parameters, parameters );
149 ajaxOptions = $.extend( {}, this.defaults.ajax, ajaxOptions );
150
151 // Ensure that token parameter is last (per [[mw:API:Edit#Token]]).
152 if ( parameters.token ) {
153 token = parameters.token;
154 delete parameters.token;
155 }
156
157 this.preprocessParameters( parameters );
158
159 // If multipart/form-data has been requested and emulation is possible, emulate it
160 if (
161 ajaxOptions.type === 'POST' &&
162 window.FormData &&
163 ajaxOptions.contentType === 'multipart/form-data'
164 ) {
165
166 formData = new FormData();
167
168 for ( key in parameters ) {
169 formData.append( key, parameters[ key ] );
170 }
171 // If we extracted a token parameter, add it back in.
172 if ( token ) {
173 formData.append( 'token', token );
174 }
175
176 ajaxOptions.data = formData;
177
178 // Prevent jQuery from mangling our FormData object
179 ajaxOptions.processData = false;
180 // Prevent jQuery from overriding the Content-Type header
181 ajaxOptions.contentType = false;
182 } else {
183 // Some deployed MediaWiki >= 1.17 forbid periods in URLs, due to an IE XSS bug
184 // So let's escape them here. See bug #28235
185 // This works because jQuery accepts data as a query string or as an Object
186 ajaxOptions.data = $.param( parameters ).replace( /\./g, '%2E' );
187
188 // If we extracted a token parameter, add it back in.
189 if ( token ) {
190 ajaxOptions.data += '&token=' + encodeURIComponent( token );
191 }
192
193 if ( ajaxOptions.contentType === 'multipart/form-data' ) {
194 // We were asked to emulate but can't, so drop the Content-Type header, otherwise
195 // it'll be wrong and the server will fail to decode the POST body
196 delete ajaxOptions.contentType;
197 }
198 }
199
200 // Make the AJAX request
201 xhr = $.ajax( ajaxOptions )
202 // If AJAX fails, reject API call with error code 'http'
203 // and details in second argument.
204 .fail( function ( xhr, textStatus, exception ) {
205 apiDeferred.reject( 'http', {
206 xhr: xhr,
207 textStatus: textStatus,
208 exception: exception
209 } );
210 } )
211 // AJAX success just means "200 OK" response, also check API error codes
212 .done( function ( result, textStatus, jqXHR ) {
213 if ( result === undefined || result === null || result === '' ) {
214 apiDeferred.reject( 'ok-but-empty',
215 'OK response but empty result (check HTTP headers?)'
216 );
217 } else if ( result.error ) {
218 var code = result.error.code === undefined ? 'unknown' : result.error.code;
219 apiDeferred.reject( code, result );
220 } else {
221 apiDeferred.resolve( result, jqXHR );
222 }
223 } );
224
225 // Return the Promise
226 return apiDeferred.promise( { abort: xhr.abort } ).fail( function ( code, details ) {
227 if ( !( code === 'http' && details && details.textStatus === 'abort' ) ) {
228 mw.log( 'mw.Api error: ', code, details );
229 }
230 } );
231 },
232
233 /**
234 * Post to API with specified type of token. If we have no token, get one and try to post.
235 * If we have a cached token try using that, and if it fails, blank out the
236 * cached token and start over. For example to change an user option you could do:
237 *
238 * new mw.Api().postWithToken( 'options', {
239 * action: 'options',
240 * optionname: 'gender',
241 * optionvalue: 'female'
242 * } );
243 *
244 * @param {string} tokenType The name of the token, like options or edit.
245 * @param {Object} params API parameters
246 * @param {Object} [ajaxOptions]
247 * @return {jQuery.Promise} See #post
248 * @since 1.22
249 */
250 postWithToken: function ( tokenType, params, ajaxOptions ) {
251 var api = this;
252
253 return api.getToken( tokenType, params.assert ).then( function ( token ) {
254 params.token = token;
255 return api.post( params, ajaxOptions ).then(
256 // If no error, return to caller as-is
257 null,
258 // Error handler
259 function ( code ) {
260 if ( code === 'badtoken' ) {
261 api.badToken( tokenType );
262 // Try again, once
263 params.token = undefined;
264 return api.getToken( tokenType, params.assert ).then( function ( token ) {
265 params.token = token;
266 return api.post( params, ajaxOptions );
267 } );
268 }
269
270 // Different error, pass on to let caller handle the error code
271 return this;
272 }
273 );
274 } );
275 },
276
277 /**
278 * Get a token for a certain action from the API.
279 *
280 * The assert parameter is only for internal use by postWithToken.
281 *
282 * @param {string} type Token type
283 * @return {jQuery.Promise}
284 * @return {Function} return.done
285 * @return {string} return.done.token Received token.
286 * @since 1.22
287 */
288 getToken: function ( type, assert ) {
289 var apiPromise,
290 promiseGroup = promises[ this.defaults.ajax.url ],
291 d = promiseGroup && promiseGroup[ type + 'Token' ];
292
293 if ( !d ) {
294 apiPromise = this.get( { action: 'tokens', type: type, assert: assert } );
295
296 d = apiPromise
297 .then( function ( data ) {
298 if ( data.tokens && data.tokens[ type + 'token' ] ) {
299 return data.tokens[ type + 'token' ];
300 }
301
302 // If token type is not available for this user,
303 // key '...token' is either missing or set to boolean false
304 return $.Deferred().reject( 'token-missing', data );
305 }, function () {
306 // Clear promise. Do not cache errors.
307 delete promiseGroup[ type + 'Token' ];
308 // Pass on to allow the caller to handle the error
309 return this;
310 } )
311 // Attach abort handler
312 .promise( { abort: apiPromise.abort } );
313
314 // Store deferred now so that we can use it again even if it isn't ready yet
315 if ( !promiseGroup ) {
316 promiseGroup = promises[ this.defaults.ajax.url ] = {};
317 }
318 promiseGroup[ type + 'Token' ] = d;
319 }
320
321 return d;
322 },
323
324 /**
325 * Indicate that the cached token for a certain action of the API is bad.
326 *
327 * Call this if you get a 'badtoken' error when using the token returned by #getToken.
328 * You may also want to use #postWithToken instead, which invalidates bad cached tokens
329 * automatically.
330 *
331 * @param {string} type Token type
332 * @since 1.26
333 */
334 badToken: function ( type ) {
335 var promiseGroup = promises[ this.defaults.ajax.url ];
336 if ( promiseGroup ) {
337 delete promiseGroup[ type + 'Token' ];
338 }
339 }
340 };
341
342 /**
343 * @static
344 * @property {Array}
345 * List of errors we might receive from the API.
346 * For now, this just documents our expectation that there should be similar messages
347 * available.
348 */
349 mw.Api.errors = [
350 // occurs when POST aborted
351 // jQuery 1.4 can't distinguish abort or lost connection from 200 OK + empty result
352 'ok-but-empty',
353
354 // timeout
355 'timeout',
356
357 // really a warning, but we treat it like an error
358 'duplicate',
359 'duplicate-archive',
360
361 // upload succeeded, but no image info.
362 // this is probably impossible, but might as well check for it
363 'noimageinfo',
364 // remote errors, defined in API
365 'uploaddisabled',
366 'nomodule',
367 'mustbeposted',
368 'badaccess-groups',
369 'missingresult',
370 'missingparam',
371 'invalid-file-key',
372 'copyuploaddisabled',
373 'mustbeloggedin',
374 'empty-file',
375 'file-too-large',
376 'filetype-missing',
377 'filetype-banned',
378 'filetype-banned-type',
379 'filename-tooshort',
380 'illegal-filename',
381 'verification-error',
382 'hookaborted',
383 'unknown-error',
384 'internal-error',
385 'overwrite',
386 'badtoken',
387 'fetchfileerror',
388 'fileexists-shared-forbidden',
389 'invalidtitle',
390 'notloggedin',
391
392 // Stash-specific errors - expanded
393 'stashfailed',
394 'stasherror',
395 'stashedfilenotfound',
396 'stashpathinvalid',
397 'stashfilestorage',
398 'stashzerolength',
399 'stashnotloggedin',
400 'stashwrongowner',
401 'stashnosuchfilekey'
402 ];
403
404 /**
405 * @static
406 * @property {Array}
407 * List of warnings we might receive from the API.
408 * For now, this just documents our expectation that there should be similar messages
409 * available.
410 */
411 mw.Api.warnings = [
412 'duplicate',
413 'exists'
414 ];
415
416 }( mediaWiki, jQuery ) );