2019-05-09 01:36:18 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MediaWiki\Rest;
|
|
|
|
|
|
2024-09-16 16:39:02 +00:00
|
|
|
use HttpStatus;
|
2022-07-04 13:04:26 +00:00
|
|
|
use MediaWiki\Config\ServiceOptions;
|
Hooks::run() call site migration
Migrate all callers of Hooks::run() to use the new
HookContainer/HookRunner system.
General principles:
* Use DI if it is already used. We're not changing the way state is
managed in this patch.
* HookContainer is always injected, not HookRunner. HookContainer
is a service, it's a more generic interface, it is the only
thing that provides isRegistered() which is needed in some cases,
and a HookRunner can be efficiently constructed from it
(confirmed by benchmark). Because HookContainer is needed
for object construction, it is also needed by all factories.
* "Ask your friendly local base class". Big hierarchies like
SpecialPage and ApiBase have getHookContainer() and getHookRunner()
methods in the base class, and classes that extend that base class
are not expected to know or care where the base class gets its
HookContainer from.
* ProtectedHookAccessorTrait provides protected getHookContainer() and
getHookRunner() methods, getting them from the global service
container. The point of this is to ease migration to DI by ensuring
that call sites ask their local friendly base class rather than
getting a HookRunner from the service container directly.
* Private $this->hookRunner. In some smaller classes where accessor
methods did not seem warranted, there is a private HookRunner property
which is accessed directly. Very rarely (two cases), there is a
protected property, for consistency with code that conventionally
assumes protected=private, but in cases where the class might actually
be overridden, a protected accessor is preferred over a protected
property.
* The last resort: Hooks::runner(). Mostly for static, file-scope and
global code. In a few cases it was used for objects with broken
construction schemes, out of horror or laziness.
Constructors with new required arguments:
* AuthManager
* BadFileLookup
* BlockManager
* ClassicInterwikiLookup
* ContentHandlerFactory
* ContentSecurityPolicy
* DefaultOptionsManager
* DerivedPageDataUpdater
* FullSearchResultWidget
* HtmlCacheUpdater
* LanguageFactory
* LanguageNameUtils
* LinkRenderer
* LinkRendererFactory
* LocalisationCache
* MagicWordFactory
* MessageCache
* NamespaceInfo
* PageEditStash
* PageHandlerFactory
* PageUpdater
* ParserFactory
* PermissionManager
* RevisionStore
* RevisionStoreFactory
* SearchEngineConfig
* SearchEngineFactory
* SearchFormWidget
* SearchNearMatcher
* SessionBackend
* SpecialPageFactory
* UserNameUtils
* UserOptionsManager
* WatchedItemQueryService
* WatchedItemStore
Constructors with new optional arguments:
* DefaultPreferencesFactory
* Language
* LinkHolderArray
* MovePage
* Parser
* ParserCache
* PasswordReset
* Router
setHookContainer() now required after construction:
* AuthenticationProvider
* ResourceLoaderModule
* SearchEngine
Change-Id: Id442b0dbe43aba84bd5cf801d86dedc768b082c7
2020-03-19 02:42:09 +00:00
|
|
|
use MediaWiki\HookContainer\HookContainer;
|
2022-07-04 13:04:26 +00:00
|
|
|
use MediaWiki\MainConfigNames;
|
2024-05-21 20:02:31 +00:00
|
|
|
use MediaWiki\MainConfigSchema;
|
2021-01-06 18:10:15 +00:00
|
|
|
use MediaWiki\Permissions\Authority;
|
2019-06-26 02:33:35 +00:00
|
|
|
use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
|
2024-02-21 20:51:29 +00:00
|
|
|
use MediaWiki\Rest\Module\ExtraRoutesModule;
|
2023-10-30 16:04:41 +00:00
|
|
|
use MediaWiki\Rest\Module\Module;
|
2024-02-21 20:51:29 +00:00
|
|
|
use MediaWiki\Rest\Module\SpecBasedModule;
|
2023-10-30 16:04:41 +00:00
|
|
|
use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
|
2021-07-02 11:12:00 +00:00
|
|
|
use MediaWiki\Rest\Reporter\ErrorReporter;
|
2019-06-12 19:51:59 +00:00
|
|
|
use MediaWiki\Rest\Validator\Validator;
|
2022-05-04 16:29:51 +00:00
|
|
|
use MediaWiki\Session\Session;
|
2021-07-02 11:12:00 +00:00
|
|
|
use Throwable;
|
2020-01-10 00:00:51 +00:00
|
|
|
use Wikimedia\Message\MessageValue;
|
2024-07-09 13:37:44 +00:00
|
|
|
use Wikimedia\ObjectCache\BagOStuff;
|
2022-03-09 22:16:22 +00:00
|
|
|
use Wikimedia\ObjectFactory\ObjectFactory;
|
2024-06-07 06:41:15 +00:00
|
|
|
use Wikimedia\Stats\StatsFactory;
|
2019-05-09 01:36:18 +00:00
|
|
|
|
|
|
|
|
/**
|
2023-10-30 16:04:41 +00:00
|
|
|
* The REST router is responsible for gathering module configuration, matching
|
|
|
|
|
* an input path against the defined modules, and constructing
|
|
|
|
|
* and executing the relevant module for a request.
|
2019-05-09 01:36:18 +00:00
|
|
|
*/
|
|
|
|
|
class Router {
|
2023-10-30 16:04:41 +00:00
|
|
|
private const PREFIX_PATTERN = '!^/([-_.\w]+(?:/v\d+)?)(/.*)$!';
|
|
|
|
|
|
2019-05-09 01:36:18 +00:00
|
|
|
/** @var string[] */
|
|
|
|
|
private $routeFiles;
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
/** @var array[] */
|
2019-05-09 01:36:18 +00:00
|
|
|
private $extraRoutes;
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
/** @var null|array[] */
|
|
|
|
|
private $moduleMap = null;
|
|
|
|
|
|
|
|
|
|
/** @var Module[] */
|
|
|
|
|
private $modules = [];
|
2019-05-09 01:36:18 +00:00
|
|
|
|
|
|
|
|
/** @var int[]|null */
|
2023-10-30 16:04:41 +00:00
|
|
|
private $moduleFileTimestamps = null;
|
2019-05-09 01:36:18 +00:00
|
|
|
|
2020-03-06 15:53:01 +00:00
|
|
|
/** @var string */
|
|
|
|
|
private $baseUrl;
|
|
|
|
|
|
2022-07-04 13:04:26 +00:00
|
|
|
/** @var string */
|
|
|
|
|
private $privateBaseUrl;
|
|
|
|
|
|
2019-05-09 01:36:18 +00:00
|
|
|
/** @var string */
|
|
|
|
|
private $rootPath;
|
|
|
|
|
|
2024-05-21 20:02:31 +00:00
|
|
|
/** @var string */
|
|
|
|
|
private $scriptPath;
|
|
|
|
|
|
2019-05-09 01:36:18 +00:00
|
|
|
/** @var string|null */
|
2023-10-30 16:04:41 +00:00
|
|
|
private $configHash = null;
|
2019-05-09 01:36:18 +00:00
|
|
|
|
2020-08-22 19:26:19 +00:00
|
|
|
/** @var CorsUtils|null */
|
|
|
|
|
private $cors;
|
|
|
|
|
|
2024-06-10 20:49:30 +00:00
|
|
|
private BagOStuff $cacheBag;
|
|
|
|
|
private ResponseFactory $responseFactory;
|
|
|
|
|
private BasicAuthorizerInterface $basicAuth;
|
|
|
|
|
private Authority $authority;
|
|
|
|
|
private ObjectFactory $objectFactory;
|
|
|
|
|
private Validator $restValidator;
|
|
|
|
|
private ErrorReporter $errorReporter;
|
|
|
|
|
private HookContainer $hookContainer;
|
|
|
|
|
private Session $session;
|
2022-05-04 16:29:51 +00:00
|
|
|
|
2024-06-07 06:41:15 +00:00
|
|
|
/** @var ?StatsFactory */
|
2024-05-21 15:02:16 +00:00
|
|
|
private $stats = null;
|
2022-12-13 21:00:59 +00:00
|
|
|
|
2022-07-04 13:04:26 +00:00
|
|
|
/**
|
|
|
|
|
* @internal
|
|
|
|
|
*/
|
|
|
|
|
public const CONSTRUCTOR_OPTIONS = [
|
|
|
|
|
MainConfigNames::CanonicalServer,
|
|
|
|
|
MainConfigNames::InternalServer,
|
|
|
|
|
MainConfigNames::RestPath,
|
2024-05-21 20:02:31 +00:00
|
|
|
MainConfigNames::ScriptPath,
|
2022-07-04 13:04:26 +00:00
|
|
|
];
|
|
|
|
|
|
2019-05-09 01:36:18 +00:00
|
|
|
/**
|
2023-10-30 16:04:41 +00:00
|
|
|
* @param string[] $routeFiles
|
|
|
|
|
* @param array[] $extraRoutes
|
2022-07-04 13:04:26 +00:00
|
|
|
* @param ServiceOptions $options
|
2019-05-09 01:36:18 +00:00
|
|
|
* @param BagOStuff $cacheBag A cache in which to store the matcher trees
|
|
|
|
|
* @param ResponseFactory $responseFactory
|
2019-06-26 02:33:35 +00:00
|
|
|
* @param BasicAuthorizerInterface $basicAuth
|
2021-01-06 18:10:15 +00:00
|
|
|
* @param Authority $authority
|
2019-06-12 19:51:59 +00:00
|
|
|
* @param ObjectFactory $objectFactory
|
|
|
|
|
* @param Validator $restValidator
|
2021-07-02 11:12:00 +00:00
|
|
|
* @param ErrorReporter $errorReporter
|
2020-05-19 03:50:08 +00:00
|
|
|
* @param HookContainer $hookContainer
|
2022-05-04 16:29:51 +00:00
|
|
|
* @param Session $session
|
2021-01-06 18:10:15 +00:00
|
|
|
* @internal
|
2019-05-09 01:36:18 +00:00
|
|
|
*/
|
2021-07-10 03:49:41 +00:00
|
|
|
public function __construct(
|
2023-10-30 16:04:41 +00:00
|
|
|
array $routeFiles,
|
|
|
|
|
array $extraRoutes,
|
2022-07-04 13:04:26 +00:00
|
|
|
ServiceOptions $options,
|
2021-07-10 03:49:41 +00:00
|
|
|
BagOStuff $cacheBag,
|
|
|
|
|
ResponseFactory $responseFactory,
|
|
|
|
|
BasicAuthorizerInterface $basicAuth,
|
|
|
|
|
Authority $authority,
|
|
|
|
|
ObjectFactory $objectFactory,
|
|
|
|
|
Validator $restValidator,
|
2021-07-02 11:12:00 +00:00
|
|
|
ErrorReporter $errorReporter,
|
2022-05-04 16:29:51 +00:00
|
|
|
HookContainer $hookContainer,
|
|
|
|
|
Session $session
|
2019-05-09 01:36:18 +00:00
|
|
|
) {
|
2022-07-04 13:04:26 +00:00
|
|
|
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
|
|
|
|
|
|
2019-05-09 01:36:18 +00:00
|
|
|
$this->routeFiles = $routeFiles;
|
|
|
|
|
$this->extraRoutes = $extraRoutes;
|
2022-07-04 13:04:26 +00:00
|
|
|
$this->baseUrl = $options->get( MainConfigNames::CanonicalServer );
|
|
|
|
|
$this->privateBaseUrl = $options->get( MainConfigNames::InternalServer );
|
|
|
|
|
$this->rootPath = $options->get( MainConfigNames::RestPath );
|
2024-05-21 20:02:31 +00:00
|
|
|
$this->scriptPath = $options->get( MainConfigNames::ScriptPath );
|
2019-05-09 01:36:18 +00:00
|
|
|
$this->cacheBag = $cacheBag;
|
|
|
|
|
$this->responseFactory = $responseFactory;
|
2019-06-26 02:33:35 +00:00
|
|
|
$this->basicAuth = $basicAuth;
|
2021-01-06 18:10:15 +00:00
|
|
|
$this->authority = $authority;
|
2019-06-12 19:51:59 +00:00
|
|
|
$this->objectFactory = $objectFactory;
|
|
|
|
|
$this->restValidator = $restValidator;
|
2021-07-02 11:12:00 +00:00
|
|
|
$this->errorReporter = $errorReporter;
|
Hooks::run() call site migration
Migrate all callers of Hooks::run() to use the new
HookContainer/HookRunner system.
General principles:
* Use DI if it is already used. We're not changing the way state is
managed in this patch.
* HookContainer is always injected, not HookRunner. HookContainer
is a service, it's a more generic interface, it is the only
thing that provides isRegistered() which is needed in some cases,
and a HookRunner can be efficiently constructed from it
(confirmed by benchmark). Because HookContainer is needed
for object construction, it is also needed by all factories.
* "Ask your friendly local base class". Big hierarchies like
SpecialPage and ApiBase have getHookContainer() and getHookRunner()
methods in the base class, and classes that extend that base class
are not expected to know or care where the base class gets its
HookContainer from.
* ProtectedHookAccessorTrait provides protected getHookContainer() and
getHookRunner() methods, getting them from the global service
container. The point of this is to ease migration to DI by ensuring
that call sites ask their local friendly base class rather than
getting a HookRunner from the service container directly.
* Private $this->hookRunner. In some smaller classes where accessor
methods did not seem warranted, there is a private HookRunner property
which is accessed directly. Very rarely (two cases), there is a
protected property, for consistency with code that conventionally
assumes protected=private, but in cases where the class might actually
be overridden, a protected accessor is preferred over a protected
property.
* The last resort: Hooks::runner(). Mostly for static, file-scope and
global code. In a few cases it was used for objects with broken
construction schemes, out of horror or laziness.
Constructors with new required arguments:
* AuthManager
* BadFileLookup
* BlockManager
* ClassicInterwikiLookup
* ContentHandlerFactory
* ContentSecurityPolicy
* DefaultOptionsManager
* DerivedPageDataUpdater
* FullSearchResultWidget
* HtmlCacheUpdater
* LanguageFactory
* LanguageNameUtils
* LinkRenderer
* LinkRendererFactory
* LocalisationCache
* MagicWordFactory
* MessageCache
* NamespaceInfo
* PageEditStash
* PageHandlerFactory
* PageUpdater
* ParserFactory
* PermissionManager
* RevisionStore
* RevisionStoreFactory
* SearchEngineConfig
* SearchEngineFactory
* SearchFormWidget
* SearchNearMatcher
* SessionBackend
* SpecialPageFactory
* UserNameUtils
* UserOptionsManager
* WatchedItemQueryService
* WatchedItemStore
Constructors with new optional arguments:
* DefaultPreferencesFactory
* Language
* LinkHolderArray
* MovePage
* Parser
* ParserCache
* PasswordReset
* Router
setHookContainer() now required after construction:
* AuthenticationProvider
* ResourceLoaderModule
* SearchEngine
Change-Id: Id442b0dbe43aba84bd5cf801d86dedc768b082c7
2020-03-19 02:42:09 +00:00
|
|
|
$this->hookContainer = $hookContainer;
|
2022-05-04 16:29:51 +00:00
|
|
|
$this->session = $session;
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2024-05-21 20:02:31 +00:00
|
|
|
* Remove the REST path prefix. Return the part of the path with the
|
2023-10-30 16:04:41 +00:00
|
|
|
* prefix removed, or false if the prefix did not match.
|
2024-05-21 20:02:31 +00:00
|
|
|
* Both the $this->rootPath and the default REST path are accepted,
|
|
|
|
|
* so on a site that uses /api as the RestPath, requests to /w/rest.php
|
|
|
|
|
* still work. This is equivalent to supporting both /wiki and /w/index.php
|
|
|
|
|
* for page views.
|
2019-05-09 01:36:18 +00:00
|
|
|
*
|
2023-10-30 16:04:41 +00:00
|
|
|
* @param string $path
|
|
|
|
|
* @return false|string
|
2019-05-09 01:36:18 +00:00
|
|
|
*/
|
2023-10-30 16:04:41 +00:00
|
|
|
private function getRelativePath( $path ) {
|
2024-05-21 20:02:31 +00:00
|
|
|
$allowed = [
|
|
|
|
|
$this->rootPath,
|
|
|
|
|
MainConfigSchema::getDefaultRestPath( $this->scriptPath )
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ( $allowed as $prefix ) {
|
|
|
|
|
if ( str_starts_with( $path, $prefix ) ) {
|
|
|
|
|
return substr( $path, strlen( $prefix ) );
|
|
|
|
|
}
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
2024-05-21 20:02:31 +00:00
|
|
|
|
|
|
|
|
return false;
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2023-10-30 16:04:41 +00:00
|
|
|
* @param string $fullPath
|
|
|
|
|
*
|
|
|
|
|
* @return string[] [ string $module, string $path ]
|
2019-05-09 01:36:18 +00:00
|
|
|
*/
|
2023-10-30 16:04:41 +00:00
|
|
|
private function splitPath( string $fullPath ): array {
|
|
|
|
|
$pathWithModule = $this->getRelativePath( $fullPath );
|
|
|
|
|
|
2024-05-28 08:29:56 +00:00
|
|
|
if ( $pathWithModule === false ) {
|
2023-10-30 16:04:41 +00:00
|
|
|
throw new LocalizedHttpException(
|
|
|
|
|
( new MessageValue( 'rest-prefix-mismatch' ) )
|
|
|
|
|
->plaintextParams( $fullPath, $this->rootPath ),
|
|
|
|
|
404
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-28 08:29:56 +00:00
|
|
|
if ( preg_match( self::PREFIX_PATTERN, $pathWithModule, $matches ) ) {
|
|
|
|
|
[ , $module, $pathUnderModule ] = $matches;
|
|
|
|
|
} else {
|
|
|
|
|
// No prefix found in the given path, assume prefix-less module.
|
|
|
|
|
$module = '';
|
|
|
|
|
$pathUnderModule = $pathWithModule;
|
2023-10-30 16:04:41 +00:00
|
|
|
}
|
|
|
|
|
|
2024-05-28 08:29:56 +00:00
|
|
|
if ( $module !== '' && !$this->getModuleInfo( $module ) ) {
|
2023-10-30 16:04:41 +00:00
|
|
|
// Prefix doesn't match any module, try the prefix-less module...
|
|
|
|
|
// TODO: At some point in the future, we'll want to warn and redirect...
|
|
|
|
|
$module = '';
|
|
|
|
|
$pathUnderModule = $pathWithModule;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [ $module, $pathUnderModule ];
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2023-10-30 16:04:41 +00:00
|
|
|
* Get the cache data, or false if it is missing or invalid
|
2019-05-09 01:36:18 +00:00
|
|
|
*
|
2023-10-30 16:04:41 +00:00
|
|
|
* @return ?array
|
2019-05-09 01:36:18 +00:00
|
|
|
*/
|
2023-10-30 16:04:41 +00:00
|
|
|
private function fetchCachedModuleMap(): ?array {
|
|
|
|
|
$moduleMapCacheKey = $this->getModuleMapCacheKey();
|
|
|
|
|
$cacheData = $this->cacheBag->get( $moduleMapCacheKey );
|
|
|
|
|
if ( $cacheData && $cacheData[Module::CACHE_CONFIG_HASH_KEY] === $this->getModuleMapHash() ) {
|
|
|
|
|
unset( $cacheData[Module::CACHE_CONFIG_HASH_KEY] );
|
|
|
|
|
return $cacheData;
|
|
|
|
|
} else {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function fetchCachedModuleData( string $module ): ?array {
|
|
|
|
|
$moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
|
|
|
|
|
$cacheData = $this->cacheBag->get( $moduleDataCacheKey );
|
|
|
|
|
return $cacheData ?: null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function cacheModuleMap( array $map ) {
|
|
|
|
|
$map[Module::CACHE_CONFIG_HASH_KEY] = $this->getModuleMapHash();
|
|
|
|
|
$moduleMapCacheKey = $this->getModuleMapCacheKey();
|
|
|
|
|
$this->cacheBag->set( $moduleMapCacheKey, $map );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function cacheModuleData( string $module, array $map ) {
|
|
|
|
|
$moduleDataCacheKey = $this->getModuleDataCacheKey( $module );
|
|
|
|
|
$this->cacheBag->set( $moduleDataCacheKey, $map );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function getModuleDataCacheKey( string $module ): string {
|
|
|
|
|
if ( $module === '' ) {
|
|
|
|
|
// Proper key for the prefix-less module.
|
|
|
|
|
$module = '-';
|
|
|
|
|
}
|
|
|
|
|
return $this->cacheBag->makeKey( __CLASS__, 'module', $module );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function getModuleMapCacheKey(): string {
|
|
|
|
|
return $this->cacheBag->makeKey( __CLASS__, 'map', '1' );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a config version hash for cache invalidation
|
|
|
|
|
*/
|
|
|
|
|
private function getModuleMapHash(): string {
|
2019-05-09 01:36:18 +00:00
|
|
|
if ( $this->configHash === null ) {
|
|
|
|
|
$this->configHash = md5( json_encode( [
|
|
|
|
|
$this->extraRoutes,
|
2023-10-30 16:04:41 +00:00
|
|
|
$this->getModuleFileTimestamps()
|
2019-05-09 01:36:18 +00:00
|
|
|
] ) );
|
|
|
|
|
}
|
|
|
|
|
return $this->configHash;
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
private function buildModuleMap(): array {
|
|
|
|
|
$modules = [];
|
|
|
|
|
$noPrefixFiles = [];
|
2024-02-21 20:51:29 +00:00
|
|
|
$id = ''; // should not be used, make Phan happy
|
2023-10-30 16:04:41 +00:00
|
|
|
|
|
|
|
|
foreach ( $this->routeFiles as $file ) {
|
|
|
|
|
// NOTE: we end up loading the file here (for the meta-data) as well
|
|
|
|
|
// as in the Module object (for the routes). But since we have
|
|
|
|
|
// caching on both levels, that shouldn't matter.
|
|
|
|
|
$spec = Module::loadJsonFile( $file );
|
|
|
|
|
|
2024-02-21 20:51:29 +00:00
|
|
|
if ( isset( $spec['mwapi'] ) || isset( $spec['moduleId'] ) || isset( $spec['routes'] ) ) {
|
|
|
|
|
// OpenAPI 3, with some extras like the "module" field
|
|
|
|
|
if ( !isset( $spec['moduleId'] ) ) {
|
2023-10-30 16:04:41 +00:00
|
|
|
throw new ModuleConfigurationException(
|
2024-02-21 20:51:29 +00:00
|
|
|
"Missing 'moduleId' field in $file"
|
2023-10-30 16:04:41 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-21 20:51:29 +00:00
|
|
|
$id = $spec['moduleId'];
|
2023-10-30 16:04:41 +00:00
|
|
|
|
2024-02-21 20:51:29 +00:00
|
|
|
$moduleInfo = [
|
|
|
|
|
'class' => SpecBasedModule::class,
|
|
|
|
|
'pathPrefix' => $id,
|
|
|
|
|
'specFile' => $file
|
2023-10-30 16:04:41 +00:00
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
// Old-style route file containing a flat list of routes.
|
|
|
|
|
$noPrefixFiles[] = $file;
|
2024-02-21 20:51:29 +00:00
|
|
|
$moduleInfo = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( $moduleInfo ) {
|
|
|
|
|
if ( isset( $modules[$id] ) ) {
|
|
|
|
|
$otherFiles = implode( ' and ', $modules[$id]['routeFiles'] );
|
|
|
|
|
throw new ModuleConfigurationException(
|
|
|
|
|
"Duplicate module $id in $file, also used in $otherFiles"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$modules[$id] = $moduleInfo;
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
2023-10-30 16:04:41 +00:00
|
|
|
|
|
|
|
|
// The prefix-less module will be used when no prefix is matched.
|
|
|
|
|
// It provides a mechanism to integrate extra routes and route files
|
|
|
|
|
// registered by extensions.
|
|
|
|
|
if ( $noPrefixFiles || $this->extraRoutes ) {
|
|
|
|
|
$modules[''] = [
|
2024-02-21 20:51:29 +00:00
|
|
|
'class' => ExtraRoutesModule::class,
|
2023-10-30 16:04:41 +00:00
|
|
|
'pathPrefix' => '',
|
|
|
|
|
'routeFiles' => $noPrefixFiles,
|
|
|
|
|
'extraRoutes' => $this->extraRoutes,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $modules;
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get an array of last modification times of the defined route files.
|
|
|
|
|
*
|
|
|
|
|
* @return int[] Last modification times
|
|
|
|
|
*/
|
2023-10-30 16:04:41 +00:00
|
|
|
private function getModuleFileTimestamps() {
|
|
|
|
|
if ( $this->moduleFileTimestamps === null ) {
|
|
|
|
|
$this->moduleFileTimestamps = [];
|
2019-05-09 01:36:18 +00:00
|
|
|
foreach ( $this->routeFiles as $fileName ) {
|
2023-10-30 16:04:41 +00:00
|
|
|
$this->moduleFileTimestamps[$fileName] = filemtime( $fileName );
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
2023-10-30 16:04:41 +00:00
|
|
|
return $this->moduleFileTimestamps;
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
private function getModuleMap(): array {
|
|
|
|
|
if ( !$this->moduleMap ) {
|
|
|
|
|
$map = $this->fetchCachedModuleMap();
|
2019-05-09 01:36:18 +00:00
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
if ( !$map ) {
|
|
|
|
|
$map = $this->buildModuleMap();
|
|
|
|
|
$this->cacheModuleMap( $map );
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
2023-10-30 16:04:41 +00:00
|
|
|
|
|
|
|
|
$this->moduleMap = $map;
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
2023-10-30 16:04:41 +00:00
|
|
|
return $this->moduleMap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function getModuleInfo( $module ): ?array {
|
|
|
|
|
$map = $this->getModuleMap();
|
|
|
|
|
return $map[$module] ?? null;
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
|
2019-05-31 03:41:40 +00:00
|
|
|
/**
|
2023-10-30 16:04:41 +00:00
|
|
|
* @return string[]
|
2019-05-31 03:41:40 +00:00
|
|
|
*/
|
2024-05-06 13:17:08 +00:00
|
|
|
public function getModuleIds(): array {
|
2023-10-30 16:04:41 +00:00
|
|
|
return array_keys( $this->getModuleMap() );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getModuleForPath( string $fullPath ): ?Module {
|
|
|
|
|
[ $moduleName, ] = $this->splitPath( $fullPath );
|
|
|
|
|
return $this->getModule( $moduleName );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getModule( string $name ): ?Module {
|
|
|
|
|
if ( isset( $this->modules[$name] ) ) {
|
|
|
|
|
return $this->modules[$name];
|
2019-05-31 03:41:40 +00:00
|
|
|
}
|
2023-10-30 16:04:41 +00:00
|
|
|
|
|
|
|
|
$info = $this->getModuleInfo( $name );
|
|
|
|
|
|
|
|
|
|
if ( !$info ) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-21 20:51:29 +00:00
|
|
|
$module = $this->instantiateModule( $info, $name );
|
2023-10-30 16:04:41 +00:00
|
|
|
|
|
|
|
|
$cacheData = $this->fetchCachedModuleData( $name );
|
|
|
|
|
|
|
|
|
|
if ( $cacheData !== null ) {
|
|
|
|
|
$cacheOk = $module->initFromCacheData( $cacheData );
|
|
|
|
|
} else {
|
|
|
|
|
$cacheOk = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( !$cacheOk ) {
|
|
|
|
|
$cacheData = $module->getCacheData();
|
|
|
|
|
$this->cacheModuleData( $name, $cacheData );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( $this->cors ) {
|
|
|
|
|
$module->setCors( $this->cors );
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-21 15:02:16 +00:00
|
|
|
if ( $this->stats ) {
|
|
|
|
|
$module->setStats( $this->stats );
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
$this->modules[$name] = $module;
|
|
|
|
|
return $module;
|
2019-05-31 03:41:40 +00:00
|
|
|
}
|
|
|
|
|
|
2023-10-12 16:53:30 +00:00
|
|
|
/**
|
|
|
|
|
* @since 1.42
|
|
|
|
|
*/
|
|
|
|
|
public function getRoutePath(
|
2023-10-30 16:04:41 +00:00
|
|
|
string $routeWithModulePrefix,
|
2023-10-12 16:53:30 +00:00
|
|
|
array $pathParams = [],
|
|
|
|
|
array $queryParams = []
|
|
|
|
|
): string {
|
2023-10-30 16:04:41 +00:00
|
|
|
$routeWithModulePrefix = $this->substPathParams( $routeWithModulePrefix, $pathParams );
|
|
|
|
|
$path = $this->rootPath . $routeWithModulePrefix;
|
2023-10-12 16:53:30 +00:00
|
|
|
return wfAppendQuery( $path, $queryParams );
|
|
|
|
|
}
|
|
|
|
|
|
2022-07-04 13:04:26 +00:00
|
|
|
public function getRouteUrl(
|
2023-10-30 16:04:41 +00:00
|
|
|
string $routeWithModulePrefix,
|
2022-07-04 13:04:26 +00:00
|
|
|
array $pathParams = [],
|
|
|
|
|
array $queryParams = []
|
|
|
|
|
): string {
|
2023-10-30 16:04:41 +00:00
|
|
|
return $this->baseUrl . $this->getRoutePath( $routeWithModulePrefix, $pathParams, $queryParams );
|
2022-07-04 13:04:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getPrivateRouteUrl(
|
2023-10-30 16:04:41 +00:00
|
|
|
string $routeWithModulePrefix,
|
2022-07-04 13:04:26 +00:00
|
|
|
array $pathParams = [],
|
|
|
|
|
array $queryParams = []
|
|
|
|
|
): string {
|
2023-10-30 16:04:41 +00:00
|
|
|
return $this->privateBaseUrl . $this->getRoutePath( $routeWithModulePrefix, $pathParams, $queryParams );
|
2022-07-04 13:04:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string $route
|
|
|
|
|
* @param array $pathParams
|
|
|
|
|
*
|
|
|
|
|
* @return string
|
2020-03-06 15:53:01 +00:00
|
|
|
*/
|
2022-07-04 13:04:26 +00:00
|
|
|
protected function substPathParams( string $route, array $pathParams ): string {
|
2020-06-12 22:29:35 +00:00
|
|
|
foreach ( $pathParams as $param => $value ) {
|
2020-06-16 13:52:40 +00:00
|
|
|
// NOTE: we use rawurlencode here, since execute() uses rawurldecode().
|
|
|
|
|
// Spaces in path params must be encoded to %20 (not +).
|
2022-07-04 13:20:59 +00:00
|
|
|
// Slashes must be encoded as %2F.
|
|
|
|
|
$route = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $route );
|
2020-03-06 15:53:01 +00:00
|
|
|
}
|
2022-07-04 13:04:26 +00:00
|
|
|
return $route;
|
2020-03-06 15:53:01 +00:00
|
|
|
}
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
public function execute( RequestInterface $request ): ResponseInterface {
|
|
|
|
|
try {
|
|
|
|
|
$fullPath = $request->getUri()->getPath();
|
|
|
|
|
$response = $this->doExecute( $fullPath, $request );
|
|
|
|
|
} catch ( HttpException $e ) {
|
2024-09-16 16:39:02 +00:00
|
|
|
$extraData = [];
|
|
|
|
|
if ( $this->isRestbaseCompatEnabled( $request )
|
|
|
|
|
&& $e instanceof LocalizedHttpException
|
|
|
|
|
) {
|
|
|
|
|
$extraData = $this->getRestbaseCompatErrorData( $request, $e );
|
|
|
|
|
}
|
|
|
|
|
$response = $this->responseFactory->createFromException( $e, $extraData );
|
2023-10-30 16:04:41 +00:00
|
|
|
} catch ( Throwable $e ) {
|
|
|
|
|
$this->errorReporter->reportError( $e, null, $request );
|
|
|
|
|
$response = $this->responseFactory->createFromException( $e );
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
2019-05-31 03:41:40 +00:00
|
|
|
|
2024-09-24 15:43:42 +00:00
|
|
|
// TODO: Only send the vary header for handlers that opt into
|
|
|
|
|
// restbase compat!
|
|
|
|
|
$this->varyOnRestbaseCompat( $response );
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
return $response;
|
|
|
|
|
}
|
2019-09-10 06:26:53 +00:00
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
private function doExecute( string $fullPath, RequestInterface $request ): ResponseInterface {
|
|
|
|
|
[ $modulePrefix, $path ] = $this->splitPath( $fullPath );
|
2024-05-28 08:29:56 +00:00
|
|
|
|
|
|
|
|
// If there is no path at all, redirect to "/".
|
|
|
|
|
// That's the minimal path that can be routed.
|
|
|
|
|
if ( $modulePrefix === '' && $path === '' ) {
|
|
|
|
|
$target = $this->getRoutePath( '/' );
|
|
|
|
|
return $this->responseFactory->createRedirect( $target, 308 );
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
$module = $this->getModule( $modulePrefix );
|
2020-08-22 19:26:19 +00:00
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
if ( !$module ) {
|
|
|
|
|
throw new LocalizedHttpException(
|
|
|
|
|
MessageValue::new( 'rest-unknown-module' )->plaintextParams( $modulePrefix ),
|
|
|
|
|
404,
|
|
|
|
|
[ 'prefix' => $modulePrefix ]
|
|
|
|
|
);
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
2019-05-31 03:41:40 +00:00
|
|
|
|
2024-05-21 15:02:16 +00:00
|
|
|
return $module->execute( $path, $request );
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
|
|
|
|
|
2020-08-22 19:26:19 +00:00
|
|
|
/**
|
2023-10-30 16:04:41 +00:00
|
|
|
* Prepare the handler by injecting relevant service objects and state
|
|
|
|
|
* into $handler.
|
2020-08-22 19:26:19 +00:00
|
|
|
*
|
2023-10-30 16:04:41 +00:00
|
|
|
* @internal
|
2023-12-05 18:00:26 +00:00
|
|
|
*/
|
2023-10-30 16:04:41 +00:00
|
|
|
public function prepareHandler( Handler $handler ) {
|
|
|
|
|
// Injecting services in the Router class means we don't have to inject
|
|
|
|
|
// them into each Module.
|
|
|
|
|
$handler->initServices(
|
|
|
|
|
$this->authority,
|
|
|
|
|
$this->responseFactory,
|
|
|
|
|
$this->hookContainer
|
2020-11-17 06:15:02 +00:00
|
|
|
);
|
2020-01-22 19:08:38 +00:00
|
|
|
|
2023-10-30 16:04:41 +00:00
|
|
|
$handler->initSession( $this->session );
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|
2020-08-22 19:26:19 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param CorsUtils $cors
|
|
|
|
|
* @return self
|
|
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
public function setCors( CorsUtils $cors ): self {
|
2020-08-22 19:26:19 +00:00
|
|
|
$this->cors = $cors;
|
|
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
|
}
|
2022-07-04 13:04:26 +00:00
|
|
|
|
2022-12-13 21:00:59 +00:00
|
|
|
/**
|
2024-06-07 06:41:15 +00:00
|
|
|
* @internal
|
|
|
|
|
*
|
|
|
|
|
* @param StatsFactory $stats
|
2022-12-13 21:00:59 +00:00
|
|
|
*
|
|
|
|
|
* @return self
|
|
|
|
|
*/
|
2024-06-07 06:41:15 +00:00
|
|
|
public function setStats( StatsFactory $stats ): self {
|
2022-12-13 21:00:59 +00:00
|
|
|
$this->stats = $stats;
|
|
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-21 20:51:29 +00:00
|
|
|
/**
|
|
|
|
|
* @param array $info
|
|
|
|
|
* @param string $name
|
|
|
|
|
*/
|
|
|
|
|
private function instantiateModule( array $info, string $name ): Module {
|
|
|
|
|
if ( $info['class'] === SpecBasedModule::class ) {
|
|
|
|
|
$module = new SpecBasedModule(
|
|
|
|
|
$info['specFile'],
|
|
|
|
|
$this,
|
|
|
|
|
$info['pathPrefix'] ?? $name,
|
|
|
|
|
$this->responseFactory,
|
|
|
|
|
$this->basicAuth,
|
|
|
|
|
$this->objectFactory,
|
|
|
|
|
$this->restValidator,
|
|
|
|
|
$this->errorReporter
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
$module = new ExtraRoutesModule(
|
|
|
|
|
$info['routeFiles'] ?? [],
|
|
|
|
|
$info['extraRoutes'] ?? [],
|
|
|
|
|
$this,
|
|
|
|
|
$this->responseFactory,
|
|
|
|
|
$this->basicAuth,
|
|
|
|
|
$this->objectFactory,
|
|
|
|
|
$this->restValidator,
|
|
|
|
|
$this->errorReporter
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $module;
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-16 16:39:02 +00:00
|
|
|
/**
|
|
|
|
|
* @internal
|
|
|
|
|
*
|
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
|
|
|
|
public function isRestbaseCompatEnabled( RequestInterface $request ): bool {
|
2024-09-24 15:43:42 +00:00
|
|
|
// See T374136
|
2024-09-16 16:39:02 +00:00
|
|
|
return $request->getHeaderLine( 'x-restbase-compat' ) === 'true';
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-24 15:43:42 +00:00
|
|
|
private function varyOnRestbaseCompat( ResponseInterface $response ) {
|
|
|
|
|
// See T374136
|
|
|
|
|
$response->addHeader( 'Vary', 'x-restbase-compat' );
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-16 16:39:02 +00:00
|
|
|
/**
|
|
|
|
|
* @internal
|
|
|
|
|
*
|
|
|
|
|
* @return array
|
|
|
|
|
*/
|
|
|
|
|
public function getRestbaseCompatErrorData( RequestInterface $request, LocalizedHttpException $e ): array {
|
|
|
|
|
$msg = $e->getMessageValue();
|
|
|
|
|
|
|
|
|
|
// Match error fields emitted by the RESTBase endpoints.
|
|
|
|
|
// EntryPoint::getTextFormatters() ensures 'en' is always available.
|
|
|
|
|
return [
|
|
|
|
|
'type' => "MediaWikiError/" .
|
|
|
|
|
str_replace( ' ', '_', HttpStatus::getMessage( $e->getCode() ) ),
|
|
|
|
|
'title' => $msg->getKey(),
|
|
|
|
|
'method' => strtolower( $request->getMethod() ),
|
2024-10-08 02:14:15 +00:00
|
|
|
'detail' => $this->responseFactory->getFormattedMessage( $msg, 'en' ),
|
2024-09-16 16:39:02 +00:00
|
|
|
'uri' => (string)$request->getUri()
|
|
|
|
|
];
|
|
|
|
|
}
|
2019-05-09 01:36:18 +00:00
|
|
|
}
|