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