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;
7 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
19 use Throwable;
21 use Wikimedia\ObjectFactory\ObjectFactory;
22 
28 class Router {
30  private $routeFiles;
31 
33  private $extraRoutes;
34 
36  private $routesFromFiles;
37 
39  private $routeFileTimestamps;
40 
42  private $baseUrl;
43 
45  private $privateBaseUrl;
46 
48  private $rootPath;
49 
51  private $cacheBag;
52 
54  private $matchers;
55 
57  private $configHash;
58 
60  private $responseFactory;
61 
63  private $basicAuth;
64 
66  private $authority;
67 
69  private $objectFactory;
70 
72  private $restValidator;
73 
75  private $cors;
76 
78  private $errorReporter;
79 
81  private $hookContainer;
82 
84  private $session;
85 
87  private $stats;
88 
93  public const CONSTRUCTOR_OPTIONS = [
97  ];
98 
114  public function __construct(
115  $routeFiles,
116  $extraRoutes,
117  ServiceOptions $options,
118  BagOStuff $cacheBag,
119  ResponseFactory $responseFactory,
120  BasicAuthorizerInterface $basicAuth,
121  Authority $authority,
122  ObjectFactory $objectFactory,
123  Validator $restValidator,
124  ErrorReporter $errorReporter,
125  HookContainer $hookContainer,
126  Session $session
127  ) {
128  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
129 
130  $this->routeFiles = $routeFiles;
131  $this->extraRoutes = $extraRoutes;
132  $this->baseUrl = $options->get( MainConfigNames::CanonicalServer );
133  $this->privateBaseUrl = $options->get( MainConfigNames::InternalServer );
134  $this->rootPath = $options->get( MainConfigNames::RestPath );
135  $this->cacheBag = $cacheBag;
136  $this->responseFactory = $responseFactory;
137  $this->basicAuth = $basicAuth;
138  $this->authority = $authority;
139  $this->objectFactory = $objectFactory;
140  $this->restValidator = $restValidator;
141  $this->errorReporter = $errorReporter;
142  $this->hookContainer = $hookContainer;
143  $this->session = $session;
144 
145  $this->stats = new NullStatsdDataFactory();
146  }
147 
153  private function fetchCacheData() {
154  $cacheData = $this->cacheBag->get( $this->getCacheKey() );
155  if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
156  unset( $cacheData['CONFIG-HASH'] );
157  return $cacheData;
158  } else {
159  return false;
160  }
161  }
162 
166  private function getCacheKey() {
167  return $this->cacheBag->makeKey( __CLASS__, '1' );
168  }
169 
175  private function getConfigHash() {
176  if ( $this->configHash === null ) {
177  $this->configHash = md5( json_encode( [
178  $this->extraRoutes,
179  $this->getRouteFileTimestamps()
180  ] ) );
181  }
182  return $this->configHash;
183  }
184 
190  private function getRoutesFromFiles() {
191  if ( $this->routesFromFiles === null ) {
192  $this->routeFileTimestamps = [];
193  foreach ( $this->routeFiles as $fileName ) {
194  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
195  $routes = json_decode( file_get_contents( $fileName ), true );
196  if ( $this->routesFromFiles === null ) {
197  $this->routesFromFiles = $routes;
198  } else {
199  $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
200  }
201  }
202  }
203  return $this->routesFromFiles;
204  }
205 
211  private function getRouteFileTimestamps() {
212  if ( $this->routeFileTimestamps === null ) {
213  $this->routeFileTimestamps = [];
214  foreach ( $this->routeFiles as $fileName ) {
215  $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
216  }
217  }
218  return $this->routeFileTimestamps;
219  }
220 
227  private function getAllRoutes() {
228  $iterator = new AppendIterator;
229  $iterator->append( new \ArrayIterator( $this->getRoutesFromFiles() ) );
230  $iterator->append( new \ArrayIterator( $this->extraRoutes ) );
231  return $iterator;
232  }
233 
239  private function getMatchers() {
240  if ( $this->matchers === null ) {
241  $cacheData = $this->fetchCacheData();
242  $matchers = [];
243  if ( $cacheData ) {
244  foreach ( $cacheData as $method => $data ) {
245  $matchers[$method] = PathMatcher::newFromCache( $data );
246  }
247  } else {
248  foreach ( $this->getAllRoutes() as $spec ) {
249  $methods = $spec['method'] ?? [ 'GET' ];
250  if ( !is_array( $methods ) ) {
251  $methods = [ $methods ];
252  }
253  foreach ( $methods as $method ) {
254  if ( !isset( $matchers[$method] ) ) {
255  $matchers[$method] = new PathMatcher;
256  }
257  $matchers[$method]->add( $spec['path'], $spec );
258  }
259  }
260 
261  $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
262  foreach ( $matchers as $method => $matcher ) {
263  $cacheData[$method] = $matcher->getCacheData();
264  }
265  $this->cacheBag->set( $this->getCacheKey(), $cacheData );
266  }
267  $this->matchers = $matchers;
268  }
269  return $this->matchers;
270  }
271 
279  private function getRelativePath( $path ) {
280  if ( !str_starts_with( $path, $this->rootPath ) ) {
281  return false;
282  }
283  return substr( $path, strlen( $this->rootPath ) );
284  }
285 
298  public function getRouteUrl(
299  string $route,
300  array $pathParams = [],
301  array $queryParams = []
302  ): string {
303  $route = $this->substPathParams( $route, $pathParams );
304  $url = $this->baseUrl . $this->rootPath . $route;
305  return wfAppendQuery( $url, $queryParams );
306  }
307 
327  public function getPrivateRouteUrl(
328  string $route,
329  array $pathParams = [],
330  array $queryParams = []
331  ): string {
332  $route = $this->substPathParams( $route, $pathParams );
333  $url = $this->privateBaseUrl . $this->rootPath . $route;
334  return wfAppendQuery( $url, $queryParams );
335  }
336 
343  protected function substPathParams( string $route, array $pathParams ): string {
344  foreach ( $pathParams as $param => $value ) {
345  // NOTE: we use rawurlencode here, since execute() uses rawurldecode().
346  // Spaces in path params must be encoded to %20 (not +).
347  // Slashes must be encoded as %2F.
348  $route = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $route );
349  }
350 
351  return $route;
352  }
353 
360  public function execute( RequestInterface $request ) {
361  $path = $request->getUri()->getPath();
362  $relPath = $this->getRelativePath( $path );
363  if ( $relPath === false ) {
364  return $this->responseFactory->createLocalizedHttpError( 404,
365  ( new MessageValue( 'rest-prefix-mismatch' ) )
366  ->plaintextParams( $path, $this->rootPath )
367  );
368  }
369 
370  $requestMethod = $request->getMethod();
371  $matchers = $this->getMatchers();
372  $matcher = $matchers[$requestMethod] ?? null;
373  $match = $matcher ? $matcher->match( $relPath ) : null;
374 
375  // For a HEAD request, execute the GET handler instead if one exists.
376  // The webserver will discard the body.
377  if ( !$match && $requestMethod === 'HEAD' && isset( $matchers['GET'] ) ) {
378  $match = $matchers['GET']->match( $relPath );
379  }
380 
381  if ( !$match ) {
382  // Check for 405 wrong method
383  $allowed = $this->getAllowedMethods( $relPath );
384 
385  // Check for CORS Preflight. This response will *not* allow the request unless
386  // an Access-Control-Allow-Origin header is added to this response.
387  if ( $this->cors && $requestMethod === 'OPTIONS' ) {
388  return $this->cors->createPreflightResponse( $allowed );
389  }
390 
391  if ( $allowed ) {
392  $response = $this->responseFactory->createLocalizedHttpError( 405,
393  ( new MessageValue( 'rest-wrong-method' ) )
394  ->textParams( $requestMethod )
395  ->commaListParams( $allowed )
396  ->numParams( count( $allowed ) )
397  );
398  $response->setHeader( 'Allow', $allowed );
399  return $response;
400  } else {
401  // Did not match with any other method, must be 404
402  return $this->responseFactory->createLocalizedHttpError( 404,
403  ( new MessageValue( 'rest-no-match' ) )
404  ->plaintextParams( $relPath )
405  );
406  }
407  }
408 
409  $handler = null;
410  try {
411  // Use rawurldecode so a "+" in path params is not interpreted as a space character.
412  $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
413  $handler = $this->createHandler( $request, $match['userData'] );
414 
415  // Replace any characters that may have a special meaning in the metrics DB.
416  $pathForMetrics = $handler->getPath();
417  $pathForMetrics = strtr( $pathForMetrics, '{}:', '-' );
418  $pathForMetrics = strtr( $pathForMetrics, '/.', '_' );
419 
420  $statTime = microtime( true );
421 
422  $response = $this->executeHandler( $handler );
423  } catch ( HttpException $e ) {
424  $response = $this->responseFactory->createFromException( $e );
425  } catch ( Throwable $e ) {
426  $this->errorReporter->reportError( $e, $handler, $request );
427  $response = $this->responseFactory->createFromException( $e );
428  }
429 
430  // gather metrics
431  if ( $response->getStatusCode() >= 400 ) {
432  // count how often we return which error code
433  $statusCode = $response->getStatusCode();
434  $this->stats->increment( "rest_api_errors.$pathForMetrics.$requestMethod.$statusCode" );
435  } else {
436  // measure how long it takes to generate a response
437  $microtime = ( microtime( true ) - $statTime ) * 1000;
438  $this->stats->timing( "rest_api_latency.$pathForMetrics.$requestMethod", $microtime );
439  }
440 
441  return $response;
442  }
443 
450  private function getAllowedMethods( string $relPath ): array {
451  // Check for 405 wrong method
452  $allowed = [];
453  foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) {
454  if ( $allowedMatcher->match( $relPath ) ) {
455  $allowed[] = $allowedMethod;
456  }
457  }
458 
459  return array_unique(
460  in_array( 'GET', $allowed ) ? array_merge( [ 'HEAD' ], $allowed ) : $allowed
461  );
462  }
463 
470  private function createHandler( RequestInterface $request, array $spec ): Handler {
471  $objectFactorySpec = array_intersect_key(
472  $spec,
473  [
474  'factory' => true,
475  'class' => true,
476  'args' => true,
477  'services' => true,
478  'optional_services' => true
479  ]
480  );
482  $handler = $this->objectFactory->createObject( $objectFactorySpec );
483  $handler->init( $this, $request, $spec, $this->authority, $this->responseFactory,
484  $this->hookContainer, $this->session
485  );
486 
487  return $handler;
488  }
489 
496  private function executeHandler( $handler ): ResponseInterface {
497  ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() );
498  // Check for basic authorization, to avoid leaking data from private wikis
499  $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
500  if ( $authResult ) {
501  return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
502  }
503 
504  // Check session (and session provider)
505  $handler->checkSession();
506 
507  // Validate the parameters
508  $handler->validate( $this->restValidator );
509 
510  // Check conditional request headers
511  $earlyResponse = $handler->checkPreconditions();
512  if ( $earlyResponse ) {
513  return $earlyResponse;
514  }
515 
516  // Run the main part of the handler
517  $response = $handler->execute();
518  if ( !( $response instanceof ResponseInterface ) ) {
519  $response = $this->responseFactory->createFromReturnValue( $response );
520  }
521 
522  // Set Last-Modified and ETag headers in the response if available
523  $handler->applyConditionalResponseHeaders( $response );
524 
525  $handler->applyCacheControl( $response );
526 
527  return $response;
528  }
529 
534  public function setCors( CorsUtils $cors ): self {
535  $this->cors = $cors;
536 
537  return $this;
538  }
539 
545  public function setStats( StatsdDataFactoryInterface $stats ): self {
546  $this->stats = $stats;
547 
548  return $this;
549  }
550 
551 }
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
const MW_ENTRY_POINT
Definition: api.php:42
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()
Class for tracking request-level classification information for profiling/stats/logging.
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:28
getRouteUrl(string $route, array $pathParams=[], array $queryParams=[])
Returns a full URL for the given route.
Definition: Router.php:298
__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:114
getPrivateRouteUrl(string $route, array $pathParams=[], array $queryParams=[])
Returns a full private URL for the given route.
Definition: Router.php:327
substPathParams(string $route, array $pathParams)
Definition: Router.php:343
execute(RequestInterface $request)
Find the handler for a request and execute it.
Definition: Router.php:360
setCors(CorsUtils $cors)
Definition: Router.php:534
setStats(StatsdDataFactoryInterface $stats)
Definition: Router.php:545
Wrapper for ParamValidator.
Definition: Validator.php:32
Manages data for an authenticated session.
Definition: Session.php:50
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