MediaWiki REL1_41
Router.php
Go to the documentation of this file.
1<?php
2
3namespace MediaWiki\Rest;
4
5use AppendIterator;
6use BagOStuff;
7use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
19use Throwable;
21use Wikimedia\ObjectFactory\ObjectFactory;
22
28class 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:44
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.
add( $template, $userData)
Add a template to the matcher.
static newFromCache( $data)
Create a PathMatcher from cache data.
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:92