3 use MediaWiki\Context\RequestContext
;
4 use MediaWiki\HookContainer\HookContainer
;
5 use MediaWiki\HookContainer\StaticHookRegistry
;
6 use MediaWiki\Language\Language
;
7 use MediaWiki\MediaWikiServices
;
8 use MediaWiki\Message\Message
;
9 use MediaWiki\ParamValidator\TypeDef\ArrayDef
;
10 use MediaWiki\Permissions\SimpleAuthority
;
11 use MediaWiki\Request\WebRequest
;
12 use MediaWiki\Rest\CorsUtils
;
13 use MediaWiki\Rest\EntryPoint
;
14 use MediaWiki\Rest\Handler
;
15 use MediaWiki\Rest\PathTemplateMatcher\PathMatcher
;
16 use MediaWiki\Rest\RequestData
;
17 use MediaWiki\Rest\ResponseFactory
;
18 use MediaWiki\Rest\Router
;
19 use MediaWiki\Rest\Validator\Validator
;
20 use MediaWiki\Session\Session
;
21 use MediaWiki\Tests\Unit\DummyServicesTrait
;
22 use MediaWiki\Title\Title
;
23 use MediaWiki\User\UserIdentityValue
;
24 use Wikimedia\Message\MessageValue
;
25 use Wikimedia\ObjectCache\EmptyBagOStuff
;
26 use Wikimedia\ParamValidator\ParamValidator
;
27 use Wikimedia\Stats\StatsFactory
;
28 use Wikimedia\TestingAccessWrapper
;
31 * Checks that all REST Handlers, core and extensions, conform to the conventions:
32 * - parameters in path have correct PARAM_SOURCE
33 * - path parameters not in path are not required
34 * - do not have inconsistencies in the parameter definitions
39 class RestStructureTest
extends MediaWikiIntegrationTestCase
{
40 use DummyServicesTrait
;
41 use JsonSchemaAssertionTrait
;
43 private const SPEC_FILES
= [
44 'https://spec.openapis.org/oas/3.0/schema/2021-09-28#' =>
45 MW_INSTALL_PATH
. '/tests/phpunit/integration/includes/' .
46 'Rest/Handler/data/OpenApi-3.0.json',
48 'http://json-schema.org/draft-04/schema#' =>
49 MW_INSTALL_PATH
. '/vendor/justinrainbow/json-schema/dist/' .
50 'schema/json-schema-draft-04.json',
52 'https://www.mediawiki.org/schema/mwapi-1.0#' =>
53 MW_INSTALL_PATH
. '/docs/rest/mwapi-1.0.json',
55 'https://www.mediawiki.org/schema/discovery-1.0#' =>
56 MW_INSTALL_PATH
. '/docs/rest/discovery-1.0.json',
60 private $router = null;
63 * Constructs a fake MediaWikiServices instance for use in data providers.
65 * @return MediaWikiServices
67 private function getFakeServiceContainer(): MediaWikiServices
{
68 $realConfig = MediaWikiServices
::getInstance()->getMainConfig();
70 $objectFactory = $this->getDummyObjectFactory();
71 $hookContainer = new HookContainer(
72 new StaticHookRegistry(),
76 $services = $this->createNoOpMock(
77 MediaWikiServices
::class,
82 'getLocalServerObjectCache',
86 $services->method( 'getMainConfig' )->willReturn( $realConfig );
87 $services->method( 'getHookContainer' )->willReturn( $hookContainer );
88 $services->method( 'getObjectFactory' )->willReturn( $objectFactory );
89 $services->method( 'getLocalServerObjectCache' )->willReturn( new EmptyBagOStuff() );
90 $services->method( 'getStatsFactory' )->willReturn( StatsFactory
::newNull() );
95 private function getRouterForDataProviders(): Router
{
96 static $router = null;
99 $language = $this->createNoOpMock( Language
::class, [ 'getCode' ] );
100 $language->method( 'getCode' )->willReturn( 'en' );
102 $title = Title
::makeTitle( NS_SPECIAL
, 'Badtitle/dummy title for RestStructureTest' );
103 $authority = new SimpleAuthority( new UserIdentityValue( 0, 'Testor' ), [] );
105 $request = $this->createNoOpMock( WebRequest
::class, [ 'getSession' ] );
106 $request->method( 'getSession' )->willReturn( $this->createNoOpMock( Session
::class ) );
108 $context = $this->createNoOpMock(
109 RequestContext
::class,
110 [ 'getLanguage', 'getTitle', 'getAuthority', 'getRequest' ]
112 $context->method( 'getLanguage' )->willReturn( $language );
113 $context->method( 'getTitle' )->willReturn( $title );
114 $context->method( 'getAuthority' )->willReturn( $authority );
115 $context->method( 'getRequest' )->willReturn( $request );
117 $responseFactory = $this->createNoOpMock( ResponseFactory
::class );
118 $cors = $this->createNoOpMock( CorsUtils
::class );
120 $services = $this->getFakeServiceContainer();
122 // NOTE: createRouter() implements the logic for determining the list of route files to load.
123 $entryPoint = TestingAccessWrapper
::newFromClass( EntryPoint
::class );
124 $router = $entryPoint->createRouter(
137 * Initialize/fetch the Router instance for testing
138 * @warning Must not be called in data providers!
141 private function getTestRouter(): Router
{
142 if ( !$this->router
) {
143 $language = $this->createNoOpMock( Language
::class, [ 'getCode' ] );
144 $language->method( 'getCode' )->willReturn( 'en' );
146 $title = Title
::makeTitle( NS_SPECIAL
, 'Badtitle/dummy title for RestStructureTest' );
147 $authority = new SimpleAuthority( new UserIdentityValue( 0, 'Testor' ), [] );
149 $request = $this->createNoOpMock( WebRequest
::class, [ 'getSession' ] );
150 $request->method( 'getSession' )->willReturn( $this->createNoOpMock( Session
::class ) );
152 $context = $this->createNoOpMock(
153 RequestContext
::class,
154 [ 'getLanguage', 'getTitle', 'getAuthority', 'getRequest' ]
156 $context->method( 'getLanguage' )->willReturn( $language );
157 $context->method( 'getTitle' )->willReturn( $title );
158 $context->method( 'getAuthority' )->willReturn( $authority );
159 $context->method( 'getRequest' )->willReturn( $request );
161 $responseFactory = $this->createNoOpMock( ResponseFactory
::class );
162 $cors = $this->createNoOpMock( CorsUtils
::class );
164 $this->router
= EntryPoint
::createRouter(
165 $this->getServiceContainer(), $context, new RequestData(), $responseFactory, $cors
168 return $this->router
;
172 * @dataProvider provideRoutes
174 public function testPathParameters( string $moduleName, string $method, string $path ): void
{
175 $router = $this->getTestRouter();
176 $module = $router->getModule( $moduleName );
178 $request = new RequestData( [ 'method' => $method ] );
179 $handler = $module->getHandlerForPath( $path, $request, false );
181 $params = $handler->getParamSettings();
182 $dataName = $this->dataName();
184 // Test that all parameters in the path exist and are declared as such
185 $matcher = TestingAccessWrapper
::newFromObject( new PathMatcher
);
187 foreach ( explode( '/', $path ) as $part ) {
188 $param = $matcher->getParamName( $part );
189 if ( $param !== false ) {
190 $this->assertArrayHasKey( $param, $params, "Path parameter $param exists" );
191 $this->assertSame( 'path', $params[$param][Handler
::PARAM_SOURCE
] ??
null,
192 "$dataName: Path parameter {{$param}} must have PARAM_SOURCE = 'path'" );
193 $pathParams[$param] = true;
197 // Test that any path parameters not in the path aren't marked as required
198 foreach ( $params as $param => $settings ) {
199 if ( ( $settings[Handler
::PARAM_SOURCE
] ??
null ) === 'path' &&
200 !isset( $pathParams[$param] )
202 $this->assertFalse( $settings[ParamValidator
::PARAM_REQUIRED
] ??
false,
203 "$dataName, parameter $param: PARAM_REQUIRED cannot be true for a path parameter "
209 // In case there were no path parameters
210 $this->addToAssertionCount( 1 );
214 * @dataProvider provideRoutes
216 public function testBodyParameters( string $moduleName, string $method, string $path ): void
{
217 $router = $this->getTestRouter();
218 $module = $router->getModule( $moduleName );
220 $request = new RequestData( [ 'method' => $method ] );
221 $handler = $module->getHandlerForPath( $path, $request, false );
223 $bodySettings = $handler->getBodyParamSettings();
225 if ( !$bodySettings ) {
226 $this->addToAssertionCount( 1 );
230 foreach ( $bodySettings as $settings ) {
231 $this->assertArrayHasKey( Handler
::PARAM_SOURCE
, $settings );
232 $this->assertSame( 'body', $settings[Handler
::PARAM_SOURCE
] );
234 if ( isset( $settings[ ArrayDef
::PARAM_SCHEMA
] ) ) {
236 $this->assertValidJsonSchema( $settings[ ArrayDef
::PARAM_SCHEMA
] );
237 } catch ( LogicException
$e ) {
238 $this->fail( "Invalid JSON schema for parameter {$settings['name']}: " . $e->getMessage() );
245 * @dataProvider provideRoutes
247 public function testBodyParametersNotInParamSettings( string $moduleName, string $method, string $path ): void
{
248 $router = $this->getTestRouter();
249 $module = $router->getModule( $moduleName );
251 $request = new RequestData( [ 'method' => $method ] );
252 $handler = $module->getHandlerForPath( $path, $request, false );
254 $paramSettings = $handler->getParamSettings();
256 if ( !$paramSettings ) {
257 $this->addToAssertionCount( 1 );
261 foreach ( $paramSettings as $settings ) {
262 $this->assertArrayHasKey( Handler
::PARAM_SOURCE
, $settings );
263 $this->assertNotSame( 'body', $settings[Handler
::PARAM_SOURCE
] );
267 public function provideModules(): Iterator
{
268 $router = $this->getRouterForDataProviders();
270 foreach ( $router->getModuleIds() as $name ) {
271 yield
"Module '$name'" => [ $name ];
275 public function provideRoutes(): Iterator
{
276 $router = $this->getRouterForDataProviders();
278 foreach ( $router->getModuleIds() as $moduleName ) {
279 $module = $router->getModule( $moduleName );
281 foreach ( $module->getDefinedPaths() as $path => $methods ) {
283 foreach ( $methods as $method ) {
284 // NOTE: we can't use the $module object directly, since it
285 // may hold references to incorrect service instance.
286 yield
"Handler in module '$moduleName' for $method $path"
287 => [ $moduleName, $method, $path ];
294 * @dataProvider provideRoutes
296 public function testParameters( string $moduleName, string $method, string $path ): void
{
297 $router = $this->getTestRouter();
298 $module = $router->getModule( $moduleName );
300 $request = new RequestData( [ 'method' => $method ] );
301 $handler = $module->getHandlerForPath( $path, $request, false );
303 $params = $handler->getParamSettings();
304 foreach ( $params as $param => $settings ) {
305 $method = $routeSpec['method'] ??
'GET';
306 $method = implode( ",", (array)$method );
308 $this->assertParameter( $param, $settings, "Handler {$method} {$path}, parameter $param" );
312 private function assertParameter( string $name, $settings, $msg ) {
313 $router = TestingAccessWrapper
::newFromObject( $this->getTestRouter() );
315 $dataName = $this->dataName();
316 $this->assertNotSame( '', $name, "$msg: $dataName: Name cannot be empty" );
318 $paramValidator = TestingAccessWrapper
::newFromObject( $router->restValidator
)->paramValidator
;
319 $ret = $paramValidator->checkSettings( $name, $settings, [ 'source' => 'unspecified' ] );
321 // REST-specific parameters
322 $ret['allowedKeys'][] = Handler
::PARAM_SOURCE
;
323 $ret['allowedKeys'][] = Handler
::PARAM_DESCRIPTION
;
324 if ( !in_array( $settings[Handler
::PARAM_SOURCE
] ??
'', Validator
::KNOWN_PARAM_SOURCES
, true ) ) {
325 $ret['issues'][Handler
::PARAM_SOURCE
] = "PARAM_SOURCE must be one of " . implode( ', ', Validator
::KNOWN_PARAM_SOURCES
);
328 // Check that "array" type is not used in getParamSettings
329 if ( isset( $settings[ParamValidator
::PARAM_TYPE
] ) && $settings[ParamValidator
::PARAM_TYPE
] === 'array' ) {
330 $this->fail( "$msg: $dataName: 'array' type is not allowed in getParamSettings" );
333 // Warn about unknown keys. Don't fail, they might be for forward- or back-compat.
334 if ( is_array( $settings ) ) {
336 array_keys( $settings ),
341 "$msg: $dataName: Unrecognized settings keys were used: " . implode( ', ', $keys )
346 if ( count( $ret['issues'] ) === 1 ) {
347 $this->fail( "$msg: $dataName: Validation failed: " . reset( $ret['issues'] ) );
348 } elseif ( $ret['issues'] ) {
349 $this->fail( "$msg: $dataName: Validation failed:\n* " . implode( "\n* ", $ret['issues'] ) );
352 // Check message existence
354 foreach ( $ret['messages'] as $msg ) {
355 // We don't really care about the parameters, so do it simply
356 $key = $msg->getKey();
357 if ( !isset( $done[$key] ) ) {
359 $this->assertTrue( Message
::newFromKey( $key )->exists(),
360 "$msg: $dataName: Parameter message $key exists" );
364 $description = $settings[Handler
::PARAM_DESCRIPTION
] ??
null;
365 if ( $description && !is_string( $description ) ) {
366 $this->assertInstanceOf( MessageValue
::class, $description );
368 wfMessage( $description->getKey() )->exists(),
369 'Message key of parameter description should exit: '
370 . $description->getKey()
375 public function testRoutePathAndMethodForDuplicates() {
376 $router = $this->getTestRouter();
379 foreach ( $router->getModuleIds() as $moduleName ) {
380 $module = $router->getModule( $moduleName );
381 $paths = $module->getDefinedPaths();
383 foreach ( $paths as $path => $methods ) {
384 foreach ( $methods as $method ) {
385 // NOTE: we can't use the $module object directly, since it
386 // may hold references to incorrect service instance.
387 $key = "$moduleName: $method $path";
389 $this->assertArrayNotHasKey( $key, $routes, "{$key} already exists in routes" );
390 $routes[$key] = true;
396 public function provideModuleDefinitionFiles() {
397 $conf = MediaWikiServices
::getInstance()->getMainConfig();
398 $entryPoint = TestingAccessWrapper
::newFromClass( EntryPoint
::class );
399 $routeFiles = $entryPoint->getRouteFiles( $conf );
401 foreach ( $routeFiles as $file ) {
402 $moduleSpec = self
::loadJsonData( $file );
403 if ( !isset( $moduleSpec->mwapi
) ) {
404 // old-school flat route file, skip
407 yield
$file => [ $moduleSpec ];
412 * @dataProvider provideModuleDefinitionFiles
414 public function testModuleDefinitionFiles( stdClass
$moduleSpec ) {
415 $schemaFile = MW_INSTALL_PATH
. '/docs/rest/mwapi-1.0.json';
417 $this->assertMatchesJsonSchema( $schemaFile, $moduleSpec, self
::SPEC_FILES
);
421 * @dataProvider provideModules
423 public function testGetModuleDescription( string $moduleName ): void
{
424 static $infoSchema = [ '$ref' =>
425 'https://www.mediawiki.org/schema/discovery-1.0#/definitions/Module'
428 $router = $this->getTestRouter();
429 $module = $router->getModule( $moduleName );
430 $info = $module->getModuleDescription();
432 $this->assertMatchesJsonSchema( $infoSchema, $info, self
::SPEC_FILES
);
436 * @dataProvider provideModules
438 public function testGetOpenApiInfo( string $moduleName ): void
{
439 static $infoSchema = [ '$ref' =>
440 'https://spec.openapis.org/oas/3.0/schema/2021-09-28#/definitions/Info'
443 $router = $this->getTestRouter();
444 $module = $router->getModule( $moduleName );
445 $info = $module->getOpenApiInfo();
447 $this->assertMatchesJsonSchema( $infoSchema, $info, self
::SPEC_FILES
);