3 namespace MediaWiki\Rest
;
7 use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface
;
8 use MediaWiki\Rest\PathTemplateMatcher\PathMatcher
;
9 use MediaWiki\Rest\Validator\Validator
;
10 use Wikimedia\ObjectFactory
;
13 * The REST router is responsible for gathering handler configuration, matching
14 * an input path and HTTP method against the defined routes, and constructing
15 * and executing the relevant handler for a request.
24 /** @var array|null */
25 private $routesFromFiles;
27 /** @var int[]|null */
28 private $routeFileTimestamps;
33 /** @var \BagOStuff */
36 /** @var PathMatcher[]|null Path matchers by method */
39 /** @var string|null */
42 /** @var ResponseFactory */
43 private $responseFactory;
45 /** @var BasicAuthorizerInterface */
48 /** @var ObjectFactory */
49 private $objectFactory;
52 private $restValidator;
55 * @param string[] $routeFiles List of names of JSON files containing routes
56 * @param array $extraRoutes Extension route array
57 * @param string $rootPath The base URL path
58 * @param BagOStuff $cacheBag A cache in which to store the matcher trees
59 * @param ResponseFactory $responseFactory
60 * @param BasicAuthorizerInterface $basicAuth
61 * @param ObjectFactory $objectFactory
62 * @param Validator $restValidator
64 public function __construct( $routeFiles, $extraRoutes, $rootPath,
65 BagOStuff
$cacheBag, ResponseFactory
$responseFactory,
66 BasicAuthorizerInterface
$basicAuth, ObjectFactory
$objectFactory,
67 Validator
$restValidator
69 $this->routeFiles
= $routeFiles;
70 $this->extraRoutes
= $extraRoutes;
71 $this->rootPath
= $rootPath;
72 $this->cacheBag
= $cacheBag;
73 $this->responseFactory
= $responseFactory;
74 $this->basicAuth
= $basicAuth;
75 $this->objectFactory
= $objectFactory;
76 $this->restValidator
= $restValidator;
80 * Get the cache data, or false if it is missing or invalid
84 private function fetchCacheData() {
85 $cacheData = $this->cacheBag
->get( $this->getCacheKey() );
86 if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
87 unset( $cacheData['CONFIG-HASH'] );
95 * @return string The cache key
97 private function getCacheKey() {
98 return $this->cacheBag
->makeKey( __CLASS__
, '1' );
102 * Get a config version hash for cache invalidation
106 private function getConfigHash() {
107 if ( $this->configHash
=== null ) {
108 $this->configHash
= md5( json_encode( [
110 $this->getRouteFileTimestamps()
113 return $this->configHash
;
117 * Load the defined JSON files and return the merged routes
121 private function getRoutesFromFiles() {
122 if ( $this->routesFromFiles
=== null ) {
123 $this->routeFileTimestamps
= [];
124 foreach ( $this->routeFiles
as $fileName ) {
125 $this->routeFileTimestamps
[$fileName] = filemtime( $fileName );
126 $routes = json_decode( file_get_contents( $fileName ), true );
127 if ( $this->routesFromFiles
=== null ) {
128 $this->routesFromFiles
= $routes;
130 $this->routesFromFiles
= array_merge( $this->routesFromFiles
, $routes );
134 return $this->routesFromFiles
;
138 * Get an array of last modification times of the defined route files.
140 * @return int[] Last modification times
142 private function getRouteFileTimestamps() {
143 if ( $this->routeFileTimestamps
=== null ) {
144 $this->routeFileTimestamps
= [];
145 foreach ( $this->routeFiles
as $fileName ) {
146 $this->routeFileTimestamps
[$fileName] = filemtime( $fileName );
149 return $this->routeFileTimestamps
;
153 * Get an iterator for all defined routes, including loading the routes from
156 * @return AppendIterator
158 private function getAllRoutes() {
159 $iterator = new AppendIterator
;
160 $iterator->append( new \
ArrayIterator( $this->getRoutesFromFiles() ) );
161 $iterator->append( new \
ArrayIterator( $this->extraRoutes
) );
166 * Get an array of PathMatcher objects indexed by HTTP method
168 * @return PathMatcher[]
170 private function getMatchers() {
171 if ( $this->matchers
=== null ) {
172 $cacheData = $this->fetchCacheData();
175 foreach ( $cacheData as $method => $data ) {
176 $matchers[$method] = PathMatcher
::newFromCache( $data );
179 foreach ( $this->getAllRoutes() as $spec ) {
180 $methods = $spec['method'] ??
[ 'GET' ];
181 if ( !is_array( $methods ) ) {
182 $methods = [ $methods ];
184 foreach ( $methods as $method ) {
185 if ( !isset( $matchers[$method] ) ) {
186 $matchers[$method] = new PathMatcher
;
188 $matchers[$method]->add( $spec['path'], $spec );
192 $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
193 foreach ( $matchers as $method => $matcher ) {
194 $cacheData[$method] = $matcher->getCacheData();
196 $this->cacheBag
->set( $this->getCacheKey(), $cacheData );
198 $this->matchers
= $matchers;
200 return $this->matchers
;
204 * Remove the path prefix $this->rootPath. Return the part of the path with the
205 * prefix removed, or false if the prefix did not match.
207 * @param string $path
208 * @return false|string
210 private function getRelativePath( $path ) {
211 if ( strlen( $this->rootPath
) > strlen( $path ) ||
212 substr_compare( $path, $this->rootPath
, 0, strlen( $this->rootPath
) ) !== 0
216 return substr( $path, strlen( $this->rootPath
) );
220 * Find the handler for a request and execute it
222 * @param RequestInterface $request
223 * @return ResponseInterface
225 public function execute( RequestInterface
$request ) {
226 $path = $request->getUri()->getPath();
227 $relPath = $this->getRelativePath( $path );
228 if ( $relPath === false ) {
229 return $this->responseFactory
->createHttpError( 404 );
232 $matchers = $this->getMatchers();
233 $matcher = $matchers[$request->getMethod()] ??
null;
234 $match = $matcher ?
$matcher->match( $relPath ) : null;
237 // Check for 405 wrong method
239 foreach ( $matchers as $allowedMethod => $allowedMatcher ) {
240 if ( $allowedMethod === $request->getMethod() ) {
243 if ( $allowedMatcher->match( $relPath ) ) {
244 $allowed[] = $allowedMethod;
248 $response = $this->responseFactory
->createHttpError( 405 );
249 $response->setHeader( 'Allow', $allowed );
252 // Did not match with any other method, must be 404
253 return $this->responseFactory
->createHttpError( 404 );
257 $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) );
258 $spec = $match['userData'];
259 $objectFactorySpec = array_intersect_key( $spec,
260 // @todo ObjectFactory supports more keys than this.
261 [ 'factory' => true, 'class' => true, 'args' => true ] );
262 /** @var $handler Handler (annotation for PHPStorm) */
263 $handler = $this->objectFactory
->createObject( $objectFactorySpec );
264 $handler->init( $this, $request, $spec, $this->responseFactory
);
267 return $this->executeHandler( $handler );
268 } catch ( HttpException
$e ) {
269 return $this->responseFactory
->createFromException( $e );
274 * Execute a fully-constructed handler
275 * @param Handler $handler
276 * @return ResponseInterface
278 private function executeHandler( $handler ): ResponseInterface
{
279 // @phan-suppress-next-line PhanAccessMethodInternal
280 $authResult = $this->basicAuth
->authorize( $handler->getRequest(), $handler );
282 return $this->responseFactory
->createHttpError( 403, [ 'error' => $authResult ] );
285 $handler->validate( $this->restValidator
);
287 $response = $handler->execute();
288 if ( !( $response instanceof ResponseInterface
) ) {
289 $response = $this->responseFactory
->createFromReturnValue( $response );