Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.19% covered (warning)
86.19%
181 / 210
80.00% covered (warning)
80.00%
24 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
86.19% covered (warning)
86.19%
181 / 210
80.00% covered (warning)
80.00%
24 / 30
81.54
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%
24 / 24
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 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\Http\HttpStatus;
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    private function fetchCachedModuleMap(): ?array {
201        $moduleMapCacheKey = $this->getModuleMapCacheKey();
202        $cacheData = $this->cacheBag->get( $moduleMapCacheKey );
203        if ( $cacheData && $cacheData[Module::CACHE_CONFIG_HASH_KEY] === $this->getModuleMapHash() ) {
204            unset( $cacheData[Module::CACHE_CONFIG_HASH_KEY] );
205            return $cacheData;
206        } else {
207            return null;
208        }
209    }
210
211    private function fetchCachedModuleData( string $module ): ?array {
212        $moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
213        $cacheData = $this->cacheBag->get( $moduleDataCacheKey );
214        return $cacheData ?: null;
215    }
216
217    private function cacheModuleMap( array $map ) {
218        $map[Module::CACHE_CONFIG_HASH_KEY] = $this->getModuleMapHash();
219        $moduleMapCacheKey = $this->getModuleMapCacheKey();
220        $this->cacheBag->set( $moduleMapCacheKey, $map );
221    }
222
223    private function cacheModuleData( string $module, array $map ) {
224        $moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
225        $this->cacheBag->set( $moduleDataCacheKey, $map );
226    }
227
228    private function getModuleDataCacheKey( string $module ): string {
229        if ( $module === '' ) {
230            // Proper key for the prefix-less module.
231            $module = '-';
232        }
233        return $this->cacheBag->makeKey( __CLASS__, 'module', $module );
234    }
235
236    private function getModuleMapCacheKey(): string {
237        return $this->cacheBag->makeKey( __CLASS__, 'map', '1' );
238    }
239
240    /**
241     * Get a config version hash for cache invalidation
242     */
243    private function getModuleMapHash(): string {
244        if ( $this->configHash === null ) {
245            $this->configHash = md5( json_encode( [
246                $this->extraRoutes,
247                $this->getModuleFileTimestamps()
248            ] ) );
249        }
250        return $this->configHash;
251    }
252
253    private function buildModuleMap(): array {
254        $modules = [];
255        $noPrefixFiles = [];
256        $id = ''; // should not be used, make Phan happy
257
258        foreach ( $this->routeFiles as $file ) {
259            // NOTE: we end up loading the file here (for the meta-data) as well
260            // as in the Module object (for the routes). But since we have
261            // caching on both levels, that shouldn't matter.
262            $spec = Module::loadJsonFile( $file );
263
264            if ( isset( $spec['mwapi'] ) || isset( $spec['moduleId'] ) || isset( $spec['routes'] ) ) {
265                // OpenAPI 3, with some extras like the "module" field
266                if ( !isset( $spec['moduleId'] ) ) {
267                    throw new ModuleConfigurationException(
268                        "Missing 'moduleId' field in $file"
269                    );
270                }
271
272                $id = $spec['moduleId'];
273
274                $moduleInfo = [
275                    'class' => SpecBasedModule::class,
276                    'pathPrefix' => $id,
277                    'specFile' => $file
278                ];
279            } else {
280                // Old-style route file containing a flat list of routes.
281                $noPrefixFiles[] = $file;
282                $moduleInfo = null;
283            }
284
285            if ( $moduleInfo ) {
286                if ( isset( $modules[$id] ) ) {
287                    $otherFiles = implode( ' and ', $modules[$id]['routeFiles'] );
288                    throw new ModuleConfigurationException(
289                        "Duplicate module $id in $file, also used in $otherFiles"
290                    );
291                }
292
293                $modules[$id] = $moduleInfo;
294            }
295        }
296
297        // The prefix-less module will be used when no prefix is matched.
298        // It provides a mechanism to integrate extra routes and route files
299        // registered by extensions.
300        if ( $noPrefixFiles || $this->extraRoutes ) {
301            $modules[''] = [
302                'class' => ExtraRoutesModule::class,
303                'pathPrefix' => '',
304                'routeFiles' => $noPrefixFiles,
305                'extraRoutes' => $this->extraRoutes,
306            ];
307        }
308
309        return $modules;
310    }
311
312    /**
313     * Get an array of last modification times of the defined route files.
314     *
315     * @return int[] Last modification times
316     */
317    private function getModuleFileTimestamps() {
318        if ( $this->moduleFileTimestamps === null ) {
319            $this->moduleFileTimestamps = [];
320            foreach ( $this->routeFiles as $fileName ) {
321                $this->moduleFileTimestamps[$fileName] = filemtime( $fileName );
322            }
323        }
324        return $this->moduleFileTimestamps;
325    }
326
327    private function getModuleMap(): array {
328        if ( !$this->moduleMap ) {
329            $map = $this->fetchCachedModuleMap();
330
331            if ( !$map ) {
332                $map = $this->buildModuleMap();
333                $this->cacheModuleMap( $map );
334            }
335
336            $this->moduleMap = $map;
337        }
338        return $this->moduleMap;
339    }
340
341    private function getModuleInfo( string $module ): ?array {
342        $map = $this->getModuleMap();
343        return $map[$module] ?? null;
344    }
345
346    /**
347     * @return string[]
348     */
349    public function getModuleIds(): array {
350        return array_keys( $this->getModuleMap() );
351    }
352
353    public function getModuleForPath( string $fullPath ): ?Module {
354        [ $moduleName, ] = $this->splitPath( $fullPath );
355        return $this->getModule( $moduleName );
356    }
357
358    public function getModule( string $name ): ?Module {
359        if ( isset( $this->modules[$name] ) ) {
360            return $this->modules[$name];
361        }
362
363        $info = $this->getModuleInfo( $name );
364
365        if ( !$info ) {
366            return null;
367        }
368
369        $module = $this->instantiateModule( $info, $name );
370
371        $cacheData = $this->fetchCachedModuleData( $name );
372
373        if ( $cacheData !== null ) {
374            $cacheOk = $module->initFromCacheData( $cacheData );
375        } else {
376            $cacheOk = false;
377        }
378
379        if ( !$cacheOk ) {
380            $cacheData = $module->getCacheData();
381            $this->cacheModuleData( $name, $cacheData );
382        }
383
384        if ( $this->cors ) {
385            $module->setCors( $this->cors );
386        }
387
388        if ( $this->stats ) {
389            $module->setStats( $this->stats );
390        }
391
392        $this->modules[$name] = $module;
393        return $module;
394    }
395
396    /**
397     * @since 1.42
398     */
399    public function getRoutePath(
400        string $routeWithModulePrefix,
401        array $pathParams = [],
402        array $queryParams = []
403    ): string {
404        $routeWithModulePrefix = $this->substPathParams( $routeWithModulePrefix, $pathParams );
405        $path = $this->rootPath . $routeWithModulePrefix;
406        return wfAppendQuery( $path, $queryParams );
407    }
408
409    public function getRouteUrl(
410        string $routeWithModulePrefix,
411        array $pathParams = [],
412        array $queryParams = []
413    ): string {
414        return $this->baseUrl . $this->getRoutePath( $routeWithModulePrefix, $pathParams, $queryParams );
415    }
416
417    public function getPrivateRouteUrl(
418        string $routeWithModulePrefix,
419        array $pathParams = [],
420        array $queryParams = []
421    ): string {
422        return $this->privateBaseUrl . $this->getRoutePath( $routeWithModulePrefix, $pathParams, $queryParams );
423    }
424
425    /**
426     * @param string $route
427     * @param array $pathParams
428     *
429     * @return string
430     */
431    protected function substPathParams( string $route, array $pathParams ): string {
432        foreach ( $pathParams as $param => $value ) {
433            // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
434            // Spaces in path params must be encoded to %20 (not +).
435            // Slashes must be encoded as %2F.
436            $route = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $route );
437        }
438        return $route;
439    }
440
441    public function execute( RequestInterface $request ): ResponseInterface {
442        try {
443            $fullPath = $request->getUri()->getPath();
444            $response = $this->doExecute( $fullPath, $request );
445        } catch ( HttpException $e ) {
446            $extraData = [];
447            if ( $this->isRestbaseCompatEnabled( $request )
448                && $e instanceof LocalizedHttpException
449            ) {
450                $extraData = $this->getRestbaseCompatErrorData( $request, $e );
451            }
452            $response = $this->responseFactory->createFromException( $e, $extraData );
453        } catch ( Throwable $e ) {
454            $this->errorReporter->reportError( $e, null, $request );
455            $response = $this->responseFactory->createFromException( $e );
456        }
457
458        // TODO: Only send the vary header for handlers that opt into
459        //       restbase compat!
460        $this->varyOnRestbaseCompat( $response );
461
462        return $response;
463    }
464
465    private function doExecute( string $fullPath, RequestInterface $request ): ResponseInterface {
466        [ $modulePrefix, $path ] = $this->splitPath( $fullPath );
467
468        // If there is no path at all, redirect to "/".
469        // That's the minimal path that can be routed.
470        if ( $modulePrefix === '' && $path === '' ) {
471            $target = $this->getRoutePath( '/' );
472            return $this->responseFactory->createRedirect( $target, 308 );
473        }
474
475        $module = $this->getModule( $modulePrefix );
476
477        if ( !$module ) {
478            throw new LocalizedHttpException(
479                MessageValue::new( 'rest-unknown-module' )->plaintextParams( $modulePrefix ),
480                404,
481                [ 'prefix' => $modulePrefix ]
482            );
483        }
484
485        return $module->execute( $path, $request );
486    }
487
488    /**
489     * Prepare the handler by injecting relevant service objects and state
490     * into $handler.
491     *
492     * @internal
493     */
494    public function prepareHandler( Handler $handler ) {
495        // Injecting services in the Router class means we don't have to inject
496        // them into each Module.
497        $handler->initServices(
498            $this->authority,
499            $this->responseFactory,
500            $this->hookContainer
501        );
502
503        $handler->initSession( $this->session );
504    }
505
506    public function setCors( CorsUtils $cors ): self {
507        $this->cors = $cors;
508
509        return $this;
510    }
511
512    /**
513     * @internal
514     *
515     * @param StatsFactory $stats
516     *
517     * @return self
518     */
519    public function setStats( StatsFactory $stats ): self {
520        $this->stats = $stats;
521
522        return $this;
523    }
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                $this->hookContainer
537            );
538        } else {
539            $module = new ExtraRoutesModule(
540                $info['routeFiles'] ?? [],
541                $info['extraRoutes'] ?? [],
542                $this,
543                $this->responseFactory,
544                $this->basicAuth,
545                $this->objectFactory,
546                $this->restValidator,
547                $this->errorReporter,
548                $this->hookContainer
549            );
550        }
551
552        return $module;
553    }
554
555    /**
556     * @internal
557     *
558     * @return bool
559     */
560    public function isRestbaseCompatEnabled( RequestInterface $request ): bool {
561        // See T374136
562        return $request->getHeaderLine( 'x-restbase-compat' ) === 'true';
563    }
564
565    private function varyOnRestbaseCompat( ResponseInterface $response ) {
566        // See T374136
567        $response->addHeader( 'Vary', 'x-restbase-compat' );
568    }
569
570    /**
571     * @internal
572     *
573     * @return array
574     */
575    public function getRestbaseCompatErrorData( RequestInterface $request, LocalizedHttpException $e ): array {
576        $msg = $e->getMessageValue();
577
578        // Match error fields emitted by the RESTBase endpoints.
579        // EntryPoint::getTextFormatters() ensures 'en' is always available.
580        return [
581            'type' => "MediaWikiError/" .
582                str_replace( ' ', '_', HttpStatus::getMessage( $e->getCode() ) ),
583            'title' => $msg->getKey(),
584            'method' => strtolower( $request->getMethod() ),
585            'detail' => $this->responseFactory->getFormattedMessage( $msg, 'en' ),
586            'uri' => (string)$request->getUri()
587        ];
588    }
589}