Commit e096b1b4 authored by crell's avatar crell Committed by Benjamin Franzke
Browse files

[FEATURE] Auto-detect event types on listener services

The 'event:' tag in Services.yaml can be omitted since,
the wiring is done automatically by reflecting the
target method at compile-time.

Resolves: #94345
Releases: master
Change-Id: I58c9072efec3dd70c8d8768dc290cf1aa6543902
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/69495

Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
parent 669ab638
......@@ -121,11 +121,9 @@ services:
- name: event.listener
identifier: 'backend-user-permissions'
method: 'addUserPermissionsToCategoryTreeData'
event: TYPO3\CMS\Core\Tree\Event\ModifyTreeDataEvent
# Listener to provide information about column without a colPos set
TYPO3\CMS\Backend\View\PageLayoutViewDrawEmptyColposContent:
tags:
- name: event.listener
identifier: 'backend-empty-colpos'
event: TYPO3\CMS\Backend\View\Event\AfterSectionMarkupGeneratedEvent
......@@ -12,4 +12,3 @@ services:
- name: event.listener
identifier: 'belog/show-latest-errors'
method: 'appendMessage'
event: TYPO3\CMS\Backend\Backend\Event\SystemInformationToolbarCollectorEvent
......@@ -19,6 +19,8 @@ namespace TYPO3\CMS\Core\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\RuntimeException;
use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -28,10 +30,11 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*/
final class ListenerProviderPass implements CompilerPassInterface
{
/**
* @var string
*/
private $tagName;
private string $tagName;
private ContainerBuilder $container;
private DependencyOrderingService $orderer;
/**
* @param string $tagName
......@@ -39,30 +42,55 @@ final class ListenerProviderPass implements CompilerPassInterface
public function __construct(string $tagName)
{
$this->tagName = $tagName;
$this->orderer = new DependencyOrderingService();
}
/**
* @param ContainerBuilder $container
*/
public function process(ContainerBuilder $container)
public function process(ContainerBuilder $container): void
{
$this->container = $container;
$listenerProviderDefinition = $container->findDefinition(ListenerProvider::class);
// If there's no listener provider registered to begin with, don't bother registering listeners with it.
if (!$listenerProviderDefinition) {
return;
}
$unorderedEventListeners = $this->collectListeners($container);
foreach ($unorderedEventListeners as $eventName => $listeners) {
// Configure ListenerProvider factory to include these listeners
foreach ($this->orderer->orderByDependencies($listeners) as $listener) {
$listenerProviderDefinition->addMethodCall('addListener', [
$eventName,
$listener['service'],
$listener['method'],
]);
}
}
}
/**
* Collects all listeners from the container.
*/
protected function collectListeners(ContainerBuilder $container): array
{
$unorderedEventListeners = [];
foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) {
$container->findDefinition($serviceName)->setPublic(true);
$service = $container->findDefinition($serviceName);
$service->setPublic(true);
foreach ($tags as $attributes) {
if (!isset($attributes['event'])) {
$eventIdentifier = $attributes['event'] ?? $this->getParameterType($serviceName, $service, $attributes['method'] ?? '__invoke');
if (!$eventIdentifier) {
throw new \InvalidArgumentException(
'Service tag "event.listener" requires an event attribute to be defined, missing in: ' . $serviceName,
'Service tag "event.listener" requires an event attribute to be defined or the listener method must declare a parameter type. Missing in: ' . $serviceName,
1563217364
);
}
$eventIdentifier = $attributes['event'];
$listenerIdentifier = $attributes['identifier'] ?? $serviceName;
$unorderedEventListeners[$eventIdentifier][$listenerIdentifier] = [
'service' => $serviceName,
......@@ -72,19 +100,63 @@ final class ListenerProviderPass implements CompilerPassInterface
];
}
}
return $unorderedEventListeners;
}
$dependencyOrderingService = new DependencyOrderingService();
foreach ($unorderedEventListeners as $eventName => $listeners) {
// Sort them
$listeners = $dependencyOrderingService->orderByDependencies($listeners);
// Configure ListenerProvider factory to include these listeners
foreach ($listeners as $listener) {
$listenerProviderDefinition->addMethodCall('addListener', [
$eventName,
$listener['service'],
$listener['method'],
]);
/**
* Derives the class type of the first argument of a given method.
*/
protected function getParameterType(string $serviceName, Definition $definition, string $method = '__invoke'): ?string
{
// A Reflection exception should never actually get thrown here, but linters want a try-catch just in case.
try {
if (!$definition->isAutowired()) {
throw new \InvalidArgumentException(
sprintf('Service "%s" has event listeners defined but does not declare an event to listen to and is not configured to autowire it from the listener method. Set autowire: true to enable auto-detection of the listener event.', $serviceName),
1623881314,
);
}
$params = $this->getReflectionMethod($serviceName, $definition, $method)->getParameters();
$rType = count($params) ? $params[0]->getType() : null;
if ($rType === null) {
throw new \InvalidArgumentException(
sprintf('Service "%s" registers method "%s" as an event listener, but does not specify an event type and the method does not type a parameter. Declare a class type for the method parameter or specify an event class explicitly', $serviceName, $method),
1623881315,
);
}
return $rType->getName();
} catch (\ReflectionException $e) {
// The collectListeners() method will convert this to an exception.
return null;
}
}
/**
* @throws RuntimeException
*
* This method borrowed very closely from Symfony's AbstractRecurisvePass.
*
* @return \ReflectionFunctionAbstract
*/
protected function getReflectionMethod(string $serviceName, Definition $definition, string $method): \ReflectionFunctionAbstract
{
if (!$class = $definition->getClass()) {
throw new RuntimeException(sprintf('Invalid service "%s": the class is not set.', $serviceName), 1623881310);
}
if (!$r = $this->container->getReflectionClass($class)) {
throw new RuntimeException(sprintf('Invalid service "%s": class "%s" does not exist.', $serviceName, $class), 1623881311);
}
if (!$r->hasMethod($method)) {
throw new RuntimeException(sprintf('Invalid service "%s": method "%s()" does not exist.', $serviceName, $class !== $serviceName ? $class . '::' . $method : $method), 1623881312);
}
$r = $r->getMethod($method);
if (!$r->isPublic()) {
throw new RuntimeException(sprintf('Invalid service "%s": method "%s()" must be public.', $serviceName, $class !== $serviceName ? $class . '::' . $method : $method), 1623881313);
}
return $r;
}
}
......@@ -102,11 +102,9 @@ services:
- name: event.listener
identifier: 'non-composer-class-loader'
method: 'updateClassLoadingInformationAfterPackageActivation'
event: TYPO3\CMS\Core\Package\Event\AfterPackageActivationEvent
- name: event.listener
identifier: 'non-composer-class-loader'
method: 'updateClassLoadingInformationAfterPackageDeactivation'
event: TYPO3\CMS\Core\Package\Event\AfterPackageDeactivationEvent
TYPO3\CMS\Core\Tree\TableConfiguration\DatabaseTreeDataProvider:
shared: false
......@@ -122,21 +120,18 @@ services:
- name: event.listener
identifier: 'backend-user-permissions'
method: 'addUserPermissionsToStorage'
event: TYPO3\CMS\Core\Resource\Event\AfterResourceStorageInitializationEvent
TYPO3\CMS\Core\Cache\DatabaseSchemaService:
tags:
- name: event.listener
identifier: 'caching-framework'
method: 'addCachingFrameworkDatabaseSchema'
event: TYPO3\CMS\Core\Database\Event\AlterTableDefinitionStatementsEvent
TYPO3\CMS\Core\Category\CategoryRegistry:
tags:
- name: event.listener
identifier: 'category-registry'
method: 'addCategoryDatabaseSchema'
event: TYPO3\CMS\Core\Database\Event\AlterTableDefinitionStatementsEvent
# @internal
# @todo: deprecate makeInstance(LanguageService::class)
......@@ -163,15 +158,12 @@ services:
- name: event.listener
identifier: 'delete-processed-files-after-add'
method: 'cleanupProcessedFilesPostFileAdd'
event: TYPO3\CMS\Core\Resource\Event\AfterFileAddedEvent
- name: event.listener
identifier: 'delete-processed-files-after-replace'
method: 'cleanupProcessedFilesPostFileReplace'
event: TYPO3\CMS\Core\Resource\Event\AfterFileReplacedEvent
- name: event.listener
identifier: 'delete-processed-files-after-delete'
method: 'removeFromRepositoryAfterFileDeleted'
event: TYPO3\CMS\Core\Resource\Event\AfterFileDeletedEvent
# synchronize folder relations
TYPO3\CMS\Core\Resource\SynchronizeFolderRelations:
......@@ -179,11 +171,9 @@ services:
- name: event.listener
identifier: 'synchronize-file-collections-after-folder-renamed'
method: 'synchronizeFileCollectionsAfterRename'
event: TYPO3\CMS\Core\Resource\Event\AfterFolderRenamedEvent
- name: event.listener
identifier: 'synchronize-filemounts-after-folder-renamed'
method: 'synchronizeFilemountsAfterRename'
event: TYPO3\CMS\Core\Resource\Event\AfterFolderRenamedEvent
# Core caches, cache.core and cache.assets are injected as early
# entries in TYPO3\CMS\Core\Core\Bootstrap and therefore omitted here
......
.. include:: ../../Includes.txt
=========================================
Feature: #94345 - Auto-detect event types
=========================================
See :issue:`94345`
Description
===========
If no "event" tag is specified on an event listener in Services.yaml, the
event is automatically derived from the event method itself using reflection.
Impact
======
In the vast majority of cases, the "event" tag on an event listener in Services.yaml
is no longer necessary.
Given this example event listener implementation:
.. code-block:: php
final class CategoryPermissionsAspect
{
public function addUserPermissionsToCategoryTreeData(ModifyTreeDataEvent $event): void
{
...
}
}
With this registration:
.. code-block:: yaml
TYPO3\CMS\Backend\Security\CategoryPermissionsAspect:
tags:
- name: event.listener
identifier: 'backend-user-permissions'
method: 'addUserPermissionsToCategoryTreeData'
event: TYPO3\CMS\Core\Tree\Event\ModifyTreeDataEvent
The :yaml:`event:` tag can be omitted, since it's automatically read from
the method signature :php:`addUserPermissionsToCategoryTreeData(ModifyTreeDataEvent $event)`
of the listener implementation:
.. code-block:: yaml
TYPO3\CMS\Backend\Security\CategoryPermissionsAspect:
tags:
- name: event.listener
identifier: 'backend-user-permissions'
method: 'addUserPermissionsToCategoryTreeData'
.. index:: PHP-API, ext:core
......@@ -28,34 +28,27 @@ services:
- name: event.listener
identifier: 'form-framework/resource-getPublicUrl'
method: 'getPublicUrl'
event: TYPO3\CMS\Core\Resource\Event\GeneratePublicUrlForResourceEvent
TYPO3\CMS\Form\Slot\FilePersistenceSlot:
tags:
- name: event.listener
identifier: 'form-framework/creation'
method: 'onPreFileCreate'
event: TYPO3\CMS\Core\Resource\Event\BeforeFileCreatedEvent
- name: event.listener
identifier: 'form-framework/add'
method: 'onPreFileAdd'
event: TYPO3\CMS\Core\Resource\Event\BeforeFileAddedEvent
- name: event.listener
identifier: 'form-framework/rename'
method: 'onPreFileRename'
event: TYPO3\CMS\Core\Resource\Event\BeforeFileRenamedEvent
- name: event.listener
identifier: 'form-framework/replace'
method: 'onPreFileReplace'
event: TYPO3\CMS\Core\Resource\Event\BeforeFileReplacedEvent
- name: event.listener
identifier: 'form-framework/move'
method: 'onPreFileMove'
event: TYPO3\CMS\Core\Resource\Event\BeforeFileMovedEvent
- name: event.listener
identifier: 'form-framework/update-content'
method: 'onPreFileSetContents'
event: TYPO3\CMS\Core\Resource\Event\BeforeFileContentsSetEvent
lowlevel.configuration.module.provider.formyamlconfiguration:
class: 'TYPO3\CMS\Form\ConfigurationModuleProvider\FormYamlProvider'
......
......@@ -22,4 +22,3 @@ services:
- name: event.listener
identifier: 'typo3-frontend/overlay'
method: 'languageAndWorkspaceOverlay'
event: TYPO3\CMS\Core\Resource\Event\EnrichFileMetaDataEvent
......@@ -12,4 +12,3 @@ services:
- name: event.listener
identifier: 'indexed-search'
method: 'addMysqlFulltextIndex'
event: TYPO3\CMS\Core\Database\Event\AlterTableDefinitionStatementsEvent
......@@ -23,13 +23,10 @@ services:
tags:
- name: event.listener
identifier: 'rte-check-link-external'
event: TYPO3\CMS\Core\Html\Event\BrokenLinkAnalysisEvent
method: 'checkExternalLink'
- name: event.listener
identifier: 'rte-check-link-to-page'
event: TYPO3\CMS\Core\Html\Event\BrokenLinkAnalysisEvent
method: 'checkPageLink'
- name: event.listener
identifier: 'rte-check-link-to-file'
event: TYPO3\CMS\Core\Html\Event\BrokenLinkAnalysisEvent
method: 'checkFileLink'
......@@ -33,4 +33,3 @@ services:
tags:
- name: event.listener
identifier: 'record-list-content-legacy-hook'
event: TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent
......@@ -17,18 +17,20 @@ declare(strict_types=1);
namespace TYPO3\CMS\Redirects\EventListener;
use TYPO3\CMS\Backend\History\Event\AfterHistoryRollbackFinishedEvent;
use TYPO3\CMS\Backend\History\Event\BeforeHistoryRollbackStartEvent;
use TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook;
class RecordHistoryRollbackEventsListener
{
public function afterHistoryRollbackFinishedEvent(): void
public function afterHistoryRollbackFinishedEvent(AfterHistoryRollbackFinishedEvent $event): void
{
// Re-Enable hook to after rollback finished
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] =
DataHandlerSlugUpdateHook::class;
}
public function beforeHistoryRollbackStartEvent(): void
public function beforeHistoryRollbackStartEvent(BeforeHistoryRollbackStartEvent $event): void
{
// Disable hook to prevent slug change again on rollback
unset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']);
......
......@@ -32,9 +32,7 @@ services:
tags:
- name: event.listener
identifier: 'redirects-disable-hook'
event: TYPO3\CMS\Backend\History\Event\BeforeHistoryRollbackStartEvent
method: 'beforeHistoryRollbackStartEvent'
- name: event.listener
identifier: 'redirects-enable-hook'
event: TYPO3\CMS\Backend\History\Event\AfterHistoryRollbackFinishedEvent
method: 'afterHistoryRollbackFinishedEvent'
......@@ -22,4 +22,3 @@ services:
- name: event.listener
identifier: 'scheduler/show-latest-errors'
method: 'getItem'
event: TYPO3\CMS\Backend\Backend\Event\SystemInformationToolbarCollectorEvent
......@@ -11,7 +11,6 @@ services:
tags:
- name: event.listener
identifier: 'typo3-seo/hreflangGenerator'
event: TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent
TYPO3\CMS\Seo\XmlSitemap\XmlSitemapRenderer:
shared: false
......
......@@ -10,4 +10,3 @@ services:
tags:
- name: event.listener
identifier: 'note-to-record-list'
event: TYPO3\CMS\Recordlist\Event\RenderAdditionalContentToRecordListEvent
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment