Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.06% covered (warning)
86.06%
179 / 208
80.00% covered (warning)
80.00%
24 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
86.06% covered (warning)
86.06%
179 / 208
80.00% covered (warning)
80.00%
24 / 30
81.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 getRelativePath
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 splitPath
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 fetchCachedModuleMap
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fetchCachedModuleData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 cacheModuleMap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 cacheModuleData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getModuleDataCacheKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getModuleMapCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleMapHash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 buildModuleMap
78.79% covered (warning)
78.79%
26 / 33
0.00% covered (danger)
0.00%
0 / 1
10.95
 getModuleFileTimestamps
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getModuleMap
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getModuleInfo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getModuleIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleForPath
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getModule
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
7.06
 getRoutePath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRouteUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrivateRouteUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 substPathParams
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 execute
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 doExecute
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
 prepareHandler
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setCors
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStats
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 instantiateModule
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 isRestbaseCompatEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 varyOnRestbaseCompat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRestbaseCompatErrorData
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest;
4
5use HttpStatus;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\HookContainer\HookContainer;
8use MediaWiki\MainConfigNames;
9use MediaWiki\MainConfigSchema;
10use MediaWiki\Permissions\Authority;
11use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
12use MediaWiki\Rest\Module\ExtraRoutesModule;
13use MediaWiki\Rest\Module\Module;
14use MediaWiki\Rest\Module\SpecBasedModule;
15use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
16use MediaWiki\Rest\Reporter\ErrorReporter;
17use MediaWiki\Rest\Validator\Validator;
18use MediaWiki\Session\Session;
19use Throwable;
20use Wikimedia\Message\MessageValue;
21use Wikimedia\ObjectCache\BagOStuff;
22use Wikimedia\ObjectFactory\ObjectFactory;
23use Wikimedia\Stats\StatsFactory;
24
25/**
26 * The REST router is responsible for gathering module configuration, matching
27 * an input path against the defined modules, and constructing
28 * and executing the relevant module for a request.
29 */
30class Router {
31    private const PREFIX_PATTERN = '!^/([-_.\w]+(?:/v\d+)?)(/.*)$!';
32
33    /** @var string[] */
34    private $routeFiles;
35
36    /** @var array[] */
37    private $extraRoutes;
38
39    /** @var null|array[] */
40    private $moduleMap = null;
41
42    /** @var Module[] */
43    private $modules = [];
44
45    /** @var int[]|null */
46    private $moduleFileTimestamps = null;
47
48    /** @var string */
49    private $baseUrl;
50
51    /** @var string */
52    private $privateBaseUrl;
53
54    /** @var string */
55    private $rootPath;
56
57    /** @var string */
58    private $scriptPath;
59
60    /** @var string|null */
61    private $configHash = null;
62
63    /** @var CorsUtils|null */
64    private $cors;
65
66    private BagOStuff $cacheBag;
67    private ResponseFactory $responseFactory;
68    private BasicAuthorizerInterface $basicAuth;
69    private Authority $authority;
70    private ObjectFactory $objectFactory;
71    private Validator $restValidator;
72    private ErrorReporter $errorReporter;
73    private HookContainer $hookContainer;
74    private Session $session;
75
76    /** @var ?StatsFactory */
77    private $stats = null;
78
79    /**
80     * @internal
81     */
82    public const CONSTRUCTOR_OPTIONS = [
83        MainConfigNames::CanonicalServer,
84        MainConfigNames::InternalServer,
85        MainConfigNames::RestPath,
86        MainConfigNames::ScriptPath,
87    ];
88
89    /**
90     * @param string[] $routeFiles
91     * @param array[] $extraRoutes
92     * @param ServiceOptions $options
93     * @param BagOStuff $cacheBag A cache in which to store the matcher trees
94     * @param ResponseFactory $responseFactory
95     * @param BasicAuthorizerInterface $basicAuth
96     * @param Authority $authority
97     * @param ObjectFactory $objectFactory
98     * @param Validator $restValidator
99     * @param ErrorReporter $errorReporter
100     * @param HookContainer $hookContainer
101     * @param Session $session
102     * @internal
103     */
104    public function __construct(
105        array $routeFiles,
106        array $extraRoutes,
107        ServiceOptions $options,
108        BagOStuff $cacheBag,
109        ResponseFactory $responseFactory,
110        BasicAuthorizerInterface $basicAuth,
111        Authority $authority,
112        ObjectFactory $objectFactory,
113        Validator $restValidator,
114        ErrorReporter $errorReporter,
115        HookContainer $hookContainer,
116        Session $session
117    ) {
118        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
119
120        $this->routeFiles = $routeFiles;
121        $this->extraRoutes = $extraRoutes;
122        $this->baseUrl = $options->get( MainConfigNames::CanonicalServer );
123        $this->privateBaseUrl = $options->get( MainConfigNames::InternalServer );
124        $this->rootPath = $options->get( MainConfigNames::RestPath );
125        $this->scriptPath = $options->get( MainConfigNames::ScriptPath );
126        $this->cacheBag = $cacheBag;
127        $this->responseFactory = $responseFactory;
128        $this->basicAuth = $basicAuth;
129        $this->authority = $authority;
130        $this->objectFactory = $objectFactory;
131        $this->restValidator = $restValidator;
132        $this->errorReporter = $errorReporter;
133        $this->hookContainer = $hookContainer;
134        $this->session = $session;
135    }
136
137    /**
138     * Remove the REST path prefix. Return the part of the path with the
139     * prefix removed, or false if the prefix did not match.
140     * Both the $this->rootPath and the default REST path are accepted,
141     * so on a site that uses /api as the RestPath, requests to /w/rest.php
142     * still work. This is equivalent to supporting both /wiki and /w/index.php
143     * for page views.
144     *
145     * @param string $path
146     * @return false|string
147     */
148    private function getRelativePath( $path ) {
149        $allowed = [
150            $this->rootPath,
151            MainConfigSchema::getDefaultRestPath( $this->scriptPath )
152        ];
153
154        foreach ( $allowed as $prefix ) {
155            if ( str_starts_with( $path, $prefix ) ) {
156                return substr( $path, strlen( $prefix ) );
157            }
158        }
159
160        return false;
161    }
162
163    /**
164     * @param string $fullPath
165     *
166     * @return string[] [ string $module, string $path ]
167     */
168    private function splitPath( string $fullPath ): array {
169        $pathWithModule = $this->getRelativePath( $fullPath );
170
171        if ( $pathWithModule === false ) {
172            throw new LocalizedHttpException(
173                ( new MessageValue( 'rest-prefix-mismatch' ) )
174                    ->plaintextParams( $fullPath, $this->rootPath ),
175                404
176            );
177        }
178
179        if ( preg_match( self::PREFIX_PATTERN, $pathWithModule, $matches ) ) {
180            [ , $module, $pathUnderModule ] = $matches;
181        } else {
182            // No prefix found in the given path, assume prefix-less module.
183            $module = '';
184            $pathUnderModule = $pathWithModule;
185        }
186
187        if ( $module !== '' && !$this->getModuleInfo( $module ) ) {
188            // Prefix doesn't match any module, try the prefix-less module...
189            // TODO: At some point in the future, we'll want to warn and redirect...
190            $module = '';
191            $pathUnderModule = $pathWithModule;
192        }
193
194        return [ $module, $pathUnderModule ];
195    }
196
197    /**
198     * Get the cache data, or false if it is missing or invalid
199     *
200     * @return ?array
201     */
202    private function fetchCachedModuleMap(): ?array {
203        $moduleMapCacheKey = $this->getModuleMapCacheKey();
204        $cacheData = $this->cacheBag->get( $moduleMapCacheKey );
205        if ( $cacheData && $cacheData[Module::CACHE_CONFIG_HASH_KEY] === $this->getModuleMapHash() ) {
206            unset( $cacheData[Module::CACHE_CONFIG_HASH_KEY] );
207            return $cacheData;
208        } else {
209            return null;
210        }
211    }
212
213    private function fetchCachedModuleData( string $module ): ?array {
214        $moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
215        $cacheData = $this->cacheBag->get( $moduleDataCacheKey );
216        return $cacheData ?: null;
217    }
218
219    private function cacheModuleMap( array $map ) {
220        $map[Module::CACHE_CONFIG_HASH_KEY] = $this->getModuleMapHash();
221        $moduleMapCacheKey = $this->getModuleMapCacheKey();
222        $this->cacheBag->set( $moduleMapCacheKey, $map );
223    }
224
225    private function cacheModuleData( string $module, array $map ) {
226        $moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
227        $this->cacheBag->set( $moduleDataCacheKey, $map );
228    }
229
230    private function getModuleDataCacheKey( string $module ): string {
231        if ( $module === '' ) {
232            // Proper key for the prefix-less module.
233            $module = '-';
234        }
235        return $this->cacheBag->makeKey( __CLASS__, 'module', $module );
236    }
237
238    private function getModuleMapCacheKey(): string {
239        return $this->cacheBag->makeKey( __CLASS__, 'map', '1' );
240    }
241
242    /**
243     * Get a config version hash for cache invalidation
244     */
245    private function getModuleMapHash(): string {
246        if ( $this->configHash === null ) {
247            $this->configHash = md5( json_encode( [
248                $this->extraRoutes,
249                $this->getModuleFileTimestamps()
250            ] ) );
251        }
252        return $this->configHash;
253    }
254
255    private function buildModuleMap(): array {
256        $modules = [];
257        $noPrefixFiles = [];
258        $id = ''; // should not be used, make Phan happy
259
260        foreach ( $this->routeFiles as $file ) {
261            // NOTE: we end up loading the file here (for the meta-data) as well
262            // as in the Module object (for the routes). But since we have
263            // caching on both levels, that shouldn't matter.
264            $spec = Module::loadJsonFile( $file );
265
266            if ( isset( $spec['mwapi'] ) || isset( $spec['moduleId'] ) || isset( $spec['routes'] ) ) {
267                // OpenAPI 3, with some extras like the "module" field
268                if ( !isset( $spec['moduleId'] ) ) {
269                    throw new ModuleConfigurationException(
270                        "Missing 'moduleId' field in $file"
271                    );
272                }
273
274                $id = $spec['moduleId'];
275
276                $moduleInfo = [
277                    'class' => SpecBasedModule::class,
278                    'pathPrefix' => $id,
279                    'specFile' => $file
280                ];
281            } else {
282                // Old-style route file containing a flat list of routes.
283                $noPrefixFiles[] = $file;
284                $moduleInfo = null;
285            }
286
287            if ( $moduleInfo ) {
288                if ( isset( $modules[$id] ) ) {
289                    $otherFiles = implode( ' and ', $modules[$id]['routeFiles'] );
290                    throw new ModuleConfigurationException(
291                        "Duplicate module $id in $file, also used in $otherFiles"
292                    );
293                }
294
295                $modules[$id] = $moduleInfo;
296            }
297        }
298
299        // The prefix-less module will be used when no prefix is matched.
300        // It provides a mechanism to integrate extra routes and route files
301        // registered by extensions.
302        if ( $noPrefixFiles || $this->extraRoutes ) {
303            $modules[''] = [
304                'class' => ExtraRoutesModule::class,
305                'pathPrefix' => '',
306                'routeFiles' => $noPrefixFiles,
307                'extraRoutes' => $this->extraRoutes,
308            ];
309        }
310
311        return $modules;
312    }
313
314    /**
315     * Get an array of last modification times of the defined route files.
316     *
317     * @return int[] Last modification times
318     */
319    private function getModuleFileTimestamps() {
320        if ( $this->moduleFileTimestamps === null ) {
321            $this->moduleFileTimestamps = [];
322            foreach ( $this->routeFiles as $fileName ) {
323                $this->moduleFileTimestamps[$fileName] = filemtime( $fileName );
324            }
325        }
326        return $this->moduleFileTimestamps;
327    }
328
329    private function getModuleMap(): array {
330        if ( !$this->moduleMap ) {
331            $map = $this->fetchCachedModuleMap();
332
333            if ( !$map ) {
334                $map = $this->buildModuleMap();
335                $this->cacheModuleMap( $map );
336            }
337
338            $this->moduleMap = $map;
339        }
340        return $this->moduleMap;
341    }
342
343    private function getModuleInfo( $module ): ?array {
344        $map = $this->getModuleMap();
345        return $map[$module] ?? null;
346    }
347
348    /**
349     * @return string[]
350     */
351    public function getModuleIds(): array {
352        return array_keys( $this->getModuleMap() );
353    }
354
355    public function getModuleForPath( string $fullPath ): ?Module {
356        [ $moduleName, ] = $this->splitPath( $fullPath );
357        return $this->getModule( $moduleName );
358    }
359
360    public function getModule( string $name ): ?Module {
361        if ( isset( $this->modules[$name] ) ) {
362            return $this->modules[$name];
363        }
364
365        $info = $this->getModuleInfo( $name );
366
367        if ( !$info ) {
368            return null;
369        }
370
371        $module = $this->instantiateModule( $info, $name );
372
373        $cacheData = $this->fetchCachedModuleData( $name );
374
375        if ( $cacheData !== null ) {
376            $cacheOk = $module->initFromCacheData( $cacheData );
377        } else {
378            $cacheOk = false;
379        }
380
381        if ( !$cacheOk ) {
382            $cacheData = $module->getCacheData();
383            $this->cacheModuleData( $name, $cacheData );
384        }
385
386        if ( $this->cors ) {
387            $module->setCors( $this->cors );
388        }
389
390        if ( $this->stats ) {
391            $module->setStats( $this->stats );
392        }
393
394        $this->modules[$name] = $module;
395        return $module;
396    }
397
398    /**
399     * @since 1.42
400     */
401    public function getRoutePath(
402        string $routeWithModulePrefix,
403        array $pathParams = [],
404        array $queryParams = []
405    ): string {
406        $routeWithModulePrefix = $this->substPathParams( $routeWithModulePrefix, $pathParams );
407        $path = $this->rootPath . $routeWithModulePrefix;
408        return wfAppendQuery( $path, $queryParams );
409    }
410
411    public function getRouteUrl(
412        string $routeWithModulePrefix,
413        array $pathParams = [],
414        array $queryParams = []
415    ): string {
416        return $this->baseUrl . $this->getRoutePath( $routeWithModulePrefix, $pathParams, $queryParams );
417    }
418
419    public function getPrivateRouteUrl(
420        string $routeWithModulePrefix,
421        array $pathParams = [],
422        array $queryParams = []
423    ): string {
424        return $this->privateBaseUrl . $this->getRoutePath( $routeWithModulePrefix, $pathParams, $queryParams );
425    }
426
427    /**
428     * @param string $route
429     * @param array $pathParams
430     *
431     * @return string
432     */
433    protected function substPathParams( string $route, array $pathParams ): string {
434        foreach ( $pathParams as $param => $value ) {
435            // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
436            // Spaces in path params must be encoded to %20 (not +).
437            // Slashes must be encoded as %2F.
438            $route = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $route );
439        }
440        return $route;
441    }
442
443    public function execute( RequestInterface $request ): ResponseInterface {
444        try {
445            $fullPath = $request->getUri()->getPath();
446            $response = $this->doExecute( $fullPath, $request );
447        } catch ( HttpException $e ) {
448            $extraData = [];
449            if ( $this->isRestbaseCompatEnabled( $request )
450                && $e instanceof LocalizedHttpException
451            ) {
452                $extraData = $this->getRestbaseCompatErrorData( $request, $e );
453            }
454            $response = $this->responseFactory->createFromException( $e, $extraData );
455        } catch ( Throwable $e ) {
456            $this->errorReporter->reportError( $e, null, $request );
457            $response = $this->responseFactory->createFromException( $e );
458        }
459
460        // TODO: Only send the vary header for handlers that opt into
461        //       restbase compat!
462        $this->varyOnRestbaseCompat( $response );
463
464        return $response;
465    }
466
467    private function doExecute( string $fullPath, RequestInterface $request ): ResponseInterface {
468        [ $modulePrefix, $path ] = $this->splitPath( $fullPath );
469
470        // If there is no path at all, redirect to "/".
471        // That's the minimal path that can be routed.
472        if ( $modulePrefix === '' && $path === '' ) {
473            $target = $this->getRoutePath( '/' );
474            return $this->responseFactory->createRedirect( $target, 308 );
475        }
476
477        $module = $this->getModule( $modulePrefix );
478
479        if ( !$module ) {
480            throw new LocalizedHttpException(
481                MessageValue::new( 'rest-unknown-module' )->plaintextParams( $modulePrefix ),
482                404,
483                [ 'prefix' => $modulePrefix ]
484            );
485        }
486
487        return $module->execute( $path, $request );
488    }
489
490    /**
491     * Prepare the handler by injecting relevant service objects and state
492     * into $handler.
493     *
494     * @internal
495     */
496    public function prepareHandler( Handler $handler ) {
497        // Injecting services in the Router class means we don't have to inject
498        // them into each Module.
499        $handler->initServices(
500            $this->authority,
501            $this->responseFactory,
502            $this->hookContainer
503        );
504
505        $handler->initSession( $this->session );
506    }
507
508    /**
509     * @param CorsUtils $cors
510     * @return self
511     */
512    public function setCors( CorsUtils $cors ): self {
513        $this->cors = $cors;
514
515        return $this;
516    }
517
518    /**
519     * @internal
520     *
521     * @param StatsFactory $stats
522     *
523     * @return self
524     */
525    public function setStats( StatsFactory $stats ): self {
526        $this->stats = $stats;
527
528        return $this;
529    }
530
531    /**
532     * @param array $info
533     * @param string $name
534     */
535    private function instantiateModule( array $info, string $name ): Module {
536        if ( $info['class'] === SpecBasedModule::class ) {
537            $module = new SpecBasedModule(
538                $info['specFile'],
539                $this,
540                $info['pathPrefix'] ?? $name,
541                $this->responseFactory,
542                $this->basicAuth,
543                $this->objectFactory,
544                $this->restValidator,
545                $this->errorReporter
546            );
547        } else {
548            $module = new ExtraRoutesModule(
549                $info['routeFiles'] ?? [],
550                $info['extraRoutes'] ?? [],
551                $this,
552                $this->responseFactory,
553                $this->basicAuth,
554                $this->objectFactory,
555                $this->restValidator,
556                $this->errorReporter
557            );
558        }
559
560        return $module;
561    }
562
563    /**
564     * @internal
565     *
566     * @return bool
567     */
568    public function isRestbaseCompatEnabled( RequestInterface $request ): bool {
569        // See T374136
570        return $request->getHeaderLine( 'x-restbase-compat' ) === 'true';
571    }
572
573    private function varyOnRestbaseCompat( ResponseInterface $response ) {
574        // See T374136
575        $response->addHeader( 'Vary', 'x-restbase-compat' );
576    }
577
578    /**
579     * @internal
580     *
581     * @return array
582     */
583    public function getRestbaseCompatErrorData( RequestInterface $request, LocalizedHttpException $e ): array {
584        $msg = $e->getMessageValue();
585
586        // Match error fields emitted by the RESTBase endpoints.
587        // EntryPoint::getTextFormatters() ensures 'en' is always available.
588        return [
589            'type' => "MediaWikiError/" .
590                str_replace( ' ', '_', HttpStatus::getMessage( $e->getCode() ) ),
591            'title' => $msg->getKey(),
592            'method' => strtolower( $request->getMethod() ),
593            'detail' => $this->responseFactory->getFormattedMessage( $msg, 'en' ),
594            'uri' => (string)$request->getUri()
595        ];
596    }
597}