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 Wikimedia\ObjectFactory;
14 
20 class Router {
22  private $routeFiles;
23 
25  private $extraRoutes;
26 
29 
32 
34  private $baseUrl;
35 
37  private $rootPath;
38 
40  private $cacheBag;
41 
43  private $matchers;
44 
46  private $configHash;
47 
50 
52  private $basicAuth;
53 
55  private $authority;
56 
58  private $objectFactory;
59 
61  private $restValidator;
62 
64  private $cors;
65 
67  private $hookContainer;
68 
83  public function __construct(
86  $baseUrl,
87  $rootPath,
92  ObjectFactory $objectFactory,
95  ) {
96  $this->routeFiles = $routeFiles;
97  $this->extraRoutes = $extraRoutes;
98  $this->baseUrl = $baseUrl;
99  $this->rootPath = $rootPath;
100  $this->cacheBag = $cacheBag;
101  $this->responseFactory = $responseFactory;
102  $this->basicAuth = $basicAuth;
103  $this->authority = $authority;
104  $this->objectFactory = $objectFactory;
105  $this->restValidator = $restValidator;
106  $this->hookContainer = $hookContainer;
107  }
108 
114  private function fetchCacheData() {
115  $cacheData = $this->cacheBag->get( $this->getCacheKey() );
116  if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
117  unset( $cacheData['CONFIG-HASH'] );
118  return $cacheData;
119  } else {
120  return false;
121  }
122  }
123 
127  private function getCacheKey() {
128  return $this->cacheBag->makeKey( __CLASS__, '1' );
129  }
130 
136  private function getConfigHash() {
137  if ( $this->configHash === null ) {
138  $this->configHash = md5( json_encode( [
139  $this->extraRoutes,
140  $this->getRouteFileTimestamps()
141  ] ) );
142  }
143  return $this->configHash;
144  }
145 
151  private function getRoutesFromFiles() {
152  if ( $this->routesFromFiles === null ) {
153  $this->routeFileTimestamps = [];
154  foreach ( $this->routeFiles as $fileName ) {
155  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
156  $routes = json_decode( file_get_contents( $fileName ), true );
157  if ( $this->routesFromFiles === null ) {
158  $this->routesFromFiles = $routes;
159  } else {
160  $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
161  }
162  }
163  }
164  return $this->routesFromFiles;
165  }
166 
172  private function getRouteFileTimestamps() {
173  if ( $this->routeFileTimestamps === null ) {
174  $this->routeFileTimestamps = [];
175  foreach ( $this->routeFiles as $fileName ) {
176  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
177  }
178  }
180  }
181 
188  private function getAllRoutes() {
189  $iterator = new AppendIterator;
190  $iterator->append( new \ArrayIterator( $this->getRoutesFromFiles() ) );
191  $iterator->append( new \ArrayIterator( $this->extraRoutes ) );
192  return $iterator;
193  }
194 
200  private function getMatchers() {
201  if ( $this->matchers === null ) {
202  $cacheData = $this->fetchCacheData();
203  $matchers = [];
204  if ( $cacheData ) {
205  foreach ( $cacheData as $method => $data ) {
206  $matchers[$method] = PathMatcher::newFromCache( $data );
207  }
208  } else {
209  foreach ( $this->getAllRoutes() as $spec ) {
210  $methods = $spec['method'] ?? [ 'GET' ];
211  if ( !is_array( $methods ) ) {
212  $methods = [ $methods ];
213  }
214  foreach ( $methods as $method ) {
215  if ( !isset( $matchers[$method] ) ) {
216  $matchers[$method] = new PathMatcher;
217  }
218  $matchers[$method]->add( $spec['path'], $spec );
219  }
220  }
221 
222  $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
223  foreach ( $matchers as $method => $matcher ) {
224  $cacheData[$method] = $matcher->getCacheData();
225  }
226  $this->cacheBag->set( $this->getCacheKey(), $cacheData );
227  }
228  $this->matchers = $matchers;
229  }
230  return $this->matchers;
231  }
232 
240  private function getRelativePath( $path ) {
241  if ( strlen( $this->rootPath ) > strlen( $path ) ||
242  substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0
243  ) {
244  return false;
245  }
246  return substr( $path, strlen( $this->rootPath ) );
247  }
248 
259  public function getRouteUrl( $route, $pathParams = [], $queryParams = [] ) {
260  foreach ( $pathParams as $param => $value ) {
261  // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
262  // Spaces in path params must be encoded to %20 (not +).
263  $route = str_replace( '{' . $param . '}', rawurlencode( $value ), $route );
264  }
265 
266  $url = $this->baseUrl . $this->rootPath . $route;
267  return wfAppendQuery( $url, $queryParams );
268  }
269 
276  public function execute( RequestInterface $request ) {
277  $path = $request->getUri()->getPath();
278  $relPath = $this->getRelativePath( $path );
279  if ( $relPath === false ) {
280  return $this->responseFactory->createLocalizedHttpError( 404,
281  ( new MessageValue( 'rest-prefix-mismatch' ) )
282  ->plaintextParams( $path, $this->rootPath )
283  );
284  }
285 
286  $requestMethod = $request->getMethod();
287  $matchers = $this->getMatchers();
288  $matcher = $matchers[$requestMethod] ?? null;
289  $match = $matcher ? $matcher->match( $relPath ) : null;
290 
291  // For a HEAD request, execute the GET handler instead if one exists.
292  // The webserver will discard the body.
293  if ( !$match && $requestMethod === 'HEAD' && isset( $matchers['GET'] ) ) {
294  $match = $matchers['GET']->match( $relPath );
295  }
296 
297  if ( !$match ) {
298  // Check for 405 wrong method
299  $allowed = $this->getAllowedMethods( $relPath );
300 
301  // Check for CORS Preflight. This response will *not* allow the request unless
302  // an Access-Control-Allow-Origin header is added to this response.
303  if ( $this->cors && $requestMethod === 'OPTIONS' ) {
304  return $this->cors->createPreflightResponse( $allowed );
305  }
306 
307  if ( $allowed ) {
308  $response = $this->responseFactory->createLocalizedHttpError( 405,
309  ( new MessageValue( 'rest-wrong-method' ) )
310  ->textParams( $requestMethod )
311  ->commaListParams( $allowed )
312  ->numParams( count( $allowed ) )
313  );
314  $response->setHeader( 'Allow', $allowed );
315  return $response;
316  } else {
317  // Did not match with any other method, must be 404
318  return $this->responseFactory->createLocalizedHttpError( 404,
319  ( new MessageValue( 'rest-no-match' ) )
320  ->plaintextParams( $relPath )
321  );
322  }
323  }
324 
325  // Use rawurldecode so a "+" in path params is not interpreted as a space character.
326  $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
327  $handler = $this->createHandler( $request, $match['userData'] );
328 
329  try {
330  return $this->executeHandler( $handler );
331  } catch ( HttpException $e ) {
332  return $this->responseFactory->createFromException( $e );
333  }
334  }
335 
342  private function getAllowedMethods( string $relPath ): array {
343  // Check for 405 wrong method
344  $allowed = [];
345  foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) {
346  if ( $allowedMatcher->match( $relPath ) ) {
347  $allowed[] = $allowedMethod;
348  }
349  }
350 
351  return array_unique(
352  in_array( 'GET', $allowed ) ? array_merge( [ 'HEAD' ], $allowed ) : $allowed
353  );
354  }
355 
362  private function createHandler( RequestInterface $request, array $spec ): Handler {
363  $objectFactorySpec = array_intersect_key(
364  $spec,
365  [
366  'factory' => true,
367  'class' => true,
368  'args' => true,
369  'services' => true,
370  'optional_services' => true
371  ]
372  );
374  $handler = $this->objectFactory->createObject( $objectFactorySpec );
375  $handler->init( $this, $request, $spec, $this->authority, $this->responseFactory, $this->hookContainer );
376 
377  return $handler;
378  }
379 
386  private function executeHandler( $handler ): ResponseInterface {
387  // Check for basic authorization, to avoid leaking data from private wikis
388  $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
389  if ( $authResult ) {
390  return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
391  }
392 
393  // Validate the parameters
394  $handler->validate( $this->restValidator );
395 
396  // Check conditional request headers
397  $earlyResponse = $handler->checkPreconditions();
398  if ( $earlyResponse ) {
399  return $earlyResponse;
400  }
401 
402  // Run the main part of the handler
403  $response = $handler->execute();
404  if ( !( $response instanceof ResponseInterface ) ) {
405  $response = $this->responseFactory->createFromReturnValue( $response );
406  }
407 
408  // Set Last-Modified and ETag headers in the response if available
409  $handler->applyConditionalResponseHeaders( $response );
410 
411  return $response;
412  }
413 
418  public function setCors( CorsUtils $cors ): self {
419  $this->cors = $cors;
420 
421  return $this;
422  }
423 }
MediaWiki\Rest\Router\$responseFactory
ResponseFactory $responseFactory
Definition: Router.php:49
MediaWiki\Rest\Router\$authority
Authority $authority
Definition: Router.php:55
MediaWiki\Rest\ResponseFactory
Generates standardized response objects.
Definition: ResponseFactory.php:17
MediaWiki\Rest\Router\$routesFromFiles
array null $routesFromFiles
Definition: Router.php:28
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:114
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:52
MediaWiki\Rest\Router\$extraRoutes
array $extraRoutes
Definition: Router.php:25
MediaWiki\Rest\Router\$matchers
PathMatcher[] null $matchers
Path matchers by method.
Definition: Router.php:43
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:86
MediaWiki\Rest\Router\$routeFiles
string[] $routeFiles
Definition: Router.php:22
MediaWiki\Rest\Router\createHandler
createHandler(RequestInterface $request, array $spec)
Create a handler from its spec.
Definition: Router.php:362
MediaWiki\Rest\Router\$configHash
string null $configHash
Definition: Router.php:46
MediaWiki\Rest\Router\getRelativePath
getRelativePath( $path)
Remove the path prefix $this->rootPath.
Definition: Router.php:240
MediaWiki\Rest\Router\getConfigHash
getConfigHash()
Get a config version hash for cache invalidation.
Definition: Router.php:136
MediaWiki\Rest\Router\setCors
setCors(CorsUtils $cors)
Definition: Router.php:418
Wikimedia\Message\MessageValue
Value object representing a message for i18n.
Definition: MessageValue.php:16
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:276
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:342
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:20
MediaWiki\Rest\Router\$baseUrl
string $baseUrl
Definition: Router.php:34
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:259
MediaWiki\Rest\Router\executeHandler
executeHandler( $handler)
Execute a fully-constructed handler.
Definition: Router.php:386
MediaWiki\Rest
MediaWiki\Rest\Router\$cacheBag
BagOStuff $cacheBag
Definition: Router.php:40
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:64
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:58
MediaWiki\Rest\Router\getAllRoutes
getAllRoutes()
Get an iterator for all defined routes, including loading the routes from the JSON files.
Definition: Router.php:188
MediaWiki\Rest\Router\getMatchers
getMatchers()
Get an array of PathMatcher objects indexed by HTTP method.
Definition: Router.php:200
$path
$path
Definition: NoLocalSettings.php:25
MediaWiki\Rest\Router\__construct
__construct( $routeFiles, $extraRoutes, $baseUrl, $rootPath, BagOStuff $cacheBag, ResponseFactory $responseFactory, BasicAuthorizerInterface $basicAuth, Authority $authority, ObjectFactory $objectFactory, Validator $restValidator, HookContainer $hookContainer)
Definition: Router.php:83
MediaWiki\Rest\Router\getCacheKey
getCacheKey()
Definition: Router.php:127
MediaWiki\Rest\Router\$routeFileTimestamps
int[] null $routeFileTimestamps
Definition: Router.php:31
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:172
MediaWiki\Rest\Router\$rootPath
string $rootPath
Definition: Router.php:37
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:67
MediaWiki\Rest\Router\getRoutesFromFiles
getRoutesFromFiles()
Load the defined JSON files and return the merged routes.
Definition: Router.php:151
MediaWiki\Rest\Router\$restValidator
Validator $restValidator
Definition: Router.php:61