3 namespace MediaWiki\Rest
;
7 use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface
;
8 use MediaWiki\Rest\PathTemplateMatcher\PathMatcher
;
9 use Wikimedia\ObjectFactory
;
12 * The REST router is responsible for gathering handler configuration, matching
13 * an input path and HTTP method against the defined routes, and constructing
14 * and executing the relevant handler for a request.
23 /** @var array|null */
24 private $routesFromFiles;
26 /** @var int[]|null */
27 private $routeFileTimestamps;
32 /** @var \BagOStuff */
35 /** @var PathMatcher[]|null Path matchers by method */
38 /** @var string|null */
41 /** @var ResponseFactory */
42 private $responseFactory;
44 /** @var BasicAuthorizerInterface */
48 * @param string[] $routeFiles List of names of JSON files containing routes
49 * @param array $extraRoutes Extension route array
50 * @param string $rootPath The base URL path
51 * @param BagOStuff $cacheBag A cache in which to store the matcher trees
52 * @param ResponseFactory $responseFactory
53 * @param BasicAuthorizerInterface $basicAuth
55 public function __construct( $routeFiles, $extraRoutes, $rootPath,
56 BagOStuff
$cacheBag, ResponseFactory
$responseFactory,
57 BasicAuthorizerInterface
$basicAuth
59 $this->routeFiles
= $routeFiles;
60 $this->extraRoutes
= $extraRoutes;
61 $this->rootPath
= $rootPath;
62 $this->cacheBag
= $cacheBag;
63 $this->responseFactory
= $responseFactory;
64 $this->basicAuth
= $basicAuth;
68 * Get the cache data, or false if it is missing or invalid
72 private function fetchCacheData() {
73 $cacheData = $this->cacheBag
->get( $this->getCacheKey() );
74 if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
75 unset( $cacheData['CONFIG-HASH'] );
83 * @return string The cache key
85 private function getCacheKey() {
86 return $this->cacheBag
->makeKey( __CLASS__
, '1' );
90 * Get a config version hash for cache invalidation
94 private function getConfigHash() {
95 if ( $this->configHash
=== null ) {
96 $this->configHash
= md5( json_encode( [
98 $this->getRouteFileTimestamps()
101 return $this->configHash
;
105 * Load the defined JSON files and return the merged routes
109 private function getRoutesFromFiles() {
110 if ( $this->routesFromFiles
=== null ) {
111 $this->routeFileTimestamps
= [];
112 foreach ( $this->routeFiles
as $fileName ) {
113 $this->routeFileTimestamps
[$fileName] = filemtime( $fileName );
114 $routes = json_decode( file_get_contents( $fileName ), true );
115 if ( $this->routesFromFiles
=== null ) {
116 $this->routesFromFiles
= $routes;
118 $this->routesFromFiles
= array_merge( $this->routesFromFiles
, $routes );
122 return $this->routesFromFiles
;
126 * Get an array of last modification times of the defined route files.
128 * @return int[] Last modification times
130 private function getRouteFileTimestamps() {
131 if ( $this->routeFileTimestamps
=== null ) {
132 $this->routeFileTimestamps
= [];
133 foreach ( $this->routeFiles
as $fileName ) {
134 $this->routeFileTimestamps
[$fileName] = filemtime( $fileName );
137 return $this->routeFileTimestamps
;
141 * Get an iterator for all defined routes, including loading the routes from
144 * @return AppendIterator
146 private function getAllRoutes() {
147 $iterator = new AppendIterator
;
148 $iterator->append( new \
ArrayIterator( $this->getRoutesFromFiles() ) );
149 $iterator->append( new \
ArrayIterator( $this->extraRoutes
) );
154 * Get an array of PathMatcher objects indexed by HTTP method
156 * @return PathMatcher[]
158 private function getMatchers() {
159 if ( $this->matchers
=== null ) {
160 $cacheData = $this->fetchCacheData();
163 foreach ( $cacheData as $method => $data ) {
164 $matchers[$method] = PathMatcher
::newFromCache( $data );
167 foreach ( $this->getAllRoutes() as $spec ) {
168 $methods = $spec['method'] ??
[ 'GET' ];
169 if ( !is_array( $methods ) ) {
170 $methods = [ $methods ];
172 foreach ( $methods as $method ) {
173 if ( !isset( $matchers[$method] ) ) {
174 $matchers[$method] = new PathMatcher
;
176 $matchers[$method]->add( $spec['path'], $spec );
180 $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
181 foreach ( $matchers as $method => $matcher ) {
182 $cacheData[$method] = $matcher->getCacheData();
184 $this->cacheBag
->set( $this->getCacheKey(), $cacheData );
186 $this->matchers
= $matchers;
188 return $this->matchers
;
192 * Remove the path prefix $this->rootPath. Return the part of the path with the
193 * prefix removed, or false if the prefix did not match.
195 * @param string $path
196 * @return false|string
198 private function getRelativePath( $path ) {
199 if ( strlen( $this->rootPath
) > strlen( $path ) ||
200 substr_compare( $path, $this->rootPath
, 0, strlen( $this->rootPath
) ) !== 0
204 return substr( $path, strlen( $this->rootPath
) );
208 * Find the handler for a request and execute it
210 * @param RequestInterface $request
211 * @return ResponseInterface
213 public function execute( RequestInterface
$request ) {
214 $path = $request->getUri()->getPath();
215 $relPath = $this->getRelativePath( $path );
216 if ( $relPath === false ) {
217 return $this->responseFactory
->createHttpError( 404 );
220 $matchers = $this->getMatchers();
221 $matcher = $matchers[$request->getMethod()] ??
null;
222 $match = $matcher ?
$matcher->match( $relPath ) : null;
225 // Check for 405 wrong method
227 foreach ( $matchers as $allowedMethod => $allowedMatcher ) {
228 if ( $allowedMethod === $request->getMethod() ) {
231 if ( $allowedMatcher->match( $relPath ) ) {
232 $allowed[] = $allowedMethod;
236 $response = $this->responseFactory
->createHttpError( 405 );
237 $response->setHeader( 'Allow', $allowed );
240 // Did not match with any other method, must be 404
241 return $this->responseFactory
->createHttpError( 404 );
245 $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
246 $spec = $match['userData'];
247 $objectFactorySpec = array_intersect_key( $spec,
248 [ 'factory' => true, 'class' => true, 'args' => true ] );
249 /** @var $handler Handler (annotation for PHPStorm) */
250 $handler = ObjectFactory
::getObjectFromSpec( $objectFactorySpec );
251 $handler->init( $this, $request, $spec, $this->responseFactory
);
254 return $this->executeHandler( $handler );
255 } catch ( HttpException
$e ) {
256 return $this->responseFactory
->createFromException( $e );
261 * Execute a fully-constructed handler
262 * @param Handler $handler
263 * @return ResponseInterface
265 private function executeHandler( $handler ): ResponseInterface
{
266 // @phan-suppress-next-line PhanAccessMethodInternal
267 $authResult = $this->basicAuth
->authorize( $handler->getRequest(), $handler );
269 return $this->responseFactory
->createHttpError( 403, [ 'error' => $authResult ] );
271 $response = $handler->execute();
272 if ( !( $response instanceof ResponseInterface
) ) {
273 $response = $this->responseFactory
->createFromReturnValue( $response );