MediaWiki REL1_39
Router.php
Go to the documentation of this file.
1<?php
2
3namespace MediaWiki\Rest;
4
5use AppendIterator;
6use BagOStuff;
16use Throwable;
18use Wikimedia\ObjectFactory\ObjectFactory;
19
25class 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.
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: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:92