Add a HookRegistry interface and two concrete implementations, representing HookContainer's view of its environment. This simplifies creation of a HookContainer for testing. Add MediaWikiTestCaseTrait::createHookContainer() which can be used in most of the places that were previously creating mock hook containers. It can also replace setTemporaryHook() in some cases. Change-Id: I9ce15591dc40b3d717c203fa973141aa45a2500c
420 lines
15 KiB
PHP
420 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 2 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along
|
|
* with this program; if not, write to the Free Software Foundation, Inc.,
|
|
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
* http://www.gnu.org/copyleft/gpl.html
|
|
*
|
|
* @file
|
|
*/
|
|
|
|
namespace MediaWiki\HookContainer;
|
|
|
|
use Closure;
|
|
use MWDebug;
|
|
use MWException;
|
|
use UnexpectedValueException;
|
|
use Wikimedia\Assert\Assert;
|
|
use Wikimedia\ObjectFactory;
|
|
use Wikimedia\ScopedCallback;
|
|
use Wikimedia\Services\SalvageableService;
|
|
|
|
/**
|
|
* HookContainer class.
|
|
*
|
|
* Main class for managing hooks
|
|
*
|
|
* @since 1.35
|
|
*/
|
|
class HookContainer implements SalvageableService {
|
|
|
|
/** @var array Hooks and their callbacks registered through $this->register() */
|
|
private $legacyRegisteredHandlers = [];
|
|
|
|
/** @var array handler name and their handler objects */
|
|
private $handlersByName = [];
|
|
|
|
/** @var HookRegistry */
|
|
private $registry;
|
|
|
|
/** @var ObjectFactory */
|
|
private $objectFactory;
|
|
|
|
/** @var int The next ID to be used by scopedRegister() */
|
|
private $nextScopedRegisterId = 0;
|
|
|
|
/** @var array existing hook names and their handlers to restore between tests */
|
|
private $originalHooks;
|
|
|
|
/**
|
|
* @param HookRegistry $hookRegistry
|
|
* @param ObjectFactory $objectFactory
|
|
*/
|
|
public function __construct(
|
|
HookRegistry $hookRegistry,
|
|
ObjectFactory $objectFactory
|
|
) {
|
|
$this->registry = $hookRegistry;
|
|
$this->objectFactory = $objectFactory;
|
|
}
|
|
|
|
/**
|
|
* Salvage the state of HookContainer by retaining existing handler objects
|
|
* and hooks registered via HookContainer::register(). Necessary in the event
|
|
* that MediaWikiServices::resetGlobalInstance() is called after hooks have already
|
|
* been registered.
|
|
*
|
|
* @param HookContainer|SalvageableService $other The object to salvage state from. $other be
|
|
* of type HookContainer
|
|
* @throws MWException
|
|
*/
|
|
public function salvage( SalvageableService $other ) {
|
|
Assert::parameterType( self::class, $other, '$other' );
|
|
if ( $this->legacyRegisteredHandlers || $this->handlersByName ) {
|
|
throw new MWException( 'salvage() must be called immediately after construction' );
|
|
}
|
|
$this->handlersByName = $other->handlersByName;
|
|
$this->legacyRegisteredHandlers = $other->legacyRegisteredHandlers;
|
|
}
|
|
|
|
/**
|
|
* Call registered hook functions through either the legacy $wgHooks or extension.json
|
|
*
|
|
* For the given hook, fetch the array of handler objects and
|
|
* process them. Determine the proper callback for each hook and
|
|
* then call the actual hook using the appropriate arguments.
|
|
* Finally, process the return value and return/throw accordingly.
|
|
*
|
|
* For hooks that are not abortable through a handler's return value,
|
|
* use runWithoutAbort() instead.
|
|
*
|
|
* @param string $hook Name of the hook
|
|
* @param array $args Arguments to pass to hook handler
|
|
* @param array $options options map:
|
|
* - abortable: (bool) If false, handlers will not be allowed to abort the call sequenece.
|
|
* An exception will be raised if a handler returns anything other than true or null.
|
|
* - deprecatedVersion: (string) Version of MediaWiki this hook was deprecated in. For supporting
|
|
* Hooks::run() legacy $deprecatedVersion parameter. New core code should add deprecated
|
|
* hooks to the DeprecatedHooks::$deprecatedHooks array literal. New extension code should
|
|
* use the DeprecatedHooks attribute.
|
|
* @return bool True if no handler aborted the hook
|
|
* @throws UnexpectedValueException if handlers return an invalid value
|
|
*/
|
|
public function run( string $hook, array $args = [], array $options = [] ) : bool {
|
|
$legacyHandlers = $this->getLegacyHandlers( $hook );
|
|
$options = array_merge(
|
|
$this->registry->getDeprecatedHooks()->getDeprecationInfo( $hook ) ?? [],
|
|
$options
|
|
);
|
|
// Equivalent of legacy Hooks::runWithoutAbort()
|
|
$notAbortable = ( isset( $options['abortable'] ) && $options['abortable'] === false );
|
|
foreach ( $legacyHandlers as $handler ) {
|
|
$normalizedHandler = $this->normalizeHandler( $handler, $hook );
|
|
if ( $normalizedHandler ) {
|
|
$functionName = $normalizedHandler['functionName'];
|
|
$return = $this->callLegacyHook( $hook, $normalizedHandler, $args, $options );
|
|
if ( $notAbortable && $return !== null && $return !== true ) {
|
|
throw new UnexpectedValueException( "Invalid return from $functionName" .
|
|
" for unabortable $hook." );
|
|
}
|
|
if ( $return === false ) {
|
|
return false;
|
|
}
|
|
if ( is_string( $return ) ) {
|
|
wfDeprecated(
|
|
"returning a string from a hook handler (done by $functionName for $hook)",
|
|
'1.35'
|
|
);
|
|
throw new UnexpectedValueException( $return );
|
|
}
|
|
}
|
|
}
|
|
|
|
$handlers = $this->getHandlers( $hook );
|
|
$funcName = 'on' . str_replace( ':', '_', ucfirst( $hook ) );
|
|
|
|
foreach ( $handlers as $handler ) {
|
|
$return = $handler->$funcName( ...$args );
|
|
if ( $notAbortable && $return !== null && $return !== true ) {
|
|
throw new UnexpectedValueException(
|
|
"Invalid return from " . $funcName . " for unabortable $hook."
|
|
);
|
|
}
|
|
if ( $return === false ) {
|
|
return false;
|
|
}
|
|
if ( $return !== null && !is_bool( $return ) ) {
|
|
throw new UnexpectedValueException( "Invalid return from " . $funcName . " for $hook." );
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clear hooks registered via Hooks::register().
|
|
* This is intended for use while testing and will fail if MW_PHPUNIT_TEST
|
|
* and MW_PARSER_TEST are not defined.
|
|
*
|
|
* @param string $hook Name of hook to clear
|
|
*
|
|
* @internal For use by Hooks.php
|
|
* @throws MWException If not in testing mode.
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function clear( string $hook ) : void {
|
|
if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
|
|
throw new MWException( 'Cannot reset hooks in operation.' );
|
|
}
|
|
unset( $this->legacyRegisteredHandlers[$hook] ); // dynamically registered legacy handlers
|
|
}
|
|
|
|
/**
|
|
* Register hook and handler, allowing for easy removal.
|
|
* Intended for use in temporary registration e.g. testing
|
|
*
|
|
* @param string $hook Name of hook
|
|
* @param callable|string|array $callback Handler object to attach
|
|
* @param bool $replace (optional) By default adds callback to handler array.
|
|
* Set true to remove all existing callbacks for the hook.
|
|
* @return ScopedCallback
|
|
*/
|
|
public function scopedRegister( string $hook, $callback, bool $replace = false ) : ScopedCallback {
|
|
if ( $replace ) {
|
|
// Stash any previously registered hooks
|
|
if ( !isset( $this->originalHooks[$hook] ) &&
|
|
isset( $this->legacyRegisteredHandlers[$hook] )
|
|
) {
|
|
$this->originalHooks[$hook] = $this->legacyRegisteredHandlers[$hook];
|
|
}
|
|
$this->legacyRegisteredHandlers[$hook] = [ $callback ];
|
|
return new ScopedCallback( function () use ( $hook ) {
|
|
unset( $this->legacyRegisteredHandlers[$hook] );
|
|
} );
|
|
}
|
|
$id = $this->nextScopedRegisterId++;
|
|
$this->legacyRegisteredHandlers[$hook][$id] = $callback;
|
|
return new ScopedCallback( function () use ( $hook, $id ) {
|
|
unset( $this->legacyRegisteredHandlers[$hook][$id] );
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Return hooks that were set before being potentially overridden by scopedRegister().
|
|
* For use in restoring registered hook handlers between tests.
|
|
*
|
|
* @return array Associative array mapping hook names to array of handlers
|
|
* @throws MWException
|
|
*/
|
|
public function getOriginalHooksForTest() {
|
|
if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
|
|
throw new MWException( 'Cannot get original hooks outside when not in test mode' );
|
|
}
|
|
return $this->originalHooks ?? [];
|
|
}
|
|
|
|
/**
|
|
* Normalize/clean up format of argument passed as hook handler
|
|
*
|
|
* @param array|callable $handler Executable handler function
|
|
* @param string $hook Hook name
|
|
* @return array|false
|
|
* - handler: (callable) Executable handler function
|
|
* - functionName: (string) Handler name for passing to wfDeprecated() or Exceptions thrown
|
|
* - args: (array) handler function arguments
|
|
*/
|
|
private function normalizeHandler( $handler, string $hook ) {
|
|
$normalizedHandler = $handler;
|
|
if ( !is_array( $handler ) ) {
|
|
$normalizedHandler = [ $normalizedHandler ];
|
|
}
|
|
|
|
// Empty array or array filled with null/false/empty.
|
|
if ( !array_filter( $normalizedHandler ) ) {
|
|
return false;
|
|
}
|
|
|
|
if ( is_array( $normalizedHandler[0] ) ) {
|
|
// First element is an array, meaning the developer intended
|
|
// the first element to be a callback. Merge it in so that
|
|
// processing can be uniform.
|
|
$normalizedHandler = array_merge( $normalizedHandler[0], array_slice( $normalizedHandler, 1 ) );
|
|
}
|
|
|
|
$firstArg = $normalizedHandler[0];
|
|
|
|
// Extract function name, handler object, and any arguments for handler object
|
|
if ( $firstArg instanceof Closure ) {
|
|
$functionName = "hook-$hook-closure";
|
|
$callback = array_shift( $normalizedHandler );
|
|
} elseif ( is_object( $firstArg ) ) {
|
|
$object = array_shift( $normalizedHandler );
|
|
$functionName = array_shift( $normalizedHandler );
|
|
|
|
// If no method was specified, default to on$event
|
|
if ( $functionName === null ) {
|
|
$functionName = "on$hook";
|
|
} else {
|
|
$colonPos = strpos( $functionName, '::' );
|
|
if ( $colonPos !== false ) {
|
|
// Some extensions use [ $object, 'Class::func' ] which
|
|
// worked with call_user_func_array() but doesn't work now
|
|
// that we use a plain varadic call
|
|
$functionName = substr( $functionName, $colonPos + 2 );
|
|
}
|
|
}
|
|
|
|
$callback = [ $object, $functionName ];
|
|
} elseif ( is_string( $firstArg ) ) {
|
|
$functionName = $callback = array_shift( $normalizedHandler );
|
|
} else {
|
|
throw new UnexpectedValueException( 'Unknown datatype in hooks for ' . $hook );
|
|
}
|
|
return [
|
|
'callback' => $callback,
|
|
'args' => $normalizedHandler,
|
|
'functionName' => $functionName,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Run legacy hooks
|
|
* Hook can be: a function, an object, an array of $function and
|
|
* $data, an array of just a function, an array of object and
|
|
* method, or an array of object, method, and data
|
|
* (See hooks.txt for more details)
|
|
*
|
|
* @param string $hook
|
|
* @param array|callable $handler The name of the hooks handler function
|
|
* @param array $args Arguments for hook handler function
|
|
* @param array $options
|
|
* @return null|string|bool
|
|
*/
|
|
private function callLegacyHook( string $hook, $handler, array $args, array $options ) {
|
|
$callback = $handler['callback'];
|
|
$hookArgs = array_merge( $handler['args'], $args );
|
|
if ( isset( $options['deprecatedVersion'] ) ) {
|
|
wfDeprecated(
|
|
"$hook hook (used in " . $handler['functionName'] . ")",
|
|
$options['deprecatedVersion'] ?? false,
|
|
$options['component'] ?? false
|
|
);
|
|
}
|
|
// Call the hooks
|
|
return $callback( ...$hookArgs );
|
|
}
|
|
|
|
/**
|
|
* Return whether hook has any handlers registered to it.
|
|
* The function may have been registered via Hooks::register or in extension.json
|
|
*
|
|
* @param string $hook Name of hook
|
|
* @return bool Whether the hook has a handler registered to it
|
|
*/
|
|
public function isRegistered( string $hook ) : bool {
|
|
$legacyRegisteredHook = !empty( $this->registry->getGlobalHooks()[$hook] ) ||
|
|
!empty( $this->legacyRegisteredHandlers[$hook] );
|
|
$registeredHooks = $this->registry->getExtensionHooks();
|
|
return !empty( $registeredHooks[$hook] ) || $legacyRegisteredHook;
|
|
}
|
|
|
|
/**
|
|
* Attach an event handler to a given hook.
|
|
*
|
|
* @param string $hook Name of hook
|
|
* @param callable|string|array $callback handler object to attach
|
|
*/
|
|
public function register( string $hook, $callback ) {
|
|
$deprecatedHooks = $this->registry->getDeprecatedHooks();
|
|
$deprecated = $deprecatedHooks->isHookDeprecated( $hook );
|
|
if ( $deprecated ) {
|
|
$info = $deprecatedHooks->getDeprecationInfo( $hook );
|
|
$deprecatedVersion = $info['deprecatedVersion'] ?? false;
|
|
$component = $info['component'] ?? false;
|
|
wfDeprecated(
|
|
"$hook hook", $deprecatedVersion, $component
|
|
);
|
|
}
|
|
$this->legacyRegisteredHandlers[$hook][] = $callback;
|
|
}
|
|
|
|
/**
|
|
* Get all handlers for legacy hooks system
|
|
*
|
|
* @internal For use by Hooks.php
|
|
* @param string $hook Name of hook
|
|
* @return array function names
|
|
*/
|
|
public function getLegacyHandlers( string $hook ) : array {
|
|
$handlers = array_merge(
|
|
$this->legacyRegisteredHandlers[$hook] ?? [],
|
|
$this->registry->getGlobalHooks()[$hook] ?? []
|
|
);
|
|
return $handlers;
|
|
}
|
|
|
|
/**
|
|
* Return array of handler objects registered with given hook in the new system
|
|
* @internal For use by Hooks.php
|
|
* @param string $hook Name of the hook
|
|
* @return array non-deprecated handler objects
|
|
*/
|
|
public function getHandlers( string $hook ) : array {
|
|
$handlers = [];
|
|
$deprecatedHooks = $this->registry->getDeprecatedHooks();
|
|
$registeredHooks = $this->registry->getExtensionHooks();
|
|
if ( isset( $registeredHooks[$hook] ) ) {
|
|
foreach ( $registeredHooks[$hook] as $hookReference ) {
|
|
// Non-legacy hooks have handler attributes
|
|
$handlerObject = $hookReference['handler'];
|
|
// Skip hooks that both acknowledge deprecation and are deprecated in core
|
|
$flaggedDeprecated = !empty( $hookReference['deprecated'] );
|
|
$deprecated = $deprecatedHooks->isHookDeprecated( $hook );
|
|
if ( $deprecated && $flaggedDeprecated ) {
|
|
continue;
|
|
}
|
|
$handlerName = $handlerObject['name'];
|
|
if ( !isset( $this->handlersByName[$handlerName] ) ) {
|
|
$this->handlersByName[$handlerName] =
|
|
$this->objectFactory->createObject( $handlerObject );
|
|
}
|
|
$handlers[] = $this->handlersByName[$handlerName];
|
|
}
|
|
}
|
|
return $handlers;
|
|
}
|
|
|
|
/**
|
|
* Will log a deprecation warning if:
|
|
* 1. the hook is marked deprecated
|
|
* 2. an extension registers a handler in the new way but does not acknowledge deprecation
|
|
*/
|
|
public function emitDeprecationWarnings() {
|
|
$deprecatedHooks = $this->registry->getDeprecatedHooks();
|
|
$registeredHooks = $this->registry->getExtensionHooks();
|
|
foreach ( $registeredHooks as $name => $handlers ) {
|
|
if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
|
|
$deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
|
|
$version = $deprecationInfo['deprecatedVersion'] ?? '';
|
|
$component = $deprecationInfo['component'] ?? 'MediaWiki';
|
|
foreach ( $handlers as $handler ) {
|
|
if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) {
|
|
MWDebug::sendRawDeprecated(
|
|
"Hook $name was deprecated in $component $version " .
|
|
"but is registered in " . $handler['extensionPath']
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|