]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - tests/phpunit/includes/api/ApiMainTest.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / tests / phpunit / includes / api / ApiMainTest.php
1 <?php
2
3 use Wikimedia\TestingAccessWrapper;
4
5 /**
6  * @group API
7  * @group Database
8  * @group medium
9  *
10  * @covers ApiMain
11  */
12 class ApiMainTest extends ApiTestCase {
13
14         /**
15          * Test that the API will accept a FauxRequest and execute.
16          */
17         public function testApi() {
18                 $api = new ApiMain(
19                         new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
20                 );
21                 $api->execute();
22                 $data = $api->getResult()->getResultData();
23                 $this->assertInternalType( 'array', $data );
24                 $this->assertArrayHasKey( 'query', $data );
25         }
26
27         public static function provideAssert() {
28                 return [
29                         [ false, [], 'user', 'assertuserfailed' ],
30                         [ true, [], 'user', false ],
31                         [ true, [], 'bot', 'assertbotfailed' ],
32                         [ true, [ 'bot' ], 'user', false ],
33                         [ true, [ 'bot' ], 'bot', false ],
34                 ];
35         }
36
37         /**
38          * Tests the assert={user|bot} functionality
39          *
40          * @covers ApiMain::checkAsserts
41          * @dataProvider provideAssert
42          * @param bool $registered
43          * @param array $rights
44          * @param string $assert
45          * @param string|bool $error False if no error expected
46          */
47         public function testAssert( $registered, $rights, $assert, $error ) {
48                 if ( $registered ) {
49                         $user = $this->getMutableTestUser()->getUser();
50                         $user->load(); // load before setting mRights
51                 } else {
52                         $user = new User();
53                 }
54                 $user->mRights = $rights;
55                 try {
56                         $this->doApiRequest( [
57                                 'action' => 'query',
58                                 'assert' => $assert,
59                         ], null, null, $user );
60                         $this->assertFalse( $error ); // That no error was expected
61                 } catch ( ApiUsageException $e ) {
62                         $this->assertTrue( self::apiExceptionHasCode( $e, $error ),
63                                 "Error '{$e->getMessage()}' matched expected '$error'" );
64                 }
65         }
66
67         /**
68          * Tests the assertuser= functionality
69          *
70          * @covers ApiMain::checkAsserts
71          */
72         public function testAssertUser() {
73                 $user = $this->getTestUser()->getUser();
74                 $this->doApiRequest( [
75                         'action' => 'query',
76                         'assertuser' => $user->getName(),
77                 ], null, null, $user );
78
79                 try {
80                         $this->doApiRequest( [
81                                 'action' => 'query',
82                                 'assertuser' => $user->getName() . 'X',
83                         ], null, null, $user );
84                         $this->fail( 'Expected exception not thrown' );
85                 } catch ( ApiUsageException $e ) {
86                         $this->assertTrue( self::apiExceptionHasCode( $e, 'assertnameduserfailed' ) );
87                 }
88         }
89
90         /**
91          * Test if all classes in the main module manager exists
92          */
93         public function testClassNamesInModuleManager() {
94                 $api = new ApiMain(
95                         new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
96                 );
97                 $modules = $api->getModuleManager()->getNamesWithClasses();
98
99                 foreach ( $modules as $name => $class ) {
100                         $this->assertTrue(
101                                 class_exists( $class ),
102                                 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
103                         );
104                 }
105         }
106
107         /**
108          * Test HTTP precondition headers
109          *
110          * @covers ApiMain::checkConditionalRequestHeaders
111          * @dataProvider provideCheckConditionalRequestHeaders
112          * @param array $headers HTTP headers
113          * @param array $conditions Return data for ApiBase::getConditionalRequestData
114          * @param int $status Expected response status
115          * @param bool $post Request is a POST
116          */
117         public function testCheckConditionalRequestHeaders(
118                 $headers, $conditions, $status, $post = false
119         ) {
120                 $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post );
121                 $request->setHeaders( $headers );
122                 $request->response()->statusHeader( 200 ); // Why doesn't it default?
123
124                 $context = $this->apiContext->newTestContext( $request, null );
125                 $api = new ApiMain( $context );
126                 $priv = TestingAccessWrapper::newFromObject( $api );
127                 $priv->mInternalMode = false;
128
129                 $module = $this->getMockBuilder( 'ApiBase' )
130                         ->setConstructorArgs( [ $api, 'mock' ] )
131                         ->setMethods( [ 'getConditionalRequestData' ] )
132                         ->getMockForAbstractClass();
133                 $module->expects( $this->any() )
134                         ->method( 'getConditionalRequestData' )
135                         ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
136                                 return isset( $conditions[$condition] ) ? $conditions[$condition] : null;
137                         } ) );
138
139                 $ret = $priv->checkConditionalRequestHeaders( $module );
140
141                 $this->assertSame( $status, $request->response()->getStatusCode() );
142                 $this->assertSame( $status === 200, $ret );
143         }
144
145         public static function provideCheckConditionalRequestHeaders() {
146                 $now = time();
147
148                 return [
149                         // Non-existing from module is ignored
150                         [ [ 'If-None-Match' => '"foo", "bar"' ], [], 200 ],
151                         [ [ 'If-Modified-Since' => 'Tue, 18 Aug 2015 00:00:00 GMT' ], [], 200 ],
152
153                         // No headers
154                         [
155                                 [],
156                                 [
157                                         'etag' => '""',
158                                         'last-modified' => '20150815000000',
159                                 ],
160                                 200
161                         ],
162
163                         // Basic If-None-Match
164                         [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 304 ],
165                         [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"baz"' ], 200 ],
166                         [ [ 'If-None-Match' => '"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
167                         [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => '"foo"' ], 304 ],
168                         [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
169
170                         // Pointless, but supported
171                         [ [ 'If-None-Match' => '*' ], [], 304 ],
172
173                         // Basic If-Modified-Since
174                         [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
175                                 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
176                         [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
177                                 [ 'last-modified' => wfTimestamp( TS_MW, $now ) ], 304 ],
178                         [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
179                                 [ 'last-modified' => wfTimestamp( TS_MW, $now + 1 ) ], 200 ],
180
181                         // If-Modified-Since ignored when If-None-Match is given too
182                         [ [ 'If-None-Match' => '""', 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
183                                 [ 'etag' => '"x"', 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
184                         [ [ 'If-None-Match' => '""', 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
185                                 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
186
187                         // Ignored for POST
188                         [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 200, true ],
189                         [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
190                                 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200, true ],
191
192                         // Other date formats allowed by the RFC
193                         [ [ 'If-Modified-Since' => gmdate( 'l, d-M-y H:i:s', $now ) . ' GMT' ],
194                                 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
195                         [ [ 'If-Modified-Since' => gmdate( 'D M j H:i:s Y', $now ) ],
196                                 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
197
198                         // Old browser extension to HTTP/1.0
199                         [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) . '; length=123' ],
200                                 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
201
202                         // Invalid date formats should be ignored
203                         [ [ 'If-Modified-Since' => gmdate( 'Y-m-d H:i:s', $now ) . ' GMT' ],
204                                 [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
205                 ];
206         }
207
208         /**
209          * Test conditional headers output
210          * @dataProvider provideConditionalRequestHeadersOutput
211          * @param array $conditions Return data for ApiBase::getConditionalRequestData
212          * @param array $headers Expected output headers
213          * @param bool $isError $isError flag
214          * @param bool $post Request is a POST
215          */
216         public function testConditionalRequestHeadersOutput(
217                 $conditions, $headers, $isError = false, $post = false
218         ) {
219                 $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post );
220                 $response = $request->response();
221
222                 $api = new ApiMain( $request );
223                 $priv = TestingAccessWrapper::newFromObject( $api );
224                 $priv->mInternalMode = false;
225
226                 $module = $this->getMockBuilder( 'ApiBase' )
227                         ->setConstructorArgs( [ $api, 'mock' ] )
228                         ->setMethods( [ 'getConditionalRequestData' ] )
229                         ->getMockForAbstractClass();
230                 $module->expects( $this->any() )
231                         ->method( 'getConditionalRequestData' )
232                         ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
233                                 return isset( $conditions[$condition] ) ? $conditions[$condition] : null;
234                         } ) );
235                 $priv->mModule = $module;
236
237                 $priv->sendCacheHeaders( $isError );
238
239                 foreach ( [ 'Last-Modified', 'ETag' ] as $header ) {
240                         $this->assertEquals(
241                                 isset( $headers[$header] ) ? $headers[$header] : null,
242                                 $response->getHeader( $header ),
243                                 $header
244                         );
245                 }
246         }
247
248         public static function provideConditionalRequestHeadersOutput() {
249                 return [
250                         [
251                                 [],
252                                 []
253                         ],
254                         [
255                                 [ 'etag' => '"foo"' ],
256                                 [ 'ETag' => '"foo"' ]
257                         ],
258                         [
259                                 [ 'last-modified' => '20150818000102' ],
260                                 [ 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
261                         ],
262                         [
263                                 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
264                                 [ 'ETag' => '"foo"', 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
265                         ],
266                         [
267                                 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
268                                 [],
269                                 true,
270                         ],
271                         [
272                                 [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
273                                 [],
274                                 false,
275                                 true,
276                         ],
277                 ];
278         }
279
280         /**
281          * @covers ApiMain::lacksSameOriginSecurity
282          */
283         public function testLacksSameOriginSecurity() {
284                 // Basic test
285                 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
286                 $this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' );
287
288                 // JSONp
289                 $main = new ApiMain(
290                         new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] )
291                 );
292                 $this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' );
293
294                 // Header
295                 $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] );
296                 $request->setHeader( 'TrEaT-As-UnTrUsTeD', '' ); // With falsey value!
297                 $main = new ApiMain( $request );
298                 $this->assertTrue( $main->lacksSameOriginSecurity(), 'Header supplied, should lack security' );
299
300                 // Hook
301                 $this->mergeMwGlobalArrayValue( 'wgHooks', [
302                         'RequestHasSameOriginSecurity' => [ function () {
303                                 return false;
304                         } ]
305                 ] );
306                 $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
307                 $this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' );
308         }
309
310         /**
311          * Test proper creation of the ApiErrorFormatter
312          * @covers ApiMain::__construct
313          * @dataProvider provideApiErrorFormatterCreation
314          * @param array $request Request parameters
315          * @param array $expect Expected data
316          *  - uselang: ApiMain language
317          *  - class: ApiErrorFormatter class
318          *  - lang: ApiErrorFormatter language
319          *  - format: ApiErrorFormatter format
320          *  - usedb: ApiErrorFormatter use-database flag
321          */
322         public function testApiErrorFormatterCreation( array $request, array $expect ) {
323                 $context = new RequestContext();
324                 $context->setRequest( new FauxRequest( $request ) );
325                 $context->setLanguage( 'ru' );
326
327                 $main = new ApiMain( $context );
328                 $formatter = $main->getErrorFormatter();
329                 $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );
330
331                 $this->assertSame( $expect['uselang'], $main->getLanguage()->getCode() );
332                 $this->assertInstanceOf( $expect['class'], $formatter );
333                 $this->assertSame( $expect['lang'], $formatter->getLanguage()->getCode() );
334                 $this->assertSame( $expect['format'], $wrappedFormatter->format );
335                 $this->assertSame( $expect['usedb'], $wrappedFormatter->useDB );
336         }
337
338         public static function provideApiErrorFormatterCreation() {
339                 return [
340                         'Default (BC)' => [ [], [
341                                 'uselang' => 'ru',
342                                 'class' => ApiErrorFormatter_BackCompat::class,
343                                 'lang' => 'en',
344                                 'format' => 'none',
345                                 'usedb' => false,
346                         ] ],
347                         'BC ignores fields' => [ [ 'errorlang' => 'de', 'errorsuselocal' => 1 ], [
348                                 'uselang' => 'ru',
349                                 'class' => ApiErrorFormatter_BackCompat::class,
350                                 'lang' => 'en',
351                                 'format' => 'none',
352                                 'usedb' => false,
353                         ] ],
354                         'Explicit BC' => [ [ 'errorformat' => 'bc' ], [
355                                 'uselang' => 'ru',
356                                 'class' => ApiErrorFormatter_BackCompat::class,
357                                 'lang' => 'en',
358                                 'format' => 'none',
359                                 'usedb' => false,
360                         ] ],
361                         'Basic' => [ [ 'errorformat' => 'wikitext' ], [
362                                 'uselang' => 'ru',
363                                 'class' => ApiErrorFormatter::class,
364                                 'lang' => 'ru',
365                                 'format' => 'wikitext',
366                                 'usedb' => false,
367                         ] ],
368                         'Follows uselang' => [ [ 'uselang' => 'fr', 'errorformat' => 'plaintext' ], [
369                                 'uselang' => 'fr',
370                                 'class' => ApiErrorFormatter::class,
371                                 'lang' => 'fr',
372                                 'format' => 'plaintext',
373                                 'usedb' => false,
374                         ] ],
375                         'Explicitly follows uselang' => [
376                                 [ 'uselang' => 'fr', 'errorlang' => 'uselang', 'errorformat' => 'plaintext' ],
377                                 [
378                                         'uselang' => 'fr',
379                                         'class' => ApiErrorFormatter::class,
380                                         'lang' => 'fr',
381                                         'format' => 'plaintext',
382                                         'usedb' => false,
383                                 ]
384                         ],
385                         'uselang=content' => [
386                                 [ 'uselang' => 'content', 'errorformat' => 'plaintext' ],
387                                 [
388                                         'uselang' => 'en',
389                                         'class' => ApiErrorFormatter::class,
390                                         'lang' => 'en',
391                                         'format' => 'plaintext',
392                                         'usedb' => false,
393                                 ]
394                         ],
395                         'errorlang=content' => [
396                                 [ 'errorlang' => 'content', 'errorformat' => 'plaintext' ],
397                                 [
398                                         'uselang' => 'ru',
399                                         'class' => ApiErrorFormatter::class,
400                                         'lang' => 'en',
401                                         'format' => 'plaintext',
402                                         'usedb' => false,
403                                 ]
404                         ],
405                         'Explicit parameters' => [
406                                 [ 'errorlang' => 'de', 'errorformat' => 'html', 'errorsuselocal' => 1 ],
407                                 [
408                                         'uselang' => 'ru',
409                                         'class' => ApiErrorFormatter::class,
410                                         'lang' => 'de',
411                                         'format' => 'html',
412                                         'usedb' => true,
413                                 ]
414                         ],
415                         'Explicit parameters override uselang' => [
416                                 [ 'errorlang' => 'de', 'uselang' => 'fr', 'errorformat' => 'raw' ],
417                                 [
418                                         'uselang' => 'fr',
419                                         'class' => ApiErrorFormatter::class,
420                                         'lang' => 'de',
421                                         'format' => 'raw',
422                                         'usedb' => false,
423                                 ]
424                         ],
425                         'Bogus language doesn\'t explode' => [
426                                 [ 'errorlang' => '<bogus1>', 'uselang' => '<bogus2>', 'errorformat' => 'none' ],
427                                 [
428                                         'uselang' => 'en',
429                                         'class' => ApiErrorFormatter::class,
430                                         'lang' => 'en',
431                                         'format' => 'none',
432                                         'usedb' => false,
433                                 ]
434                         ],
435                         'Bogus format doesn\'t explode' => [ [ 'errorformat' => 'bogus' ], [
436                                 'uselang' => 'ru',
437                                 'class' => ApiErrorFormatter_BackCompat::class,
438                                 'lang' => 'en',
439                                 'format' => 'none',
440                                 'usedb' => false,
441                         ] ],
442                 ];
443         }
444
445         /**
446          * @covers ApiMain::errorMessagesFromException
447          * @covers ApiMain::substituteResultWithError
448          * @dataProvider provideExceptionErrors
449          * @param Exception $exception
450          * @param array $expectReturn
451          * @param array $expectResult
452          */
453         public function testExceptionErrors( $error, $expectReturn, $expectResult ) {
454                 $context = new RequestContext();
455                 $context->setRequest( new FauxRequest( [ 'errorformat' => 'plaintext' ] ) );
456                 $context->setLanguage( 'en' );
457                 $context->setConfig( new MultiConfig( [
458                         new HashConfig( [
459                                 'ShowHostnames' => true, 'ShowSQLErrors' => false,
460                                 'ShowExceptionDetails' => true, 'ShowDBErrorBacktrace' => true,
461                         ] ),
462                         $context->getConfig()
463                 ] ) );
464
465                 $main = new ApiMain( $context );
466                 $main->addWarning( new RawMessage( 'existing warning' ), 'existing-warning' );
467                 $main->addError( new RawMessage( 'existing error' ), 'existing-error' );
468
469                 $ret = TestingAccessWrapper::newFromObject( $main )->substituteResultWithError( $error );
470                 $this->assertSame( $expectReturn, $ret );
471
472                 // PHPUnit sometimes adds some SplObjectStorage garbage to the arrays,
473                 // so let's try ->assertEquals().
474                 $this->assertEquals(
475                         $expectResult,
476                         $main->getResult()->getResultData( [], [ 'Strip' => 'all' ] )
477                 );
478         }
479
480         // Not static so $this can be used
481         public function provideExceptionErrors() {
482                 $reqId = WebRequest::getRequestId();
483                 $doclink = wfExpandUrl( wfScript( 'api' ) );
484
485                 $ex = new InvalidArgumentException( 'Random exception' );
486                 $trace = wfMessage( 'api-exception-trace',
487                         get_class( $ex ),
488                         $ex->getFile(),
489                         $ex->getLine(),
490                         MWExceptionHandler::getRedactedTraceAsString( $ex )
491                 )->inLanguage( 'en' )->useDatabase( false )->text();
492
493                 $dbex = new DBQueryError(
494                         $this->createMock( 'IDatabase' ),
495                         'error', 1234, 'SELECT 1', __METHOD__ );
496                 $dbtrace = wfMessage( 'api-exception-trace',
497                         get_class( $dbex ),
498                         $dbex->getFile(),
499                         $dbex->getLine(),
500                         MWExceptionHandler::getRedactedTraceAsString( $dbex )
501                 )->inLanguage( 'en' )->useDatabase( false )->text();
502
503                 MediaWiki\suppressWarnings();
504                 $usageEx = new UsageException( 'Usage exception!', 'ue', 0, [ 'foo' => 'bar' ] );
505                 MediaWiki\restoreWarnings();
506
507                 $apiEx1 = new ApiUsageException( null,
508                         StatusValue::newFatal( new ApiRawMessage( 'An error', 'sv-error1' ) ) );
509                 TestingAccessWrapper::newFromObject( $apiEx1 )->modulePath = 'foo+bar';
510                 $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'A warning', 'sv-warn1' ) );
511                 $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'Another warning', 'sv-warn2' ) );
512                 $apiEx1->getStatusValue()->fatal( new ApiRawMessage( 'Another error', 'sv-error2' ) );
513
514                 return [
515                         [
516                                 $ex,
517                                 [ 'existing-error', 'internal_api_error_InvalidArgumentException' ],
518                                 [
519                                         'warnings' => [
520                                                 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
521                                         ],
522                                         'errors' => [
523                                                 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
524                                                 [
525                                                         'code' => 'internal_api_error_InvalidArgumentException',
526                                                         'text' => "[$reqId] Exception caught: Random exception",
527                                                 ]
528                                         ],
529                                         'trace' => $trace,
530                                         'servedby' => wfHostname(),
531                                 ]
532                         ],
533                         [
534                                 $dbex,
535                                 [ 'existing-error', 'internal_api_error_DBQueryError' ],
536                                 [
537                                         'warnings' => [
538                                                 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
539                                         ],
540                                         'errors' => [
541                                                 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
542                                                 [
543                                                         'code' => 'internal_api_error_DBQueryError',
544                                                         'text' => "[$reqId] Database query error.",
545                                                 ]
546                                         ],
547                                         'trace' => $dbtrace,
548                                         'servedby' => wfHostname(),
549                                 ]
550                         ],
551                         [
552                                 $usageEx,
553                                 [ 'existing-error', 'ue' ],
554                                 [
555                                         'warnings' => [
556                                                 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
557                                         ],
558                                         'errors' => [
559                                                 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
560                                                 [ 'code' => 'ue', 'text' => "Usage exception!", 'data' => [ 'foo' => 'bar' ] ]
561                                         ],
562                                         'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
563                                                 "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
564                                                 "for notice of API deprecations and breaking changes.",
565                                         'servedby' => wfHostname(),
566                                 ]
567                         ],
568                         [
569                                 $apiEx1,
570                                 [ 'existing-error', 'sv-error1', 'sv-error2' ],
571                                 [
572                                         'warnings' => [
573                                                 [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
574                                                 [ 'code' => 'sv-warn1', 'text' => 'A warning', 'module' => 'foo+bar' ],
575                                                 [ 'code' => 'sv-warn2', 'text' => 'Another warning', 'module' => 'foo+bar' ],
576                                         ],
577                                         'errors' => [
578                                                 [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
579                                                 [ 'code' => 'sv-error1', 'text' => 'An error', 'module' => 'foo+bar' ],
580                                                 [ 'code' => 'sv-error2', 'text' => 'Another error', 'module' => 'foo+bar' ],
581                                         ],
582                                         'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
583                                                 "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
584                                                 "for notice of API deprecations and breaking changes.",
585                                         'servedby' => wfHostname(),
586                                 ]
587                         ],
588                 ];
589         }
590 }