Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.10% covered (success)
90.10%
173 / 192
81.48% covered (warning)
81.48%
22 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
90.10% covered (success)
90.10%
173 / 192
81.48% covered (warning)
81.48%
22 / 27
67.97
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
 getModuleNames
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
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 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
1<?php
2
3namespace MediaWiki\Rest;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\HookContainer\HookContainer;
7use MediaWiki\MainConfigNames;
8use MediaWiki\MainConfigSchema;
9use MediaWiki\Permissions\Authority;
10use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
11use MediaWiki\Rest\Module\ExtraRoutesModule;
12use MediaWiki\Rest\Module\Module;
13use MediaWiki\Rest\Module\SpecBasedModule;
14use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
15use MediaWiki\Rest\Reporter\ErrorReporter;
16use MediaWiki\Rest\Validator\Validator;
17use MediaWiki\Session\Session;
18use Throwable;
19use Wikimedia\Message\MessageValue;
20use Wikimedia\ObjectCache\BagOStuff;
21use Wikimedia\ObjectFactory\ObjectFactory;
22use Wikimedia\Stats\StatsFactory;
23
24/**
25 * The REST router is responsible for gathering module configuration, matching
26 * an input path against the defined modules, and constructing
27 * and executing the relevant module for a request.
28 */
29class Router {
30    private const PREFIX_PATTERN = '!^/([-_.\w]+(?:/v\d+)?)(/.*)$!';
31
32    /** @var string[] */
33    private $routeFiles;
34
35    /** @var array[] */
36    private $extraRoutes;
37
38    /** @var null|array[] */
39    private $moduleMap = null;
40
41    /** @var Module[] */
42    private $modules = [];
43
44    /** @var int[]|null */
45    private $moduleFileTimestamps = null;
46
47    /** @var string */
48    private $baseUrl;
49
50    /** @var string */
51    private $privateBaseUrl;
52
53    /** @var string */
54    private $rootPath;
55
56    /** @var string */
57    private $scriptPath;
58
59    /** @var string|null */
60    private $configHash = null;
61
62    /** @var CorsUtils|null */
63    private $cors;
64
65    private BagOStuff $cacheBag;
66    private ResponseFactory $responseFactory;
67    private BasicAuthorizerInterface $basicAuth;
68    private Authority $authority;
69    private ObjectFactory $objectFactory;
70    private Validator $restValidator;
71    private ErrorReporter $errorReporter;
72    private HookContainer $hookContainer;
73    private Session $session;
74
75    /** @var ?StatsFactory */
76    private $stats = null;
77
78    /**
79     * @internal
80     * @var array
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 getModuleNames(): 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            $response = $this->responseFactory->createFromException( $e );
449        } catch ( Throwable $e ) {
450            $this->errorReporter->reportError( $e, null, $request );
451            $response = $this->responseFactory->createFromException( $e );
452        }
453
454        return $response;
455    }
456
457    private function doExecute( string $fullPath, RequestInterface $request ): ResponseInterface {
458        [ $modulePrefix, $path ] = $this->splitPath( $fullPath );
459
460        // If there is no path at all, redirect to "/".
461        // That's the minimal path that can be routed.
462        if ( $modulePrefix === '' && $path === '' ) {
463            $target = $this->getRoutePath( '/' );
464            return $this->responseFactory->createRedirect( $target, 308 );
465        }
466
467        $module = $this->getModule( $modulePrefix );
468
469        if ( !$module ) {
470            throw new LocalizedHttpException(
471                MessageValue::new( 'rest-unknown-module' )->plaintextParams( $modulePrefix ),
472                404,
473                [ 'prefix' => $modulePrefix ]
474            );
475        }
476
477        return $module->execute( $path, $request );
478    }
479
480    /**
481     * Prepare the handler by injecting relevant service objects and state
482     * into $handler.
483     *
484     * @internal
485     */
486    public function prepareHandler( Handler $handler ) {
487        // Injecting services in the Router class means we don't have to inject
488        // them into each Module.
489        $handler->initServices(
490            $this->authority,
491            $this->responseFactory,
492            $this->hookContainer
493        );
494
495        $handler->initSession( $this->session );
496    }
497
498    /**
499     * @param CorsUtils $cors
500     * @return self
501     */
502    public function setCors( CorsUtils $cors ): self {
503        $this->cors = $cors;
504
505        return $this;
506    }
507
508    /**
509     * @internal
510     *
511     * @param StatsFactory $stats
512     *
513     * @return self
514     */
515    public function setStats( StatsFactory $stats ): self {
516        $this->stats = $stats;
517
518        return $this;
519    }
520
521    /**
522     * @param array $info
523     * @param string $name
524     */
525    private function instantiateModule( array $info, string $name ): Module {
526        if ( $info['class'] === SpecBasedModule::class ) {
527            $module = new SpecBasedModule(
528                $info['specFile'],
529                $this,
530                $info['pathPrefix'] ?? $name,
531                $this->responseFactory,
532                $this->basicAuth,
533                $this->objectFactory,
534                $this->restValidator,
535                $this->errorReporter
536            );
537        } else {
538            $module = new ExtraRoutesModule(
539                $info['routeFiles'] ?? [],
540                $info['extraRoutes'] ?? [],
541                $this,
542                $this->responseFactory,
543                $this->basicAuth,
544                $this->objectFactory,
545                $this->restValidator,
546                $this->errorReporter
547            );
548        }
549
550        return $module;
551    }
552
553}