Page MenuHomeWickedGov Phorge

HookContainer.php
No OneTemporary

Size
19 KB
Referenced Files
None
Subscribers
None

HookContainer.php

<?php
/**
* @license GPL-2.0-or-later
* @file
* @ingroup Hooks
* @defgroup Hooks Hooks
* Hooks allow custom code to be executed when an event occurs; this module
* includes all hooks provided by MediaWiki Core; for more information, see
* https://www.mediawiki.org/wiki/Manual:Hooks.
*/
namespace MediaWiki\HookContainer;
use Closure;
use Error;
use InvalidArgumentException;
use LogicException;
use MediaWiki\Debug\MWDebug;
use UnexpectedValueException;
use Wikimedia\Assert\Assert;
use Wikimedia\NonSerializable\NonSerializableTrait;
use Wikimedia\ObjectFactory\ObjectFactory;
use Wikimedia\ScopedCallback;
use Wikimedia\Services\SalvageableService;
use function array_filter;
use function array_keys;
use function array_merge;
use function array_unique;
use function is_array;
use function is_object;
use function is_string;
use function strtr;
/**
* HookContainer class.
*
* Main class for managing hooks
*
* @since 1.35
*/
class HookContainer implements SalvageableService {
use NonSerializableTrait;
public const NOOP = '*no-op*';
/**
* Normalized hook handlers, as a 3D array:
* - the first level maps hook names to lists of handlers
* - the second is a list of handlers
* - each handler is an associative array with some well known keys, as returned by normalizeHandler()
* @var array<array>
* @phan-var array<string,array<string|int,array{callback:callable,functionName:string}>>
*/
private $handlers = [];
/** @var array<object> handler name and their handler objects */
private $handlerObjects = [];
/** @var HookRegistry */
private $registry;
/**
* Handlers registered by calling register().
* @var array
*/
private $extraHandlers = [];
/** @var ObjectFactory */
private $objectFactory;
/** @var int The next ID to be used by scopedRegister() */
private $nextScopedRegisterId = 0;
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
*/
public function salvage( SalvageableService $other ) {
Assert::parameterType( self::class, $other, '$other' );
if ( $this->handlers || $this->handlerObjects || $this->extraHandlers ) {
throw new LogicException( 'salvage() must be called immediately after construction' );
}
$this->handlerObjects = $other->handlerObjects;
$this->handlers = $other->handlers;
$this->extraHandlers = $other->extraHandlers;
}
/**
* 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.
*
* @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 sequence.
* 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.
* - silent: (bool) If true, do not raise a deprecation warning
* - noServices: (bool) If true, do not allow hook handlers with service dependencies
* @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 {
$checkDeprecation = isset( $options['deprecatedVersion'] );
$abortable = $options['abortable'] ?? true;
foreach ( $this->getHandlers( $hook, $options ) as $handler ) {
if ( $checkDeprecation ) {
$this->checkDeprecation( $hook, $handler, $options );
}
// Call the handler.
$callback = $handler['callback'];
$return = $callback( ...$args );
// Handler returned false, signal abort to caller
if ( $return === false ) {
if ( !$abortable ) {
throw new UnexpectedValueException( "Handler {$handler['functionName']}" .
" return false for unabortable $hook." );
}
return false;
} elseif ( $return !== null && $return !== true ) {
throw new UnexpectedValueException(
"Hook handlers can only return null or a boolean. Got an unexpected value from " .
"handler {$handler['functionName']} for $hook" );
}
}
return true;
}
/**
* Clear handlers of the given hook.
* This is intended for use while testing and will fail if MW_PHPUNIT_TEST
* is not defined.
*
* @param string $hook Name of hook to clear
*
* @internal For testing only
* @codeCoverageIgnore
*/
public function clear( string $hook ): void {
if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
throw new LogicException( 'Cannot reset hooks in operation.' );
}
$this->handlers[$hook] = [];
}
/**
* 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 $handler Handler to attach
* @return ScopedCallback
*/
public function scopedRegister( string $hook, $handler ): ScopedCallback {
$handler = $this->normalizeHandler( $hook, $handler );
if ( !$handler ) {
throw new InvalidArgumentException( 'Bad hook handler!' );
}
$this->checkDeprecation( $hook, $handler );
$id = 'TemporaryHook_' . $this->nextScopedRegisterId++;
$this->getHandlers( $hook );
$this->handlers[$hook][$id] = $handler;
return new ScopedCallback( function () use ( $hook, $id ) {
unset( $this->handlers[$hook][$id] );
} );
}
/**
* Returns a callable array based on the handler specification provided.
* This will find the appropriate handler object to call a method on,
* This will find the appropriate handler object to call a method on,
* instantiating it if it doesn't exist yet.
*
* @param string $hook The name of the hook the handler was registered for
* @param array $handler A hook handler specification as given in an extension.json file.
* @param array $options Options to apply. If the 'noServices' option is set and the
* handler requires service injection, this method will throw an
* UnexpectedValueException.
*
* @return array
*/
private function makeExtensionHandlerCallback( string $hook, array $handler, array $options = [] ): array {
$spec = $handler['handler'];
$name = $spec['name'];
if (
!empty( $options['noServices'] ) && (
!empty( $spec['services'] ) ||
!empty( $spec['optional_services'] )
)
) {
throw new UnexpectedValueException(
"The handler for the hook $hook registered in " .
"{$handler['extensionPath']} has a service dependency, " .
"but this hook does not allow it." );
}
if ( !isset( $this->handlerObjects[$name] ) ) {
// @phan-suppress-next-line PhanTypeInvalidCallableArraySize
$this->handlerObjects[$name] = $this->objectFactory->createObject( $spec );
}
$obj = $this->handlerObjects[$name];
$method = $this->getHookMethodName( $hook );
return [ $obj, $method ];
}
/**
* Normalize/clean up format of argument passed as hook handler
*
* @param string $hook Hook name
* @param string|callable|array{handler:array} $handler Executable handler function. See {@link self::register()}
* for supported structures.
* @param array $options see makeExtensionHandlerCallback()
*
* @return array|false
* - callback: (callable) Executable handler function
* - functionName: (string) Handler name for passing to wfDeprecated() or Exceptions thrown
* - args: (array) Extra handler function arguments (omitted when not needed)
* @phan-return array{callback:callable,functionName:string}|false
*/
private function normalizeHandler( string $hook, $handler, array $options = [] ) {
// 1 - Class instance with `on$hook` method.
if ( is_object( $handler ) && !$handler instanceof Closure ) {
$handler = [ $handler, $this->getHookMethodName( $hook ) ];
}
// 2 - No-op
if ( $handler === self::NOOP ) {
return [
'callback' => static function () {
// no-op
},
'functionName' => self::NOOP,
];
}
// 3 - Plain callback
if ( self::mayBeCallable( $handler ) ) {
return [
'callback' => $handler,
'functionName' => self::callableToString( $handler ),
];
}
// 4 - ExtensionRegistry style handler
if ( is_array( $handler ) && !empty( $handler['handler'] ) ) {
// Skip hooks that both acknowledge deprecation and are deprecated in core
if ( $handler['deprecated'] ?? false ) {
$deprecatedHooks = $this->registry->getDeprecatedHooks();
$deprecated = $deprecatedHooks->isHookDeprecated( $hook );
if ( $deprecated ) {
return false;
}
}
$callback = $this->makeExtensionHandlerCallback( $hook, $handler, $options );
return [
'callback' => $callback,
'functionName' => self::callableToString( $callback ),
];
}
// Something invalid
return false;
}
/**
* 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 {
return (bool)$this->getHandlers( $hook );
}
/**
* Attach an event handler to a given hook.
*
* The handler should be given in one of the following forms:
*
* 1) A callable (string, array, or closure)
* 2) An extension hook handler spec in the form returned by
* HookRegistry::getExtensionHooks
* 3) A class instance with an `on$hook` method (see {@link self::getHookMethodName} for normalizations applied)
* 4) {@link self::NOOP} as a no-op handler
*
* Several other forms are supported for backwards compatibility, but
* should not be used when calling this method directly.
*
* @note This method accepts "broken callables", that is, callable
* structures that reference classes that could not be found or could
* not be loaded, e.g. because they implement an interface that cannot
* be loaded. This situation may legitimately arise when implementing
* hooks defined by extensions that are not present.
* In that case, the hook will never fire and registering the "broken"
* handlers is harmless. If a broken hook handler is registered for a
* hook that is indeed called, it will cause an error. This is
* intentional: we don't want to silently ignore mistakes like mistyped
* class names in a hook handler registration.
*
* @param string $hook Name of hook
* @param string|array|callable $handler handler
*/
public function register( string $hook, $handler ) {
$this->checkDeprecation( $hook, $handler );
if ( !isset( $this->handlers[$hook] ) ) {
// Just remember the handler for later.
// NOTE: It would be nice to normalize immediately. But since some extensions make extensive
// use of this method for registering hooks on every call, that could be a performance
// issue. This is particularly true if the hook is declared in a way that would require
// service objects to be instantiated.
$this->extraHandlers[$hook][] = $handler;
return;
}
$normalized = $this->normalizeHandler( $hook, $handler );
if ( !$normalized ) {
throw new InvalidArgumentException( 'Bad hook handler!' );
}
$this->getHandlers( $hook );
$this->handlers[$hook][] = $normalized;
}
/**
* Get handler callbacks.
*
* @deprecated since 1.41.
* @internal For use by HookContainerTest. Delete when no longer needed.
* @param string $hook Name of hook
* @return callable[]
*/
public function getHandlerCallbacks( string $hook ): array {
wfDeprecated( __METHOD__, '1.41' );
$handlers = $this->getHandlers( $hook );
return array_column( $handlers, 'callback' );
}
/**
* Returns the names of all hooks that have at least one handler registered.
* @return string[]
*/
public function getHookNames(): array {
$names = array_merge(
array_keys( array_filter( $this->handlers ) ),
array_keys( array_filter( $this->extraHandlers ) ),
array_keys( array_filter( $this->registry->getGlobalHooks() ) ),
array_keys( array_filter( $this->registry->getExtensionHooks() ) )
);
return array_unique( $names );
}
/**
* Return the array of handlers for the given hook.
*
* @param string $hook Name of the hook
* @param array $options Handler options, which may include:
* - noServices: Do not allow hook handlers with service dependencies
* @return array[] A list of handler entries
* @phan-return array<string|int,array{callback:callable,functionName:string}>
*/
private function getHandlers( string $hook, array $options = [] ): array {
if ( !isset( $this->handlers[$hook] ) ) {
$handlers = [];
$registeredHooks = $this->registry->getExtensionHooks();
$configuredHooks = $this->registry->getGlobalHooks();
$rawHandlers = array_merge(
$configuredHooks[ $hook ] ?? [],
$registeredHooks[ $hook ] ?? [],
$this->extraHandlers[ $hook ] ?? [],
);
foreach ( $rawHandlers as $raw ) {
$handler = $this->normalizeHandler( $hook, $raw, $options );
if ( !$handler ) {
// XXX: log this?!
// NOTE: also happens for deprecated hooks, which is fine!
continue;
}
$handlers[] = $handler;
}
$this->handlers[ $hook ] = $handlers;
}
return $this->handlers[ $hook ];
}
/**
* Return the array of strings that describe the handler registered with the given hook.
*
* @internal Only public for use by ApiQuerySiteInfo.php and SpecialVersion.php
* @param string $hook Name of the hook
* @return string[] A list of handler descriptions
*/
public function getHandlerDescriptions( string $hook ): array {
$descriptions = [];
if ( isset( $this->handlers[ $hook ] ) ) {
$rawHandlers = $this->handlers[ $hook ];
} else {
$registeredHooks = $this->registry->getExtensionHooks();
$configuredHooks = $this->registry->getGlobalHooks();
$rawHandlers = array_merge(
$configuredHooks[ $hook ] ?? [],
$registeredHooks[ $hook ] ?? [],
$this->extraHandlers[ $hook ] ?? [],
);
}
foreach ( $rawHandlers as $raw ) {
$descr = $this->describeHandler( $hook, $raw );
if ( $descr ) {
$descriptions[] = $descr;
}
}
return $descriptions;
}
/**
* Returns a human-readable description of the given handler.
*
* @param string $hook
* @param string|array|callable $handler
*
* @return ?string
*/
private function describeHandler( string $hook, $handler ): ?string {
if ( is_array( $handler ) ) {
// already normalized
if ( isset( $handler['functionName'] ) ) {
return $handler['functionName'];
}
if ( isset( $handler['callback'] ) ) {
return self::callableToString( $handler['callback'] );
}
if ( isset( $handler['handler']['class'] ) ) {
// New style hook. Avoid instantiating the handler object
$method = $this->getHookMethodName( $hook );
return $handler['handler']['class'] . '::' . $method;
}
}
$handler = $this->normalizeHandler( $hook, $handler );
return $handler ? $handler['functionName'] : null;
}
/**
* For each hook handler of each hook, this will log a deprecation if:
* 1. the hook is marked deprecated and
* 2. the "silent" flag is absent or false, and
* 3. an extension registers a handler in the new way but does not acknowledge deprecation
*/
public function emitDeprecationWarnings() {
$deprecatedHooks = $this->registry->getDeprecatedHooks();
$extensionHooks = $this->registry->getExtensionHooks();
foreach ( $extensionHooks as $name => $handlers ) {
if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
$deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
if ( !empty( $deprecationInfo['silent'] ) ) {
continue;
}
$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']
);
}
}
}
}
}
/**
* Will trigger a deprecation warning if the given hook is deprecated and the deprecation
* is not marked as silent.
*
* @param string $hook The name of the hook.
* @param array|callable|string $handler A handler spec
* @param array|null $deprecationInfo Deprecation info if the caller already knows it.
* If not given, it will be looked up from the hook registry.
*
* @return void
*/
private function checkDeprecation( string $hook, $handler, ?array $deprecationInfo = null ): void {
if ( !$deprecationInfo ) {
$deprecatedHooks = $this->registry->getDeprecatedHooks();
$deprecationInfo = $deprecatedHooks->getDeprecationInfo( $hook );
}
if ( $deprecationInfo && empty( $deprecationInfo['silent'] ) ) {
$description = $this->describeHandler( $hook, $handler );
wfDeprecated(
"$hook hook (used in $description)",
$deprecationInfo['deprecatedVersion'] ?? false,
$deprecationInfo['component'] ?? false
);
}
}
/**
* Returns a human-readable representation of the given callable.
*
* @param callable $callable
*
* @return string
*/
private static function callableToString( $callable ): string {
if ( is_string( $callable ) ) {
return $callable;
}
if ( $callable instanceof Closure ) {
$hash = spl_object_hash( $callable );
return "*closure#$hash*";
}
if ( is_array( $callable ) ) {
[ $on, $func ] = $callable;
if ( is_object( $on ) ) {
$on = get_class( $on );
}
return "$on::$func";
}
throw new InvalidArgumentException( 'Unexpected kind of callable' );
}
/**
* Returns the default handler method name for the given hook.
*
* @param string $hook
*
* @return string
*/
private function getHookMethodName( string $hook ): string {
$hook = strtr( $hook, ':\\-', '___' );
return "on$hook";
}
/**
* Replacement for is_callable that will also return true when the callable uses a class
* that cannot be loaded.
*
* This may legitimately happen when a hook handler uses a hook interfaces that is defined
* in another extension. In that case, the hook itself is also defined in the other extension,
* so the hook will never be called and no problem arises.
*
* However, it is entirely possible to register broken handlers for hooks that will indeed
* be called, causing an error. This is intentional: we don't want to silently ignore
* mistakes like mistyped class names in a hook handler registration.
*
* @param mixed $v
*
* @return bool
*/
private static function mayBeCallable( $v ): bool {
try {
return is_callable( $v );
} catch ( Error $error ) {
// If the callable uses a class that can't be loaded because it extends an unknown base class.
// Continue as if is_callable had returned true, to allow the handler to be registered.
if ( preg_match( '/Class.*not found/', $error->getMessage() ) ) {
return true;
}
throw $error;
}
}
}

File Metadata

Mime Type
text/x-php
Expires
Tue, Mar 31, 11:10 (1 d, 5 h)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
b8/fa/8030bb8ccd909e20cc8365f5ceab
Default Alt Text
HookContainer.php (19 KB)

Event Timeline