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;
16 use Throwable;
18 use Wikimedia\ObjectFactory\ObjectFactory;
19 
25 class Router {
27  private $routeFiles;
28 
30  private $extraRoutes;
31 
33  private $routesFromFiles;
34 
36  private $routeFileTimestamps;
37 
39  private $baseUrl;
40 
42  private $privateBaseUrl;
43 
45  private $rootPath;
46 
48  private $cacheBag;
49 
51  private $matchers;
52 
54  private $configHash;
55 
57  private $responseFactory;
58 
60  private $basicAuth;
61 
63  private $authority;
64 
66  private $objectFactory;
67 
69  private $restValidator;
70 
72  private $cors;
73 
75  private $errorReporter;
76 
78  private $hookContainer;
79 
81  private $session;
82 
87  public const CONSTRUCTOR_OPTIONS = [
91  ];
92 
108  public function __construct(
109  $routeFiles,
110  $extraRoutes,
111  ServiceOptions $options,
112  BagOStuff $cacheBag,
113  ResponseFactory $responseFactory,
114  BasicAuthorizerInterface $basicAuth,
115  Authority $authority,
116  ObjectFactory $objectFactory,
117  Validator $restValidator,
118  ErrorReporter $errorReporter,
119  HookContainer $hookContainer,
120  Session $session
121  ) {
122  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
123 
124  $this->routeFiles = $routeFiles;
125  $this->extraRoutes = $extraRoutes;
126  $this->baseUrl = $options->get( MainConfigNames::CanonicalServer );
127  $this->privateBaseUrl = $options->get( MainConfigNames::InternalServer );
128  $this->rootPath = $options->get( MainConfigNames::RestPath );
129  $this->cacheBag = $cacheBag;
130  $this->responseFactory = $responseFactory;
131  $this->basicAuth = $basicAuth;
132  $this->authority = $authority;
133  $this->objectFactory = $objectFactory;
134  $this->restValidator = $restValidator;
135  $this->errorReporter = $errorReporter;
136  $this->hookContainer = $hookContainer;
137  $this->session = $session;
138  }
139 
145  private function fetchCacheData() {
146  $cacheData = $this->cacheBag->get( $this->getCacheKey() );
147  if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
148  unset( $cacheData['CONFIG-HASH'] );
149  return $cacheData;
150  } else {
151  return false;
152  }
153  }
154 
158  private function getCacheKey() {
159  return $this->cacheBag->makeKey( __CLASS__, '1' );
160  }
161 
167  private function getConfigHash() {
168  if ( $this->configHash === null ) {
169  $this->configHash = md5( json_encode( [
170  $this->extraRoutes,
171  $this->getRouteFileTimestamps()
172  ] ) );
173  }
174  return $this->configHash;
175  }
176 
182  private function getRoutesFromFiles() {
183  if ( $this->routesFromFiles === null ) {
184  $this->routeFileTimestamps = [];
185  foreach ( $this->routeFiles as $fileName ) {
186  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
187  $routes = json_decode( file_get_contents( $fileName ), true );
188  if ( $this->routesFromFiles === null ) {
189  $this->routesFromFiles = $routes;
190  } else {
191  $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
192  }
193  }
194  }
195  return $this->routesFromFiles;
196  }
197 
203  private function getRouteFileTimestamps() {
204  if ( $this->routeFileTimestamps === null ) {
205  $this->routeFileTimestamps = [];
206  foreach ( $this->routeFiles as $fileName ) {
207  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
208  }
209  }
210  return $this->routeFileTimestamps;
211  }
212 
219  private function getAllRoutes() {
220  $iterator = new AppendIterator;
221  $iterator->append( new \ArrayIterator( $this->getRoutesFromFiles() ) );
222  $iterator->append( new \ArrayIterator( $this->extraRoutes ) );
223  return $iterator;
224  }
225 
231  private function getMatchers() {
232  if ( $this->matchers === null ) {
233  $cacheData = $this->fetchCacheData();
234  $matchers = [];
235  if ( $cacheData ) {
236  foreach ( $cacheData as $method => $data ) {
237  $matchers[$method] = PathMatcher::newFromCache( $data );
238  }
239  } else {
240  foreach ( $this->getAllRoutes() as $spec ) {
241  $methods = $spec['method'] ?? [ 'GET' ];
242  if ( !is_array( $methods ) ) {
243  $methods = [ $methods ];
244  }
245  foreach ( $methods as $method ) {
246  if ( !isset( $matchers[$method] ) ) {
247  $matchers[$method] = new PathMatcher;
248  }
249  $matchers[$method]->add( $spec['path'], $spec );
250  }
251  }
252 
253  $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
254  foreach ( $matchers as $method => $matcher ) {
255  $cacheData[$method] = $matcher->getCacheData();
256  }
257  $this->cacheBag->set( $this->getCacheKey(), $cacheData );
258  }
259  $this->matchers = $matchers;
260  }
261  return $this->matchers;
262  }
263 
271  private function getRelativePath( $path ) {
272  if ( !str_starts_with( $path, $this->rootPath ) ) {
273  return false;
274  }
275  return substr( $path, strlen( $this->rootPath ) );
276  }
277 
290  public function getRouteUrl(
291  string $route,
292  array $pathParams = [],
293  array $queryParams = []
294  ): string {
295  $route = $this->substPathParams( $route, $pathParams );
296  $url = $this->baseUrl . $this->rootPath . $route;
297  return wfAppendQuery( $url, $queryParams );
298  }
299 
319  public function getPrivateRouteUrl(
320  string $route,
321  array $pathParams = [],
322  array $queryParams = []
323  ): string {
324  $route = $this->substPathParams( $route, $pathParams );
325  $url = $this->privateBaseUrl . $this->rootPath . $route;
326  return wfAppendQuery( $url, $queryParams );
327  }
328 
335  protected function substPathParams( string $route, array $pathParams ): string {
336  foreach ( $pathParams as $param => $value ) {
337  // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
338  // Spaces in path params must be encoded to %20 (not +).
339  // Slashes must be encoded as %2F.
340  $route = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $route );
341  }
342 
343  return $route;
344  }
345 
352  public function execute( RequestInterface $request ) {
353  $path = $request->getUri()->getPath();
354  $relPath = $this->getRelativePath( $path );
355  if ( $relPath === false ) {
356  return $this->responseFactory->createLocalizedHttpError( 404,
357  ( new MessageValue( 'rest-prefix-mismatch' ) )
358  ->plaintextParams( $path, $this->rootPath )
359  );
360  }
361 
362  $requestMethod = $request->getMethod();
363  $matchers = $this->getMatchers();
364  $matcher = $matchers[$requestMethod] ?? null;
365  $match = $matcher ? $matcher->match( $relPath ) : null;
366 
367  // For a HEAD request, execute the GET handler instead if one exists.
368  // The webserver will discard the body.
369  if ( !$match && $requestMethod === 'HEAD' && isset( $matchers['GET'] ) ) {
370  $match = $matchers['GET']->match( $relPath );
371  }
372 
373  if ( !$match ) {
374  // Check for 405 wrong method
375  $allowed = $this->getAllowedMethods( $relPath );
376 
377  // Check for CORS Preflight. This response will *not* allow the request unless
378  // an Access-Control-Allow-Origin header is added to this response.
379  if ( $this->cors && $requestMethod === 'OPTIONS' ) {
380  return $this->cors->createPreflightResponse( $allowed );
381  }
382 
383  if ( $allowed ) {
384  $response = $this->responseFactory->createLocalizedHttpError( 405,
385  ( new MessageValue( 'rest-wrong-method' ) )
386  ->textParams( $requestMethod )
387  ->commaListParams( $allowed )
388  ->numParams( count( $allowed ) )
389  );
390  $response->setHeader( 'Allow', $allowed );
391  return $response;
392  } else {
393  // Did not match with any other method, must be 404
394  return $this->responseFactory->createLocalizedHttpError( 404,
395  ( new MessageValue( 'rest-no-match' ) )
396  ->plaintextParams( $relPath )
397  );
398  }
399  }
400 
401  // Use rawurldecode so a "+" in path params is not interpreted as a space character.
402  $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
403  $handler = $this->createHandler( $request, $match['userData'] );
404 
405  try {
406  return $this->executeHandler( $handler );
407  } catch ( HttpException $e ) {
408  return $this->responseFactory->createFromException( $e );
409  } catch ( Throwable $e ) {
410  $this->errorReporter->reportError( $e, $handler, $request );
411  return $this->responseFactory->createFromException( $e );
412  }
413  }
414 
421  private function getAllowedMethods( string $relPath ): array {
422  // Check for 405 wrong method
423  $allowed = [];
424  foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) {
425  if ( $allowedMatcher->match( $relPath ) ) {
426  $allowed[] = $allowedMethod;
427  }
428  }
429 
430  return array_unique(
431  in_array( 'GET', $allowed ) ? array_merge( [ 'HEAD' ], $allowed ) : $allowed
432  );
433  }
434 
441  private function createHandler( RequestInterface $request, array $spec ): Handler {
442  $objectFactorySpec = array_intersect_key(
443  $spec,
444  [
445  'factory' => true,
446  'class' => true,
447  'args' => true,
448  'services' => true,
449  'optional_services' => true
450  ]
451  );
453  $handler = $this->objectFactory->createObject( $objectFactorySpec );
454  $handler->init( $this, $request, $spec, $this->authority, $this->responseFactory,
455  $this->hookContainer, $this->session
456  );
457 
458  return $handler;
459  }
460 
467  private function executeHandler( $handler ): ResponseInterface {
468  // Check for basic authorization, to avoid leaking data from private wikis
469  $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
470  if ( $authResult ) {
471  return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
472  }
473 
474  // Check session (and session provider)
475  $handler->checkSession();
476 
477  // Validate the parameters
478  $handler->validate( $this->restValidator );
479 
480  // Check conditional request headers
481  $earlyResponse = $handler->checkPreconditions();
482  if ( $earlyResponse ) {
483  return $earlyResponse;
484  }
485 
486  // Run the main part of the handler
487  $response = $handler->execute();
488  if ( !( $response instanceof ResponseInterface ) ) {
489  $response = $this->responseFactory->createFromReturnValue( $response );
490  }
491 
492  // Set Last-Modified and ETag headers in the response if available
493  $handler->applyConditionalResponseHeaders( $response );
494 
495  return $response;
496  }
497 
502  public function setCors( CorsUtils $cors ): self {
503  $this->cors = $cors;
504 
505  return $this;
506  }
507 
508 }
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:85
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
A class containing constants representing the names of configuration variables.
const CanonicalServer
Name constant for the CanonicalServer setting, for use with Config::get()
const RestPath
Name constant for the RestPath setting, for use with Config::get()
const InternalServer
Name constant for the InternalServer setting, for use with Config::get()
This is the base exception class for non-fatal exceptions thrown from REST handlers.
A tree-based path routing algorithm.
Definition: PathMatcher.php:16
add( $template, $userData)
Add a template to the matcher.
static newFromCache( $data)
Create a PathMatcher from cache data.
Definition: PathMatcher.php:42
Generates standardized response objects.
The REST router is responsible for gathering handler configuration, matching an input path and HTTP m...
Definition: Router.php:25
getRouteUrl(string $route, array $pathParams=[], array $queryParams=[])
Returns a full URL for the given route.
Definition: Router.php:290
__construct( $routeFiles, $extraRoutes, ServiceOptions $options, BagOStuff $cacheBag, ResponseFactory $responseFactory, BasicAuthorizerInterface $basicAuth, Authority $authority, ObjectFactory $objectFactory, Validator $restValidator, ErrorReporter $errorReporter, HookContainer $hookContainer, Session $session)
Definition: Router.php:108
getPrivateRouteUrl(string $route, array $pathParams=[], array $queryParams=[])
Returns a full private URL for the given route.
Definition: Router.php:319
substPathParams(string $route, array $pathParams)
Definition: Router.php:335
execute(RequestInterface $request)
Find the handler for a request and execute it.
Definition: Router.php:352
setCors(CorsUtils $cors)
Definition: Router.php:502
Wrapper for ParamValidator.
Definition: Validator.php:32
Manages data for an authenticated session.
Definition: Session.php:50
get( $key, $default=null)
Fetch a value from the session.
Definition: Session.php:302
Value object representing a message for i18n.
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
An interface used by Router to ensure that the client has "basic" access, i.e.
An ErrorReporter internally reports an error that happened during the handling of a request.
A request interface similar to PSR-7's ServerRequestInterface.
getMethod()
Retrieves the HTTP method of the request.
getUri()
Retrieves the URI instance.
setPathParams( $params)
Erase all path parameters from the object and set the parameter array to the one specified.
return true
Definition: router.php:90