36 private $routesFromFiles;
39 private $routeFileTimestamps;
45 private $privateBaseUrl;
60 private $responseFactory;
69 private $objectFactory;
72 private $restValidator;
78 private $errorReporter;
81 private $hookContainer;
93 public const CONSTRUCTOR_OPTIONS = [
122 ObjectFactory $objectFactory,
130 $this->routeFiles = $routeFiles;
131 $this->extraRoutes = $extraRoutes;
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;
153 private function fetchCacheData() {
154 $cacheData = $this->cacheBag->get( $this->getCacheKey() );
155 if ( $cacheData && $cacheData[
'CONFIG-HASH'] === $this->getConfigHash() ) {
156 unset( $cacheData[
'CONFIG-HASH'] );
166 private function getCacheKey() {
167 return $this->cacheBag->makeKey( __CLASS__,
'1' );
175 private function getConfigHash() {
176 if ( $this->configHash ===
null ) {
177 $this->configHash = md5( json_encode( [
179 $this->getRouteFileTimestamps()
182 return $this->configHash;
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;
199 $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
203 return $this->routesFromFiles;
211 private function getRouteFileTimestamps() {
212 if ( $this->routeFileTimestamps ===
null ) {
213 $this->routeFileTimestamps = [];
214 foreach ( $this->routeFiles as $fileName ) {
215 $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
218 return $this->routeFileTimestamps;
227 private function getAllRoutes() {
228 $iterator =
new AppendIterator;
229 $iterator->append(
new \ArrayIterator( $this->getRoutesFromFiles() ) );
230 $iterator->append(
new \ArrayIterator( $this->extraRoutes ) );
239 private function getMatchers() {
240 if ( $this->matchers ===
null ) {
241 $cacheData = $this->fetchCacheData();
244 foreach ( $cacheData as $method => $data ) {
248 foreach ( $this->getAllRoutes() as $spec ) {
249 $methods = $spec[
'method'] ?? [
'GET' ];
250 if ( !is_array( $methods ) ) {
251 $methods = [ $methods ];
253 foreach ( $methods as $method ) {
254 if ( !isset( $matchers[$method] ) ) {
255 $matchers[$method] =
new PathMatcher;
257 $matchers[$method]->
add( $spec[
'path'], $spec );
261 $cacheData = [
'CONFIG-HASH' => $this->getConfigHash() ];
262 foreach ( $matchers as $method => $matcher ) {
263 $cacheData[$method] = $matcher->getCacheData();
265 $this->cacheBag->set( $this->getCacheKey(), $cacheData );
267 $this->matchers = $matchers;
269 return $this->matchers;
279 private function getRelativePath(
$path ) {
280 if ( !str_starts_with(
$path, $this->rootPath ) ) {
283 return substr(
$path, strlen( $this->rootPath ) );
300 array $pathParams = [],
301 array $queryParams = []
304 $url = $this->baseUrl . $this->rootPath . $route;
329 array $pathParams = [],
330 array $queryParams = []
332 $route = $this->substPathParams( $route, $pathParams );
333 $url = $this->privateBaseUrl . $this->rootPath . $route;
344 foreach ( $pathParams as $param => $value ) {
348 $route = str_replace(
'{' . $param .
'}', rawurlencode( (
string)$value ), $route );
362 $relPath = $this->getRelativePath(
$path );
363 if ( $relPath ===
false ) {
364 return $this->responseFactory->createLocalizedHttpError( 404,
366 ->plaintextParams(
$path, $this->rootPath )
371 $matchers = $this->getMatchers();
372 $matcher = $matchers[$requestMethod] ??
null;
373 $match = $matcher ? $matcher->match( $relPath ) :
null;
377 if ( !$match && $requestMethod ===
'HEAD' && isset( $matchers[
'GET'] ) ) {
378 $match = $matchers[
'GET']->match( $relPath );
383 $allowed = $this->getAllowedMethods( $relPath );
387 if ( $this->cors && $requestMethod ===
'OPTIONS' ) {
388 return $this->cors->createPreflightResponse( $allowed );
392 $response = $this->responseFactory->createLocalizedHttpError( 405,
394 ->textParams( $requestMethod )
395 ->commaListParams( $allowed )
396 ->numParams( count( $allowed ) )
398 $response->setHeader(
'Allow', $allowed );
402 return $this->responseFactory->createLocalizedHttpError( 404,
404 ->plaintextParams( $relPath )
412 $request->
setPathParams( array_map(
'rawurldecode', $match[
'params'] ) );
413 $handler = $this->createHandler( $request, $match[
'userData'] );
416 $pathForMetrics = $handler->getPath();
417 $pathForMetrics = strtr( $pathForMetrics,
'{}:',
'-' );
418 $pathForMetrics = strtr( $pathForMetrics,
'/.',
'_' );
420 $statTime = microtime(
true );
422 $response = $this->executeHandler( $handler );
424 $response = $this->responseFactory->createFromException( $e );
425 }
catch ( Throwable $e ) {
426 $this->errorReporter->reportError( $e, $handler, $request );
427 $response = $this->responseFactory->createFromException( $e );
431 if ( $response->getStatusCode() >= 400 ) {
433 $statusCode = $response->getStatusCode();
434 $this->stats->increment(
"rest_api_errors.$pathForMetrics.$requestMethod.$statusCode" );
437 $microtime = ( microtime(
true ) - $statTime ) * 1000;
438 $this->stats->timing(
"rest_api_latency.$pathForMetrics.$requestMethod", $microtime );
450 private function getAllowedMethods(
string $relPath ): array {
453 foreach ( $this->getMatchers() as $allowedMethod => $allowedMatcher ) {
454 if ( $allowedMatcher->match( $relPath ) ) {
455 $allowed[] = $allowedMethod;
460 in_array(
'GET', $allowed ) ? array_merge( [
'HEAD' ], $allowed ) : $allowed
470 private function createHandler( RequestInterface $request, array $spec ): Handler {
471 $objectFactorySpec = array_intersect_key(
478 'optional_services' =>
true
482 $handler = $this->objectFactory->createObject( $objectFactorySpec );
483 $handler->init( $this, $request, $spec, $this->authority, $this->responseFactory,
484 $this->hookContainer, $this->session
496 private function executeHandler( $handler ): ResponseInterface {
497 ProfilingContext::singleton()->init(
MW_ENTRY_POINT, $handler->getPath() );
499 $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
501 return $this->responseFactory->createHttpError( 403, [
'error' => $authResult ] );
505 $handler->checkSession();
508 $handler->validate( $this->restValidator );
511 $earlyResponse = $handler->checkPreconditions();
512 if ( $earlyResponse ) {
513 return $earlyResponse;
517 $response = $handler->execute();
518 if ( !( $response instanceof ResponseInterface ) ) {
519 $response = $this->responseFactory->createFromReturnValue( $response );
523 $handler->applyConditionalResponseHeaders( $response );
525 $handler->applyCacheControl( $response );
545 public function setStats( StatsdDataFactoryInterface $stats ): self {
546 $this->stats = $stats;