diff --git a/composer.json b/composer.json index d3f57d09ae9d7..32c626605adb9 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,7 @@ "symfony/config": "self.version", "symfony/console": "self.version", "symfony/css-selector": "self.version", + "symfony/callable-wrapper": "self.version", "symfony/dependency-injection": "self.version", "symfony/debug-bundle": "self.version", "symfony/doctrine-bridge": "self.version", diff --git a/src/Symfony/Bridge/Doctrine/CHANGELOG.md b/src/Symfony/Bridge/Doctrine/CHANGELOG.md index f1133dfefe9a6..dec9207237da5 100644 --- a/src/Symfony/Bridge/Doctrine/CHANGELOG.md +++ b/src/Symfony/Bridge/Doctrine/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Accept `ReadableCollection` in `CollectionToArrayTransformer` + * Add `Transactional` attribute and `TransactionalCallableWrapper` 7.1 --- diff --git a/src/Symfony/Bridge/Doctrine/CallableWrapper/Transactional.php b/src/Symfony/Bridge/Doctrine/CallableWrapper/Transactional.php new file mode 100644 index 0000000000000..dac4fc70af039 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/CallableWrapper/Transactional.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\CallableWrapper; + +use Symfony\Component\CallableWrapper\Attribute\CallableWrapperAttribute; + +/** + * Wraps persistence method operations within a single Doctrine transaction. + * + * @author Yonel Ceruto + */ +#[\Attribute(\Attribute::TARGET_METHOD)] +class Transactional extends CallableWrapperAttribute +{ + /** + * @param string|null $name The entity manager name (null for the default one) + */ + public function __construct( + public ?string $name = null, + ) { + } +} diff --git a/src/Symfony/Bridge/Doctrine/CallableWrapper/TransactionalCallableWrapper.php b/src/Symfony/Bridge/Doctrine/CallableWrapper/TransactionalCallableWrapper.php new file mode 100644 index 0000000000000..c40e304a34c79 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/CallableWrapper/TransactionalCallableWrapper.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\CallableWrapper; + +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; + +/** + * @author Yonel Ceruto + */ +class TransactionalCallableWrapper implements CallableWrapperInterface +{ + public function __construct( + private readonly ManagerRegistry $managerRegistry, + ) { + } + + public function wrap(\Closure $func, Transactional $transactional = new Transactional()): \Closure + { + $entityManager = $this->managerRegistry->getManager($transactional->name); + + if (!$entityManager instanceof EntityManagerInterface) { + throw new \RuntimeException(\sprintf('The manager "%s" is not an entity manager.', $transactional->name)); + } + + return static function (mixed ...$args) use ($func, $entityManager) { + $entityManager->getConnection()->beginTransaction(); + + try { + $return = $func(...$args); + + $entityManager->flush(); + $entityManager->getConnection()->commit(); + + return $return; + } catch (\Throwable $e) { + $entityManager->close(); + $entityManager->getConnection()->rollBack(); + + throw $e; + } + }; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/CallableWrapper/TransactionalCallableWrapperTest.php b/src/Symfony/Bridge/Doctrine/Tests/CallableWrapper/TransactionalCallableWrapperTest.php new file mode 100644 index 0000000000000..07222fed1f752 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/CallableWrapper/TransactionalCallableWrapperTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\CallableWrapper; + +use Doctrine\DBAL\Connection; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\CallableWrapper\Transactional; +use Symfony\Bridge\Doctrine\CallableWrapper\TransactionalCallableWrapper; +use Symfony\Component\CallableWrapper\CallableWrapper; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolver; + +class TransactionalCallableWrapperTest extends TestCase +{ + private ManagerRegistry $managerRegistry; + private Connection $connection; + private EntityManagerInterface $entityManager; + private CallableWrapper $wrapper; + + protected function setUp(): void + { + $this->connection = $this->createMock(Connection::class); + + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->entityManager->method('getConnection')->willReturn($this->connection); + + $this->managerRegistry = $this->createMock(ManagerRegistry::class); + $this->managerRegistry->method('getManager')->willReturn($this->entityManager); + + $this->wrapper = new CallableWrapper(new CallableWrapperResolver([ + TransactionalCallableWrapper::class => fn () => new TransactionalCallableWrapper($this->managerRegistry), + ])); + } + + public function testWrapInTransactionAndFlushes() + { + $handler = new TestHandler(); + + $this->connection->expects($this->once())->method('beginTransaction'); + $this->connection->expects($this->once())->method('commit'); + $this->entityManager->expects($this->once())->method('flush'); + + $result = $this->wrapper->call($handler->handle(...)); + $this->assertSame('success', $result); + } + + public function testTransactionIsRolledBackOnException() + { + $this->connection->expects($this->once())->method('beginTransaction'); + $this->connection->expects($this->once())->method('rollBack'); + + $handler = new TestHandler(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('A runtime error.'); + + $this->wrapper->call($handler->handleWithError(...)); + } + + public function testInvalidEntityManagerThrowsException() + { + $this->managerRegistry + ->method('getManager') + ->with('unknown_manager') + ->willThrowException(new \InvalidArgumentException()); + + $handler = new TestHandler(); + + $this->expectException(\InvalidArgumentException::class); + + $this->wrapper->call($handler->handleWithUnknownManager(...)); + } +} + +class TestHandler +{ + #[Transactional] + public function handle(): string + { + return 'success'; + } + + #[Transactional] + public function handleWithError(): void + { + throw new \RuntimeException('A runtime error.'); + } + + #[Transactional('unknown_manager')] + public function handleWithUnknownManager(): void + { + } +} diff --git a/src/Symfony/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index 1ca2abc4b13c4..e752036100990 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -27,6 +27,7 @@ "require-dev": { "symfony/cache": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", + "symfony/callable-wrapper": "^7.3", "symfony/dependency-injection": "^6.4|^7.0", "symfony/doctrine-messenger": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index d63b0172335d1..23683605e5e39 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add support for assets pre-compression * Rename `TranslationUpdateCommand` to `TranslationExtractCommand` * Add JsonEncoder services and configuration + * Add CallableWrapper services and support 7.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 862abe3ca5942..b326f84aa6c71 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -41,6 +41,7 @@ use Symfony\Component\Cache\DependencyInjection\CachePoolPass; use Symfony\Component\Cache\Marshaller\MarshallerInterface; use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\FileLocator; @@ -235,6 +236,10 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('fragment_renderer.php'); $loader->load('error_renderer.php'); + if (ContainerBuilder::willBeAvailable('symfony/callable-wrapper', CallableWrapperInterface::class, ['symfony/framework-bundle'])) { + $loader->load('callable_wrapper.php'); + } + if (!ContainerBuilder::willBeAvailable('symfony/clock', ClockInterface::class, ['symfony/framework-bundle'])) { $container->removeDefinition('clock'); $container->removeAlias(ClockInterface::class); @@ -683,6 +688,8 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('mime.mime_type_guesser'); $container->registerForAutoconfiguration(LoggerAwareInterface::class) ->addMethodCall('setLogger', [new Reference('logger')]); + $container->registerForAutoconfiguration(CallableWrapperInterface::class) + ->addTag('callable_wrapper'); $container->registerAttributeForAutoconfiguration(AsEventListener::class, static function (ChildDefinition $definition, AsEventListener $attribute, \ReflectionClass|\ReflectionMethod $reflector) { $tagAttributes = get_object_vars($attribute); diff --git a/src/Symfony/Bundle/FrameworkBundle/EventListener/WrapControllerListener.php b/src/Symfony/Bundle/FrameworkBundle/EventListener/WrapControllerListener.php new file mode 100644 index 0000000000000..2bbfafab445d3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/EventListener/WrapControllerListener.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\EventListener; + +use Symfony\Component\CallableWrapper\CallableWrapperInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * @author Yonel Ceruto + */ +class WrapControllerListener implements EventSubscriberInterface +{ + public function __construct( + private readonly CallableWrapperInterface $wrapper, + ) { + } + + public function decorate(ControllerArgumentsEvent $event): void + { + $event->setController($this->wrapper->wrap($event->getController()(...))); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER_ARGUMENTS => ['decorate', -1024], + ]; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index a1eb059bb01ce..cf166b3e7b72c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -30,6 +30,7 @@ use Symfony\Component\Cache\DependencyInjection\CachePoolClearerPass; use Symfony\Component\Cache\DependencyInjection\CachePoolPass; use Symfony\Component\Cache\DependencyInjection\CachePoolPrunerPass; +use Symfony\Component\CallableWrapper\DependencyInjection\CallableWrappersPass; use Symfony\Component\Config\Resource\ClassExistenceResource; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; @@ -181,6 +182,7 @@ public function build(ContainerBuilder $container): void // must be registered after MonologBundle's LoggerChannelPass $container->addCompilerPass(new ErrorLoggerCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); $container->addCompilerPass(new VirtualRequestStackPass()); + $this->addCompilerPassIfExists($container, CallableWrappersPass::class); if ($container->getParameter('kernel.debug')) { $container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 2); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/callable_wrapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/callable_wrapper.php new file mode 100644 index 0000000000000..5ea6e30eb6c59 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/callable_wrapper.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bundle\FrameworkBundle\EventListener\WrapControllerListener; +use Symfony\Component\CallableWrapper\CallableWrapper; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolverInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('callable_wrapper', CallableWrapper::class) + ->args([ + service(CallableWrapperResolverInterface::class), + ]) + + ->alias(CallableWrapperInterface::class, 'callable_wrapper') + + ->set('callable_wrapper.wrap_controller.listener', WrapControllerListener::class) + ->args([ + service('callable_wrapper'), + ]) + ->tag('kernel.event_subscriber') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php index 40f5b84caa2e0..9eae1fa9e4ef6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php @@ -82,6 +82,8 @@ ->abstract() ->args([ abstract_arg('bus handler resolver'), + false, + service('callable_wrapper')->ignoreOnInvalid(), ]) ->tag('monolog.logger', ['channel' => 'messenger']) ->call('setLogger', [service('logger')->ignoreOnInvalid()]) diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index afaa9b03b6832..3be47559fb9c1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -42,6 +42,7 @@ "symfony/console": "^6.4|^7.0", "symfony/clock": "^6.4|^7.0", "symfony/css-selector": "^6.4|^7.0", + "symfony/callable-wrapper": "^7.3", "symfony/dom-crawler": "^6.4|^7.0", "symfony/dotenv": "^6.4|^7.0", "symfony/polyfill-intl-icu": "~1.0", diff --git a/src/Symfony/Component/CallableWrapper/.gitattributes b/src/Symfony/Component/CallableWrapper/.gitattributes new file mode 100644 index 0000000000000..14c3c35940427 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/CallableWrapper/.gitignore b/src/Symfony/Component/CallableWrapper/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/CallableWrapper/Attribute/CallableWrapperAttribute.php b/src/Symfony/Component/CallableWrapper/Attribute/CallableWrapperAttribute.php new file mode 100644 index 0000000000000..90a3aa70c8820 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Attribute/CallableWrapperAttribute.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Attribute; + +use Symfony\Component\CallableWrapper\CallableWrapperInterface; + +/** + * Abstract class for all CallableWrapper attributes. + * + * @author Yonel Ceruto + * + * @experimental + */ +abstract class CallableWrapperAttribute implements CallableWrapperAttributeInterface +{ + public function wrappedBy(): string + { + if ($this instanceof CallableWrapperInterface) { + return $this::class; + } + + return $this::class.'CallableWrapper'; + } +} diff --git a/src/Symfony/Component/CallableWrapper/Attribute/CallableWrapperAttributeInterface.php b/src/Symfony/Component/CallableWrapper/Attribute/CallableWrapperAttributeInterface.php new file mode 100644 index 0000000000000..68e1de0196b7f --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Attribute/CallableWrapperAttributeInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Attribute; + +/** + * Interface for all CallableWrapper attributes. + * + * @author Yonel Ceruto + * + * @experimental + */ +interface CallableWrapperAttributeInterface +{ + /** + * @return string the class or id of the wrapper associated with this attribute + */ + public function wrappedBy(): string; +} diff --git a/src/Symfony/Component/CallableWrapper/CHANGELOG.md b/src/Symfony/Component/CallableWrapper/CHANGELOG.md new file mode 100644 index 0000000000000..0f29770616c5f --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.3 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/CallableWrapper/CallableWrapper.php b/src/Symfony/Component/CallableWrapper/CallableWrapper.php new file mode 100644 index 0000000000000..e0aec5dade6b4 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/CallableWrapper.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper; + +use Symfony\Component\CallableWrapper\Attribute\CallableWrapperAttributeInterface; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolver; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolverInterface; + +/** + * Wraps a callable with all the CallableWrappers linked to it. + * + * @author Yonel Ceruto + * + * @experimental + */ +class CallableWrapper implements CallableWrapperInterface +{ + public function __construct( + private readonly CallableWrapperResolverInterface $resolver = new CallableWrapperResolver([]), + ) { + } + + /** + * @throws \ReflectionException + */ + public function call(callable $callable, mixed ...$args): mixed + { + return $this->wrap($callable(...))(...$args); + } + + /** + * @throws \ReflectionException + */ + public function wrap(\Closure $func): \Closure + { + foreach ($this->getAttributes($func) as $attribute) { + $func = $this->resolver->resolve($attribute)->wrap($func, $attribute); + } + + return $func; + } + + /** + * Extract all wrapper attributes from a given callable. + * + * @return iterable + * + * @throws \ReflectionException + */ + private function getAttributes(\Closure $func): iterable + { + $function = new \ReflectionFunction($func); + + $attributes = $function->getAttributes(CallableWrapperAttributeInterface::class, \ReflectionAttribute::IS_INSTANCEOF); + + if (!$attributes && '__invoke' === $function->getName() && $class = $function->getClosureCalledClass()) { + $attributes = $class->getAttributes(CallableWrapperAttributeInterface::class, \ReflectionAttribute::IS_INSTANCEOF); + } + + foreach (array_reverse($attributes) as $attribute) { + yield $attribute->newInstance(); + } + } +} diff --git a/src/Symfony/Component/CallableWrapper/CallableWrapperInterface.php b/src/Symfony/Component/CallableWrapper/CallableWrapperInterface.php new file mode 100644 index 0000000000000..6b893ad3f0886 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/CallableWrapperInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper; + +/** + * Wraps the functionality of a given function. + * + * @author Yonel Ceruto + * + * @experimental + */ +interface CallableWrapperInterface +{ + public function wrap(\Closure $func): \Closure; +} diff --git a/src/Symfony/Component/CallableWrapper/DependencyInjection/CallableWrappersPass.php b/src/Symfony/Component/CallableWrapper/DependencyInjection/CallableWrappersPass.php new file mode 100644 index 0000000000000..c7c485938d4ba --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/DependencyInjection/CallableWrappersPass.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\DependencyInjection; + +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolver; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolverInterface; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +/** + * @author Yonel Ceruto + */ +final readonly class CallableWrappersPass implements CompilerPassInterface +{ + use PriorityTaggedServiceTrait; + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('callable_wrapper')) { + return; + } + + $tagName = new TaggedIteratorArgument('callable_wrapper', needsIndexes: true); + $wrappers = $this->findAndSortTaggedServices($tagName, $container); + + $resolver = (new Definition(CallableWrapperResolver::class)) + ->addArgument(ServiceLocatorTagPass::map($wrappers)) + ->addTag('container.service_locator'); + + $id = '.service_locator.'.ContainerBuilder::hash($resolver); + $container->setDefinition($id, $resolver); + + $container->setAlias(CallableWrapperResolverInterface::class, $id); + } +} diff --git a/src/Symfony/Component/CallableWrapper/LICENSE b/src/Symfony/Component/CallableWrapper/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/CallableWrapper/README.md b/src/Symfony/Component/CallableWrapper/README.md new file mode 100644 index 0000000000000..d7569194df5b7 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/README.md @@ -0,0 +1,72 @@ +CallableWrapper Component +========================= + +This component implements the [Decorator Pattern](https://en.wikipedia.org/wiki/Decorator_pattern) around +any [PHP callable](https://www.php.net/manual/en/language.types.callable.php), allowing you to: +* Execute logic before or after a callable is executed +* Skip the execution of a callable by returning earlier +* Modify the result of a callable + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Getting Started +--------------- + +```bash +composer require symfony/callable-wrapper +``` + +```php +use Symfony\Component\CallableWrapper\Attribute\CallableWrapperAttribute; +use Symfony\Component\CallableWrapper\CallableWrapper; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; + +#[\Attribute(\Attribute::TARGET_METHOD)] +class Debug extends CallableWrapperAttribute implements CallableWrapperInterface +{ + public function wrap(\Closure $func): \Closure + { + return function (mixed ...$args) use ($func): mixed + { + echo "Do something before\n"; + + $result = $func(...$args); + + echo "Do something after\n"; + + return $result; + }; + } +} + +class Greeting +{ + #[Debug] + public function sayHello(string $name): void + { + echo "Hello $name!\n"; + } +} + +$greeting = new Greeting(); +$CallableWrapper = new CallableWrapper(); +$CallableWrapper->call($greeting->sayHello(...), 'Fabien'); +``` +Output: +``` +Do something before +Hello Fabien! +Do something after +``` + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/callable-wrapper.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/CallableWrapper/Resolver/CallableWrapperResolver.php b/src/Symfony/Component/CallableWrapper/Resolver/CallableWrapperResolver.php new file mode 100644 index 0000000000000..93290e817e42b --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Resolver/CallableWrapperResolver.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Resolver; + +use Psr\Container\ContainerInterface; +use Symfony\Component\CallableWrapper\Attribute\CallableWrapperAttributeInterface; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; +use Symfony\Contracts\Service\ServiceLocatorTrait; + +/** + * @author Yonel Ceruto + * + * @internal + */ +class CallableWrapperResolver implements CallableWrapperResolverInterface, ContainerInterface +{ + use ServiceLocatorTrait; + + public function resolve(CallableWrapperAttributeInterface $attribute): CallableWrapperInterface + { + $id = $attribute->wrappedBy(); + + if ($this->has($id)) { + return $this->get($id); + } + + if ($attribute::class === $id && $attribute instanceof CallableWrapperInterface) { + return $attribute; + } + + if (class_exists($id)) { + return new $id(); + } + + return $this->get($id); // let it throw + } +} diff --git a/src/Symfony/Component/CallableWrapper/Resolver/CallableWrapperResolverInterface.php b/src/Symfony/Component/CallableWrapper/Resolver/CallableWrapperResolverInterface.php new file mode 100644 index 0000000000000..a98c39f829382 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Resolver/CallableWrapperResolverInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Resolver; + +use Symfony\Component\CallableWrapper\Attribute\CallableWrapperAttributeInterface; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; + +/** + * Resolves the wrapper linked to a given wrapper attribute. + * + * @author Yonel Ceruto + */ +interface CallableWrapperResolverInterface +{ + public function resolve(CallableWrapperAttributeInterface $attribute): CallableWrapperInterface; +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/CallableWrapperTest.php b/src/Symfony/Component/CallableWrapper/Tests/CallableWrapperTest.php new file mode 100644 index 0000000000000..ff1bbea9ab80b --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/CallableWrapperTest.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\CallableWrapper\CallableWrapper; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolver; +use Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper\Logging; +use Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper\LoggingCallableWrapper; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Controller\CreateTaskController; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Handler\InvokableMessageHandler; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Handler\Message; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Handler\MessageHandler; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Logger\TestLogger; + +class CallableWrapperTest extends TestCase +{ + private TestLogger $logger; + private CallableWrapper $wrapper; + + protected function setUp(): void + { + $this->logger = new TestLogger(); + $this->wrapper = new CallableWrapper(new CallableWrapperResolver([ + LoggingCallableWrapper::class => fn () => new LoggingCallableWrapper($this->logger), + ])); + } + + public function testTopWrappedFunc() + { + $func = $this->wrapper->wrap(MessageHandler::handle2(...)); + $reflection = new \ReflectionFunction($func); + + $this->assertSame(LoggingCallableWrapper::class, $reflection->getClosureThis()::class); + } + + public function testNestedWrappers() + { + $controller = new CreateTaskController(); + + $result = $this->wrapper->call($controller); + + $expectedRecords = [ + [ + 'level' => 'debug', + 'message' => 'Before calling func', + 'context' => ['args' => 0], + ], + [ + 'level' => 'debug', + 'message' => 'After calling func', + 'context' => ['result' => '{"id":1,"description":"Take a break!"}'], + ], + ]; + + $this->assertSame('{"id":1,"description":"Take a break!"}', $result); + $this->assertSame($expectedRecords, $this->logger->records); + } + + /** + * @dataProvider getCallableProvider + */ + public function testWrap(callable $callable, array $args, mixed $expectedResult, array $expectedRecords) + { + $result = $this->wrapper->call($callable, ...$args); + + $this->assertSame($expectedResult, $result); + $this->assertSame($expectedRecords, $this->logger->records); + } + + public function getCallableProvider(): iterable + { + yield 'non_decorated_function' => [ + strtoupper(...), ['bar'], 'BAR', [], + ]; + + #[Logging] + function foo(string $bar): string + { + return $bar; + } + + yield 'function' => [ + foo(...), ['bar'], 'bar', [ + [ + 'level' => 'debug', + 'message' => 'Before calling func', + 'context' => ['args' => 1], + ], + [ + 'level' => 'debug', + 'message' => 'After calling func', + 'context' => ['result' => 'bar'], + ], + ], + ]; + + $message = new Message(); + $handler = new MessageHandler(); + $invokableHandler = new InvokableMessageHandler(); + + yield 'invokable_object' => [ + $invokableHandler, [$message], $message, [ + [ + 'level' => 'debug', + 'message' => 'Before calling func', + 'context' => ['args' => 1], + ], + [ + 'level' => 'debug', + 'message' => 'After calling func', + 'context' => ['result' => $message], + ], + ], + ]; + + yield 'invokable_method' => [ + $handler, [$message], $message, [ + [ + 'level' => 'debug', + 'message' => 'Before calling func', + 'context' => ['args' => 1], + ], + [ + 'level' => 'debug', + 'message' => 'After calling func', + 'context' => ['result' => $message], + ], + ], + ]; + + yield 'array' => [ + [$handler, 'handle1'], [$message], $message, [ + [ + 'level' => 'info', + 'message' => 'Before calling func', + 'context' => ['args' => 1], + ], + [ + 'level' => 'info', + 'message' => 'After calling func', + 'context' => ['result' => $message], + ], + ], + ]; + + yield 'array_static_method' => [ + [$handler::class, 'handle2'], [$message], $message, [ + [ + 'level' => 'debug', + 'message' => 'Before calling func', + 'context' => ['args' => 1], + ], + [ + 'level' => 'debug', + 'message' => 'After calling func', + 'context' => ['result' => $message], + ], + ], + ]; + + yield 'first_class_static_method' => [ + $handler::handle2(...), [$message], $message, [ + [ + 'level' => 'debug', + 'message' => 'Before calling func', + 'context' => ['args' => 1], + ], + [ + 'level' => 'debug', + 'message' => 'After calling func', + 'context' => ['result' => $message], + ], + ], + ]; + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/DependencyInjection/CallableWrapperPassTest.php b/src/Symfony/Component/CallableWrapper/Tests/DependencyInjection/CallableWrapperPassTest.php new file mode 100644 index 0000000000000..350c93c7cbe0e --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/DependencyInjection/CallableWrapperPassTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\CallableWrapper\CallableWrapper; +use Symfony\Component\CallableWrapper\DependencyInjection\CallableWrappersPass; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolver; +use Symfony\Component\CallableWrapper\Resolver\CallableWrapperResolverInterface; +use Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper\LoggingCallableWrapper; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Handler\Message; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Handler\MessageHandler; +use Symfony\Component\CallableWrapper\Tests\Fixtures\Logger\TestLogger; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +class CallableWrapperPassTest extends TestCase +{ + public function testDefinition() + { + $container = $this->getDefinition(); + + $pass = new CallableWrappersPass(); + $pass->process($container); + + $argument = $container->getDefinition('callable_wrapper')->getArgument(0); + $resolver = $container->findDefinition((string) $argument); + + $this->assertSame(CallableWrapperResolver::class, $resolver->getClass()); + $this->assertSame([LoggingCallableWrapper::class], array_keys($resolver->getArgument(0))); + } + + public function testService() + { + $container = $this->getDefinition(); + $pass = new CallableWrappersPass(); + $pass->process($container); + + $container->compile(); + + $wrapper = $container->get('callable_wrapper'); + $this->assertInstanceOf(CallableWrapper::class, $wrapper); + + $message = new Message(); + $result = $wrapper->call(MessageHandler::handle2(...), $message); + $expectedRecords = [ + [ + 'level' => 'debug', + 'message' => 'Before calling func', + 'context' => ['args' => 1], + ], + [ + 'level' => 'debug', + 'message' => 'After calling func', + 'context' => ['result' => $message], + ], + ]; + $this->assertSame($message, $result); + $this->assertSame($expectedRecords, $container->get(TestLogger::class)->records); + } + + private function getDefinition(): ContainerBuilder + { + $container = new ContainerBuilder(); + + $container->register('callable_wrapper', CallableWrapper::class) + ->addArgument(new Reference(CallableWrapperResolverInterface::class)) + ->setPublic(true); + + $container->register(TestLogger::class) + ->setPublic(true); + + $container->register(LoggingCallableWrapper::class) + ->addArgument(new Reference(TestLogger::class)) + ->addTag('callable_wrapper'); + + return $container; + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/Json.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/Json.php new file mode 100644 index 0000000000000..07108a8caf1d5 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/Json.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper; + +use Symfony\Component\CallableWrapper\Attribute\CallableWrapperAttribute; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; + +#[\Attribute(\Attribute::TARGET_METHOD)] +final class Json extends CallableWrapperAttribute implements CallableWrapperInterface +{ + public function wrap(\Closure $func): \Closure + { + return static function (mixed ...$args) use ($func): string { + $result = $func(...$args); + + return json_encode($result, \JSON_THROW_ON_ERROR); + }; + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/Logging.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/Logging.php new file mode 100644 index 0000000000000..f2b253e01377d --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/Logging.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper; + +use Psr\Log\LogLevel; +use Symfony\Component\CallableWrapper\Attribute\CallableWrapperAttribute; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class Logging extends CallableWrapperAttribute +{ + public function __construct( + public string $level = LogLevel::DEBUG, + ) { + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/LoggingCallableWrapper.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/LoggingCallableWrapper.php new file mode 100644 index 0000000000000..54b672e1ddd05 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/CallableWrapper/LoggingCallableWrapper.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper; + +use Psr\Log\LoggerInterface; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; + +final readonly class LoggingCallableWrapper implements CallableWrapperInterface +{ + public function __construct( + private LoggerInterface $logger, + ) { + } + + public function wrap(\Closure $func, Logging $logging = new Logging()): \Closure + { + return function (mixed ...$args) use ($func, $logging): mixed { + $this->logger->log($logging->level, 'Before calling func', ['args' => \count($args)]); + + $result = $func(...$args); + + $this->logger->log($logging->level, 'After calling func', ['result' => $result]); + + return $result; + }; + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Controller/CreateTaskController.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Controller/CreateTaskController.php new file mode 100644 index 0000000000000..0ec631e291672 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Controller/CreateTaskController.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\Controller; + +use Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper\Json; +use Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper\Logging; + +final readonly class CreateTaskController +{ + #[Logging] + #[Json] + public function __invoke(): Task + { + return new Task(1, 'Take a break!'); + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Controller/Task.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Controller/Task.php new file mode 100644 index 0000000000000..3bceff71a8e74 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Controller/Task.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\Controller; + +final readonly class Task +{ + public function __construct( + public int $id, + public string $description, + ) { + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/InvokableMessageHandler.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/InvokableMessageHandler.php new file mode 100644 index 0000000000000..10b5b4081f445 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/InvokableMessageHandler.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\Handler; + +use Psr\Log\LogLevel; +use Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper\Logging; + +#[Logging] +final class InvokableMessageHandler +{ + public function __invoke(Message $message): Message + { + return $message; + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/Message.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/Message.php new file mode 100644 index 0000000000000..d805fe3264905 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/Message.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\Handler; + +final class Message +{ +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/MessageHandler.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/MessageHandler.php new file mode 100644 index 0000000000000..39653cad9e666 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Handler/MessageHandler.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\Handler; + +use Psr\Log\LogLevel; +use Symfony\Component\CallableWrapper\Tests\Fixtures\CallableWrapper\Logging; + +final class MessageHandler +{ + #[Logging] + public function __invoke(Message $message): Message + { + return $message; + } + + #[Logging(LogLevel::INFO)] + public function handle1(Message $message): Message + { + return $message; + } + + #[Logging] + public static function handle2(Message $message): Message + { + return $message; + } +} diff --git a/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Logger/TestLogger.php b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Logger/TestLogger.php new file mode 100644 index 0000000000000..e2e27bc5c8815 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/Tests/Fixtures/Logger/TestLogger.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\CallableWrapper\Tests\Fixtures\Logger; + +use Psr\Log\AbstractLogger; + +final class TestLogger extends AbstractLogger +{ + public array $records = []; + + public function log($level, $message, array $context = []): void + { + $this->records[] = [ + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } +} diff --git a/src/Symfony/Component/CallableWrapper/composer.json b/src/Symfony/Component/CallableWrapper/composer.json new file mode 100644 index 0000000000000..5fb45eff27151 --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/callable-wrapper", + "type": "library", + "description": "Wrap the functionality of other functions", + "keywords": ["wrapper", "decorator"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Yonel Ceruto", + "email": "open@yceruto.dev" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/dependency-injection": "^7.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\CallableWrapper\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/CallableWrapper/phpunit.xml.dist b/src/Symfony/Component/CallableWrapper/phpunit.xml.dist new file mode 100644 index 0000000000000..43433790c745d --- /dev/null +++ b/src/Symfony/Component/CallableWrapper/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/Symfony/Component/Messenger/CHANGELOG.md b/src/Symfony/Component/Messenger/CHANGELOG.md index 4d492dfd49524..c09f7e1695557 100644 --- a/src/Symfony/Component/Messenger/CHANGELOG.md +++ b/src/Symfony/Component/Messenger/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Add support for callable wrappers in message handlers + 7.2 --- diff --git a/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php b/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php index aa5ddab26c470..3c541451516ea 100644 --- a/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php +++ b/src/Symfony/Component/Messenger/Middleware/HandleMessageMiddleware.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Messenger\Middleware; use Psr\Log\LoggerAwareTrait; +use Symfony\Component\CallableWrapper\CallableWrapperInterface; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Exception\HandlerFailedException; use Symfony\Component\Messenger\Exception\LogicException; @@ -35,6 +36,7 @@ class HandleMessageMiddleware implements MiddlewareInterface public function __construct( private HandlersLocatorInterface $handlersLocator, private bool $allowNoHandlers = false, + private ?CallableWrapperInterface $wrapper = null, ) { } @@ -149,6 +151,10 @@ private function callHandler(callable $handler, object $message, ?Acknowledger $ $arguments = [...$arguments, ...$handlerArgumentsStamp->getAdditionalArguments()]; } + if ($this->wrapper) { + $handler = $this->wrapper->wrap($handler(...)); + } + return $handler(...$arguments); } }