MediaWiki  master
Router.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Rest;
4 
5 use AppendIterator;
6 use BagOStuff;
13 use Throwable;
15 use Wikimedia\ObjectFactory;
16 
22 class Router {
24  private $routeFiles;
25 
27  private $extraRoutes;
28 
31 
34 
36  private $baseUrl;
37 
39  private $rootPath;
40 
42  private $cacheBag;
43 
45  private $matchers;
46 
48  private $configHash;
49 
52 
54  private $basicAuth;
55 
57  private $authority;
58 
60  private $objectFactory;
61 
63  private $restValidator;
64 
66  private $cors;
67 
69  private $errorReporter;
70 
72  private $hookContainer;
73 
89  public function __construct(
92  $baseUrl,
93  $rootPath,
98  ObjectFactory $objectFactory,
102  ) {
103  $this->routeFiles = $routeFiles;
104  $this->extraRoutes = $extraRoutes;
105  $this->baseUrl = $baseUrl;
106  $this->rootPath = $rootPath;
107  $this->cacheBag = $cacheBag;
108  $this->responseFactory = $responseFactory;
109  $this->basicAuth = $basicAuth;
110  $this->authority = $authority;
111  $this->objectFactory = $objectFactory;
112  $this->restValidator = $restValidator;
113  $this->errorReporter = $errorReporter;
114  $this->hookContainer = $hookContainer;
115  }
116 
122  private function fetchCacheData() {
123  $cacheData = $this->cacheBag->get( $this->getCacheKey() );
124  if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
125  unset( $cacheData['CONFIG-HASH'] );
126  return $cacheData;
127  } else {
128  return false;
129  }
130  }
131 
135  private function getCacheKey() {
136  return $this->cacheBag->makeKey( __CLASS__, '1' );
137  }
138 
144  private function getConfigHash() {
145  if ( $this->configHash === null ) {
146  $this->configHash = md5( json_encode( [
147  $this->extraRoutes,
148  $this->getRouteFileTimestamps()
149  ] ) );
150  }
151  return $this->configHash;
152  }
153 
159  private function getRoutesFromFiles() {
160  if ( $this->routesFromFiles === null ) {
161  $this->routeFileTimestamps = [];
162  foreach ( $this->routeFiles as $fileName ) {
163  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
164  $routes = json_decode( file_get_contents( $fileName ), true );
165  if ( $this->routesFromFiles === null ) {
166  $this->routesFromFiles = $routes;
167  } else {
168  $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
169  }
170  }
171  }
172  return $this->routesFromFiles;
173  }
174 
180  private function getRouteFileTimestamps() {
181  if ( $this->routeFileTimestamps === null ) {
182  $this->routeFileTimestamps = [];
183  foreach ( $this->routeFiles as $fileName ) {
184  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
185  }
186  }
188  }
189 
196  private function getAllRoutes() {
197  $iterator = new AppendIterator;
198  $iterator->append( new \ArrayIterator( $this->getRoutesFromFiles() ) );
199  $iterator->append( new \ArrayIterator( $this->extraRoutes ) );
200  return $iterator;
201  }
202 
208  private function getMatchers() {
209  if ( $this->matchers === null ) {
210  $cacheData = $this->fetchCacheData();
211  $matchers = [];
212  if ( $cacheData ) {
213  foreach ( $cacheData as $method => $data ) {
214  $matchers[$method] = PathMatcher::newFromCache( $data );
215  }
216  } else {
217  foreach ( $this->getAllRoutes() as $spec ) {
218  $methods = $spec['method'] ?? [ 'GET' ];
219  if ( !is_array( $methods ) ) {
220  $methods = [ $methods ];
221  }
222  foreach ( $methods as $method ) {
223  if ( !isset( $matchers[$method] ) ) {
224  $matchers[$method] = new PathMatcher;
225  }
226  $matchers[$method]->add( $spec['path'], $spec );
227  }
228  }
229 
230  $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
231  foreach ( $matchers as $method => $matcher ) {
232  $cacheData[$method] = $matcher->getCacheData();
233  }
234  $this->cacheBag->set( $this->getCacheKey(), $cacheData );
235  }
236  $this->matchers = $matchers;
237  }
238  return $this->matchers;
239  }
240 
248  private function getRelativePath( $path ) {
249  if ( strlen( $this->rootPath ) > strlen( $path ) ||
250  substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0
251  ) {
252  return false;
253  }
254  return substr( $path, strlen( $this->rootPath ) );
255  }
256 
267  public function getRouteUrl( $route, $pathParams = [], $queryParams = [] ) {
268  foreach ( $pathParams as $param => $value ) {
269  // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
270  // Spaces in path params must be encoded to %20 (not +).
271  $route = str_replace( '{' . $param . '}', rawurlencode( $value ), $route );
272  }
273 
274  $url = $this->baseUrl . $this->rootPath . $route;
275  return wfAppendQuery( $url, $queryParams );
276  }
277 
284  public function execute( RequestInterface $request ) {
285  $path = $request->getUri()->getPath();
286  $relPath = $this->getRelativePath( $path );
287  if ( $relPath === false ) {
288  return $this->responseFactory->createLocalizedHttpError( 404,
289  ( new MessageValue( 'rest-prefix-mismatch' ) )
290  ->plaintextParams( $path, $this->rootPath )
291  );
292  }
293 
294  $requestMethod = $request->getMethod();
295  $matchers = $this->getMatchers();
296  $matcher = $matchers[$requestMethod] ?? null;
297  $match = $matcher ? $matcher->match( $relPath ) : null;
298 
299  // For a HEAD request, execute the GET handler instead if one exists.
300  // The webserver will discard the body.
301  if ( !$match && $requestMethod === 'HEAD' && isset( $matchers['GET'] ) ) {
302  $match = $matchers['GET']->match( $relPath );
303  }
304 
305  if ( !$match ) {
306  // Check for 405 wrong method
307  $allowed = $this->getAllowedMethods( $relPath );
308 
309  // Check for CORS Preflight. This response will *not* allow the request unless
310  // an Access-Control-Allow-Origin header is added to this response.
311  if ( $this->cors && $requestMethod === 'OPTIONS' ) {
312  return $this->cors->createPreflightResponse( $allowed );
313  }
314 
315  if ( $allowed ) {
316  $response = $this->responseFactory->createLocalizedHttpError( 405,
317  ( new MessageValue( 'rest-wrong-method' ) )
318  ->textParams( $requestMethod )
319  ->commaListParams( $allowed )
320  ->numParams( count( $allowed ) )
321  );
322  $response->setHeader( 'Allow', $allowed );
323  return $response;
324  } else {
325  // Did not match with any other method, must be 404
326  return $this->responseFactory->createLocalizedHttpError( 404,
327  ( new MessageValue( 'rest-no-match' ) )
328  ->plaintextParams( $relPath )
329  );
330  }
331  }
332 
333  // Use rawurldecode so a "+" in path params is not interpreted as a space character.
334  $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
335  $handler = $this->createHandler( $request, $match['userData'] );
336 
337  try {
338  return $this->executeHandler( $handler );
339  } catch ( HttpException $e ) {
340  return $this->responseFactory->createFromException( $e );
341  } catch ( Throwable $e ) {
342  $this->errorReporter->reportError( $e, $handler, $request );
343  return $this->responseFactory->createFromException( $e );
344  }
345  }
346 
353  private function getAllowedMethods( string $relPath ): array {
354  // Check for 405 wrong method
355  $allowed = [];
356  foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) {
357  if ( $allowedMatcher->match( $relPath ) ) {
358  $allowed[] = $allowedMethod;
359  }
360  }
361 
362  return array_unique(
363  in_array( 'GET', $allowed ) ? array_merge( [ 'HEAD' ], $allowed ) : $allowed
364  );
365  }
366 
373  private function createHandler( RequestInterface $request, array $spec ): Handler {
374  $objectFactorySpec = array_intersect_key(
375  $spec,
376  [
377  'factory' => true,
378  'class' => true,
379  'args' => true,
380  'services' => true,
381  'optional_services' => true
382  ]
383  );
385  $handler = $this->objectFactory->createObject( $objectFactorySpec );
386  $handler->init( $this, $request, $spec, $this->authority, $this->responseFactory, $this->hookContainer );
387 
388  return $handler;
389  }
390 
397  private function executeHandler( $handler ): ResponseInterface {
398  // Check for basic authorization, to avoid leaking data from private wikis
399  $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
400  if ( $authResult ) {
401  return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
402  }
403 
404  // Validate the parameters
405  $handler->validate( $this->restValidator );
406 
407  // Check conditional request headers
408  $earlyResponse = $handler->checkPreconditions();
409  if ( $earlyResponse ) {
410  return $earlyResponse;
411  }
412 
413  // Run the main part of the handler
414  $response = $handler->execute();
415  if ( !( $response instanceof ResponseInterface ) ) {
416  $response = $this->responseFactory->createFromReturnValue( $response );
417  }
418 
419  // Set Last-Modified and ETag headers in the response if available
420  $handler->applyConditionalResponseHeaders( $response );
421 
422  return $response;
423  }
424 
429  public function setCors( CorsUtils $cors ): self {
430  $this->cors = $cors;
431 
432  return $this;
433  }
434 }
MediaWiki\Rest\Router\$responseFactory
ResponseFactory $responseFactory
Definition: Router.php:51
MediaWiki\Rest\Router\$authority
Authority $authority
Definition: Router.php:57
MediaWiki\Rest\ResponseFactory
Generates standardized response objects.
Definition: ResponseFactory.php:17
MediaWiki\Rest\Router\$routesFromFiles
array null $routesFromFiles
Definition: Router.php:30
MediaWiki\Rest\Validator\Validator
Wrapper for ParamValidator.
Definition: Validator.php:32
MediaWiki\Rest\RequestInterface\setPathParams
setPathParams( $params)
Erase all path parameters from the object and set the parameter array to the one specified.
MediaWiki\Rest\CorsUtils
Definition: CorsUtils.php:13
MediaWiki\Rest\Router\fetchCacheData
fetchCacheData()
Get the cache data, or false if it is missing or invalid.
Definition: Router.php:122
MediaWiki\Rest\PathTemplateMatcher\PathMatcher\match
match( $path)
Match a path against the current match trees.
Definition: PathMatcher.php:196
MediaWiki\Rest\Router\$basicAuth
BasicAuthorizerInterface $basicAuth
Definition: Router.php:54
MediaWiki\Rest\Router\$extraRoutes
array $extraRoutes
Definition: Router.php:27
MediaWiki\Rest\Router\$matchers
PathMatcher[] null $matchers
Path matchers by method.
Definition: Router.php:45
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:86
MediaWiki\Rest\Router\$routeFiles
string[] $routeFiles
Definition: Router.php:24
MediaWiki\Rest\Router\createHandler
createHandler(RequestInterface $request, array $spec)
Create a handler from its spec.
Definition: Router.php:373
MediaWiki\Rest\Router\$configHash
string null $configHash
Definition: Router.php:48
MediaWiki\Rest\Router\getRelativePath
getRelativePath( $path)
Remove the path prefix $this->rootPath.
Definition: Router.php:248
MediaWiki\Rest\Router\getConfigHash
getConfigHash()
Get a config version hash for cache invalidation.
Definition: Router.php:144
MediaWiki\Rest\Router\setCors
setCors(CorsUtils $cors)
Definition: Router.php:429
Wikimedia\Message\MessageValue
Value object representing a message for i18n.
Definition: MessageValue.php:18
MediaWiki\Rest\Handler
Base class for REST route handlers.
Definition: Handler.php:17
MediaWiki\Rest\Router\execute
execute(RequestInterface $request)
Find the handler for a request and execute it.
Definition: Router.php:284
wfAppendQuery
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
Definition: GlobalFunctions.php:422
MediaWiki\Rest\Router\getAllowedMethods
getAllowedMethods(string $relPath)
Get the allow methods for a path.
Definition: Router.php:353
MediaWiki\Rest\PathTemplateMatcher\PathMatcher\add
add( $template, $userData)
Add a template to the matcher.
Definition: PathMatcher.php:149
MediaWiki\Rest\Router
The REST router is responsible for gathering handler configuration, matching an input path and HTTP m...
Definition: Router.php:22
MediaWiki\Rest\Router\$baseUrl
string $baseUrl
Definition: Router.php:36
MediaWiki\Rest\RequestInterface\getMethod
getMethod()
Retrieves the HTTP method of the request.
MediaWiki\Rest\Router\getRouteUrl
getRouteUrl( $route, $pathParams=[], $queryParams=[])
Returns a full URL for the given route.
Definition: Router.php:267
MediaWiki\Rest\Router\__construct
__construct( $routeFiles, $extraRoutes, $baseUrl, $rootPath, BagOStuff $cacheBag, ResponseFactory $responseFactory, BasicAuthorizerInterface $basicAuth, Authority $authority, ObjectFactory $objectFactory, Validator $restValidator, ErrorReporter $errorReporter, HookContainer $hookContainer)
Definition: Router.php:89
MediaWiki\Rest\Router\executeHandler
executeHandler( $handler)
Execute a fully-constructed handler.
Definition: Router.php:397
MediaWiki\Rest
MediaWiki\Rest\Router\$cacheBag
BagOStuff $cacheBag
Definition: Router.php:42
MediaWiki\Permissions\Authority
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
MediaWiki\Rest\PathTemplateMatcher\PathMatcher\newFromCache
static newFromCache( $data)
Create a PathMatcher from cache data.
Definition: PathMatcher.php:42
MediaWiki\Rest\RequestInterface\getUri
getUri()
Retrieves the URI instance.
MediaWiki\Rest\ResponseInterface
An interface similar to PSR-7's ResponseInterface, the primary difference being that it is mutable.
Definition: ResponseInterface.php:41
MediaWiki\Rest\Router\$cors
CorsUtils null $cors
Definition: Router.php:66
MediaWiki\Rest\RequestInterface
A request interface similar to PSR-7's ServerRequestInterface.
Definition: RequestInterface.php:39
MediaWiki\Rest\HttpException
This is the base exception class for non-fatal exceptions thrown from REST handlers.
Definition: HttpException.php:12
MediaWiki\Rest\PathTemplateMatcher\PathMatcher
A tree-based path routing algorithm.
Definition: PathMatcher.php:16
MediaWiki\Rest\Router\$objectFactory
ObjectFactory $objectFactory
Definition: Router.php:60
MediaWiki\Rest\Router\getAllRoutes
getAllRoutes()
Get an iterator for all defined routes, including loading the routes from the JSON files.
Definition: Router.php:196
MediaWiki\Rest\Router\getMatchers
getMatchers()
Get an array of PathMatcher objects indexed by HTTP method.
Definition: Router.php:208
$path
$path
Definition: NoLocalSettings.php:25
MediaWiki\Rest\Router\$errorReporter
ErrorReporter $errorReporter
Definition: Router.php:69
MediaWiki\Rest\Router\getCacheKey
getCacheKey()
Definition: Router.php:135
MediaWiki\Rest\Router\$routeFileTimestamps
int[] null $routeFileTimestamps
Definition: Router.php:33
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\Rest\Router\getRouteFileTimestamps
getRouteFileTimestamps()
Get an array of last modification times of the defined route files.
Definition: Router.php:180
MediaWiki\Rest\Router\$rootPath
string $rootPath
Definition: Router.php:39
MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface
An interface used by Router to ensure that the client has "basic" access, i.e.
Definition: BasicAuthorizerInterface.php:14
MediaWiki\Rest\Router\$hookContainer
HookContainer $hookContainer
Definition: Router.php:72
MediaWiki\Rest\Router\getRoutesFromFiles
getRoutesFromFiles()
Load the defined JSON files and return the merged routes.
Definition: Router.php:159
MediaWiki\Rest\Router\$restValidator
Validator $restValidator
Definition: Router.php:63
MediaWiki\Rest\Reporter\ErrorReporter
An ErrorReporter internally reports an error that happened during the handling of a request.
Definition: ErrorReporter.php:15