Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.37% covered (success)
90.37%
169 / 187
61.90% covered (warning)
61.90%
13 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
90.37% covered (success)
90.37%
169 / 187
61.90% covered (warning)
61.90%
13 / 21
68.77
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 fetchCacheData
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConfigHash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getRoutesFromFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getRouteFileTimestamps
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 getAllRoutes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getMatchers
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
9.27
 getRelativePath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
13
 getAllowedMethods
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 createHandler
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 instantiateHandlerObject
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
4.59
 executeHandler
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 setCors
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setStats
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setBodyData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace MediaWiki\Rest;
4
5use AppendIterator;
6use BagOStuff;
7use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
8use MediaWiki\Config\ServiceOptions;
9use MediaWiki\HookContainer\HookContainer;
10use MediaWiki\MainConfigNames;
11use MediaWiki\Permissions\Authority;
12use MediaWiki\Profiler\ProfilingContext;
13use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
14use MediaWiki\Rest\Handler\RedirectHandler;
15use MediaWiki\Rest\PathTemplateMatcher\PathMatcher;
16use MediaWiki\Rest\Reporter\ErrorReporter;
17use MediaWiki\Rest\Validator\Validator;
18use MediaWiki\Session\Session;
19use NullStatsdDataFactory;
20use Throwable;
21use Wikimedia\Message\MessageValue;
22use Wikimedia\ObjectFactory\ObjectFactory;
23
24/**
25 * The REST router is responsible for gathering handler configuration, matching
26 * an input path and HTTP method against the defined routes, and constructing
27 * and executing the relevant handler for a request.
28 */
29class Router {
30    /** @var string[] */
31    private $routeFiles;
32
33    /** @var array */
34    private $extraRoutes;
35
36    /** @var array|null */
37    private $routesFromFiles;
38
39    /** @var int[]|null */
40    private $routeFileTimestamps;
41
42    /** @var string */
43    private $baseUrl;
44
45    /** @var string */
46    private $privateBaseUrl;
47
48    /** @var string */
49    private $rootPath;
50
51    /** @var \BagOStuff */
52    private $cacheBag;
53
54    /** @var PathMatcher[]|null Path matchers by method */
55    private $matchers;
56
57    /** @var string|null */
58    private $configHash;
59
60    /** @var ResponseFactory */
61    private $responseFactory;
62
63    /** @var BasicAuthorizerInterface */
64    private $basicAuth;
65
66    /** @var Authority */
67    private $authority;
68
69    /** @var ObjectFactory */
70    private $objectFactory;
71
72    /** @var Validator */
73    private $restValidator;
74
75    /** @var CorsUtils|null */
76    private $cors;
77
78    /** @var ErrorReporter */
79    private $errorReporter;
80
81    /** @var HookContainer */
82    private $hookContainer;
83
84    /** @var Session */
85    private $session;
86
87    /** @var StatsdDataFactoryInterface */
88    private $stats;
89
90    /** @var ServiceOptions */
91    private $options;
92
93    /**
94     * @internal
95     * @var array
96     */
97    public const CONSTRUCTOR_OPTIONS = [
98        MainConfigNames::CanonicalServer,
99        MainConfigNames::InternalServer,
100        MainConfigNames::RestPath,
101        // From RootSpecHandler::CONSTRUCTOR_OPTIONS
102        MainConfigNames::RightsUrl,
103        MainConfigNames::RightsText,
104        MainConfigNames::EmergencyContact,
105        MainConfigNames::Sitename,
106    ];
107
108    /**
109     * @param string[] $routeFiles List of names of JSON files containing routes
110     * @param array $extraRoutes Extension route array
111     * @param ServiceOptions $options
112     * @param BagOStuff $cacheBag A cache in which to store the matcher trees
113     * @param ResponseFactory $responseFactory
114     * @param BasicAuthorizerInterface $basicAuth
115     * @param Authority $authority
116     * @param ObjectFactory $objectFactory
117     * @param Validator $restValidator
118     * @param ErrorReporter $errorReporter
119     * @param HookContainer $hookContainer
120     * @param Session $session
121     * @internal
122     */
123    public function __construct(
124        $routeFiles,
125        $extraRoutes,
126        ServiceOptions $options,
127        BagOStuff $cacheBag,
128        ResponseFactory $responseFactory,
129        BasicAuthorizerInterface $basicAuth,
130        Authority $authority,
131        ObjectFactory $objectFactory,
132        Validator $restValidator,
133        ErrorReporter $errorReporter,
134        HookContainer $hookContainer,
135        Session $session
136    ) {
137        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
138
139        $this->routeFiles = $routeFiles;
140        $this->extraRoutes = $extraRoutes;
141        $this->options = $options;
142        $this->baseUrl = $options->get( MainConfigNames::CanonicalServer );
143        $this->privateBaseUrl = $options->get( MainConfigNames::InternalServer );
144        $this->rootPath = $options->get( MainConfigNames::RestPath );
145        $this->cacheBag = $cacheBag;
146        $this->responseFactory = $responseFactory;
147        $this->basicAuth = $basicAuth;
148        $this->authority = $authority;
149        $this->objectFactory = $objectFactory;
150        $this->restValidator = $restValidator;
151        $this->errorReporter = $errorReporter;
152        $this->hookContainer = $hookContainer;
153        $this->session = $session;
154
155        $this->stats = new NullStatsdDataFactory();
156    }
157
158    /**
159     * Get the cache data, or false if it is missing or invalid
160     *
161     * @return bool|array
162     */
163    private function fetchCacheData() {
164        $cacheData = $this->cacheBag->get( $this->getCacheKey() );
165        if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
166            unset( $cacheData['CONFIG-HASH'] );
167            return $cacheData;
168        } else {
169            return false;
170        }
171    }
172
173    /**
174     * @return string The cache key
175     */
176    private function getCacheKey() {
177        return $this->cacheBag->makeKey( __CLASS__, '4' );
178    }
179
180    /**
181     * Get a config version hash for cache invalidation
182     *
183     * @return string
184     */
185    private function getConfigHash() {
186        if ( $this->configHash === null ) {
187            $this->configHash = md5( json_encode( [
188                $this->extraRoutes,
189                $this->getRouteFileTimestamps()
190            ] ) );
191        }
192        return $this->configHash;
193    }
194
195    /**
196     * Load the defined JSON files and return the merged routes
197     *
198     * @return array
199     */
200    private function getRoutesFromFiles() {
201        if ( $this->routesFromFiles === null ) {
202            $this->routeFileTimestamps = [];
203            foreach ( $this->routeFiles as $fileName ) {
204                $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
205                $routes = json_decode( file_get_contents( $fileName ), true );
206                if ( $this->routesFromFiles === null ) {
207                    $this->routesFromFiles = $routes;
208                } else {
209                    $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
210                }
211            }
212        }
213        return $this->routesFromFiles;
214    }
215
216    /**
217     * Get an array of last modification times of the defined route files.
218     *
219     * @return int[] Last modification times
220     */
221    private function getRouteFileTimestamps() {
222        if ( $this->routeFileTimestamps === null ) {
223            $this->routeFileTimestamps = [];
224            foreach ( $this->routeFiles as $fileName ) {
225                $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
226            }
227        }
228        return $this->routeFileTimestamps;
229    }
230
231    /**
232     * Get an iterator for all defined routes, including loading the routes from
233     * the JSON files.
234     *
235     * @unstable
236     *
237     * @return AppendIterator
238     */
239    public function getAllRoutes() {
240        $iterator = new AppendIterator;
241        $iterator->append( new \ArrayIterator( $this->getRoutesFromFiles() ) );
242        $iterator->append( new \ArrayIterator( $this->extraRoutes ) );
243        return $iterator;
244    }
245
246    /**
247     * Get an array of PathMatcher objects indexed by HTTP method
248     *
249     * @return PathMatcher[]
250     */
251    private function getMatchers() {
252        if ( $this->matchers === null ) {
253            $cacheData = $this->fetchCacheData();
254            $matchers = [];
255            if ( $cacheData ) {
256                foreach ( $cacheData as $method => $data ) {
257                    $matchers[$method] = PathMatcher::newFromCache( $data );
258                }
259            } else {
260                foreach ( $this->getAllRoutes() as $spec ) {
261                    $methods = $spec['method'] ?? [ 'GET' ];
262                    if ( !is_array( $methods ) ) {
263                        $methods = [ $methods ];
264                    }
265                    foreach ( $methods as $method ) {
266                        if ( !isset( $matchers[$method] ) ) {
267                            $matchers[$method] = new PathMatcher;
268                        }
269                        $matchers[$method]->add( $spec['path'], $spec );
270                    }
271                }
272
273                $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
274                foreach ( $matchers as $method => $matcher ) {
275                    $cacheData[$method] = $matcher->getCacheData();
276                }
277                $this->cacheBag->set( $this->getCacheKey(), $cacheData );
278            }
279            $this->matchers = $matchers;
280        }
281        return $this->matchers;
282    }
283
284    /**
285     * Remove the path prefix $this->rootPath. Return the part of the path with the
286     * prefix removed, or false if the prefix did not match.
287     *
288     * @param string $path
289     * @return false|string
290     */
291    private function getRelativePath( $path ) {
292        if ( !str_starts_with( $path, $this->rootPath ) ) {
293            return false;
294        }
295        return substr( $path, strlen( $this->rootPath ) );
296    }
297
298    /**
299     * Returns the path part of the route URL for the given route, including the root path.
300     * Intended for use in relative redirects.
301     *
302     * @since 1.42
303     *
304     * @param string $route
305     * @param array $pathParams
306     * @param array $queryParams
307     *
308     * @return string
309     * @see getPrivateRouteUrl
310     */
311    public function getRoutePath(
312        string $route,
313        array $pathParams = [],
314        array $queryParams = []
315    ): string {
316        $route = $this->substPathParams( $route, $pathParams );
317        $path = $this->rootPath . $route;
318        return wfAppendQuery( $path, $queryParams );
319    }
320
321    /**
322     * Returns a full URL for the given route.
323     * Intended for use in redirects and when including links to endpoints in output.
324     *
325     * @param string $route
326     * @param array $pathParams
327     * @param array $queryParams
328     *
329     * @return string
330     * @see getPrivateRouteUrl
331     *
332     */
333    public function getRouteUrl(
334        string $route,
335        array $pathParams = [],
336        array $queryParams = []
337    ): string {
338        return $this->baseUrl . $this->getRoutePath( $route, $pathParams, $queryParams );
339    }
340
341    /**
342     * Returns a full private URL for the given route.
343     * Private URLs are for use within the local subnet, they may use host names or ports
344     * or paths that are not publicly accessible.
345     * Intended for use in redirects and when including links to endpoints in output.
346     *
347     * @note Only private endpoints should use this method for redirects or links to
348     *       include on the response! Public endpoints should not expose the URLs
349     *       of private endpoints to the public!
350     *
351     * @since 1.39
352     * @see getRouteUrl
353     *
354     * @param string $route
355     * @param array $pathParams
356     * @param array $queryParams
357     *
358     * @return string
359     */
360    public function getPrivateRouteUrl(
361        string $route,
362        array $pathParams = [],
363        array $queryParams = []
364    ): string {
365        return $this->privateBaseUrl . $this->getRoutePath( $route, $pathParams, $queryParams );
366    }
367
368    /**
369     * @param string $route
370     * @param array $pathParams
371     *
372     * @return string
373     */
374    protected function substPathParams( string $route, array $pathParams ): string {
375        foreach ( $pathParams as $param => $value ) {
376            // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
377            // Spaces in path params must be encoded to %20 (not +).
378            // Slashes must be encoded as %2F.
379            $route = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $route );
380        }
381
382        return $route;
383    }
384
385    /**
386     * Find the handler for a request and execute it
387     *
388     * @param RequestInterface $request
389     * @return ResponseInterface
390     */
391    public function execute( RequestInterface $request ) {
392        $path = $request->getUri()->getPath();
393        $relPath = $this->getRelativePath( $path );
394        if ( $relPath === false ) {
395            return $this->responseFactory->createLocalizedHttpError( 404,
396                ( new MessageValue( 'rest-prefix-mismatch' ) )
397                    ->plaintextParams( $path, $this->rootPath )
398            );
399        }
400
401        $requestMethod = $request->getMethod();
402        $matchers = $this->getMatchers();
403        $matcher = $matchers[$requestMethod] ?? null;
404        $match = $matcher ? $matcher->match( $relPath ) : null;
405
406        // For a HEAD request, execute the GET handler instead if one exists.
407        // The webserver will discard the body.
408        if ( !$match && $requestMethod === 'HEAD' && isset( $matchers['GET'] ) ) {
409            $match = $matchers['GET']->match( $relPath );
410        }
411
412        if ( !$match ) {
413            // Check for 405 wrong method
414            $allowed = $this->getAllowedMethods( $relPath );
415
416            // Check for CORS Preflight. This response will *not* allow the request unless
417            // an Access-Control-Allow-Origin header is added to this response.
418            if ( $this->cors && $requestMethod === 'OPTIONS' ) {
419                return $this->cors->createPreflightResponse( $allowed );
420            }
421
422            if ( $allowed ) {
423                $response = $this->responseFactory->createLocalizedHttpError( 405,
424                    ( new MessageValue( 'rest-wrong-method' ) )
425                        ->textParams( $requestMethod )
426                        ->commaListParams( $allowed )
427                        ->numParams( count( $allowed ) )
428                );
429                $response->setHeader( 'Allow', $allowed );
430                return $response;
431            } else {
432                // Did not match with any other method, must be 404
433                return $this->responseFactory->createLocalizedHttpError( 404,
434                    ( new MessageValue( 'rest-no-match' ) )
435                        ->plaintextParams( $relPath )
436                );
437            }
438        }
439
440        $pathForMetrics = '(unknown)';
441        $handler = null;
442        try {
443            // Use rawurldecode so a "+" in path params is not interpreted as a space character.
444            $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
445            $handler = $this->createHandler( $request, $match['userData'] );
446
447            $this->setBodyData( $request, $handler );
448
449            // Replace any characters that may have a special meaning in the metrics DB.
450            $pathForMetrics = $handler->getPath();
451            $pathForMetrics = strtr( $pathForMetrics, '{}:', '-' );
452            $pathForMetrics = strtr( $pathForMetrics, '/.', '_' );
453
454            $statTime = microtime( true );
455
456            $response = $this->executeHandler( $handler );
457        } catch ( HttpException $e ) {
458            $response = $this->responseFactory->createFromException( $e );
459        } catch ( Throwable $e ) {
460            $this->errorReporter->reportError( $e, $handler, $request );
461            $response = $this->responseFactory->createFromException( $e );
462        }
463
464        // gather metrics
465        $statusCode = $response->getStatusCode();
466        if ( $response->getStatusCode() >= 400 ) {
467            // count how often we return which error code
468            $this->stats->increment( "rest_api_errors.$pathForMetrics.$requestMethod.$statusCode" );
469        } else {
470            // measure how long it takes to generate a response
471            $microtime = ( microtime( true ) - $statTime ) * 1000;
472            $this->stats->timing(
473                "rest_api_latency.$pathForMetrics.$requestMethod.$statusCode",
474                $microtime
475            );
476        }
477
478        return $response;
479    }
480
481    /**
482     * Get the allow methods for a path.
483     *
484     * @param string $relPath
485     * @return array
486     */
487    private function getAllowedMethods( string $relPath ): array {
488        // Check for 405 wrong method
489        $allowed = [];
490        foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) {
491            if ( $allowedMatcher->match( $relPath ) ) {
492                $allowed[] = $allowedMethod;
493            }
494        }
495
496        return array_unique(
497            in_array( 'GET', $allowed ) ? array_merge( [ 'HEAD' ], $allowed ) : $allowed
498        );
499    }
500
501    /**
502     * Create a handler from its spec
503     * @param RequestInterface $request
504     * @param array $spec
505     * @return Handler
506     */
507    private function createHandler( RequestInterface $request, array $spec ): Handler {
508        $handler = $this->instantiateHandlerObject( $spec );
509
510        // TODO: split the init method, so instantiateHandlerObject can inject the spec.
511        $handler->init( $this, $request, $spec, $this->authority, $this->responseFactory,
512            $this->hookContainer, $this->session
513        );
514
515        return $handler;
516    }
517
518    /**
519     * Creates a handler from the given spec, but does not initialize it.
520     * @param array $spec
521     * @return Handler
522     */
523    public function instantiateHandlerObject( array $spec ): Handler {
524        if ( !isset( $spec['class'] ) && !isset( $spec['factory'] ) ) {
525            // Inject well known handle class for shorthand definition
526            if ( isset( $spec['redirect'] ) ) {
527                $spec['class'] = RedirectHandler::class;
528            } else {
529                throw new RouteDefinitionException(
530                    'Route handler definition must specify "class" or ' .
531                    '"factory" or "redirect"'
532                );
533            }
534        }
535
536        /** @var $handler Handler (annotation for PHPStorm) */
537        // @phan-suppress-next-line PhanTypeInvalidCallableArraySize
538        $handler = $this->objectFactory->createObject(
539            $spec,
540            [ 'assertClass' => Handler::class ]
541        );
542
543        return $handler;
544    }
545
546    /**
547     * Execute a fully-constructed handler
548     *
549     * @param Handler $handler
550     * @return ResponseInterface
551     * @throws HttpException
552     */
553    private function executeHandler( $handler ): ResponseInterface {
554        ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() );
555        // Check for basic authorization, to avoid leaking data from private wikis
556        $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
557        if ( $authResult ) {
558            return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
559        }
560
561        // Check session (and session provider)
562        $handler->checkSession();
563
564        // Validate the parameters
565        $handler->validate( $this->restValidator );
566
567        // Check conditional request headers
568        $earlyResponse = $handler->checkPreconditions();
569        if ( $earlyResponse ) {
570            return $earlyResponse;
571        }
572
573        // Run the main part of the handler
574        $response = $handler->execute();
575        if ( !( $response instanceof ResponseInterface ) ) {
576            $response = $this->responseFactory->createFromReturnValue( $response );
577        }
578
579        // Set Last-Modified and ETag headers in the response if available
580        $handler->applyConditionalResponseHeaders( $response );
581
582        $handler->applyCacheControl( $response );
583
584        return $response;
585    }
586
587    /**
588     * @param CorsUtils $cors
589     * @return self
590     */
591    public function setCors( CorsUtils $cors ): self {
592        $this->cors = $cors;
593
594        return $this;
595    }
596
597    /**
598     * @param StatsdDataFactoryInterface $stats
599     *
600     * @return self
601     */
602    public function setStats( StatsdDataFactoryInterface $stats ): self {
603        $this->stats = $stats;
604
605        return $this;
606    }
607
608    private function setBodyData( RequestInterface $request, Handler $handler ) {
609        // fail if the request method is in nobodymethod but has body
610        $requestMethod = $request->getMethod();
611        if ( in_array( $requestMethod, RequestInterface::NO_BODY_METHODS ) ) {
612            // check if the request has a body
613            if ( $request->hasBody() ) {
614                // NOTE: Don't throw, see T359509.
615                // TODO: Ignore only empty bodies, log a warning or fail if
616                //       there is actual content.
617                return;
618            }
619        }
620
621        // fail if the request method expects a body but has no body
622        if ( in_array( $requestMethod, RequestInterface::BODY_METHODS ) ) {
623            // check if it has no body
624            if ( !$request->hasBody() ) {
625                throw new LocalizedHttpException( new MessageValue( "rest-request-body-expected", [ $requestMethod ] ),
626                    411
627                );
628            }
629        }
630
631        // call parsedbody
632        if ( $request->hasBody() ) {
633            $parsedBody = $handler->parseBodyData( $request );
634            // Set the parsed body data on the request object
635            $request->setParsedBody( $parsedBody );
636        }
637    }
638
639}