From 91d08d4acd3d86809ce5270e222854e7facbb018 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Thu, 7 Dec 2023 10:33:45 +0100 Subject: [PATCH 01/46] [FeatureFlag] Propose a simple version --- composer.json | 1 + .../Twig/Extension/FeatureFlagExtension.php | 26 ++++ .../Twig/Extension/FeatureFlagRuntime.php | 39 ++++++ .../Compiler/FeatureFlagPass.php | 91 +++++++++++++ .../Compiler/UnusedTagsPass.php | 2 + .../DependencyInjection/Configuration.php | 14 ++ .../FrameworkExtension.php | 73 +++++++++++ .../FrameworkBundle/FrameworkBundle.php | 2 + .../Resources/config/feature_flag.php | 35 +++++ .../Resources/config/feature_flag_debug.php | 34 +++++ .../Resources/config/feature_flag_routing.php | 35 +++++ .../DependencyInjection/ConfigurationTest.php | 3 + .../DependencyInjection/TwigExtension.php | 5 + .../Resources/config/feature_flag.php | 27 ++++ .../views/Collector/feature_flag.html.twig | 122 ++++++++++++++++++ .../Component/FeatureFlag/.gitattributes | 4 + src/Symfony/Component/FeatureFlag/.gitignore | 3 + .../FeatureFlag/Attribute/AsFeature.php | 25 ++++ .../Component/FeatureFlag/CHANGELOG.md | 7 + .../FeatureCheckerDataCollector.php | 75 +++++++++++ .../Debug/TraceableFeatureChecker.php | 43 ++++++ .../Component/FeatureFlag/FeatureChecker.php | 37 ++++++ .../FeatureFlag/FeatureCheckerInterface.php | 19 +++ .../FeatureFlag/FeatureNotFoundException.php | 16 +++ .../Component/FeatureFlag/FeatureRegistry.php | 30 +++++ .../FeatureFlag/FeatureRegistryInterface.php | 22 ++++ src/Symfony/Component/FeatureFlag/LICENSE | 19 +++ src/Symfony/Component/FeatureFlag/README.md | 20 +++ .../Component/FeatureFlag/composer.json | 28 ++++ .../Component/FeatureFlag/phpunit.xml.dist | 30 +++++ 30 files changed, 887 insertions(+) create mode 100644 src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php create mode 100644 src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php create mode 100644 src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig create mode 100644 src/Symfony/Component/FeatureFlag/.gitattributes create mode 100644 src/Symfony/Component/FeatureFlag/.gitignore create mode 100644 src/Symfony/Component/FeatureFlag/Attribute/AsFeature.php create mode 100644 src/Symfony/Component/FeatureFlag/CHANGELOG.md create mode 100644 src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php create mode 100644 src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php create mode 100644 src/Symfony/Component/FeatureFlag/FeatureChecker.php create mode 100644 src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php create mode 100644 src/Symfony/Component/FeatureFlag/FeatureNotFoundException.php create mode 100644 src/Symfony/Component/FeatureFlag/FeatureRegistry.php create mode 100644 src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php create mode 100644 src/Symfony/Component/FeatureFlag/LICENSE create mode 100644 src/Symfony/Component/FeatureFlag/README.md create mode 100644 src/Symfony/Component/FeatureFlag/composer.json create mode 100644 src/Symfony/Component/FeatureFlag/phpunit.xml.dist diff --git a/composer.json b/composer.json index 20bcb49c4b782..b6a289bade4b2 100644 --- a/composer.json +++ b/composer.json @@ -75,6 +75,7 @@ "symfony/error-handler": "self.version", "symfony/event-dispatcher": "self.version", "symfony/expression-language": "self.version", + "symfony/feature-flag": "self.version", "symfony/filesystem": "self.version", "symfony/finder": "self.version", "symfony/form": "self.version", diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php new file mode 100644 index 0000000000000..652a83687444b --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +final class FeatureFlagExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('is_feature_enabled', [FeatureFlagRuntime::class, 'isFeatureEnabled']), + new TwigFunction('get_feature_value', [FeatureFlagRuntime::class, 'getFeatureValue']), + ]; + } +} diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php new file mode 100644 index 0000000000000..ca8f1ffedd500 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; + +final class FeatureFlagRuntime +{ + public function __construct(private readonly ?FeatureCheckerInterface $featureEnabledChecker = null) + { + } + + public function isFeatureEnabled(string $featureName, mixed $expectedValue = true): bool + { + if (null === $this->featureEnabledChecker) { + throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); + } + + return $this->featureEnabledChecker->isEnabled($featureName, $expectedValue); + } + + public function getFeatureValue(string $featureName): mixed + { + if (null === $this->featureEnabledChecker) { + throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); + } + + return $this->featureEnabledChecker->getValue($featureName); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php new file mode 100644 index 0000000000000..ff7fee78b9cb7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; + +class FeatureFlagPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('feature_flag.feature_checker')) { + return; + } + + $features = []; + foreach ($container->findTaggedServiceIds('feature_flag.feature') as $serviceId => $tags) { + $className = $this->getServiceClass($container, $serviceId); + $r = $container->getReflectionClass($className); + + if (null === $r) { + throw new \RuntimeException(sprintf('Invalid service "%s": class "%s" does not exist.', $serviceId, $className)); + } + + foreach ($tags as $tag) { + $featureName = ($tag['feature'] ?? '') ?: $className; + if (array_key_exists($featureName, $features)) { + throw new \RuntimeException(sprintf('Feature "%s" already defined.', $featureName)); + } + + $method = $tag['method'] ?? '__invoke'; + if (!$r->hasMethod($method)) { + throw new \RuntimeException(sprintf('Invalid feature strategy "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); + } + + $features[$featureName] = $container->setDefinition( + ".feature_flag.feature", + (new Definition(\Closure::class)) + ->setLazy(true) + ->setFactory([\Closure::class, 'fromCallable']) + ->setArguments([[new Reference($serviceId), $method]]), + ); + } + } + + $container->getDefinition('feature_flag.feature_registry') + ->setArgument('$features', $features) + ; + + if (!$container->has('feature_flag.data_collector')) { + return; + } + + foreach ($container->findTaggedServiceIds('feature_flag.feature_checker') as $serviceId => $tags) { + $container->register('debug.'.$serviceId, TraceableFeatureChecker::class) + ->setDecoratedService($serviceId) + ->setArguments([ + '$decorated' => new Reference('.inner'), + '$dataCollector' => new Reference('feature_flag.data_collector'), + ]) + ; + } + } + private function getServiceClass(ContainerBuilder $container, string $serviceId): string|null + { + while (true) { + $definition = $container->findDefinition($serviceId); + + if (!$definition->getClass() && $definition instanceof ChildDefinition) { + $serviceId = $definition->getParent(); + + continue; + } + + return $definition->getClass(); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 53361e3127e34..5591cf59c8c76 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -48,6 +48,8 @@ class UnusedTagsPass implements CompilerPassInterface 'controller.targeted_value_resolver', 'data_collector', 'event_dispatcher.dispatcher', + 'feature_flag.feature', + 'feature_flag.feature_checker', 'form.type', 'form.type_extension', 'form.type_guesser', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 6b168a2d4a0fd..d8910d1a1a6c4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -26,6 +26,7 @@ use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; use Symfony\Component\Form\Form; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; use Symfony\Component\HttpClient\HttpClient; @@ -184,6 +185,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addWebhookSection($rootNode, $enableIfStandalone); $this->addRemoteEventSection($rootNode, $enableIfStandalone); $this->addJsonStreamerSection($rootNode, $enableIfStandalone); + $this->addFeatureFlagSection($rootNode, $enableIfStandalone); return $treeBuilder; } @@ -2742,4 +2744,16 @@ private function addJsonStreamerSection(ArrayNodeDefinition $rootNode, callable ->end() ; } + + private function addFeatureFlagSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void + { + $rootNode + ->children() + ->arrayNode('feature_flag') + ->info('FeatureFlag configuration') + ->{$enableIfStandalone('symfony/feature-flag', FeatureCheckerInterface::class)}() + ->fixXmlConfig('feature_flag') + ->end() + ->end(); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index f5111cd1096f9..768fb8a2b1d09 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -79,6 +79,9 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\FeatureFlag\Attribute\AsFeature; +use Symfony\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureRegistryInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Glob; @@ -310,6 +313,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->readConfigEnabled('property_access', $container, $config['property_access']); $this->readConfigEnabled('profiler', $container, $config['profiler']); $this->readConfigEnabled('workflows', $container, $config['workflows']); + $this->readConfigEnabled('feature_flag', $container, $config['feature_flag']); // A translator must always be registered (as support is included by // default in the Form and Validator component). If disabled, an identity @@ -638,6 +642,13 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerHtmlSanitizerConfiguration($config['html_sanitizer'], $container, $loader); } + if ($this->readConfigEnabled('feature_flag', $container, $config['feature_flag'])) { + if (!class_exists(FeatureChecker::class)) { + throw new LogicException('FeatureFlag support cannot be enabled as the FeatureFlag component is not installed. Try running "composer require symfony/feature-flag".'); + } + $this->registerFeatureFlagConfiguration($config['feature_flag'], $container, $loader); + } + if (ContainerBuilder::willBeAvailable('symfony/mime', MimeTypes::class, ['symfony/framework-bundle'])) { $loader->load('mime_type.php'); } @@ -1011,6 +1022,10 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ $loader->load('serializer_debug.php'); } + if ($this->isInitializedConfigEnabled('feature_flag')) { + $loader->load('feature_flag_debug.php'); + } + $container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']); $container->setParameter('profiler_listener.only_main_requests', $config['only_main_requests']); @@ -3428,6 +3443,64 @@ private function registerHtmlSanitizerConfiguration(array $config, ContainerBuil } } + private function registerFeatureFlagConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('feature_flag.php'); + + $container->registerForAutoconfiguration(FeatureRegistryInterface::class) + ->addTag('feature_flag.feature_registry') + ; + + $container->registerAttributeForAutoconfiguration(AsFeature::class, + static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { + $featureName = $attribute->name; + + if ($reflector instanceof \ReflectionClass) { + $className = $reflector->getName(); + $method = $attribute->method; + + $featureName ??= $className; + } else { + $className = $reflector->getDeclaringClass()->getName(); + if (null !== $attribute->method && $reflector->getName() !== $attribute->method) { + throw new \LogicException(sprintf('Using the #[%s(method: %s)] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s)', AsFeature::class, $attribute->method, $className)); + } + + $method = $reflector->getName(); + $featureName ??= "{$className}::{$method}"; + } + + $definition->addTag('feature_flag.feature', [ + 'feature' => $featureName, + 'method' => $method, + ]); + }, + ); + + if (ContainerBuilder::willBeAvailable('symfony/routing', Router::class, ['symfony/framework-bundle', 'symfony/routing'])) { + $loader->load('feature_flag_routing.php'); + } + } + + private function resolveTrustedHeaders(array $headers): int + { + $trustedHeaders = 0; + + foreach ($headers as $h) { + $trustedHeaders |= match ($h) { + 'forwarded' => Request::HEADER_FORWARDED, + 'x-forwarded-for' => Request::HEADER_X_FORWARDED_FOR, + 'x-forwarded-host' => Request::HEADER_X_FORWARDED_HOST, + 'x-forwarded-proto' => Request::HEADER_X_FORWARDED_PROTO, + 'x-forwarded-port' => Request::HEADER_X_FORWARDED_PORT, + 'x-forwarded-prefix' => Request::HEADER_X_FORWARDED_PREFIX, + default => 0, + }; + } + + return $trustedHeaders; + } + public function getXsdValidationBasePath(): string|false { return \dirname(__DIR__).'/Resources/config/schema'; diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index faf2841f40105..62af063b7643b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -15,6 +15,7 @@ use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AssetsContextPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ContainerBuilderDebugDumpPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ErrorLoggerCompilerPass; +use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\FeatureFlagPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ProfilerPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\RemoveUnusedSessionMarshallingHandlerPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TestServiceContainerRealRefPass; @@ -190,6 +191,7 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new VirtualRequestStackPass()); $container->addCompilerPass(new TranslationUpdateCommandPass(), PassConfig::TYPE_BEFORE_REMOVING); $this->addCompilerPassIfExists($container, StreamablePass::class); + $container->addCompilerPass(new FeatureFlagPass()); if ($container->getParameter('kernel.debug')) { $container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 2); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php new file mode 100644 index 0000000000000..ce2d76abd7578 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php @@ -0,0 +1,35 @@ + + * + * 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\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; +use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\FeatureFlag\FeatureRegistryInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flag.feature_registry', FeatureRegistry::class) + ->args([ + '$features' => abstract_arg('Defined in FeatureFlagPass.'), + ]) + ->alias(FeatureRegistryInterface::class, 'feature_flag.feature_registry') + + ->set('feature_flag.feature_checker', FeatureChecker::class) + ->args([ + '$featureRegistry' => service('feature_flag.feature_registry'), + '$default' => false, + ]) + ->alias(FeatureCheckerInterface::class, 'feature_flag.feature_checker') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php new file mode 100644 index 0000000000000..17bee9c292747 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.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\Component\FeatureFlag\DataCollector\FeatureCheckerDataCollector; +use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flag.data_collector', FeatureCheckerDataCollector::class) + ->args([ + '$featureRegistry' => service('feature_flag.feature_registry'), + ]) + ->tag('data_collector', ['template' => '@WebProfiler/Collector/feature_flag.html.twig', 'id' => 'feature_flag']) + + + ->set('debug.feature_flag.feature_checker', TraceableFeatureChecker::class) + ->decorate('feature_flag.feature_checker') + ->args([ + '$decorated' => service('debug.feature_flag.feature_checker.inner'), + '$dataCollector' => service('feature_flag.data_collector'), + ]) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php new file mode 100644 index 0000000000000..e97bbf2c8eb40 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php @@ -0,0 +1,35 @@ + + * + * 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; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flag.routing_expression_language_function.is_enabled', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([ + [service('feature_flag.feature_checker'), 'isEnabled'], + ]) + ->tag('routing.expression_language_function', ['function' => 'is_feature_enabled']) + + + ->set('feature_flag.routing_expression_language_function.get_value', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([ + [service('feature_flag.feature_checker'), 'getValue'], + ]) + ->tag('routing.expression_language_function', ['function' => 'get_feature_value']) + + ->get('feature_flag.feature_checker') + ->tag('routing.condition_service', ['alias' => 'feature']) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index c8142e98ab1a7..48da7e052799d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -1014,6 +1014,9 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'json_streamer' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(JsonStreamWriter::class), ], + 'feature_flag' => [ + 'enabled' => false, + ], ]; } diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index db508873387b2..c3c0f14128630 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\FeatureFlag\FeatureChecker; use Symfony\Component\Form\AbstractRendererEngine; use Symfony\Component\Form\Form; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -107,6 +108,10 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('importmap.php'); } + if ($container::willBeAvailable('symfony/feature-flag', FeatureChecker::class, ['symfony/twig-bundle'])) { + $loader->load('feature_flag.php'); + } + $container->setParameter('twig.form.resources', $config['form_themes']); $container->setParameter('twig.default_path', $config['default_path']); $defaultTwigPath = $container->getParameterBag()->resolveValue($config['default_path']); diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php new file mode 100644 index 0000000000000..bbfd4b704c002 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.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\DependencyInjection\Loader\Configurator; + +use Symfony\Bridge\Twig\Extension\FeatureFlagExtension; +use Symfony\Bridge\Twig\Extension\FeatureFlagRuntime; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('twig.runtime.feature_flag', FeatureFlagRuntime::class) + ->args([service('feature_flag.feature_checker')->nullOnInvalid()]) + ->tag('twig.runtime') + + ->set('twig.extension.feature_flag', FeatureFlagExtension::class) + ->tag('twig.extension') + ; +}; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig new file mode 100644 index 0000000000000..23847535acfa4 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig @@ -0,0 +1,122 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block page_title 'Feature Flag' %} + +{% block toolbar %} + {% if 0 < collector.features|length %} + {% set icon %} + Feature Flag + {{ collector.features|length }} + {% endset %} + + {% set text %} +
+ {% for feature_name, data in collector.features|filter(d => d.is_enabled is not null) %} +
+ {{ loop.first ? 'Resolved features' : '' }} + + {{ feature_name }} + +
+
+ {% endfor %} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + Feature Flag + Feature Flag + +{% endblock %} + +{% block head %} + {{ parent() }} + +{% endblock %} + +{% block panel %} +

Features

+ {% if collector.features|length > 0 %} + + + + + + + + + + {% for feature_name, data in collector.features %} + {% set result = data['is_enabled'] is not null ? (data['is_enabled'] ? 'enabled' : 'disabled') : 'unknown' %} + + + + + + {% endfor %} + +
FeatureResolved valueEnabled
{{ feature_name }} + {% if data['value'].value is not null %} + {{ profiler_dump(data['value'], maxDepth=2) }} + {% else %} + Not resolved + {% endif %} + + {% if data['is_enabled'] is not null %} + + {{ result|capitalize }} + + {% else %} + + Not resolved + + {% endif %} +
+ {% else %} +
+

No features checked

+
+ {% endif %} +{% endblock %} diff --git a/src/Symfony/Component/FeatureFlag/.gitattributes b/src/Symfony/Component/FeatureFlag/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/FeatureFlag/.gitignore b/src/Symfony/Component/FeatureFlag/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.php b/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.php new file mode 100644 index 0000000000000..7143d6f93c441 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.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\FeatureFlag\Attribute; + +/** + * Service tag to autoconfigure feature flags. + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class AsFeature +{ + public function __construct( + public readonly ?string $name = null, + public readonly ?string $method = null, + ) { + } +} diff --git a/src/Symfony/Component/FeatureFlag/CHANGELOG.md b/src/Symfony/Component/FeatureFlag/CHANGELOG.md new file mode 100644 index 0000000000000..6eb821cdebc51 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php new file mode 100644 index 0000000000000..2fd74e344168f --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\DataCollector; + +use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; + +final class FeatureCheckerDataCollector extends DataCollector implements LateDataCollectorInterface +{ + private array $isEnabledLogs = []; + private array $valueLogs = []; + + public function __construct( + private readonly FeatureRegistry $featureRegistry, + ) + { + $this->data = [ + 'features' => [], + ]; + } + + public function collect(Request $request, Response $response, \Throwable $exception = null): void + { + } + + public function collectIsEnabled(string $featureName, bool $result): void + { + $this->isEnabledLogs[$featureName] = $result; + } + + public function collectValue(string $featureName, mixed $value): void + { + $this->valueLogs[$featureName] = $value; + } + + public function getName(): string + { + return 'feature_flag'; + } + + public function reset(): void + { + $this->data = [ + 'features' => [], + ]; + } + + public function lateCollect(): void + { + $this->data['features'] = []; + foreach ($this->featureRegistry->getNames() as $featureName) { + $this->data['features'][$featureName] = [ + 'is_enabled' => $this->isEnabledLogs[$featureName] ?? null, + 'value' => $this->cloneVar($this->valueLogs[$featureName] ?? null), + ]; + } + } + + public function getFeatures(): array + { + return $this->data['features']; + } +} diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php new file mode 100644 index 0000000000000..e76cf8195182c --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Debug; + +use Symfony\Component\FeatureFlag\DataCollector\FeatureCheckerDataCollector; +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; + +final class TraceableFeatureChecker implements FeatureCheckerInterface +{ + public function __construct( + private readonly FeatureCheckerInterface $decorated, + private readonly FeatureCheckerDataCollector $dataCollector, + ) { + } + + public function isEnabled(string $featureName, mixed $expectedValue = true): bool + { + $isEnabled = $this->decorated->isEnabled($featureName, $expectedValue); + + $this->dataCollector->collectIsEnabled($featureName, $isEnabled); + $this->dataCollector->collectValue($featureName, $this->decorated->getValue($featureName)); + + return $isEnabled; + } + + public function getValue(string $featureName): mixed + { + $value = $this->decorated->getValue($featureName); + + $this->dataCollector->collectValue($featureName, $value); + + return $value; + } +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php new file mode 100644 index 0000000000000..60589cc255b91 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag; + +final class FeatureChecker implements FeatureCheckerInterface +{ + private array $cache = []; + + public function __construct( + private readonly FeatureRegistry $featureRegistry, + private readonly mixed $default, + ) { + } + + public function isEnabled(string $featureName, mixed $expectedValue = true): bool + { + return $this->getValue($featureName) === $expectedValue; + } + + public function getValue(string $featureName): mixed + { + try { + return $this->cache[$featureName] ??= $this->featureRegistry->get($featureName)(); + } catch (FeatureNotFoundException) { + return $this->default; + } + } +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php new file mode 100644 index 0000000000000..72cbad6fef793 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag; + +interface FeatureCheckerInterface +{ + public function isEnabled(string $featureName, mixed $expectedValue = true): bool; + + public function getValue(string $featureName): mixed; +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureNotFoundException.php b/src/Symfony/Component/FeatureFlag/FeatureNotFoundException.php new file mode 100644 index 0000000000000..d0a265a45e8fd --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/FeatureNotFoundException.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\FeatureFlag; + +class FeatureNotFoundException extends \RuntimeException +{ +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php new file mode 100644 index 0000000000000..31fc5d205f062 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag; + +final class FeatureRegistry implements FeatureRegistryInterface +{ + /** + * @param array $features + */ + public function __construct(private readonly array $features) {} + + public function get(string $featureName): callable + { + return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found.', $featureName)); + } + + public function getNames(): array + { + return array_keys($this->features); + } +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php new file mode 100644 index 0000000000000..3f66129ad08b7 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag; + +interface FeatureRegistryInterface +{ + public function get(string $featureName): callable; + + /** + * @return array + */ + public function getNames(): array; +} diff --git a/src/Symfony/Component/FeatureFlag/LICENSE b/src/Symfony/Component/FeatureFlag/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-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/FeatureFlag/README.md b/src/Symfony/Component/FeatureFlag/README.md new file mode 100644 index 0000000000000..4519b5399f736 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/README.md @@ -0,0 +1,20 @@ +FeatureFlag Component +====================== + +The FeatureFlag component provides a service that checks if a feature is +enabled. A feature is a callable which returns a value compared to the +expected one to determine is the feature is enabled (mostly a boolean but not +limited to). + +**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). + +Resources +--------- + + * [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/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json new file mode 100644 index 0000000000000..ccda1320f7ec2 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -0,0 +1,28 @@ +{ + "name": "symfony/feature-flag", + "type": "library", + "description": "Provide a feature flag mechanism.", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\FeatureFlag\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/FeatureFlag/phpunit.xml.dist b/src/Symfony/Component/FeatureFlag/phpunit.xml.dist new file mode 100644 index 0000000000000..ff6572c57fe69 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + From 92596b39af87fd23540307360326737ec5762797 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 26 Dec 2023 14:12:22 +0100 Subject: [PATCH 02/46] extract FrameworkBundle integration --- .../Twig/Extension/FeatureFlagExtension.php | 26 ---- .../Twig/Extension/FeatureFlagRuntime.php | 39 ------ .../Compiler/FeatureFlagPass.php | 91 ------------- .../Compiler/UnusedTagsPass.php | 2 - .../Resources/config/feature_flag.php | 35 ----- .../Resources/config/feature_flag_debug.php | 34 ----- .../Resources/config/feature_flag_routing.php | 35 ----- .../DependencyInjection/TwigExtension.php | 5 - .../Resources/config/feature_flag.php | 27 ---- .../views/Collector/feature_flag.html.twig | 122 ------------------ 10 files changed, 416 deletions(-) delete mode 100644 src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php delete mode 100644 src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php delete mode 100644 src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php delete mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php deleted file mode 100644 index 652a83687444b..0000000000000 --- a/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Twig\Extension; - -use Twig\Extension\AbstractExtension; -use Twig\TwigFunction; - -final class FeatureFlagExtension extends AbstractExtension -{ - public function getFunctions(): array - { - return [ - new TwigFunction('is_feature_enabled', [FeatureFlagRuntime::class, 'isFeatureEnabled']), - new TwigFunction('get_feature_value', [FeatureFlagRuntime::class, 'getFeatureValue']), - ]; - } -} diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php deleted file mode 100644 index ca8f1ffedd500..0000000000000 --- a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Twig\Extension; - -use Symfony\Component\FeatureFlag\FeatureCheckerInterface; - -final class FeatureFlagRuntime -{ - public function __construct(private readonly ?FeatureCheckerInterface $featureEnabledChecker = null) - { - } - - public function isFeatureEnabled(string $featureName, mixed $expectedValue = true): bool - { - if (null === $this->featureEnabledChecker) { - throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); - } - - return $this->featureEnabledChecker->isEnabled($featureName, $expectedValue); - } - - public function getFeatureValue(string $featureName): mixed - { - if (null === $this->featureEnabledChecker) { - throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); - } - - return $this->featureEnabledChecker->getValue($featureName); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php deleted file mode 100644 index ff7fee78b9cb7..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; - -use Symfony\Component\DependencyInjection\ChildDefinition; -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; - -class FeatureFlagPass implements CompilerPassInterface -{ - public function process(ContainerBuilder $container): void - { - if (!$container->hasDefinition('feature_flag.feature_checker')) { - return; - } - - $features = []; - foreach ($container->findTaggedServiceIds('feature_flag.feature') as $serviceId => $tags) { - $className = $this->getServiceClass($container, $serviceId); - $r = $container->getReflectionClass($className); - - if (null === $r) { - throw new \RuntimeException(sprintf('Invalid service "%s": class "%s" does not exist.', $serviceId, $className)); - } - - foreach ($tags as $tag) { - $featureName = ($tag['feature'] ?? '') ?: $className; - if (array_key_exists($featureName, $features)) { - throw new \RuntimeException(sprintf('Feature "%s" already defined.', $featureName)); - } - - $method = $tag['method'] ?? '__invoke'; - if (!$r->hasMethod($method)) { - throw new \RuntimeException(sprintf('Invalid feature strategy "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); - } - - $features[$featureName] = $container->setDefinition( - ".feature_flag.feature", - (new Definition(\Closure::class)) - ->setLazy(true) - ->setFactory([\Closure::class, 'fromCallable']) - ->setArguments([[new Reference($serviceId), $method]]), - ); - } - } - - $container->getDefinition('feature_flag.feature_registry') - ->setArgument('$features', $features) - ; - - if (!$container->has('feature_flag.data_collector')) { - return; - } - - foreach ($container->findTaggedServiceIds('feature_flag.feature_checker') as $serviceId => $tags) { - $container->register('debug.'.$serviceId, TraceableFeatureChecker::class) - ->setDecoratedService($serviceId) - ->setArguments([ - '$decorated' => new Reference('.inner'), - '$dataCollector' => new Reference('feature_flag.data_collector'), - ]) - ; - } - } - private function getServiceClass(ContainerBuilder $container, string $serviceId): string|null - { - while (true) { - $definition = $container->findDefinition($serviceId); - - if (!$definition->getClass() && $definition instanceof ChildDefinition) { - $serviceId = $definition->getParent(); - - continue; - } - - return $definition->getClass(); - } - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 5591cf59c8c76..53361e3127e34 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -48,8 +48,6 @@ class UnusedTagsPass implements CompilerPassInterface 'controller.targeted_value_resolver', 'data_collector', 'event_dispatcher.dispatcher', - 'feature_flag.feature', - 'feature_flag.feature_checker', 'form.type', 'form.type_extension', 'form.type_guesser', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php deleted file mode 100644 index ce2d76abd7578..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * 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\Component\FeatureFlag\FeatureChecker; -use Symfony\Component\FeatureFlag\FeatureCheckerInterface; -use Symfony\Component\FeatureFlag\FeatureRegistry; -use Symfony\Component\FeatureFlag\FeatureRegistryInterface; - -return static function (ContainerConfigurator $container) { - $container->services() - - ->set('feature_flag.feature_registry', FeatureRegistry::class) - ->args([ - '$features' => abstract_arg('Defined in FeatureFlagPass.'), - ]) - ->alias(FeatureRegistryInterface::class, 'feature_flag.feature_registry') - - ->set('feature_flag.feature_checker', FeatureChecker::class) - ->args([ - '$featureRegistry' => service('feature_flag.feature_registry'), - '$default' => false, - ]) - ->alias(FeatureCheckerInterface::class, 'feature_flag.feature_checker') - ; -}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php deleted file mode 100644 index 17bee9c292747..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * 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\Component\FeatureFlag\DataCollector\FeatureCheckerDataCollector; -use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; - -return static function (ContainerConfigurator $container) { - $container->services() - - ->set('feature_flag.data_collector', FeatureCheckerDataCollector::class) - ->args([ - '$featureRegistry' => service('feature_flag.feature_registry'), - ]) - ->tag('data_collector', ['template' => '@WebProfiler/Collector/feature_flag.html.twig', 'id' => 'feature_flag']) - - - ->set('debug.feature_flag.feature_checker', TraceableFeatureChecker::class) - ->decorate('feature_flag.feature_checker') - ->args([ - '$decorated' => service('debug.feature_flag.feature_checker.inner'), - '$dataCollector' => service('feature_flag.data_collector'), - ]) - ; -}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php deleted file mode 100644 index e97bbf2c8eb40..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * 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; - -return static function (ContainerConfigurator $container) { - $container->services() - - ->set('feature_flag.routing_expression_language_function.is_enabled', \Closure::class) - ->factory([\Closure::class, 'fromCallable']) - ->args([ - [service('feature_flag.feature_checker'), 'isEnabled'], - ]) - ->tag('routing.expression_language_function', ['function' => 'is_feature_enabled']) - - - ->set('feature_flag.routing_expression_language_function.get_value', \Closure::class) - ->factory([\Closure::class, 'fromCallable']) - ->args([ - [service('feature_flag.feature_checker'), 'getValue'], - ]) - ->tag('routing.expression_language_function', ['function' => 'get_feature_value']) - - ->get('feature_flag.feature_checker') - ->tag('routing.condition_service', ['alias' => 'feature']) - ; -}; diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index c3c0f14128630..db508873387b2 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -19,7 +19,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\FeatureFlag\FeatureChecker; use Symfony\Component\Form\AbstractRendererEngine; use Symfony\Component\Form\Form; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -108,10 +107,6 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('importmap.php'); } - if ($container::willBeAvailable('symfony/feature-flag', FeatureChecker::class, ['symfony/twig-bundle'])) { - $loader->load('feature_flag.php'); - } - $container->setParameter('twig.form.resources', $config['form_themes']); $container->setParameter('twig.default_path', $config['default_path']); $defaultTwigPath = $container->getParameterBag()->resolveValue($config['default_path']); diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php deleted file mode 100644 index bbfd4b704c002..0000000000000 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * 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\Bridge\Twig\Extension\FeatureFlagExtension; -use Symfony\Bridge\Twig\Extension\FeatureFlagRuntime; - -return static function (ContainerConfigurator $container) { - $container->services() - - ->set('twig.runtime.feature_flag', FeatureFlagRuntime::class) - ->args([service('feature_flag.feature_checker')->nullOnInvalid()]) - ->tag('twig.runtime') - - ->set('twig.extension.feature_flag', FeatureFlagExtension::class) - ->tag('twig.extension') - ; -}; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig deleted file mode 100644 index 23847535acfa4..0000000000000 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig +++ /dev/null @@ -1,122 +0,0 @@ -{% extends '@WebProfiler/Profiler/layout.html.twig' %} - -{% block page_title 'Feature Flag' %} - -{% block toolbar %} - {% if 0 < collector.features|length %} - {% set icon %} - Feature Flag - {{ collector.features|length }} - {% endset %} - - {% set text %} -
- {% for feature_name, data in collector.features|filter(d => d.is_enabled is not null) %} -
- {{ loop.first ? 'Resolved features' : '' }} - - {{ feature_name }} - -
-
- {% endfor %} -
- {% endset %} - - {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }} - {% endif %} -{% endblock %} - -{% block menu %} - - Feature Flag - Feature Flag - -{% endblock %} - -{% block head %} - {{ parent() }} - -{% endblock %} - -{% block panel %} -

Features

- {% if collector.features|length > 0 %} - - - - - - - - - - {% for feature_name, data in collector.features %} - {% set result = data['is_enabled'] is not null ? (data['is_enabled'] ? 'enabled' : 'disabled') : 'unknown' %} - - - - - - {% endfor %} - -
FeatureResolved valueEnabled
{{ feature_name }} - {% if data['value'].value is not null %} - {{ profiler_dump(data['value'], maxDepth=2) }} - {% else %} - Not resolved - {% endif %} - - {% if data['is_enabled'] is not null %} - - {{ result|capitalize }} - - {% else %} - - Not resolved - - {% endif %} -
- {% else %} -
-

No features checked

-
- {% endif %} -{% endblock %} From a9431cbe6f937155f4f76c173fe0f6f4e66487ac Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 29 Dec 2023 14:40:43 +0100 Subject: [PATCH 03/46] fix typos, doc and default feature value --- src/Symfony/Component/FeatureFlag/FeatureChecker.php | 4 ++-- .../FeatureFlag/FeatureCheckerInterface.php | 4 ++++ .../FeatureFlag/FeatureRegistryInterface.php | 2 +- src/Symfony/Component/FeatureFlag/README.md | 12 +++++++----- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 60589cc255b91..5250f3556582a 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -16,8 +16,8 @@ final class FeatureChecker implements FeatureCheckerInterface private array $cache = []; public function __construct( - private readonly FeatureRegistry $featureRegistry, - private readonly mixed $default, + private readonly FeatureRegistryInterface $featureRegistry, + private readonly mixed $default = false, ) { } diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php index 72cbad6fef793..1edea568a2169 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php @@ -13,6 +13,10 @@ interface FeatureCheckerInterface { + /** + * @param string $featureName The name of the feature to check. + * @param mixed $expectedValue Comparison value required to determine if the feature is enabled. + */ public function isEnabled(string $featureName, mixed $expectedValue = true): bool; public function getValue(string $featureName): mixed; diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php index 3f66129ad08b7..8fbde824b0f75 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php @@ -16,7 +16,7 @@ interface FeatureRegistryInterface public function get(string $featureName): callable; /** - * @return array + * @return array An array of all registered feature names. */ public function getNames(): array; } diff --git a/src/Symfony/Component/FeatureFlag/README.md b/src/Symfony/Component/FeatureFlag/README.md index 4519b5399f736..0cb878bae681a 100644 --- a/src/Symfony/Component/FeatureFlag/README.md +++ b/src/Symfony/Component/FeatureFlag/README.md @@ -1,10 +1,12 @@ FeatureFlag Component -====================== +===================== -The FeatureFlag component provides a service that checks if a feature is -enabled. A feature is a callable which returns a value compared to the -expected one to determine is the feature is enabled (mostly a boolean but not -limited to). +The FeatureFlag component allows you to split the code execution flow by +enabling some features depending on context. + +It provides a service that checks if a feature is enabled. A feature is a +callable which returns a value compared to the expected one to determine is the +feature is enabled (mostly a boolean but not limited to). **This Component is experimental**. [Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) From 978f100f4b56c4e4a36fb6811f18b3bb04ca32ae Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 2 Jan 2024 13:35:56 +0100 Subject: [PATCH 04/46] review --- .../DataCollector/FeatureCheckerDataCollector.php | 13 +++++-------- .../{ => Exception}/FeatureNotFoundException.php | 2 +- .../Component/FeatureFlag/FeatureRegistry.php | 2 ++ 3 files changed, 8 insertions(+), 9 deletions(-) rename src/Symfony/Component/FeatureFlag/{ => Exception}/FeatureNotFoundException.php (85%) diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php index 2fd74e344168f..a2b7afd7970b7 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php @@ -24,11 +24,7 @@ final class FeatureCheckerDataCollector extends DataCollector implements LateDat public function __construct( private readonly FeatureRegistry $featureRegistry, - ) - { - $this->data = [ - 'features' => [], - ]; + ) { } public function collect(Request $request, Response $response, \Throwable $exception = null): void @@ -52,9 +48,10 @@ public function getName(): string public function reset(): void { - $this->data = [ - 'features' => [], - ]; + parent::reset(); + + $this->isEnabledLogs = []; + $this->valueLogs = []; } public function lateCollect(): void diff --git a/src/Symfony/Component/FeatureFlag/FeatureNotFoundException.php b/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php similarity index 85% rename from src/Symfony/Component/FeatureFlag/FeatureNotFoundException.php rename to src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php index d0a265a45e8fd..0724cb11cd05e 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureNotFoundException.php +++ b/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\FeatureFlag; +namespace Symfony\Component\FeatureFlag\Exception; class FeatureNotFoundException extends \RuntimeException { diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php index 31fc5d205f062..c157b34b874a1 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -11,6 +11,8 @@ namespace Symfony\Component\FeatureFlag; +use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; + final class FeatureRegistry implements FeatureRegistryInterface { /** From d7ee4d6ac1b50ba8aa55eca36624558c803c4428 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 2 Jan 2024 13:36:38 +0100 Subject: [PATCH 05/46] add some tests --- .../Component/FeatureFlag/FeatureChecker.php | 2 + .../FeatureFlag/Tests/FeatureCheckerTest.php | 79 +++++++++++++++++++ .../FeatureFlag/Tests/FeatureRegistryTest.php | 47 +++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php create mode 100644 src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 5250f3556582a..0ee09249a68bb 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -11,6 +11,8 @@ namespace Symfony\Component\FeatureFlag; +use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; + final class FeatureChecker implements FeatureCheckerInterface { private array $cache = []; diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php new file mode 100644 index 0000000000000..47b1b83d70c5c --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureRegistry; + +class FeatureCheckerTest extends TestCase +{ + public function testGetValue() + { + $featureChecker = new FeatureChecker(new FeatureRegistry([ + 'feature_integer' => fn () => 42, + 'feature_random' => fn () => random_int(1, 42), + ])); + + $this->assertSame(42, $featureChecker->getValue('feature_integer')); + $this->assertIsInt($value = $featureChecker->getValue('feature_random')); + $this->assertSame($value, $featureChecker->getValue('feature_random')); + } + + public function testGetDefaultValue() + { + $featureRegistry = new FeatureRegistry([ + 'existing_feature' => fn () => 1, + ]); + + $this->assertSame(1, (new FeatureChecker($featureRegistry))->getValue('existing_feature')); + $this->assertSame(1, (new FeatureChecker($featureRegistry, 42))->getValue('existing_feature')); + + $this->assertSame(false, (new FeatureChecker($featureRegistry))->getValue('unknown_feature')); + $this->assertSame(42, (new FeatureChecker($featureRegistry, 42))->getValue('unknown_feature')); + } + + public function testIsEnabled() + { + $featureChecker = new FeatureChecker(new FeatureRegistry([ + 'feature_true' => fn () => true, + 'feature_false' => fn () => false, + 'feature_integer' => fn () => 1, + ])); + + $this->assertTrue($featureChecker->isEnabled('feature_true')); + $this->assertFalse($featureChecker->isEnabled('feature_false')); + $this->assertFalse($featureChecker->isEnabled('feature_integer')); + $this->assertFalse($featureChecker->isEnabled('unknown_feature')); + } + + /** + * @dataProvider provideIsEnabledWithExpectedValue + */ + public function testIsEnabledWithExpectedValue(string $featureName, mixed $expectedFeatureValue, bool $expectedResult) + { + $featureChecker = new FeatureChecker(new FeatureRegistry([ + 'feature_true' => fn () => true, + 'feature_integer' => fn () => 1, + ])); + + $this->assertSame($expectedResult, $featureChecker->isEnabled($featureName, $expectedFeatureValue)); + } + + public static function provideIsEnabledWithExpectedValue() + { + yield 'with the same boolean' => ['feature_true', true, true]; + yield 'with the same integer' => ['feature_integer', 1, true]; + yield 'with a different boolean' => ['feature_true', false, false]; + yield 'with different types' => ['feature_integer', true, false]; + } +} diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php new file mode 100644 index 0000000000000..84749cf997cfb --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; +use Symfony\Component\FeatureFlag\FeatureRegistry; + +class FeatureRegistryTest extends TestCase +{ + private FeatureRegistry $featureRegistry; + + protected function setUp(): void + { + $this->featureRegistry = new FeatureRegistry([ + 'first_feature' => fn () => true, + 'second_feature' => fn () => false, + ]); + } + + public function testGet() + { + $this->assertIsCallable($this->featureRegistry->get('first_feature')); + } + + public function testGetNotFound() + { + $this->expectException(FeatureNotFoundException::class); + $this->expectExceptionMessage('Feature "unknown_feature" not found.'); + + $this->featureRegistry->get('unknown_feature'); + } + + public function testGetNames() + { + $this->assertSame(['first_feature', 'second_feature'], $this->featureRegistry->getNames()); + } +} From 2e1e2e5bad0a767c60e640caecd4df94ef427fa8 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 2 Jan 2024 13:44:53 +0100 Subject: [PATCH 06/46] happy new year --- src/Symfony/Component/FeatureFlag/LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/FeatureFlag/LICENSE b/src/Symfony/Component/FeatureFlag/LICENSE index 3ed9f412ce53d..e374a5c8339d3 100644 --- a/src/Symfony/Component/FeatureFlag/LICENSE +++ b/src/Symfony/Component/FeatureFlag/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2023-present Fabien Potencier +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 From c6f5df99b2915ac7e30a2b16b2063d9b4b2ac72b Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 2 Jan 2024 13:47:16 +0100 Subject: [PATCH 07/46] cs --- src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php | 4 ++-- src/Symfony/Component/FeatureFlag/FeatureRegistry.php | 4 +++- .../Component/FeatureFlag/FeatureRegistryInterface.php | 2 +- .../Component/FeatureFlag/Tests/FeatureCheckerTest.php | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php index 1edea568a2169..e9ba98b7c306a 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php @@ -14,8 +14,8 @@ interface FeatureCheckerInterface { /** - * @param string $featureName The name of the feature to check. - * @param mixed $expectedValue Comparison value required to determine if the feature is enabled. + * @param string $featureName the name of the feature to check + * @param mixed $expectedValue comparison value required to determine if the feature is enabled */ public function isEnabled(string $featureName, mixed $expectedValue = true): bool; diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php index c157b34b874a1..a01ff36ee25aa 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -18,7 +18,9 @@ final class FeatureRegistry implements FeatureRegistryInterface /** * @param array $features */ - public function __construct(private readonly array $features) {} + public function __construct(private readonly array $features) + { + } public function get(string $featureName): callable { diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php index 8fbde824b0f75..7d48458f052e3 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php @@ -16,7 +16,7 @@ interface FeatureRegistryInterface public function get(string $featureName): callable; /** - * @return array An array of all registered feature names. + * @return array an array of all registered feature names */ public function getNames(): array; } diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php index 47b1b83d70c5c..60494218905d0 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php @@ -38,7 +38,7 @@ public function testGetDefaultValue() $this->assertSame(1, (new FeatureChecker($featureRegistry))->getValue('existing_feature')); $this->assertSame(1, (new FeatureChecker($featureRegistry, 42))->getValue('existing_feature')); - $this->assertSame(false, (new FeatureChecker($featureRegistry))->getValue('unknown_feature')); + $this->assertFalse((new FeatureChecker($featureRegistry))->getValue('unknown_feature')); $this->assertSame(42, (new FeatureChecker($featureRegistry, 42))->getValue('unknown_feature')); } From 5daeb43d683919d2266057864909eaae20f883ae Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 3 Jan 2024 16:45:02 +0100 Subject: [PATCH 08/46] add base exception --- .../Exception/ExceptionInterface.php | 16 ++++++++++++++++ .../Exception/FeatureNotFoundException.php | 2 +- .../FeatureFlag/Exception/RuntimeException.php | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php diff --git a/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php b/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..85911ef5c83d2 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.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\FeatureFlag\Exception; + +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php b/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php index 0724cb11cd05e..821b1b1d00ecd 100644 --- a/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php +++ b/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php @@ -11,6 +11,6 @@ namespace Symfony\Component\FeatureFlag\Exception; -class FeatureNotFoundException extends \RuntimeException +class FeatureNotFoundException extends RuntimeException { } diff --git a/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php b/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php new file mode 100644 index 0000000000000..7dcd8e1cef009 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Exception; + +use Symfony\Component\Workflow\Exception\ExceptionInterface; + +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} From 2d41a5768daed18d4feb0f8665812632f1e179d0 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 3 Jan 2024 22:20:55 +0100 Subject: [PATCH 09/46] refact DataCollector --- ...ector.php => FeatureFlagDataCollector.php} | 49 +++++++--------- .../Debug/TraceableFeatureChecker.php | 35 +++++++---- .../Component/FeatureFlag/FeatureChecker.php | 2 +- .../FeatureFlagDataCollectorTest.php | 58 +++++++++++++++++++ .../Debug/TraceableFeatureCheckerTest.php | 52 +++++++++++++++++ 5 files changed, 156 insertions(+), 40 deletions(-) rename src/Symfony/Component/FeatureFlag/DataCollector/{FeatureCheckerDataCollector.php => FeatureFlagDataCollector.php} (56%) create mode 100644 src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php create mode 100644 src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php similarity index 56% rename from src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php rename to src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index a2b7afd7970b7..84f7cb9aad247 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureCheckerDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -11,34 +11,42 @@ namespace Symfony\Component\FeatureFlag\DataCollector; -use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; +use Symfony\Component\FeatureFlag\FeatureRegistryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; -final class FeatureCheckerDataCollector extends DataCollector implements LateDataCollectorInterface +final class FeatureFlagDataCollector extends DataCollector implements LateDataCollectorInterface { - private array $isEnabledLogs = []; - private array $valueLogs = []; - public function __construct( - private readonly FeatureRegistry $featureRegistry, + private readonly FeatureRegistryInterface $featureRegistry, + private readonly TraceableFeatureChecker $featureChecker, ) { + $this->reset(); } public function collect(Request $request, Response $response, \Throwable $exception = null): void { } - public function collectIsEnabled(string $featureName, bool $result): void + public function lateCollect(): void { - $this->isEnabledLogs[$featureName] = $result; + $checks = $this->featureChecker->getChecks(); + $values = $this->featureChecker->getValues(); + + foreach ($this->featureRegistry->getNames() as $featureName) { + $this->data['features'][$featureName] = [ + 'is_enabled' => $checks[$featureName] ?? null, + 'value' => $this->cloneVar($values[$featureName] ?? null), + ]; + } } - public function collectValue(string $featureName, mixed $value): void + public function getFeatures(): array { - $this->valueLogs[$featureName] = $value; + return $this->data['features']; } public function getName(): string @@ -48,25 +56,8 @@ public function getName(): string public function reset(): void { - parent::reset(); - - $this->isEnabledLogs = []; - $this->valueLogs = []; - } - - public function lateCollect(): void - { - $this->data['features'] = []; - foreach ($this->featureRegistry->getNames() as $featureName) { - $this->data['features'][$featureName] = [ - 'is_enabled' => $this->isEnabledLogs[$featureName] ?? null, - 'value' => $this->cloneVar($this->valueLogs[$featureName] ?? null), - ]; - } - } + $this->data = ['features' => []]; - public function getFeatures(): array - { - return $this->data['features']; + $this->featureChecker->reset(); } } diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index e76cf8195182c..68c5dcc5c3d0c 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -11,33 +11,48 @@ namespace Symfony\Component\FeatureFlag\Debug; -use Symfony\Component\FeatureFlag\DataCollector\FeatureCheckerDataCollector; use Symfony\Component\FeatureFlag\FeatureCheckerInterface; +use Symfony\Contracts\Service\ResetInterface; -final class TraceableFeatureChecker implements FeatureCheckerInterface +final class TraceableFeatureChecker implements FeatureCheckerInterface, ResetInterface { + /** @var array */ + private array $checks = []; + /** @var array */ + private array $values = []; + public function __construct( private readonly FeatureCheckerInterface $decorated, - private readonly FeatureCheckerDataCollector $dataCollector, ) { } public function isEnabled(string $featureName, mixed $expectedValue = true): bool { - $isEnabled = $this->decorated->isEnabled($featureName, $expectedValue); - - $this->dataCollector->collectIsEnabled($featureName, $isEnabled); - $this->dataCollector->collectValue($featureName, $this->decorated->getValue($featureName)); + $isEnabled = $this->checks[$featureName] = $this->decorated->isEnabled($featureName, $expectedValue); + // Force logging value. It has no cost since value is cached by decorated FeatureChecker. + $this->getValue($featureName); return $isEnabled; } public function getValue(string $featureName): mixed { - $value = $this->decorated->getValue($featureName); + return $this->values[$featureName] = $this->decorated->getValue($featureName); + } - $this->dataCollector->collectValue($featureName, $value); + public function getChecks(): array + { + return $this->checks; + } - return $value; + public function getValues(): array + { + return $this->values; + } + + public function reset(): void + { + $this->checks = []; + $this->values = []; } } diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 0ee09249a68bb..8f5c55206579a 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -33,7 +33,7 @@ public function getValue(string $featureName): mixed try { return $this->cache[$featureName] ??= $this->featureRegistry->get($featureName)(); } catch (FeatureNotFoundException) { - return $this->default; + return $this->cache[$featureName] ??= $this->default; } } } diff --git a/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php new file mode 100644 index 0000000000000..1b2151d210383 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Tests\DataCollector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\DataCollector\FeatureFlagDataCollector; +use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; +use Symfony\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureRegistry; + +class FeatureFlagDataCollectorTest extends TestCase +{ + public function testLateCollect() + { + $featureRegistry = new FeatureRegistry([ + 'feature_true' => fn () => true, + 'feature_integer' => fn () => 42, + 'feature_random' => fn () => random_int(1, 42), + ]); + $traceableFeatureChecker = new TraceableFeatureChecker(new FeatureChecker($featureRegistry)); + $dataCollector = new FeatureFlagDataCollector($featureRegistry, $traceableFeatureChecker); + + $traceableFeatureChecker->isEnabled('feature_true'); + $traceableFeatureChecker->isEnabled('feature_integer', 1); + + $this->assertSame([], $dataCollector->getFeatures()); + + $dataCollector->lateCollect(); + + $data = array_map(fn ($a) => array_merge($a, ['value' => $a['value']->getValue()]), $dataCollector->getFeatures()); + $this->assertSame( + [ + 'feature_true' => [ + 'is_enabled' => true, + 'value' => true, + ], + 'feature_integer' => [ + 'is_enabled' => false, + 'value' => 42, + ], + 'feature_random' => [ + 'is_enabled' => null, + 'value' => null, + ], + ], + $data, + ); + } +} diff --git a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php new file mode 100644 index 0000000000000..203ad07ff5fca --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Tests\Debug; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; +use Symfony\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureRegistry; + +class TraceableFeatureCheckerTest extends TestCase +{ + public function testTraces() + { + $featureChecker = new FeatureChecker(new FeatureRegistry([ + 'feature_true' => fn () => true, + 'feature_integer' => fn () => 42, + 'feature_random' => fn () => random_int(1, 42), + ])); + $traceableFeatureChecker = new TraceableFeatureChecker($featureChecker); + + $this->assertTrue($traceableFeatureChecker->isEnabled('feature_true')); + $this->assertFalse($traceableFeatureChecker->isEnabled('feature_integer', 1)); + $this->assertFalse($traceableFeatureChecker->isEnabled('unknown_feature')); + + $this->assertSame( + [ + 'feature_true' => true, + 'feature_integer' => false, + 'unknown_feature' => false, + + ], + $traceableFeatureChecker->getChecks(), + ); + $this->assertSame( + [ + 'feature_true' => true, + 'feature_integer' => 42, + 'unknown_feature' => false, + ], + $traceableFeatureChecker->getValues(), + ); + } +} From b2e690c391239d1b4821acd5c3d71f094e58d41a Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 3 Jan 2024 22:36:48 +0100 Subject: [PATCH 10/46] add isDisabled --- src/Symfony/Component/FeatureFlag/FeatureChecker.php | 5 +++++ .../FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php | 1 - .../Component/FeatureFlag/Tests/FeatureCheckerTest.php | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 8f5c55206579a..131cca1882062 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -28,6 +28,11 @@ public function isEnabled(string $featureName, mixed $expectedValue = true): boo return $this->getValue($featureName) === $expectedValue; } + public function isDisabled(string $featureName, mixed $expectedValue = true): bool + { + return !$this->isEnabled($featureName, $expectedValue); + } + public function getValue(string $featureName): mixed { try { diff --git a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php index 203ad07ff5fca..2439ef9c51fbe 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php @@ -36,7 +36,6 @@ public function testTraces() 'feature_true' => true, 'feature_integer' => false, 'unknown_feature' => false, - ], $traceableFeatureChecker->getChecks(), ); diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php index 60494218905d0..6c9fd03fe75fd 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php @@ -54,6 +54,11 @@ public function testIsEnabled() $this->assertFalse($featureChecker->isEnabled('feature_false')); $this->assertFalse($featureChecker->isEnabled('feature_integer')); $this->assertFalse($featureChecker->isEnabled('unknown_feature')); + + $this->assertFalse($featureChecker->isDisabled('feature_true')); + $this->assertTrue($featureChecker->isDisabled('feature_false')); + $this->assertTrue($featureChecker->isDisabled('feature_integer')); + $this->assertTrue($featureChecker->isDisabled('unknown_feature')); } /** From 11b2a364219e908eb78e44528be5c8772621aba8 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Thu, 4 Jan 2024 09:27:45 +0100 Subject: [PATCH 11/46] fix collector --- .../DataCollector/FeatureFlagDataCollector.php | 11 ++--------- .../FeatureFlag/Debug/TraceableFeatureChecker.php | 9 +-------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index 84f7cb9aad247..bb35698e837b5 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -24,7 +24,6 @@ public function __construct( private readonly FeatureRegistryInterface $featureRegistry, private readonly TraceableFeatureChecker $featureChecker, ) { - $this->reset(); } public function collect(Request $request, Response $response, \Throwable $exception = null): void @@ -36,6 +35,7 @@ public function lateCollect(): void $checks = $this->featureChecker->getChecks(); $values = $this->featureChecker->getValues(); + $this->data['features'] = []; foreach ($this->featureRegistry->getNames() as $featureName) { $this->data['features'][$featureName] = [ 'is_enabled' => $checks[$featureName] ?? null, @@ -46,18 +46,11 @@ public function lateCollect(): void public function getFeatures(): array { - return $this->data['features']; + return $this->data['features'] ?? []; } public function getName(): string { return 'feature_flag'; } - - public function reset(): void - { - $this->data = ['features' => []]; - - $this->featureChecker->reset(); - } } diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 68c5dcc5c3d0c..506b6c9188c71 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -12,9 +12,8 @@ namespace Symfony\Component\FeatureFlag\Debug; use Symfony\Component\FeatureFlag\FeatureCheckerInterface; -use Symfony\Contracts\Service\ResetInterface; -final class TraceableFeatureChecker implements FeatureCheckerInterface, ResetInterface +final class TraceableFeatureChecker implements FeatureCheckerInterface { /** @var array */ private array $checks = []; @@ -49,10 +48,4 @@ public function getValues(): array { return $this->values; } - - public function reset(): void - { - $this->checks = []; - $this->values = []; - } } From 23d41e4c450fdd2d7aa71ff07cda8b9636ff98ff Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Thu, 4 Jan 2024 09:40:46 +0100 Subject: [PATCH 12/46] fix forgotten method in FeatureCheckerInterface --- .../Component/FeatureFlag/FeatureCheckerInterface.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php index e9ba98b7c306a..0ff14b7bb9ce1 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php @@ -19,5 +19,11 @@ interface FeatureCheckerInterface */ public function isEnabled(string $featureName, mixed $expectedValue = true): bool; + /** + * @param string $featureName the name of the feature to check + * @param mixed $expectedValue comparison value required to determine if the feature is disabled + */ + public function isDisabled(string $featureName, mixed $expectedValue = true): bool; + public function getValue(string $featureName): mixed; } From d0b1914d524c23dd7ee1409ba106daaae654f1e5 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Thu, 4 Jan 2024 10:08:19 +0100 Subject: [PATCH 13/46] fix interface --- .../Component/FeatureFlag/Debug/TraceableFeatureChecker.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 506b6c9188c71..5b258b1ab33d9 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -34,6 +34,11 @@ public function isEnabled(string $featureName, mixed $expectedValue = true): boo return $isEnabled; } + public function isDisabled(string $featureName, mixed $expectedValue = true): bool + { + return !$this->isEnabled($featureName, $expectedValue); + } + public function getValue(string $featureName): mixed { return $this->values[$featureName] = $this->decorated->getValue($featureName); From 13ea1425e03b272176a046cd62fe7346cdf640ca Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Mon, 29 Jan 2024 20:50:32 +0100 Subject: [PATCH 14/46] remove exception catching --- .../Component/FeatureFlag/FeatureChecker.php | 13 ++- .../Component/FeatureFlag/FeatureRegistry.php | 9 +- .../FeatureFlag/FeatureRegistryInterface.php | 14 +++- .../Debug/TraceableFeatureCheckerTest.php | 3 - .../FeatureFlag/Tests/FeatureCheckerTest.php | 82 +++++++++---------- .../FeatureFlag/Tests/FeatureRegistryTest.php | 7 ++ 6 files changed, 70 insertions(+), 58 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 131cca1882062..ee4da1ac81e46 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -11,20 +11,21 @@ namespace Symfony\Component\FeatureFlag; -use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; - final class FeatureChecker implements FeatureCheckerInterface { private array $cache = []; public function __construct( private readonly FeatureRegistryInterface $featureRegistry, - private readonly mixed $default = false, ) { } public function isEnabled(string $featureName, mixed $expectedValue = true): bool { + if (!$this->featureRegistry->has($featureName)) { + return false; + } + return $this->getValue($featureName) === $expectedValue; } @@ -35,10 +36,6 @@ public function isDisabled(string $featureName, mixed $expectedValue = true): bo public function getValue(string $featureName): mixed { - try { - return $this->cache[$featureName] ??= $this->featureRegistry->get($featureName)(); - } catch (FeatureNotFoundException) { - return $this->cache[$featureName] ??= $this->default; - } + return $this->cache[$featureName] ??= $this->featureRegistry->get($featureName)(); } } diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php index a01ff36ee25aa..5952c2b4c9487 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -22,9 +22,14 @@ public function __construct(private readonly array $features) { } - public function get(string $featureName): callable + public function has(string $id): bool { - return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found.', $featureName)); + return array_key_exists($id, $this->features); + } + + public function get(string $id): callable + { + return $this->features[$id] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found.', $id)); } public function getNames(): array diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php index 7d48458f052e3..6a41294943d89 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php @@ -11,12 +11,20 @@ namespace Symfony\Component\FeatureFlag; -interface FeatureRegistryInterface +use Psr\Container\ContainerInterface; +use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; + +interface FeatureRegistryInterface extends ContainerInterface { - public function get(string $featureName): callable; + public function has(string $id): bool; + + /** + * @throws FeatureNotFoundException When the feature is not registered + */ + public function get(string $id): callable; /** - * @return array an array of all registered feature names + * @return array An array of all registered feature names */ public function getNames(): array; } diff --git a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php index 2439ef9c51fbe..1a27b49ab5e7b 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php @@ -29,13 +29,11 @@ public function testTraces() $this->assertTrue($traceableFeatureChecker->isEnabled('feature_true')); $this->assertFalse($traceableFeatureChecker->isEnabled('feature_integer', 1)); - $this->assertFalse($traceableFeatureChecker->isEnabled('unknown_feature')); $this->assertSame( [ 'feature_true' => true, 'feature_integer' => false, - 'unknown_feature' => false, ], $traceableFeatureChecker->getChecks(), ); @@ -43,7 +41,6 @@ public function testTraces() [ 'feature_true' => true, 'feature_integer' => 42, - 'unknown_feature' => false, ], $traceableFeatureChecker->getValues(), ); diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php index 6c9fd03fe75fd..e2fbdccbb8cb4 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php @@ -12,73 +12,71 @@ namespace Symfony\Component\FeatureFlag\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; use Symfony\Component\FeatureFlag\FeatureChecker; use Symfony\Component\FeatureFlag\FeatureRegistry; class FeatureCheckerTest extends TestCase { - public function testGetValue() + private FeatureChecker $featureChecker; + + protected function setUp(): void { - $featureChecker = new FeatureChecker(new FeatureRegistry([ + $this->featureChecker = new FeatureChecker(new FeatureRegistry([ + 'feature_true' => fn () => true, + 'feature_false' => fn () => false, 'feature_integer' => fn () => 42, 'feature_random' => fn () => random_int(1, 42), ])); - - $this->assertSame(42, $featureChecker->getValue('feature_integer')); - $this->assertIsInt($value = $featureChecker->getValue('feature_random')); - $this->assertSame($value, $featureChecker->getValue('feature_random')); } - public function testGetDefaultValue() + public function testGetValue() { - $featureRegistry = new FeatureRegistry([ - 'existing_feature' => fn () => 1, - ]); - - $this->assertSame(1, (new FeatureChecker($featureRegistry))->getValue('existing_feature')); - $this->assertSame(1, (new FeatureChecker($featureRegistry, 42))->getValue('existing_feature')); + $this->assertSame(42, $this->featureChecker->getValue('feature_integer')); - $this->assertFalse((new FeatureChecker($featureRegistry))->getValue('unknown_feature')); - $this->assertSame(42, (new FeatureChecker($featureRegistry, 42))->getValue('unknown_feature')); + $this->assertIsInt($value = $this->featureChecker->getValue('feature_random')); + $this->assertSame($value, $this->featureChecker->getValue('feature_random')); } - public function testIsEnabled() + public function testGetValueOnNotFound() { - $featureChecker = new FeatureChecker(new FeatureRegistry([ - 'feature_true' => fn () => true, - 'feature_false' => fn () => false, - 'feature_integer' => fn () => 1, - ])); + $this->expectException(FeatureNotFoundException::class); + $this->expectExceptionMessage('Feature "unknown_feature" not found.'); - $this->assertTrue($featureChecker->isEnabled('feature_true')); - $this->assertFalse($featureChecker->isEnabled('feature_false')); - $this->assertFalse($featureChecker->isEnabled('feature_integer')); - $this->assertFalse($featureChecker->isEnabled('unknown_feature')); - - $this->assertFalse($featureChecker->isDisabled('feature_true')); - $this->assertTrue($featureChecker->isDisabled('feature_false')); - $this->assertTrue($featureChecker->isDisabled('feature_integer')); - $this->assertTrue($featureChecker->isDisabled('unknown_feature')); + $this->featureChecker->getValue('unknown_feature'); } /** - * @dataProvider provideIsEnabledWithExpectedValue + * @dataProvider provideIsEnabled */ - public function testIsEnabledWithExpectedValue(string $featureName, mixed $expectedFeatureValue, bool $expectedResult) + public function testIsEnabled(bool $expectedResult, string $featureName) { - $featureChecker = new FeatureChecker(new FeatureRegistry([ - 'feature_true' => fn () => true, - 'feature_integer' => fn () => 1, - ])); + $this->assertSame($expectedResult, $this->featureChecker->isEnabled($featureName)); + } + + public static function provideIsEnabled() + { + yield '"true" without expected value' => [true, 'feature_true']; + yield '"false" without expected value' => [false, 'feature_false']; + yield 'an integer without expected value' => [false, 'feature_integer']; + yield 'an unknown feature' => [false, 'unknown_feature']; + } - $this->assertSame($expectedResult, $featureChecker->isEnabled($featureName, $expectedFeatureValue)); + /** + * @dataProvider providesEnabledComparedToAnExpectedValue + */ + public function testIsEnabledComparedToAnExpectedValue(bool $expectedResult, string $featureName, mixed $expectedValue) + { + $this->assertSame($expectedResult, $this->featureChecker->isEnabled($featureName, $expectedValue)); } - public static function provideIsEnabledWithExpectedValue() + public static function providesEnabledComparedToAnExpectedValue() { - yield 'with the same boolean' => ['feature_true', true, true]; - yield 'with the same integer' => ['feature_integer', 1, true]; - yield 'with a different boolean' => ['feature_true', false, false]; - yield 'with different types' => ['feature_integer', true, false]; + yield '"true" and the same expected value' => [true, 'feature_true', true]; + yield '"true" and a different expected value' => [false, 'feature_true', false]; + yield '"false" and the same expected value' => [false, 'feature_false', true]; + yield '"false" and a different expected value' => [true, 'feature_false', false]; + yield 'an integer and the same expected value' => [true, 'feature_integer', 42]; + yield 'an integer and a different expected value' => [false, 'feature_integer', 1]; } } diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php index 84749cf997cfb..1051d225df311 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php @@ -27,6 +27,13 @@ protected function setUp(): void ]); } + public function testHas() + { + $this->assertTrue($this->featureRegistry->has('first_feature')); + $this->assertTrue($this->featureRegistry->has('second_feature')); + $this->assertFalse($this->featureRegistry->has('unknown_feature')); + } + public function testGet() { $this->assertIsCallable($this->featureRegistry->get('first_feature')); From 6fd6e9ed881394f34bdb928a45dadb0bc27b460d Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 26 Dec 2023 14:23:20 +0100 Subject: [PATCH 15/46] [FeatureFlag] Add FrameworkBundle integration --- .../Twig/Extension/FeatureFlagExtension.php | 26 ++++ .../Twig/Extension/FeatureFlagRuntime.php | 39 ++++++ .../Compiler/FeatureFlagPass.php | 91 +++++++++++++ .../Compiler/UnusedTagsPass.php | 2 + .../Resources/config/feature_flag.php | 35 +++++ .../Resources/config/feature_flag_debug.php | 34 +++++ .../Resources/config/feature_flag_routing.php | 35 +++++ .../DependencyInjection/TwigExtension.php | 5 + .../Resources/config/feature_flag.php | 27 ++++ .../views/Collector/feature_flag.html.twig | 122 ++++++++++++++++++ 10 files changed, 416 insertions(+) create mode 100644 src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php create mode 100644 src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php create mode 100644 src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php new file mode 100644 index 0000000000000..652a83687444b --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +final class FeatureFlagExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('is_feature_enabled', [FeatureFlagRuntime::class, 'isFeatureEnabled']), + new TwigFunction('get_feature_value', [FeatureFlagRuntime::class, 'getFeatureValue']), + ]; + } +} diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php new file mode 100644 index 0000000000000..ca8f1ffedd500 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; + +final class FeatureFlagRuntime +{ + public function __construct(private readonly ?FeatureCheckerInterface $featureEnabledChecker = null) + { + } + + public function isFeatureEnabled(string $featureName, mixed $expectedValue = true): bool + { + if (null === $this->featureEnabledChecker) { + throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); + } + + return $this->featureEnabledChecker->isEnabled($featureName, $expectedValue); + } + + public function getFeatureValue(string $featureName): mixed + { + if (null === $this->featureEnabledChecker) { + throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); + } + + return $this->featureEnabledChecker->getValue($featureName); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php new file mode 100644 index 0000000000000..ff7fee78b9cb7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; + +class FeatureFlagPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('feature_flag.feature_checker')) { + return; + } + + $features = []; + foreach ($container->findTaggedServiceIds('feature_flag.feature') as $serviceId => $tags) { + $className = $this->getServiceClass($container, $serviceId); + $r = $container->getReflectionClass($className); + + if (null === $r) { + throw new \RuntimeException(sprintf('Invalid service "%s": class "%s" does not exist.', $serviceId, $className)); + } + + foreach ($tags as $tag) { + $featureName = ($tag['feature'] ?? '') ?: $className; + if (array_key_exists($featureName, $features)) { + throw new \RuntimeException(sprintf('Feature "%s" already defined.', $featureName)); + } + + $method = $tag['method'] ?? '__invoke'; + if (!$r->hasMethod($method)) { + throw new \RuntimeException(sprintf('Invalid feature strategy "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); + } + + $features[$featureName] = $container->setDefinition( + ".feature_flag.feature", + (new Definition(\Closure::class)) + ->setLazy(true) + ->setFactory([\Closure::class, 'fromCallable']) + ->setArguments([[new Reference($serviceId), $method]]), + ); + } + } + + $container->getDefinition('feature_flag.feature_registry') + ->setArgument('$features', $features) + ; + + if (!$container->has('feature_flag.data_collector')) { + return; + } + + foreach ($container->findTaggedServiceIds('feature_flag.feature_checker') as $serviceId => $tags) { + $container->register('debug.'.$serviceId, TraceableFeatureChecker::class) + ->setDecoratedService($serviceId) + ->setArguments([ + '$decorated' => new Reference('.inner'), + '$dataCollector' => new Reference('feature_flag.data_collector'), + ]) + ; + } + } + private function getServiceClass(ContainerBuilder $container, string $serviceId): string|null + { + while (true) { + $definition = $container->findDefinition($serviceId); + + if (!$definition->getClass() && $definition instanceof ChildDefinition) { + $serviceId = $definition->getParent(); + + continue; + } + + return $definition->getClass(); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 53361e3127e34..5591cf59c8c76 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -48,6 +48,8 @@ class UnusedTagsPass implements CompilerPassInterface 'controller.targeted_value_resolver', 'data_collector', 'event_dispatcher.dispatcher', + 'feature_flag.feature', + 'feature_flag.feature_checker', 'form.type', 'form.type_extension', 'form.type_guesser', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php new file mode 100644 index 0000000000000..ce2d76abd7578 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php @@ -0,0 +1,35 @@ + + * + * 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\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; +use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\FeatureFlag\FeatureRegistryInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flag.feature_registry', FeatureRegistry::class) + ->args([ + '$features' => abstract_arg('Defined in FeatureFlagPass.'), + ]) + ->alias(FeatureRegistryInterface::class, 'feature_flag.feature_registry') + + ->set('feature_flag.feature_checker', FeatureChecker::class) + ->args([ + '$featureRegistry' => service('feature_flag.feature_registry'), + '$default' => false, + ]) + ->alias(FeatureCheckerInterface::class, 'feature_flag.feature_checker') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php new file mode 100644 index 0000000000000..492fb4c18c5b3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.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\Component\FeatureFlag\DataCollector\FeatureFlagDataCollector; +use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('debug.feature_flag.feature_checker', TraceableFeatureChecker::class) + ->decorate('feature_flag.feature_checker') + ->args([ + '$decorated' => service('debug.feature_flag.feature_checker.inner'), + ]) + + ->set('feature_flag.data_collector', FeatureFlagDataCollector::class) + ->args([ + '$featureRegistry' => service('feature_flag.feature_registry'), + '$featureChecker' => service('debug.feature_flag.feature_checker'), + ]) + ->tag('data_collector', ['template' => '@WebProfiler/Collector/feature_flag.html.twig', 'id' => 'feature_flag']) + + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php new file mode 100644 index 0000000000000..e97bbf2c8eb40 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php @@ -0,0 +1,35 @@ + + * + * 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; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flag.routing_expression_language_function.is_enabled', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([ + [service('feature_flag.feature_checker'), 'isEnabled'], + ]) + ->tag('routing.expression_language_function', ['function' => 'is_feature_enabled']) + + + ->set('feature_flag.routing_expression_language_function.get_value', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([ + [service('feature_flag.feature_checker'), 'getValue'], + ]) + ->tag('routing.expression_language_function', ['function' => 'get_feature_value']) + + ->get('feature_flag.feature_checker') + ->tag('routing.condition_service', ['alias' => 'feature']) + ; +}; diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index db508873387b2..c3c0f14128630 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -19,6 +19,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\FeatureFlag\FeatureChecker; use Symfony\Component\Form\AbstractRendererEngine; use Symfony\Component\Form\Form; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -107,6 +108,10 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('importmap.php'); } + if ($container::willBeAvailable('symfony/feature-flag', FeatureChecker::class, ['symfony/twig-bundle'])) { + $loader->load('feature_flag.php'); + } + $container->setParameter('twig.form.resources', $config['form_themes']); $container->setParameter('twig.default_path', $config['default_path']); $defaultTwigPath = $container->getParameterBag()->resolveValue($config['default_path']); diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php new file mode 100644 index 0000000000000..bbfd4b704c002 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.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\DependencyInjection\Loader\Configurator; + +use Symfony\Bridge\Twig\Extension\FeatureFlagExtension; +use Symfony\Bridge\Twig\Extension\FeatureFlagRuntime; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('twig.runtime.feature_flag', FeatureFlagRuntime::class) + ->args([service('feature_flag.feature_checker')->nullOnInvalid()]) + ->tag('twig.runtime') + + ->set('twig.extension.feature_flag', FeatureFlagExtension::class) + ->tag('twig.extension') + ; +}; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig new file mode 100644 index 0000000000000..075b8ff94277d --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig @@ -0,0 +1,122 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block page_title 'Feature Flag' %} + +{% block toolbar %} + {% if 0 < collector.features|length %} + {% set icon %} + Feature Flag + {{ collector.features|length }} + {% endset %} + + {% set text %} +
+ {% for feature_name, data in collector.features|filter(d => d.is_enabled is not null) %} +
+ {{ loop.first ? 'Resolved features' : '' }} + + {{ feature_name }} + +
+
+ {% endfor %} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + Feature Flag + Feature Flag + +{% endblock %} + +{% block head %} + {{ parent() }} + +{% endblock %} + +{% block panel %} +

Features

+ {% if collector.features|length > 0 %} + + + + + + + + + + {% for feature_name, data in collector.features %} + {% set result = data['is_enabled'] is not null ? (data['is_enabled'] ? 'enabled' : 'disabled') : 'unknown' %} + + + + + + {% endfor %} + +
FeatureResolved valueEnabled
{{ feature_name }} + {% if data['value'].value is not null %} + {{ profiler_dump(data['value'], maxDepth=2) }} + {% else %} + Not resolved + {% endif %} + + {% if data['is_enabled'] is not null %} + + {{ result|capitalize }} + + {% else %} + + Not resolved + + {% endif %} +
+ {% else %} +
+

No features checked

+
+ {% endif %} +{% endblock %} From fc6adc3cc4ea076ce4291904c46b3e1028116326 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Thu, 29 Feb 2024 22:38:24 +0100 Subject: [PATCH 16/46] exception format --- .../FrameworkExtension.php | 2 +- .../FrameworkExtension.php.orig | 3203 +++++++++++++++++ 2 files changed, 3204 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 768fb8a2b1d09..9e7e104fb61f1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -3463,7 +3463,7 @@ static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionC } else { $className = $reflector->getDeclaringClass()->getName(); if (null !== $attribute->method && $reflector->getName() !== $attribute->method) { - throw new \LogicException(sprintf('Using the #[%s(method: %s)] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s)', AsFeature::class, $attribute->method, $className)); + throw new \LogicException(sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s).', AsFeature::class, $attribute->method, $className)); } $method = $reflector->getName(); diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig new file mode 100644 index 0000000000000..b1a0af9cf41b7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig @@ -0,0 +1,3203 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\DependencyInjection; + +use Composer\InstalledVersions; +use Http\Client\HttpAsyncClient; +use Http\Client\HttpClient; +use phpDocumentor\Reflection\DocBlockFactoryInterface; +use phpDocumentor\Reflection\Types\ContextFactory; +use PhpParser\Parser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Clock\ClockInterface as PsrClockInterface; +use Psr\Container\ContainerInterface as PsrContainerInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Log\LoggerAwareInterface; +use Symfony\Bridge\Monolog\Processor\DebugProcessor; +use Symfony\Bridge\Twig\Extension\CsrfExtension; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; +use Symfony\Bundle\FullStack; +use Symfony\Bundle\MercureBundle\MercureBundle; +use Symfony\Component\Asset\PackageInterface; +use Symfony\Component\AssetMapper\AssetMapper; +use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; +use Symfony\Component\BrowserKit\AbstractBrowser; +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\ChainAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; +use Symfony\Component\Cache\DependencyInjection\CachePoolPass; +use Symfony\Component\Cache\Marshaller\MarshallerInterface; +use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\Config\Resource\DirectoryResource; +use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Config\ResourceCheckerInterface; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\DataCollector\CommandDataCollector; +use Symfony\Component\Console\Debug\CliRequest; +use Symfony\Component\Console\Messenger\RunCommandMessageHandler; +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\EnvVarLoaderInterface; +use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\Parameter; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\Dotenv\Command\DebugCommand; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\FeatureFlag\Attribute\AsFeature; +use Symfony\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureRegistryInterface; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\Glob; +use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension; +use Symfony\Component\Form\Form; +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeGuesserInterface; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; +use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; +use Symfony\Component\HttpClient\RetryableHttpClient; +use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Component\HttpClient\ThrottlingHttpClient; +use Symfony\Component\HttpClient\UriTemplateHttpClient; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Attribute\AsTargetedValueResolver; +use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\HttpKernel\Log\DebugLoggerConfigurator; +use Symfony\Component\Lock\LockFactory; +use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Lock\PersistingStoreInterface; +use Symfony\Component\Lock\Store\StoreFactory; +use Symfony\Component\Mailer\Bridge as MailerBridge; +use Symfony\Component\Mailer\Command\MailerTestCommand; +use Symfony\Component\Mailer\EventListener\MessengerTransportListener; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mercure\HubRegistry; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\Bridge as MessengerBridge; +use Symfony\Component\Messenger\Handler\BatchHandlerInterface; +use Symfony\Component\Messenger\MessageBus; +use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Middleware\RouterContextMiddleware; +use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; +use Symfony\Component\Messenger\Transport\TransportFactoryInterface as MessengerTransportFactoryInterface; +use Symfony\Component\Messenger\Transport\TransportInterface; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\MimeTypeGuesserInterface; +use Symfony\Component\Mime\MimeTypes; +use Symfony\Component\Notifier\Bridge as NotifierBridge; +use Symfony\Component\Notifier\ChatterInterface; +use Symfony\Component\Notifier\Notifier; +use Symfony\Component\Notifier\Recipient\Recipient; +use Symfony\Component\Notifier\TexterInterface; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface as NotifierTransportFactoryInterface; +use Symfony\Component\Process\Messenger\RunProcessMessageHandler; +use Symfony\Component\PropertyAccess\PropertyAccessor; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; +use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; +use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\Storage\CacheStorage; +use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; +use Symfony\Component\RemoteEvent\RemoteEvent; +use Symfony\Component\Routing\Router; +use Symfony\Component\Scheduler\Attribute\AsCronTask; +use Symfony\Component\Scheduler\Attribute\AsPeriodicTask; +use Symfony\Component\Scheduler\Attribute\AsSchedule; +use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; +use Symfony\Component\Security\Core\AuthenticationEvents; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Semaphore\PersistingStoreInterface as SemaphoreStoreInterface; +use Symfony\Component\Semaphore\Semaphore; +use Symfony\Component\Semaphore\SemaphoreFactory; +use Symfony\Component\Semaphore\Store\StoreFactory as SemaphoreStoreFactory; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader; +use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\String\LazyString; +use Symfony\Component\String\Slugger\SluggerInterface; +use Symfony\Component\Translation\Bridge as TranslationBridge; +use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; +use Symfony\Component\Translation\Extractor\PhpAstExtractor; +use Symfony\Component\Translation\LocaleSwitcher; +use Symfony\Component\Translation\PseudoLocalizationTranslator; +use Symfony\Component\Translation\Translator; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider; +use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\GroupProviderInterface; +use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; +use Symfony\Component\Validator\ObjectInitializerInterface; +use Symfony\Component\Validator\Validation; +use Symfony\Component\Webhook\Controller\WebhookController; +use Symfony\Component\WebLink\HttpHeaderSerializer; +use Symfony\Component\Workflow; +use Symfony\Component\Workflow\WorkflowInterface; +use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand; +use Symfony\Component\Yaml\Yaml; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\CallbackInterface; +use Symfony\Contracts\Cache\TagAwareCacheInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\Service\ResetInterface; +use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Symfony\Contracts\Translation\LocaleAwareInterface; + +/** + * Process the configuration and prepare the dependency injection container with + * parameters and services. + */ +class FrameworkExtension extends Extension +{ + private array $configsEnabled = []; + + /** + * Responds to the app.config configuration parameter. + * + * @throws LogicException + */ + public function load(array $configs, ContainerBuilder $container): void + { + $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); + + if (class_exists(InstalledVersions::class) && InstalledVersions::isInstalled('symfony/symfony') && 'symfony/symfony' !== (InstalledVersions::getRootPackage()['name'] ?? '')) { + throw new \LogicException('Requiring the "symfony/symfony" package is unsupported; replace it with standalone components instead.'); + } + + $loader->load('web.php'); + $loader->load('services.php'); + $loader->load('fragment_renderer.php'); + $loader->load('error_renderer.php'); + + if (!ContainerBuilder::willBeAvailable('symfony/clock', ClockInterface::class, ['symfony/framework-bundle'])) { + $container->removeDefinition('clock'); + $container->removeAlias(ClockInterface::class); + $container->removeAlias(PsrClockInterface::class); + } + + $container->registerAliasForArgument('parameter_bag', PsrContainerInterface::class); + + $loader->load('process.php'); + + if (!class_exists(RunProcessMessageHandler::class)) { + $container->removeDefinition('process.messenger.process_message_handler'); + } + + if ($this->hasConsole()) { + $loader->load('console.php'); + + if (!class_exists(BaseXliffLintCommand::class)) { + $container->removeDefinition('console.command.xliff_lint'); + } + if (!class_exists(BaseYamlLintCommand::class)) { + $container->removeDefinition('console.command.yaml_lint'); + } + + if (!class_exists(DebugCommand::class)) { + $container->removeDefinition('console.command.dotenv_debug'); + } + + if (!class_exists(RunCommandMessageHandler::class)) { + $container->removeDefinition('console.messenger.application'); + $container->removeDefinition('console.messenger.execute_command_handler'); + } + } + + // Load Cache configuration first as it is used by other components + $loader->load('cache.php'); + + $configuration = $this->getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + // warmup config enabled + $this->readConfigEnabled('translator', $container, $config['translator']); + $this->readConfigEnabled('property_access', $container, $config['property_access']); + $this->readConfigEnabled('profiler', $container, $config['profiler']); + $this->readConfigEnabled('workflows', $container, $config['workflows']); + $this->readConfigEnabled('feature_flag', $container, $config['feature_flag']); + + // A translator must always be registered (as support is included by + // default in the Form and Validator component). If disabled, an identity + // translator will be used and everything will still work as expected. + if ($this->readConfigEnabled('translator', $container, $config['translator']) || $this->readConfigEnabled('form', $container, $config['form']) || $this->readConfigEnabled('validation', $container, $config['validation'])) { + if (!class_exists(Translator::class) && $this->readConfigEnabled('translator', $container, $config['translator'])) { + throw new LogicException('Translation support cannot be enabled as the Translation component is not installed. Try running "composer require symfony/translation".'); + } + + if (class_exists(Translator::class)) { + $loader->load('identity_translator.php'); + } + } + + $container->getDefinition('locale_listener')->replaceArgument(3, $config['set_locale_from_accept_language']); + $container->getDefinition('response_listener')->replaceArgument(1, $config['set_content_language_from_locale']); + $container->getDefinition('http_kernel')->replaceArgument(4, $config['handle_all_throwables'] ?? false); + + // If the slugger is used but the String component is not available, we should throw an error + if (!ContainerBuilder::willBeAvailable('symfony/string', SluggerInterface::class, ['symfony/framework-bundle'])) { + $container->register('slugger', SluggerInterface::class) + ->addError('You cannot use the "slugger" service since the String component is not installed. Try running "composer require symfony/string".'); + } else { + if (!ContainerBuilder::willBeAvailable('symfony/translation', LocaleAwareInterface::class, ['symfony/framework-bundle'])) { + $container->register('slugger', SluggerInterface::class) + ->addError('You cannot use the "slugger" service since the Translation contracts are not installed. Try running "composer require symfony/translation".'); + } + + if (!\extension_loaded('intl') && !\defined('PHPUNIT_COMPOSER_INSTALL')) { + trigger_deprecation('', '', 'Please install the "intl" PHP extension for best performance.'); + } + } + + if (isset($config['secret'])) { + $container->setParameter('kernel.secret', $config['secret']); + } + + $container->setParameter('kernel.http_method_override', $config['http_method_override']); + $container->setParameter('kernel.trust_x_sendfile_type_header', $config['trust_x_sendfile_type_header']); + $container->setParameter('kernel.trusted_hosts', $config['trusted_hosts']); + $container->setParameter('kernel.default_locale', $config['default_locale']); + $container->setParameter('kernel.enabled_locales', $config['enabled_locales']); + $container->setParameter('kernel.error_controller', $config['error_controller']); + + if (($config['trusted_proxies'] ?? false) && ($config['trusted_headers'] ?? false)) { + $container->setParameter('kernel.trusted_proxies', $config['trusted_proxies']); + $container->setParameter('kernel.trusted_headers', $this->resolveTrustedHeaders($config['trusted_headers'])); + } + + if (!$container->hasParameter('debug.file_link_format')) { + $container->setParameter('debug.file_link_format', $config['ide']); + } + + if (!empty($config['test'])) { + $loader->load('test.php'); + + if (!class_exists(AbstractBrowser::class)) { + $container->removeDefinition('test.client'); + } + } + + if ($this->readConfigEnabled('request', $container, $config['request'])) { + $this->registerRequestConfiguration($config['request'], $container, $loader); + } + + if ($this->readConfigEnabled('assets', $container, $config['assets'])) { + if (!class_exists(\Symfony\Component\Asset\Package::class)) { + throw new LogicException('Asset support cannot be enabled as the Asset component is not installed. Try running "composer require symfony/asset".'); + } + + $this->registerAssetsConfiguration($config['assets'], $container, $loader); + } + + if ($this->readConfigEnabled('asset_mapper', $container, $config['asset_mapper'])) { + if (!class_exists(AssetMapper::class)) { + throw new LogicException('AssetMapper support cannot be enabled as the AssetMapper component is not installed. Try running "composer require symfony/asset-mapper".'); + } + + $this->registerAssetMapperConfiguration($config['asset_mapper'], $container, $loader, $this->readConfigEnabled('assets', $container, $config['assets'])); + } else { + $container->removeDefinition('cache.asset_mapper'); + } + + if ($this->readConfigEnabled('http_client', $container, $config['http_client'])) { + $this->readConfigEnabled('rate_limiter', $container, $config['rate_limiter']); // makes sure that isInitializedConfigEnabled() will work + $this->registerHttpClientConfiguration($config['http_client'], $container, $loader); + } + + if ($this->readConfigEnabled('mailer', $container, $config['mailer'])) { + $this->registerMailerConfiguration($config['mailer'], $container, $loader, $this->readConfigEnabled('webhook', $container, $config['webhook'])); + + if (!$this->hasConsole() || !class_exists(MailerTestCommand::class)) { + $container->removeDefinition('console.command.mailer_test'); + } + } + + $propertyInfoEnabled = $this->readConfigEnabled('property_info', $container, $config['property_info']); + $this->registerHttpCacheConfiguration($config['http_cache'], $container, $config['http_method_override']); + $this->registerEsiConfiguration($config['esi'], $container, $loader); + $this->registerSsiConfiguration($config['ssi'], $container, $loader); + $this->registerFragmentsConfiguration($config['fragments'], $container, $loader); + $this->registerTranslatorConfiguration($config['translator'], $container, $loader, $config['default_locale'], $config['enabled_locales']); + $this->registerWorkflowConfiguration($config['workflows'], $container, $loader); + $this->registerDebugConfiguration($config['php_errors'], $container, $loader); + $this->registerRouterConfiguration($config['router'], $container, $loader, $config['enabled_locales']); + $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); + $this->registerSecretsConfiguration($config['secrets'], $container, $loader); + + $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); + + if ($this->readConfigEnabled('serializer', $container, $config['serializer'])) { + if (!class_exists(Serializer::class)) { + throw new LogicException('Serializer support cannot be enabled as the Serializer component is not installed. Try running "composer require symfony/serializer-pack".'); + } + + $this->registerSerializerConfiguration($config['serializer'], $container, $loader); + } else { + $container->getDefinition('argument_resolver.request_payload') + ->setArguments([]) + ->addError('You can neither use "#[MapRequestPayload]" nor "#[MapQueryString]" since the Serializer component is not ' + .(class_exists(Serializer::class) ? 'enabled. Try setting "framework.serializer.enabled" to true.' : 'installed. Try running "composer require symfony/serializer-pack".') + ) + ->addTag('container.error') + ->clearTag('kernel.event_subscriber'); + + $container->removeDefinition('console.command.serializer_debug'); + } + + if ($this->readConfigEnabled('type_info', $container, $config['type_info'])) { + $this->registerTypeInfoConfiguration($container, $loader); + } + + if ($propertyInfoEnabled) { + $this->registerPropertyInfoConfiguration($container, $loader); + } + + if ($this->readConfigEnabled('lock', $container, $config['lock'])) { + $this->registerLockConfiguration($config['lock'], $container, $loader); + } + + if ($this->readConfigEnabled('semaphore', $container, $config['semaphore'])) { + $this->registerSemaphoreConfiguration($config['semaphore'], $container, $loader); + } + + if ($this->readConfigEnabled('rate_limiter', $container, $config['rate_limiter'])) { + if (!interface_exists(LimiterInterface::class)) { + throw new LogicException('Rate limiter support cannot be enabled as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".'); + } + + $this->registerRateLimiterConfiguration($config['rate_limiter'], $container, $loader); + } + + if ($this->readConfigEnabled('web_link', $container, $config['web_link'])) { + if (!class_exists(HttpHeaderSerializer::class)) { + throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".'); + } + + $loader->load('web_link.php'); + } + + if ($this->readConfigEnabled('uid', $container, $config['uid'])) { + if (!class_exists(UuidFactory::class)) { + throw new LogicException('Uid support cannot be enabled as the Uid component is not installed. Try running "composer require symfony/uid".'); + } + + $this->registerUidConfiguration($config['uid'], $container, $loader); + } else { + $container->removeDefinition('argument_resolver.uid'); + } + + // register cache before session so both can share the connection services + $this->registerCacheConfiguration($config['cache'], $container); + + if ($this->readConfigEnabled('session', $container, $config['session'])) { + if (!\extension_loaded('session')) { + throw new LogicException('Session support cannot be enabled as the session extension is not installed. See https://php.net/session.installation for instructions.'); + } + + $this->registerSessionConfiguration($config['session'], $container, $loader); + if (!empty($config['test'])) { + // test listener will replace the existing session listener + // as we are aliasing to avoid duplicated registered events + $container->setAlias('session_listener', 'test.session.listener'); + } + } elseif (!empty($config['test'])) { + $container->removeDefinition('test.session.listener'); + } + + // csrf depends on session being registered + if (null === $config['csrf_protection']['enabled']) { + $this->writeConfigEnabled('csrf_protection', $this->readConfigEnabled('session', $container, $config['session']) && !class_exists(FullStack::class) && ContainerBuilder::willBeAvailable('symfony/security-csrf', CsrfTokenManagerInterface::class, ['symfony/framework-bundle']), $config['csrf_protection']); + } + $this->registerSecurityCsrfConfiguration($config['csrf_protection'], $container, $loader); + + // form depends on csrf being registered + if ($this->readConfigEnabled('form', $container, $config['form'])) { + if (!class_exists(Form::class)) { + throw new LogicException('Form support cannot be enabled as the Form component is not installed. Try running "composer require symfony/form".'); + } + + $this->registerFormConfiguration($config, $container, $loader); + + if (ContainerBuilder::willBeAvailable('symfony/validator', Validation::class, ['symfony/framework-bundle', 'symfony/form'])) { + $this->writeConfigEnabled('validation', true, $config['validation']); + } else { + $container->setParameter('validator.translation_domain', 'validators'); + + $container->removeDefinition('form.type_extension.form.validator'); + $container->removeDefinition('form.type_guesser.validator'); + } + if (!$this->readConfigEnabled('html_sanitizer', $container, $config['html_sanitizer']) || !class_exists(TextTypeHtmlSanitizerExtension::class)) { + $container->removeDefinition('form.type_extension.form.html_sanitizer'); + } + } else { + $container->removeDefinition('console.command.form_debug'); + } + + // validation depends on form, annotations being registered + $this->registerValidationConfiguration($config['validation'], $container, $loader, $propertyInfoEnabled); + + $messengerEnabled = $this->readConfigEnabled('messenger', $container, $config['messenger']); + + if ($this->readConfigEnabled('scheduler', $container, $config['scheduler'])) { + if (!$messengerEnabled) { + throw new LogicException('Scheduler support cannot be enabled as the Messenger component is not '.(interface_exists(MessageBusInterface::class) ? 'enabled.' : 'installed. Try running "composer require symfony/messenger".')); + } + $this->registerSchedulerConfiguration($config['scheduler'], $container, $loader); + } else { + $container->removeDefinition('cache.scheduler'); + $container->removeDefinition('console.command.scheduler_debug'); + } + + // messenger depends on validation being registered + if ($messengerEnabled) { + $this->registerMessengerConfiguration($config['messenger'], $container, $loader, $this->readConfigEnabled('validation', $container, $config['validation'])); + } else { + $container->removeDefinition('console.command.messenger_consume_messages'); + $container->removeDefinition('console.command.messenger_stats'); + $container->removeDefinition('console.command.messenger_debug'); + $container->removeDefinition('console.command.messenger_stop_workers'); + $container->removeDefinition('console.command.messenger_setup_transports'); + $container->removeDefinition('console.command.messenger_failed_messages_retry'); + $container->removeDefinition('console.command.messenger_failed_messages_show'); + $container->removeDefinition('console.command.messenger_failed_messages_remove'); + $container->removeDefinition('cache.messenger.restart_workers_signal'); + + if ($container->hasDefinition('messenger.transport.amqp.factory') && !class_exists(MessengerBridge\Amqp\Transport\AmqpTransportFactory::class)) { + if (class_exists(\Symfony\Component\Messenger\Transport\AmqpExt\AmqpTransportFactory::class)) { + $container->getDefinition('messenger.transport.amqp.factory') + ->setClass(\Symfony\Component\Messenger\Transport\AmqpExt\AmqpTransportFactory::class) + ->addTag('messenger.transport_factory'); + } else { + $container->removeDefinition('messenger.transport.amqp.factory'); + } + } + + if ($container->hasDefinition('messenger.transport.redis.factory') && !class_exists(MessengerBridge\Redis\Transport\RedisTransportFactory::class)) { + if (class_exists(\Symfony\Component\Messenger\Transport\RedisExt\RedisTransportFactory::class)) { + $container->getDefinition('messenger.transport.redis.factory') + ->setClass(\Symfony\Component\Messenger\Transport\RedisExt\RedisTransportFactory::class) + ->addTag('messenger.transport_factory'); + } else { + $container->removeDefinition('messenger.transport.redis.factory'); + } + } + } + + // notifier depends on messenger, mailer being registered + if ($this->readConfigEnabled('notifier', $container, $config['notifier'])) { + $this->registerNotifierConfiguration($config['notifier'], $container, $loader, $this->readConfigEnabled('webhook', $container, $config['webhook'])); + } + + // profiler depends on form, validation, translation, messenger, mailer, http-client, notifier, serializer being registered + $this->registerProfilerConfiguration($config['profiler'], $container, $loader); + + if ($this->readConfigEnabled('webhook', $container, $config['webhook'])) { + $this->registerWebhookConfiguration($config['webhook'], $container, $loader); + + // If Webhook is installed but the HttpClient or Serializer components are not available, we should throw an error + if (!$this->readConfigEnabled('http_client', $container, $config['http_client'])) { + $container->getDefinition('webhook.transport') + ->setArguments([]) + ->addError('You cannot use the "webhook transport" service since the HttpClient component is not ' + .(class_exists(ScopingHttpClient::class) ? 'enabled. Try setting "framework.http_client.enabled" to true.' : 'installed. Try running "composer require symfony/http-client".') + ) + ->addTag('container.error'); + } + if (!$this->readConfigEnabled('serializer', $container, $config['serializer'])) { + $container->getDefinition('webhook.body_configurator.json') + ->setArguments([]) + ->addError('You cannot use the "webhook transport" service since the Serializer component is not ' + .(class_exists(Serializer::class) ? 'enabled. Try setting "framework.serializer.enabled" to true.' : 'installed. Try running "composer require symfony/serializer-pack".') + ) + ->addTag('container.error'); + } + } + + if ($this->readConfigEnabled('remote-event', $container, $config['remote-event'])) { + $this->registerRemoteEventConfiguration($config['remote-event'], $container, $loader); + } + + if ($this->readConfigEnabled('html_sanitizer', $container, $config['html_sanitizer'])) { + if (!class_exists(HtmlSanitizerConfig::class)) { + throw new LogicException('HtmlSanitizer support cannot be enabled as the HtmlSanitizer component is not installed. Try running "composer require symfony/html-sanitizer".'); + } + + $this->registerHtmlSanitizerConfiguration($config['html_sanitizer'], $container, $loader); + } + + if ($this->readConfigEnabled('feature_flag', $container, $config['feature_flag'])) { + if (!class_exists(FeatureChecker::class)) { + throw new LogicException('FeatureFlag support cannot be enabled as the FeatureFlag component is not installed. Try running "composer require symfony/feature-flag".'); + } + $this->registerFeatureFlagConfiguration($config['feature_flag'], $container, $loader); + } + + if (ContainerBuilder::willBeAvailable('symfony/mime', MimeTypes::class, ['symfony/framework-bundle'])) { + $loader->load('mime_type.php'); + } + + $container->registerForAutoconfiguration(PackageInterface::class) + ->addTag('assets.package'); + $container->registerForAutoconfiguration(AssetCompilerInterface::class) + ->addTag('asset_mapper.compiler'); + $container->registerForAutoconfiguration(Command::class) + ->addTag('console.command'); + $container->registerForAutoconfiguration(ResourceCheckerInterface::class) + ->addTag('config_cache.resource_checker'); + $container->registerForAutoconfiguration(EnvVarLoaderInterface::class) + ->addTag('container.env_var_loader'); + $container->registerForAutoconfiguration(EnvVarProcessorInterface::class) + ->addTag('container.env_var_processor'); + $container->registerForAutoconfiguration(CallbackInterface::class) + ->addTag('container.reversible'); + $container->registerForAutoconfiguration(ServiceLocator::class) + ->addTag('container.service_locator'); + $container->registerForAutoconfiguration(ServiceSubscriberInterface::class) + ->addTag('container.service_subscriber'); + $container->registerForAutoconfiguration(ValueResolverInterface::class) + ->addTag('controller.argument_value_resolver'); + $container->registerForAutoconfiguration(AbstractController::class) + ->addTag('controller.service_arguments'); + $container->registerForAutoconfiguration(DataCollectorInterface::class) + ->addTag('data_collector'); + $container->registerForAutoconfiguration(FormTypeInterface::class) + ->addTag('form.type'); + $container->registerForAutoconfiguration(FormTypeGuesserInterface::class) + ->addTag('form.type_guesser'); + $container->registerForAutoconfiguration(FormTypeExtensionInterface::class) + ->addTag('form.type_extension'); + $container->registerForAutoconfiguration(CacheClearerInterface::class) + ->addTag('kernel.cache_clearer'); + $container->registerForAutoconfiguration(CacheWarmerInterface::class) + ->addTag('kernel.cache_warmer'); + $container->registerForAutoconfiguration(EventDispatcherInterface::class) + ->addTag('event_dispatcher.dispatcher'); + $container->registerForAutoconfiguration(EventSubscriberInterface::class) + ->addTag('kernel.event_subscriber'); + $container->registerForAutoconfiguration(LocaleAwareInterface::class) + ->addTag('kernel.locale_aware'); + $container->registerForAutoconfiguration(ResetInterface::class) + ->addTag('kernel.reset', ['method' => 'reset']); + + if (!interface_exists(MarshallerInterface::class)) { + $container->registerForAutoconfiguration(ResettableInterface::class) + ->addTag('kernel.reset', ['method' => 'reset']); + } + + $container->registerForAutoconfiguration(PropertyListExtractorInterface::class) + ->addTag('property_info.list_extractor'); + $container->registerForAutoconfiguration(PropertyTypeExtractorInterface::class) + ->addTag('property_info.type_extractor'); + $container->registerForAutoconfiguration(PropertyDescriptionExtractorInterface::class) + ->addTag('property_info.description_extractor'); + $container->registerForAutoconfiguration(PropertyAccessExtractorInterface::class) + ->addTag('property_info.access_extractor'); + $container->registerForAutoconfiguration(PropertyInitializableExtractorInterface::class) + ->addTag('property_info.initializable_extractor'); + $container->registerForAutoconfiguration(EncoderInterface::class) + ->addTag('serializer.encoder'); + $container->registerForAutoconfiguration(DecoderInterface::class) + ->addTag('serializer.encoder'); + $container->registerForAutoconfiguration(NormalizerInterface::class) + ->addTag('serializer.normalizer'); + $container->registerForAutoconfiguration(DenormalizerInterface::class) + ->addTag('serializer.normalizer'); + $container->registerForAutoconfiguration(ConstraintValidatorInterface::class) + ->addTag('validator.constraint_validator'); + $container->registerForAutoconfiguration(GroupProviderInterface::class) + ->addTag('validator.group_provider'); + $container->registerForAutoconfiguration(ObjectInitializerInterface::class) + ->addTag('validator.initializer'); + $container->registerForAutoconfiguration(BatchHandlerInterface::class) + ->addTag('messenger.message_handler'); + $container->registerForAutoconfiguration(MessengerTransportFactoryInterface::class) + ->addTag('messenger.transport_factory'); + $container->registerForAutoconfiguration(MimeTypeGuesserInterface::class) + ->addTag('mime.mime_type_guesser'); + $container->registerForAutoconfiguration(LoggerAwareInterface::class) + ->addMethodCall('setLogger', [new Reference('logger')]); + + $container->registerAttributeForAutoconfiguration(AsEventListener::class, static function (ChildDefinition $definition, AsEventListener $attribute, \ReflectionClass|\ReflectionMethod $reflector) { + $tagAttributes = get_object_vars($attribute); + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new LogicException(sprintf('AsEventListener attribute cannot declare a method on "%s::%s()".', $reflector->class, $reflector->name)); + } + $tagAttributes['method'] = $reflector->getName(); + } + $definition->addTag('kernel.event_listener', $tagAttributes); + }); + $container->registerAttributeForAutoconfiguration(AsController::class, static function (ChildDefinition $definition, AsController $attribute): void { + $definition->addTag('controller.service_arguments'); + }); + $container->registerAttributeForAutoconfiguration(AsRemoteEventConsumer::class, static function (ChildDefinition $definition, AsRemoteEventConsumer $attribute): void { + $definition->addTag('remote_event.consumer', ['consumer' => $attribute->name]); + }); + $container->registerAttributeForAutoconfiguration(AsMessageHandler::class, static function (ChildDefinition $definition, AsMessageHandler $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { + $tagAttributes = get_object_vars($attribute); + $tagAttributes['from_transport'] = $tagAttributes['fromTransport']; + unset($tagAttributes['fromTransport']); + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new LogicException(sprintf('AsMessageHandler attribute cannot declare a method on "%s::%s()".', $reflector->class, $reflector->name)); + } + $tagAttributes['method'] = $reflector->getName(); + } + $definition->addTag('messenger.message_handler', $tagAttributes); + }); + $container->registerAttributeForAutoconfiguration(AsTargetedValueResolver::class, static function (ChildDefinition $definition, AsTargetedValueResolver $attribute): void { + $definition->addTag('controller.targeted_value_resolver', $attribute->name ? ['name' => $attribute->name] : []); + }); + $container->registerAttributeForAutoconfiguration(AsSchedule::class, static function (ChildDefinition $definition, AsSchedule $attribute): void { + $definition->addTag('scheduler.schedule_provider', ['name' => $attribute->name]); + }); + foreach ([AsPeriodicTask::class, AsCronTask::class] as $taskAttributeClass) { + $container->registerAttributeForAutoconfiguration( + $taskAttributeClass, + static function (ChildDefinition $definition, AsPeriodicTask|AsCronTask $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { + $tagAttributes = get_object_vars($attribute) + [ + 'trigger' => match ($attribute::class) { + AsPeriodicTask::class => 'every', + AsCronTask::class => 'cron', + }, + ]; + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new LogicException(sprintf('"%s" attribute cannot declare a method on "%s::%s()".', $attribute::class, $reflector->class, $reflector->name)); + } + $tagAttributes['method'] = $reflector->getName(); + } + $definition->addTag('scheduler.task', $tagAttributes); + } + ); + } + + if (!$container->getParameter('kernel.debug')) { + // remove tagged iterator argument for resource checkers + $container->getDefinition('config_cache_factory')->setArguments([]); + } + + if (!$config['disallow_search_engine_index'] ?? false) { + $container->removeDefinition('disallow_search_engine_index_response_listener'); + } + + $container->registerForAutoconfiguration(RouteLoaderInterface::class) + ->addTag('routing.route_loader'); + + $container->setParameter('container.behavior_describing_tags', [ + 'container.do_not_inline', + 'container.service_locator', + 'container.service_subscriber', + 'kernel.event_subscriber', + 'kernel.event_listener', + 'kernel.locale_aware', + 'kernel.reset', + ]); + } + + public function getConfiguration(array $config, ContainerBuilder $container): ?ConfigurationInterface + { + return new Configuration($container->getParameter('kernel.debug')); + } + + protected function hasConsole(): bool + { + return class_exists(Application::class); + } + + private function registerFormConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('form.php'); + + if (null === $config['form']['csrf_protection']['enabled']) { + $this->writeConfigEnabled('form.csrf_protection', $config['csrf_protection']['enabled'], $config['form']['csrf_protection']); + } + + if ($this->readConfigEnabled('form.csrf_protection', $container, $config['form']['csrf_protection'])) { + if (!$container->hasDefinition('security.csrf.token_generator')) { + throw new \LogicException('To use form CSRF protection, "framework.csrf_protection" must be enabled.'); + } + + $loader->load('form_csrf.php'); + + $container->setParameter('form.type_extension.csrf.enabled', true); + $container->setParameter('form.type_extension.csrf.field_name', $config['form']['csrf_protection']['field_name']); + } else { + $container->setParameter('form.type_extension.csrf.enabled', false); + } + + if (!ContainerBuilder::willBeAvailable('symfony/translation', Translator::class, ['symfony/framework-bundle', 'symfony/form'])) { + $container->removeDefinition('form.type_extension.upload.validator'); + } + } + + private function registerHttpCacheConfiguration(array $config, ContainerBuilder $container, bool $httpMethodOverride): void + { + $options = $config; + unset($options['enabled']); + + if (!$options['private_headers']) { + unset($options['private_headers']); + } + + if (!$options['skip_response_headers']) { + unset($options['skip_response_headers']); + } + + $container->getDefinition('http_cache') + ->setPublic($config['enabled']) + ->replaceArgument(3, $options); + + if ($httpMethodOverride) { + $container->getDefinition('http_cache') + ->addArgument((new Definition('void')) + ->setFactory([Request::class, 'enableHttpMethodParameterOverride']) + ); + } + } + + private function registerEsiConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$this->readConfigEnabled('esi', $container, $config)) { + $container->removeDefinition('fragment.renderer.esi'); + + return; + } + + $loader->load('esi.php'); + } + + private function registerSsiConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$this->readConfigEnabled('ssi', $container, $config)) { + $container->removeDefinition('fragment.renderer.ssi'); + + return; + } + + $loader->load('ssi.php'); + } + + private function registerFragmentsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$this->readConfigEnabled('fragments', $container, $config)) { + $container->removeDefinition('fragment.renderer.hinclude'); + + return; + } + + $container->setParameter('fragment.renderer.hinclude.global_template', $config['hinclude_default_template']); + + $loader->load('fragment_listener.php'); + $container->setParameter('fragment.path', $config['path']); + } + + private function registerProfilerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$this->readConfigEnabled('profiler', $container, $config)) { + // this is needed for the WebProfiler to work even if the profiler is disabled + $container->setParameter('data_collector.templates', []); + + return; + } + + $loader->load('profiling.php'); + $loader->load('collectors.php'); + $loader->load('cache_debug.php'); + + if ($this->isInitializedConfigEnabled('form')) { + $loader->load('form_debug.php'); + } + + if ($this->isInitializedConfigEnabled('validation')) { + $loader->load('validator_debug.php'); + } + + if ($this->isInitializedConfigEnabled('translator')) { + $loader->load('translation_debug.php'); + + $container->getDefinition('translator.data_collector')->setDecoratedService('translator'); + } + + if ($this->isInitializedConfigEnabled('messenger')) { + $loader->load('messenger_debug.php'); + } + + if ($this->isInitializedConfigEnabled('mailer')) { + $loader->load('mailer_debug.php'); + } + + if ($this->isInitializedConfigEnabled('workflows')) { + $loader->load('workflow_debug.php'); + } + + if ($this->isInitializedConfigEnabled('http_client')) { + $loader->load('http_client_debug.php'); + } + + if ($this->isInitializedConfigEnabled('notifier')) { + $loader->load('notifier_debug.php'); + } + + if ($this->isInitializedConfigEnabled('serializer') && $config['collect_serializer_data']) { + $loader->load('serializer_debug.php'); + } + + if ($this->isInitializedConfigEnabled('feature_flag')) { + $loader->load('feature_flag_debug.php'); + } + + $container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']); + $container->setParameter('profiler_listener.only_main_requests', $config['only_main_requests']); + + // Choose storage class based on the DSN + [$class] = explode(':', $config['dsn'], 2); + if ('file' !== $class) { + throw new \LogicException(sprintf('Driver "%s" is not supported for the profiler.', $class)); + } + + $container->setParameter('profiler.storage.dsn', $config['dsn']); + + $container->getDefinition('profiler') + ->addArgument($config['collect']) + ->addTag('kernel.reset', ['method' => 'reset']); + + $container->getDefinition('profiler_listener') + ->addArgument($config['collect_parameter']); + + if (!$container->getParameter('kernel.debug') || !class_exists(CliRequest::class) || !$container->has('debug.stopwatch')) { + $container->removeDefinition('console_profiler_listener'); + } + + if (!class_exists(CommandDataCollector::class)) { + $container->removeDefinition('.data_collector.command'); + } + } + + private function registerWorkflowConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$config['enabled']) { + $container->removeDefinition('console.command.workflow_dump'); + + return; + } + + if (!class_exists(Workflow\Workflow::class)) { + throw new LogicException('Workflow support cannot be enabled as the Workflow component is not installed. Try running "composer require symfony/workflow".'); + } + + $loader->load('workflow.php'); + + $registryDefinition = $container->getDefinition('workflow.registry'); + + foreach ($config['workflows'] as $name => $workflow) { + $type = $workflow['type']; + $workflowId = sprintf('%s.%s', $type, $name); + + // Process Metadata (workflow + places (transition is done in the "create transition" block)) + $metadataStoreDefinition = new Definition(Workflow\Metadata\InMemoryMetadataStore::class, [[], [], null]); + if ($workflow['metadata']) { + $metadataStoreDefinition->replaceArgument(0, $workflow['metadata']); + } + $placesMetadata = []; + foreach ($workflow['places'] as $place) { + if ($place['metadata']) { + $placesMetadata[$place['name']] = $place['metadata']; + } + } + if ($placesMetadata) { + $metadataStoreDefinition->replaceArgument(1, $placesMetadata); + } + + // Create transitions + $transitions = []; + $guardsConfiguration = []; + $transitionsMetadataDefinition = new Definition(\SplObjectStorage::class); + // Global transition counter per workflow + $transitionCounter = 0; + foreach ($workflow['transitions'] as $transition) { + if ('workflow' === $type) { + $transitionDefinition = new Definition(Workflow\Transition::class, [$transition['name'], $transition['from'], $transition['to']]); + $transitionId = sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); + $container->setDefinition($transitionId, $transitionDefinition); + $transitions[] = new Reference($transitionId); + if (isset($transition['guard'])) { + $configuration = new Definition(Workflow\EventListener\GuardExpression::class); + $configuration->addArgument(new Reference($transitionId)); + $configuration->addArgument($transition['guard']); + $eventName = sprintf('workflow.%s.guard.%s', $name, $transition['name']); + $guardsConfiguration[$eventName][] = $configuration; + } + if ($transition['metadata']) { + $transitionsMetadataDefinition->addMethodCall('attach', [ + new Reference($transitionId), + $transition['metadata'], + ]); + } + } elseif ('state_machine' === $type) { + foreach ($transition['from'] as $from) { + foreach ($transition['to'] as $to) { + $transitionDefinition = new Definition(Workflow\Transition::class, [$transition['name'], $from, $to]); + $transitionId = sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); + $container->setDefinition($transitionId, $transitionDefinition); + $transitions[] = new Reference($transitionId); + if (isset($transition['guard'])) { + $configuration = new Definition(Workflow\EventListener\GuardExpression::class); + $configuration->addArgument(new Reference($transitionId)); + $configuration->addArgument($transition['guard']); + $eventName = sprintf('workflow.%s.guard.%s', $name, $transition['name']); + $guardsConfiguration[$eventName][] = $configuration; + } + if ($transition['metadata']) { + $transitionsMetadataDefinition->addMethodCall('attach', [ + new Reference($transitionId), + $transition['metadata'], + ]); + } + } + } + } + } + $metadataStoreDefinition->replaceArgument(2, $transitionsMetadataDefinition); + $container->setDefinition(sprintf('%s.metadata_store', $workflowId), $metadataStoreDefinition); + + // Create places + $places = array_column($workflow['places'], 'name'); + $initialMarking = $workflow['initial_marking'] ?? []; + + // Create a Definition + $definitionDefinition = new Definition(Workflow\Definition::class); + $definitionDefinition->addArgument($places); + $definitionDefinition->addArgument($transitions); + $definitionDefinition->addArgument($initialMarking); + $definitionDefinition->addArgument(new Reference(sprintf('%s.metadata_store', $workflowId))); + + // Create MarkingStore + $markingStoreDefinition = null; + if (isset($workflow['marking_store']['type']) || isset($workflow['marking_store']['property'])) { + $markingStoreDefinition = new ChildDefinition('workflow.marking_store.method'); + $markingStoreDefinition->setArguments([ + 'state_machine' === $type, // single state + $workflow['marking_store']['property'] ?? 'marking', + ]); + } elseif (isset($workflow['marking_store']['service'])) { + $markingStoreDefinition = new Reference($workflow['marking_store']['service']); + } + + // Create Workflow + $workflowDefinition = new ChildDefinition(sprintf('%s.abstract', $type)); + $workflowDefinition->replaceArgument(0, new Reference(sprintf('%s.definition', $workflowId))); + $workflowDefinition->replaceArgument(1, $markingStoreDefinition); + $workflowDefinition->replaceArgument(3, $name); + $workflowDefinition->replaceArgument(4, $workflow['events_to_dispatch']); + + $workflowDefinition->addTag('workflow', ['name' => $name]); + if ('workflow' === $type) { + $workflowDefinition->addTag('workflow.workflow', ['name' => $name]); + } elseif ('state_machine' === $type) { + $workflowDefinition->addTag('workflow.state_machine', ['name' => $name]); + } + + // Store to container + $container->setDefinition($workflowId, $workflowDefinition); + $container->setDefinition(sprintf('%s.definition', $workflowId), $definitionDefinition); + $container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name.'.'.$type); + $container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name); + + // Validate Workflow + if ('state_machine' === $workflow['type']) { + $validator = new Workflow\Validator\StateMachineValidator(); + } else { + $validator = new Workflow\Validator\WorkflowValidator(); + } + + $trs = array_map(fn (Reference $ref): Workflow\Transition => $container->get((string) $ref), $transitions); + $realDefinition = new Workflow\Definition($places, $trs, $initialMarking); + $validator->validate($realDefinition, $name); + + // Add workflow to Registry + if ($workflow['supports']) { + foreach ($workflow['supports'] as $supportedClassName) { + $strategyDefinition = new Definition(Workflow\SupportStrategy\InstanceOfSupportStrategy::class, [$supportedClassName]); + $registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), $strategyDefinition]); + } + } elseif (isset($workflow['support_strategy'])) { + $registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), new Reference($workflow['support_strategy'])]); + } + + // Enable the AuditTrail + if ($workflow['audit_trail']['enabled']) { + $listener = new Definition(Workflow\EventListener\AuditTrailListener::class); + $listener->addTag('monolog.logger', ['channel' => 'workflow']); + $listener->addTag('kernel.event_listener', ['event' => sprintf('workflow.%s.leave', $name), 'method' => 'onLeave']); + $listener->addTag('kernel.event_listener', ['event' => sprintf('workflow.%s.transition', $name), 'method' => 'onTransition']); + $listener->addTag('kernel.event_listener', ['event' => sprintf('workflow.%s.enter', $name), 'method' => 'onEnter']); + $listener->addArgument(new Reference('logger')); + $container->setDefinition(sprintf('.%s.listener.audit_trail', $workflowId), $listener); + } + + // Add Guard Listener + if ($guardsConfiguration) { + if (!class_exists(ExpressionLanguage::class)) { + throw new LogicException('Cannot guard workflows as the ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'); + } + + if (!class_exists(AuthenticationEvents::class)) { + throw new LogicException('Cannot guard workflows as the Security component is not installed. Try running "composer require symfony/security-core".'); + } + + $guard = new Definition(Workflow\EventListener\GuardListener::class); + + $guard->setArguments([ + $guardsConfiguration, + new Reference('workflow.security.expression_language'), + new Reference('security.token_storage'), + new Reference('security.authorization_checker'), + new Reference('security.authentication.trust_resolver'), + new Reference('security.role_hierarchy'), + new Reference('validator', ContainerInterface::NULL_ON_INVALID_REFERENCE), + ]); + foreach ($guardsConfiguration as $eventName => $config) { + $guard->addTag('kernel.event_listener', ['event' => $eventName, 'method' => 'onTransition']); + } + + $container->setDefinition(sprintf('.%s.listener.guard', $workflowId), $guard); + $container->setParameter('workflow.has_guard_listeners', true); + } + } + + $listenerAttributes = [ + Workflow\Attribute\AsAnnounceListener::class, + Workflow\Attribute\AsCompletedListener::class, + Workflow\Attribute\AsEnterListener::class, + Workflow\Attribute\AsEnteredListener::class, + Workflow\Attribute\AsGuardListener::class, + Workflow\Attribute\AsLeaveListener::class, + Workflow\Attribute\AsTransitionListener::class, + ]; + + foreach ($listenerAttributes as $attribute) { + $container->registerAttributeForAutoconfiguration($attribute, static function (ChildDefinition $definition, AsEventListener $attribute, \ReflectionClass|\ReflectionMethod $reflector) { + $tagAttributes = get_object_vars($attribute); + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new LogicException(sprintf('"%s" attribute cannot declare a method on "%s::%s()".', $attribute::class, $reflector->class, $reflector->name)); + } + $tagAttributes['method'] = $reflector->getName(); + } + $definition->addTag('kernel.event_listener', $tagAttributes); + }); + } + } + + private function registerDebugConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('debug_prod.php'); + + $debug = $container->getParameter('kernel.debug'); + + if (class_exists(Stopwatch::class)) { + $container->register('debug.stopwatch', Stopwatch::class) + ->addArgument(true) + ->setPublic($debug) + ->addTag('kernel.reset', ['method' => 'reset']); + $container->setAlias(Stopwatch::class, new Alias('debug.stopwatch', false)); + } + + if ($debug && !$container->hasParameter('debug.container.dump')) { + $container->setParameter('debug.container.dump', '%kernel.build_dir%/%kernel.container_class%.xml'); + } + + if ($debug && class_exists(Stopwatch::class)) { + $loader->load('debug.php'); + } + + $definition = $container->findDefinition('debug.error_handler_configurator'); + + if (false === $config['log']) { + $definition->replaceArgument(0, null); + } elseif (true !== $config['log']) { + $definition->replaceArgument(1, $config['log']); + } + + if (!$config['throw']) { + $container->setParameter('debug.error_handler.throw_at', 0); + } + + if ($debug && class_exists(DebugProcessor::class)) { + $definition = new Definition(DebugProcessor::class); + $definition->addArgument(new Reference('.virtual_request_stack')); + $definition->addTag('kernel.reset', ['method' => 'reset']); + $container->setDefinition('debug.log_processor', $definition); + + $container->register('debug.debug_logger_configurator', DebugLoggerConfigurator::class) + ->setArguments([new Reference('debug.log_processor'), '%kernel.runtime_mode.web%']); + } + } + + private function registerRouterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, array $enabledLocales = []): void + { + if (!$this->readConfigEnabled('router', $container, $config)) { + $container->removeDefinition('console.command.router_debug'); + $container->removeDefinition('console.command.router_match'); + $container->removeDefinition('messenger.middleware.router_context'); + + return; + } + if (!class_exists(RouterContextMiddleware::class)) { + $container->removeDefinition('messenger.middleware.router_context'); + } + + $loader->load('routing.php'); + + if ($config['utf8']) { + $container->getDefinition('routing.loader')->replaceArgument(1, ['utf8' => true]); + } + + if ($enabledLocales) { + $enabledLocales = implode('|', array_map('preg_quote', $enabledLocales)); + $container->getDefinition('routing.loader')->replaceArgument(2, ['_locale' => $enabledLocales]); + } + + if (!ContainerBuilder::willBeAvailable('symfony/expression-language', ExpressionLanguage::class, ['symfony/framework-bundle', 'symfony/routing'])) { + $container->removeDefinition('router.expression_language_provider'); + } + + $container->setParameter('router.resource', $config['resource']); + $container->setParameter('router.cache_dir', $config['cache_dir']); + $router = $container->findDefinition('router.default'); + $argument = $router->getArgument(2); + $argument['strict_requirements'] = $config['strict_requirements']; + if (isset($config['type'])) { + $argument['resource_type'] = $config['type']; + } + $router->replaceArgument(2, $argument); + + $container->setParameter('request_listener.http_port', $config['http_port']); + $container->setParameter('request_listener.https_port', $config['https_port']); + + if (null !== $config['default_uri']) { + $container->getDefinition('router.request_context') + ->replaceArgument(0, $config['default_uri']); + } + } + + private function registerSessionConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('session.php'); + + // session storage + $container->setAlias('session.storage.factory', $config['storage_factory_id']); + + $options = ['cache_limiter' => '0']; + foreach (['name', 'cookie_lifetime', 'cookie_path', 'cookie_domain', 'cookie_secure', 'cookie_httponly', 'cookie_samesite', 'use_cookies', 'gc_maxlifetime', 'gc_probability', 'gc_divisor', 'sid_length', 'sid_bits_per_character'] as $key) { + if (isset($config[$key])) { + $options[$key] = $config[$key]; + } + } + + if ('auto' === ($options['cookie_secure'] ?? null)) { + $container->getDefinition('session.storage.factory.native')->replaceArgument(3, true); + $container->getDefinition('session.storage.factory.php_bridge')->replaceArgument(2, true); + } + + $container->setParameter('session.storage.options', $options); + + // session handler (the internal callback registered with PHP session management) + if (null === ($config['handler_id'] ?? $config['save_path'] ?? null)) { + $config['save_path'] = null; + $container->setAlias('session.handler', 'session.handler.native'); + } else { + $config['handler_id'] ??= 'session.handler.native_file'; + + if (!\array_key_exists('save_path', $config)) { + $config['save_path'] = '%kernel.cache_dir%/sessions'; + } + $container->resolveEnvPlaceholders($config['handler_id'], null, $usedEnvs); + + if ($usedEnvs || preg_match('#^[a-z]++://#', $config['handler_id'])) { + $id = '.cache_connection.'.ContainerBuilder::hash($config['handler_id']); + + $container->getDefinition('session.abstract_handler') + ->replaceArgument(0, $container->hasDefinition($id) ? new Reference($id) : $config['handler_id']); + + $container->setAlias('session.handler', 'session.abstract_handler'); + } else { + $container->setAlias('session.handler', $config['handler_id']); + } + } + + $container->setParameter('session.save_path', $config['save_path']); + + $container->setParameter('session.metadata.update_threshold', $config['metadata_update_threshold']); + } + + private function registerRequestConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if ($config['formats']) { + $loader->load('request.php'); + + $listener = $container->getDefinition('request.add_request_formats_listener'); + $listener->replaceArgument(0, $config['formats']); + } + } + + private function registerAssetsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('assets.php'); + + if ($config['version_strategy']) { + $defaultVersion = new Reference($config['version_strategy']); + } else { + $defaultVersion = $this->createVersion($container, $config['version'], $config['version_format'], $config['json_manifest_path'], '_default', $config['strict_mode']); + } + + $defaultPackage = $this->createPackageDefinition($config['base_path'], $config['base_urls'], $defaultVersion); + $container->setDefinition('assets._default_package', $defaultPackage); + + foreach ($config['packages'] as $name => $package) { + if (null !== $package['version_strategy']) { + $version = new Reference($package['version_strategy']); + } elseif (!\array_key_exists('version', $package) && null === $package['json_manifest_path']) { + // if neither version nor json_manifest_path are specified, use the default + $version = $defaultVersion; + } else { + // let format fallback to main version_format + $format = $package['version_format'] ?: $config['version_format']; + $version = $package['version'] ?? null; + $version = $this->createVersion($container, $version, $format, $package['json_manifest_path'], $name, $package['strict_mode']); + } + + $packageDefinition = $this->createPackageDefinition($package['base_path'], $package['base_urls'], $version) + ->addTag('assets.package', ['package' => $name]); + $container->setDefinition('assets._package_'.$name, $packageDefinition); + $container->registerAliasForArgument('assets._package_'.$name, PackageInterface::class, $name.'.package'); + } + } + + private function registerAssetMapperConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $assetEnabled): void + { + $loader->load('asset_mapper.php'); + + if (!$assetEnabled) { + $container->removeDefinition('asset_mapper.asset_package'); + } + + $paths = $config['paths']; + foreach ($container->getParameter('kernel.bundles_metadata') as $name => $bundle) { + if ($container->fileExists($dir = $bundle['path'].'/Resources/public') || $container->fileExists($dir = $bundle['path'].'/public')) { + $paths[$dir] = sprintf('bundles/%s', preg_replace('/bundle$/', '', strtolower($name))); + } + } + $excludedPathPatterns = []; + foreach ($config['excluded_patterns'] as $path) { + $excludedPathPatterns[] = Glob::toRegex($path, true, false); + } + + $container->getDefinition('asset_mapper.repository') + ->setArgument(0, $paths) + ->setArgument(2, $excludedPathPatterns) + ->setArgument(3, $config['exclude_dotfiles']); + + $container->getDefinition('asset_mapper.public_assets_path_resolver') + ->setArgument(0, $config['public_prefix']); + + $publicDirectory = $this->getPublicDirectory($container); + $publicAssetsDirectory = rtrim($publicDirectory.'/'.ltrim($config['public_prefix'], '/'), '/'); + $container->getDefinition('asset_mapper.local_public_assets_filesystem') + ->setArgument(0, $publicDirectory) + ; + + $container->getDefinition('asset_mapper.compiled_asset_mapper_config_reader') + ->setArgument(0, $publicAssetsDirectory); + + if (!$config['server']) { + $container->removeDefinition('asset_mapper.dev_server_subscriber'); + } else { + $container->getDefinition('asset_mapper.dev_server_subscriber') + ->setArgument(1, $config['public_prefix']) + ->setArgument(2, $config['extensions']); + } + + $container->getDefinition('asset_mapper.compiler.css_asset_url_compiler') + ->setArgument(0, $config['missing_import_mode']); + + $container->getDefinition('asset_mapper.compiler.javascript_import_path_compiler') + ->setArgument(1, $config['missing_import_mode']); + + $container + ->getDefinition('asset_mapper.importmap.remote_package_storage') + ->replaceArgument(0, $config['vendor_dir']) + ; + $container + ->getDefinition('asset_mapper.mapped_asset_factory') + ->replaceArgument(2, $config['vendor_dir']) + ; + + $container + ->getDefinition('asset_mapper.importmap.config_reader') + ->replaceArgument(0, $config['importmap_path']) + ; + + $container + ->getDefinition('asset_mapper.importmap.renderer') + ->replaceArgument(3, $config['importmap_polyfill']) + ->replaceArgument(4, $config['importmap_script_attributes']) + ; + } + + /** + * Returns a definition for an asset package. + */ + private function createPackageDefinition(?string $basePath, array $baseUrls, Reference $version): Definition + { + if ($basePath && $baseUrls) { + throw new \LogicException('An asset package cannot have base URLs and base paths.'); + } + + $package = new ChildDefinition($baseUrls ? 'assets.url_package' : 'assets.path_package'); + $package + ->replaceArgument(0, $baseUrls ?: $basePath) + ->replaceArgument(1, $version) + ; + + return $package; + } + + private function createVersion(ContainerBuilder $container, ?string $version, ?string $format, ?string $jsonManifestPath, string $name, bool $strictMode): Reference + { + // Configuration prevents $version and $jsonManifestPath from being set + if (null !== $version) { + $def = new ChildDefinition('assets.static_version_strategy'); + $def + ->replaceArgument(0, $version) + ->replaceArgument(1, $format) + ; + $container->setDefinition('assets._version_'.$name, $def); + + return new Reference('assets._version_'.$name); + } + + if (null !== $jsonManifestPath) { + $def = new ChildDefinition('assets.json_manifest_version_strategy'); + $def->replaceArgument(0, $jsonManifestPath); + $def->replaceArgument(2, $strictMode); + $container->setDefinition('assets._version_'.$name, $def); + + return new Reference('assets._version_'.$name); + } + + return new Reference('assets.empty_version_strategy'); + } + + private function registerTranslatorConfiguration(array $config, ContainerBuilder $container, LoaderInterface $loader, string $defaultLocale, array $enabledLocales): void + { + if (!$this->readConfigEnabled('translator', $container, $config)) { + $container->removeDefinition('console.command.translation_debug'); + $container->removeDefinition('console.command.translation_extract'); + $container->removeDefinition('console.command.translation_pull'); + $container->removeDefinition('console.command.translation_push'); + + return; + } + + $loader->load('translation.php'); + + if (!ContainerBuilder::willBeAvailable('symfony/translation', LocaleSwitcher::class, ['symfony/framework-bundle'])) { + $container->removeDefinition('translation.locale_switcher'); + } + + // don't use ContainerBuilder::willBeAvailable() as these are not needed in production + if (interface_exists(Parser::class) && class_exists(PhpAstExtractor::class)) { + $container->removeDefinition('translation.extractor.php'); + } else { + $container->removeDefinition('translation.extractor.php_ast'); + } + + $loader->load('translation_providers.php'); + + // Use the "real" translator instead of the identity default + $container->setAlias('translator', 'translator.default')->setPublic(true); + $container->setAlias('translator.formatter', new Alias($config['formatter'], false)); + $translator = $container->findDefinition('translator.default'); + $translator->addMethodCall('setFallbackLocales', [$config['fallbacks'] ?: [$defaultLocale]]); + + $defaultOptions = $translator->getArgument(4); + $defaultOptions['cache_dir'] = $config['cache_dir']; + $translator->setArgument(4, $defaultOptions); + $translator->setArgument(5, $enabledLocales); + + $container->setParameter('translator.logging', $config['logging']); + $container->setParameter('translator.default_path', $config['default_path']); + + // Discover translation directories + $dirs = []; + $transPaths = []; + $nonExistingDirs = []; + if (ContainerBuilder::willBeAvailable('symfony/validator', Validation::class, ['symfony/framework-bundle', 'symfony/translation'])) { + $r = new \ReflectionClass(Validation::class); + + $dirs[] = $transPaths[] = \dirname($r->getFileName()).'/Resources/translations'; + } + if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/translation'])) { + $r = new \ReflectionClass(Form::class); + + $dirs[] = $transPaths[] = \dirname($r->getFileName()).'/Resources/translations'; + } + if (ContainerBuilder::willBeAvailable('symfony/security-core', AuthenticationException::class, ['symfony/framework-bundle', 'symfony/translation'])) { + $r = new \ReflectionClass(AuthenticationException::class); + + $dirs[] = $transPaths[] = \dirname($r->getFileName(), 2).'/Resources/translations'; + } + $defaultDir = $container->getParameterBag()->resolveValue($config['default_path']); + foreach ($container->getParameter('kernel.bundles_metadata') as $name => $bundle) { + if ($container->fileExists($dir = $bundle['path'].'/Resources/translations') || $container->fileExists($dir = $bundle['path'].'/translations')) { + $dirs[] = $dir; + } else { + $nonExistingDirs[] = $dir; + } + } + + foreach ($config['paths'] as $dir) { + if ($container->fileExists($dir)) { + $dirs[] = $transPaths[] = $dir; + } else { + throw new \UnexpectedValueException(sprintf('"%s" defined in translator.paths does not exist or is not a directory.', $dir)); + } + } + + if ($container->hasDefinition('console.command.translation_debug')) { + $container->getDefinition('console.command.translation_debug')->replaceArgument(5, $transPaths); + } + + if ($container->hasDefinition('console.command.translation_extract')) { + $container->getDefinition('console.command.translation_extract')->replaceArgument(6, $transPaths); + } + + if (null === $defaultDir) { + // allow null + } elseif ($container->fileExists($defaultDir)) { + $dirs[] = $defaultDir; + } else { + $nonExistingDirs[] = $defaultDir; + } + + // Register translation resources + if ($dirs) { + $files = []; + + foreach ($dirs as $dir) { + $finder = Finder::create() + ->followLinks() + ->files() + ->filter(fn (\SplFileInfo $file) => 2 <= substr_count($file->getBasename(), '.') && preg_match('/\.\w+$/', $file->getBasename())) + ->in($dir) + ->sortByName() + ; + foreach ($finder as $file) { + $fileNameParts = explode('.', basename($file)); + $locale = $fileNameParts[\count($fileNameParts) - 2]; + if (!isset($files[$locale])) { + $files[$locale] = []; + } + + $files[$locale][] = (string) $file; + } + } + + $projectDir = $container->getParameter('kernel.project_dir'); + + $options = array_merge( + $translator->getArgument(4), + [ + 'resource_files' => $files, + 'scanned_directories' => $scannedDirectories = array_merge($dirs, $nonExistingDirs), + 'cache_vary' => [ + 'scanned_directories' => array_map(fn ($dir) => str_starts_with($dir, $projectDir.'/') ? substr($dir, 1 + \strlen($projectDir)) : $dir, $scannedDirectories), + ], + ] + ); + + $translator->replaceArgument(4, $options); + } + + if ($config['pseudo_localization']['enabled']) { + $options = $config['pseudo_localization']; + unset($options['enabled']); + + $container + ->register('translator.pseudo', PseudoLocalizationTranslator::class) + ->setDecoratedService('translator', null, -1) // Lower priority than "translator.data_collector" + ->setArguments([ + new Reference('translator.pseudo.inner'), + $options, + ]); + } + + $classToServices = [ + TranslationBridge\Crowdin\CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', + TranslationBridge\Loco\LocoProviderFactory::class => 'translation.provider_factory.loco', + TranslationBridge\Lokalise\LokaliseProviderFactory::class => 'translation.provider_factory.lokalise', + TranslationBridge\Phrase\PhraseProviderFactory::class => 'translation.provider_factory.phrase', + ]; + + $parentPackages = ['symfony/framework-bundle', 'symfony/translation', 'symfony/http-client']; + + foreach ($classToServices as $class => $service) { + $package = substr($service, \strlen('translation.provider_factory.')); + + if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable(sprintf('symfony/%s-translation-provider', $package), $class, $parentPackages)) { + $container->removeDefinition($service); + } + } + + if (!$config['providers']) { + return; + } + + $locales = $enabledLocales; + + foreach ($config['providers'] as $provider) { + if ($provider['locales']) { + $locales += $provider['locales']; + } + } + + $locales = array_unique($locales); + + $container->getDefinition('console.command.translation_pull') + ->replaceArgument(4, array_merge($transPaths, [$config['default_path']])) + ->replaceArgument(5, $locales) + ; + + $container->getDefinition('console.command.translation_push') + ->replaceArgument(2, array_merge($transPaths, [$config['default_path']])) + ->replaceArgument(3, $locales) + ; + + $container->getDefinition('translation.provider_collection_factory') + ->replaceArgument(1, $locales) + ; + + $container->getDefinition('translation.provider_collection')->setArgument(0, $config['providers']); + } + + private function registerValidationConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $propertyInfoEnabled): void + { + if (!$this->readConfigEnabled('validation', $container, $config)) { + $container->removeDefinition('console.command.validator_debug'); + + return; + } + + if (!class_exists(Validation::class)) { + throw new LogicException('Validation support cannot be enabled as the Validator component is not installed. Try running "composer require symfony/validator".'); + } + + if (!isset($config['email_validation_mode'])) { + $config['email_validation_mode'] = 'loose'; + } + + $loader->load('validator.php'); + + $validatorBuilder = $container->getDefinition('validator.builder'); + + $container->setParameter('validator.translation_domain', $config['translation_domain']); + + $files = ['xml' => [], 'yml' => []]; + $this->registerValidatorMapping($container, $config, $files); + + if (!empty($files['xml'])) { + $validatorBuilder->addMethodCall('addXmlMappings', [$files['xml']]); + } + + if (!empty($files['yml'])) { + $validatorBuilder->addMethodCall('addYamlMappings', [$files['yml']]); + } + + $definition = $container->findDefinition('validator.email'); + $definition->replaceArgument(0, $config['email_validation_mode']); + + if (\array_key_exists('enable_attributes', $config) && $config['enable_attributes']) { + $validatorBuilder->addMethodCall('enableAttributeMapping'); + } + + if (\array_key_exists('static_method', $config) && $config['static_method']) { + foreach ($config['static_method'] as $methodName) { + $validatorBuilder->addMethodCall('addMethodMapping', [$methodName]); + } + } + + if (!$container->getParameter('kernel.debug')) { + $validatorBuilder->addMethodCall('setMappingCache', [new Reference('validator.mapping.cache.adapter')]); + } + + $container->setParameter('validator.auto_mapping', $config['auto_mapping']); + if (!$propertyInfoEnabled || !class_exists(PropertyInfoLoader::class)) { + $container->removeDefinition('validator.property_info_loader'); + } + + $container + ->getDefinition('validator.not_compromised_password') + ->setArgument(2, $config['not_compromised_password']['enabled']) + ->setArgument(3, $config['not_compromised_password']['endpoint']) + ; + + if (!class_exists(ExpressionLanguage::class)) { + $container->removeDefinition('validator.expression_language'); + $container->removeDefinition('validator.expression_language_provider'); + } elseif (!class_exists(ExpressionLanguageProvider::class)) { + $container->removeDefinition('validator.expression_language_provider'); + } + } + + private function registerValidatorMapping(ContainerBuilder $container, array $config, array &$files): void + { + $fileRecorder = function ($extension, $path) use (&$files) { + $files['yaml' === $extension ? 'yml' : $extension][] = $path; + }; + + if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) { + $reflClass = new \ReflectionClass(Form::class); + $fileRecorder('xml', \dirname($reflClass->getFileName()).'/Resources/config/validation.xml'); + } + + foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) { + $configDir = is_dir($bundle['path'].'/Resources/config') ? $bundle['path'].'/Resources/config' : $bundle['path'].'/config'; + + if ( + $container->fileExists($file = $configDir.'/validation.yaml', false) + || $container->fileExists($file = $configDir.'/validation.yml', false) + ) { + $fileRecorder('yml', $file); + } + + if ($container->fileExists($file = $configDir.'/validation.xml', false)) { + $fileRecorder('xml', $file); + } + + if ($container->fileExists($dir = $configDir.'/validation', '/^$/')) { + $this->registerMappingFilesFromDir($dir, $fileRecorder); + } + } + + $projectDir = $container->getParameter('kernel.project_dir'); + if ($container->fileExists($dir = $projectDir.'/config/validator', '/^$/')) { + $this->registerMappingFilesFromDir($dir, $fileRecorder); + } + + $this->registerMappingFilesFromConfig($container, $config, $fileRecorder); + } + + private function registerMappingFilesFromDir(string $dir, callable $fileRecorder): void + { + foreach (Finder::create()->followLinks()->files()->in($dir)->name('/\.(xml|ya?ml)$/')->sortByName() as $file) { + $fileRecorder($file->getExtension(), $file->getRealPath()); + } + } + + private function registerMappingFilesFromConfig(ContainerBuilder $container, array $config, callable $fileRecorder): void + { + foreach ($config['mapping']['paths'] as $path) { + if (is_dir($path)) { + $this->registerMappingFilesFromDir($path, $fileRecorder); + $container->addResource(new DirectoryResource($path, '/^$/')); + } elseif ($container->fileExists($path, false)) { + if (!preg_match('/\.(xml|ya?ml)$/', $path, $matches)) { + throw new \RuntimeException(sprintf('Unsupported mapping type in "%s", supported types are XML & Yaml.', $path)); + } + $fileRecorder($matches[1], $path); + } else { + throw new \RuntimeException(sprintf('Could not open file or directory "%s".', $path)); + } + } + } + + private function registerPropertyAccessConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$this->readConfigEnabled('property_access', $container, $config)) { + return; + } + + $loader->load('property_access.php'); + + $magicMethods = PropertyAccessor::DISALLOW_MAGIC_METHODS; + $magicMethods |= $config['magic_call'] ? PropertyAccessor::MAGIC_CALL : 0; + $magicMethods |= $config['magic_get'] ? PropertyAccessor::MAGIC_GET : 0; + $magicMethods |= $config['magic_set'] ? PropertyAccessor::MAGIC_SET : 0; + + $throw = PropertyAccessor::DO_NOT_THROW; + $throw |= $config['throw_exception_on_invalid_index'] ? PropertyAccessor::THROW_ON_INVALID_INDEX : 0; + $throw |= $config['throw_exception_on_invalid_property_path'] ? PropertyAccessor::THROW_ON_INVALID_PROPERTY_PATH : 0; + + $container + ->getDefinition('property_accessor') + ->replaceArgument(0, $magicMethods) + ->replaceArgument(1, $throw) + ->replaceArgument(3, new Reference(PropertyReadInfoExtractorInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ->replaceArgument(4, new Reference(PropertyWriteInfoExtractorInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) + ; + } + + private function registerSecretsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$this->readConfigEnabled('secrets', $container, $config)) { + $container->removeDefinition('console.command.secrets_set'); + $container->removeDefinition('console.command.secrets_list'); + $container->removeDefinition('console.command.secrets_reveal'); + $container->removeDefinition('console.command.secrets_remove'); + $container->removeDefinition('console.command.secrets_generate_key'); + $container->removeDefinition('console.command.secrets_decrypt_to_local'); + $container->removeDefinition('console.command.secrets_encrypt_from_local'); + + return; + } + + $loader->load('secrets.php'); + + $container->getDefinition('secrets.vault')->replaceArgument(0, $config['vault_directory']); + + if ($config['local_dotenv_file']) { + $container->getDefinition('secrets.local_vault')->replaceArgument(0, $config['local_dotenv_file']); + } else { + $container->removeDefinition('secrets.local_vault'); + } + + if ($config['decryption_env_var']) { + if (!preg_match('/^(?:[-.\w\\\\]*+:)*+\w++$/', $config['decryption_env_var'])) { + throw new InvalidArgumentException(sprintf('Invalid value "%s" set as "decryption_env_var": only "word" characters are allowed.', $config['decryption_env_var'])); + } + + if (ContainerBuilder::willBeAvailable('symfony/string', LazyString::class, ['symfony/framework-bundle'])) { + $container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']); + } else { + $container->getDefinition('secrets.vault')->replaceArgument(1, "%env({$config['decryption_env_var']})%"); + $container->removeDefinition('secrets.decryption_key'); + } + } else { + $container->getDefinition('secrets.vault')->replaceArgument(1, null); + $container->removeDefinition('secrets.decryption_key'); + } + } + + private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!$this->readConfigEnabled('csrf_protection', $container, $config)) { + return; + } + + if (!class_exists(\Symfony\Component\Security\Csrf\CsrfToken::class)) { + throw new LogicException('CSRF support cannot be enabled as the Security CSRF component is not installed. Try running "composer require symfony/security-csrf".'); + } + + if (!$this->isInitializedConfigEnabled('session')) { + throw new \LogicException('CSRF protection needs sessions to be enabled.'); + } + + // Enable services for CSRF protection (even without forms) + $loader->load('security_csrf.php'); + + if (!class_exists(CsrfExtension::class)) { + $container->removeDefinition('twig.extension.security_csrf'); + } + } + + private function registerSerializerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('serializer.php'); + + $chainLoader = $container->getDefinition('serializer.mapping.chain_loader'); + + if (!$this->isInitializedConfigEnabled('property_access')) { + $container->removeAlias('serializer.property_accessor'); + $container->removeDefinition('serializer.normalizer.object'); + } + + if (!class_exists(Yaml::class)) { + $container->removeDefinition('serializer.encoder.yaml'); + } + + if (!$this->isInitializedConfigEnabled('property_access')) { + $container->removeDefinition('serializer.denormalizer.unwrapping'); + } + + if (!class_exists(Headers::class)) { + $container->removeDefinition('serializer.normalizer.mime_message'); + } + + if ($container->getParameter('kernel.debug')) { + $container->removeDefinition('serializer.mapping.cache_class_metadata_factory'); + } + + if (!$this->readConfigEnabled('translator', $container, $config)) { + $container->removeDefinition('serializer.normalizer.translatable'); + } + + $serializerLoaders = []; + if (isset($config['enable_attributes']) && $config['enable_attributes']) { + $attributeLoader = new Definition(AttributeLoader::class); + + $serializerLoaders[] = $attributeLoader; + } + + $fileRecorder = function ($extension, $path) use (&$serializerLoaders) { + $definition = new Definition(\in_array($extension, ['yaml', 'yml']) ? YamlFileLoader::class : XmlFileLoader::class, [$path]); + $serializerLoaders[] = $definition; + }; + + foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) { + $configDir = is_dir($bundle['path'].'/Resources/config') ? $bundle['path'].'/Resources/config' : $bundle['path'].'/config'; + + if ($container->fileExists($file = $configDir.'/serialization.xml', false)) { + $fileRecorder('xml', $file); + } + + if ( + $container->fileExists($file = $configDir.'/serialization.yaml', false) + || $container->fileExists($file = $configDir.'/serialization.yml', false) + ) { + $fileRecorder('yml', $file); + } + + if ($container->fileExists($dir = $configDir.'/serialization', '/^$/')) { + $this->registerMappingFilesFromDir($dir, $fileRecorder); + } + } + + $projectDir = $container->getParameter('kernel.project_dir'); + if ($container->fileExists($dir = $projectDir.'/config/serializer', '/^$/')) { + $this->registerMappingFilesFromDir($dir, $fileRecorder); + } + + $this->registerMappingFilesFromConfig($container, $config, $fileRecorder); + + $chainLoader->replaceArgument(0, $serializerLoaders); + $container->getDefinition('serializer.mapping.cache_warmer')->replaceArgument(0, $serializerLoaders); + + if (isset($config['name_converter']) && $config['name_converter']) { + $container->getDefinition('serializer.name_converter.metadata_aware')->setArgument(1, new Reference($config['name_converter'])); + } + + $defaultContext = $config['default_context'] ?? []; + + if ($defaultContext) { + $container->setParameter('serializer.default_context', $defaultContext); + } + + if (isset($config['circular_reference_handler']) && $config['circular_reference_handler']) { + $arguments = $container->getDefinition('serializer.normalizer.object')->getArguments(); + $context = ($arguments[6] ?? $defaultContext) + ['circular_reference_handler' => new Reference($config['circular_reference_handler'])]; + $container->getDefinition('serializer.normalizer.object')->setArgument(5, null); + $container->getDefinition('serializer.normalizer.object')->setArgument(6, $context); + } + + if ($config['max_depth_handler'] ?? false) { + $arguments = $container->getDefinition('serializer.normalizer.object')->getArguments(); + $context = ($arguments[6] ?? $defaultContext) + ['max_depth_handler' => new Reference($config['max_depth_handler'])]; + $container->getDefinition('serializer.normalizer.object')->setArgument(6, $context); + } + } + + private function registerPropertyInfoConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!interface_exists(PropertyInfoExtractorInterface::class)) { + throw new LogicException('PropertyInfo support cannot be enabled as the PropertyInfo component is not installed. Try running "composer require symfony/property-info".'); + } + + $loader->load('property_info.php'); + + if ( + ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/property-info']) + && ContainerBuilder::willBeAvailable('phpdocumentor/type-resolver', ContextFactory::class, ['symfony/framework-bundle', 'symfony/property-info']) + ) { + $definition = $container->register('property_info.phpstan_extractor', PhpStanExtractor::class); + $definition->addTag('property_info.type_extractor', ['priority' => -1000]); + } + + if (ContainerBuilder::willBeAvailable('phpdocumentor/reflection-docblock', DocBlockFactoryInterface::class, ['symfony/framework-bundle', 'symfony/property-info'], true)) { + $definition = $container->register('property_info.php_doc_extractor', PhpDocExtractor::class); + $definition->addTag('property_info.description_extractor', ['priority' => -1000]); + $definition->addTag('property_info.type_extractor', ['priority' => -1001]); + } + + if ($container->getParameter('kernel.debug')) { + $container->removeDefinition('property_info.cache'); + } + } + + private function registerTypeInfoConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!class_exists(Type::class)) { + throw new LogicException('TypeInfo support cannot be enabled as the TypeInfo component is not installed. Try running "composer require symfony/type-info".'); + } + + $loader->load('type_info.php'); + + if (ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/type-info'])) { + $container->register('type_info.resolver.string', StringTypeResolver::class); + + /** @var ServiceLocatorArgument $resolversLocator */ + $resolversLocator = $container->getDefinition('type_info.resolver')->getArgument(0); + $resolversLocator->setValues($resolversLocator->getValues() + [ + 'string' => new Reference('type_info.resolver.string'), + ]); + } + } + + private function registerLockConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('lock.php'); + + foreach ($config['resources'] as $resourceName => $resourceStores) { + if (0 === \count($resourceStores)) { + continue; + } + + // Generate stores + $storeDefinitions = []; + foreach ($resourceStores as $resourceStore) { + $storeDsn = $container->resolveEnvPlaceholders($resourceStore, null, $usedEnvs); + $storeDefinition = new Definition(PersistingStoreInterface::class); + $storeDefinition + ->setFactory([StoreFactory::class, 'createStore']) + ->setArguments([$resourceStore]) + ->addTag('lock.store'); + + $container->setDefinition($storeDefinitionId = '.lock.'.$resourceName.'.store.'.$container->hash($storeDsn), $storeDefinition); + + $storeDefinition = new Reference($storeDefinitionId); + + $storeDefinitions[] = $storeDefinition; + } + + // Wrap array of stores with CombinedStore + if (\count($storeDefinitions) > 1) { + $combinedDefinition = new ChildDefinition('lock.store.combined.abstract'); + $combinedDefinition->replaceArgument(0, $storeDefinitions); + $container->setDefinition($storeDefinitionId = '.lock.'.$resourceName.'.store.'.$container->hash($resourceStores), $combinedDefinition); + } + + // Generate factories for each resource + $factoryDefinition = new ChildDefinition('lock.factory.abstract'); + $factoryDefinition->replaceArgument(0, new Reference($storeDefinitionId)); + $container->setDefinition('lock.'.$resourceName.'.factory', $factoryDefinition); + + // provide alias for default resource + if ('default' === $resourceName) { + $container->setAlias('lock.factory', new Alias('lock.'.$resourceName.'.factory', false)); + $container->setAlias(LockFactory::class, new Alias('lock.factory', false)); + } else { + $container->registerAliasForArgument('lock.'.$resourceName.'.factory', LockFactory::class, $resourceName.'.lock.factory'); + } + } + } + + private function registerSemaphoreConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('semaphore.php'); + + foreach ($config['resources'] as $resourceName => $resourceStore) { + $storeDsn = $container->resolveEnvPlaceholders($resourceStore, null, $usedEnvs); + $storeDefinition = new Definition(SemaphoreStoreInterface::class); + $storeDefinition->setFactory([SemaphoreStoreFactory::class, 'createStore']); + $storeDefinition->setArguments([$resourceStore]); + + $container->setDefinition($storeDefinitionId = '.semaphore.'.$resourceName.'.store.'.$container->hash($storeDsn), $storeDefinition); + + // Generate factories for each resource + $factoryDefinition = new ChildDefinition('semaphore.factory.abstract'); + $factoryDefinition->replaceArgument(0, new Reference($storeDefinitionId)); + $container->setDefinition('semaphore.'.$resourceName.'.factory', $factoryDefinition); + + // Generate services for semaphore instances + $semaphoreDefinition = new Definition(Semaphore::class); + $semaphoreDefinition->setFactory([new Reference('semaphore.'.$resourceName.'.factory'), 'createSemaphore']); + $semaphoreDefinition->setArguments([$resourceName]); + + // provide alias for default resource + if ('default' === $resourceName) { + $container->setAlias('semaphore.factory', new Alias('semaphore.'.$resourceName.'.factory', false)); + $container->setAlias(SemaphoreFactory::class, new Alias('semaphore.factory', false)); + } else { + $container->registerAliasForArgument('semaphore.'.$resourceName.'.factory', SemaphoreFactory::class, $resourceName.'.semaphore.factory'); + } + } + } + + private function registerSchedulerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!class_exists(SchedulerTransportFactory::class)) { + throw new LogicException('Scheduler support cannot be enabled as the Scheduler component is not installed. Try running "composer require symfony/scheduler".'); + } + + $loader->load('scheduler.php'); + + if (!$this->hasConsole()) { + $container->removeDefinition('console.command.scheduler_debug'); + } + } + + private function registerMessengerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $validationEnabled): void + { + if (!interface_exists(MessageBusInterface::class)) { + throw new LogicException('Messenger support cannot be enabled as the Messenger component is not installed. Try running "composer require symfony/messenger".'); + } + + if (!$this->hasConsole()) { + $container->removeDefinition('console.command.messenger_stats'); + } + + $loader->load('messenger.php'); + + if (!interface_exists(DenormalizerInterface::class)) { + $container->removeDefinition('serializer.normalizer.flatten_exception'); + } + + if (ContainerBuilder::willBeAvailable('symfony/amqp-messenger', MessengerBridge\Amqp\Transport\AmqpTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { + $container->getDefinition('messenger.transport.amqp.factory')->addTag('messenger.transport_factory'); + } + + if (ContainerBuilder::willBeAvailable('symfony/redis-messenger', MessengerBridge\Redis\Transport\RedisTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { + $container->getDefinition('messenger.transport.redis.factory')->addTag('messenger.transport_factory'); + } + + if (ContainerBuilder::willBeAvailable('symfony/amazon-sqs-messenger', MessengerBridge\AmazonSqs\Transport\AmazonSqsTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { + $container->getDefinition('messenger.transport.sqs.factory')->addTag('messenger.transport_factory'); + } + + if (ContainerBuilder::willBeAvailable('symfony/beanstalkd-messenger', MessengerBridge\Beanstalkd\Transport\BeanstalkdTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { + $container->getDefinition('messenger.transport.beanstalkd.factory')->addTag('messenger.transport_factory'); + } + + if ($config['stop_worker_on_signals'] && $this->hasConsole()) { + $container->getDefinition('console.command.messenger_consume_messages') + ->replaceArgument(8, $config['stop_worker_on_signals']); + $container->getDefinition('console.command.messenger_failed_messages_retry') + ->replaceArgument(6, $config['stop_worker_on_signals']); + } + + if (null === $config['default_bus'] && 1 === \count($config['buses'])) { + $config['default_bus'] = key($config['buses']); + } + + $defaultMiddleware = [ + 'before' => [ + ['id' => 'add_bus_name_stamp_middleware'], + ['id' => 'reject_redelivered_message_middleware'], + ['id' => 'dispatch_after_current_bus'], + ['id' => 'failed_message_processing_middleware'], + ], + 'after' => [ + ['id' => 'send_message'], + ['id' => 'handle_message'], + ], + ]; + foreach ($config['buses'] as $busId => $bus) { + $middleware = $bus['middleware']; + + if ($bus['default_middleware']['enabled']) { + $defaultMiddleware['after'][0]['arguments'] = [$bus['default_middleware']['allow_no_senders']]; + $defaultMiddleware['after'][1]['arguments'] = [$bus['default_middleware']['allow_no_handlers']]; + + // argument to add_bus_name_stamp_middleware + $defaultMiddleware['before'][0]['arguments'] = [$busId]; + + $middleware = array_merge($defaultMiddleware['before'], $middleware, $defaultMiddleware['after']); + } + + foreach ($middleware as $middlewareItem) { + if (!$validationEnabled && \in_array($middlewareItem['id'], ['validation', 'messenger.middleware.validation'], true)) { + throw new LogicException('The Validation middleware is only available when the Validator component is installed and enabled. Try running "composer require symfony/validator".'); + } + } + + if ($container->getParameter('kernel.debug') && class_exists(Stopwatch::class)) { + array_unshift($middleware, ['id' => 'traceable', 'arguments' => [$busId]]); + } + + $container->setParameter($busId.'.middleware', $middleware); + $container->register($busId, MessageBus::class)->addArgument([])->addTag('messenger.bus'); + + if ($busId === $config['default_bus']) { + $container->setAlias('messenger.default_bus', $busId)->setPublic(true); + $container->setAlias(MessageBusInterface::class, $busId); + } else { + $container->registerAliasForArgument($busId, MessageBusInterface::class); + } + } + + if (empty($config['transports'])) { + $container->removeDefinition('messenger.transport.symfony_serializer'); + $container->removeDefinition('messenger.transport.amqp.factory'); + $container->removeDefinition('messenger.transport.redis.factory'); + $container->removeDefinition('messenger.transport.sqs.factory'); + $container->removeDefinition('messenger.transport.beanstalkd.factory'); + $container->removeAlias(SerializerInterface::class); + } else { + $container->getDefinition('messenger.transport.symfony_serializer') + ->replaceArgument(1, $config['serializer']['symfony_serializer']['format']) + ->replaceArgument(2, $config['serializer']['symfony_serializer']['context']); + $container->setAlias('messenger.default_serializer', $config['serializer']['default_serializer']); + } + + $failureTransports = []; + if ($config['failure_transport']) { + if (!isset($config['transports'][$config['failure_transport']])) { + throw new LogicException(sprintf('Invalid Messenger configuration: the failure transport "%s" is not a valid transport or service id.', $config['failure_transport'])); + } + + $container->setAlias('messenger.failure_transports.default', 'messenger.transport.'.$config['failure_transport']); + $failureTransports[] = $config['failure_transport']; + } + + $failureTransportsByName = []; + foreach ($config['transports'] as $name => $transport) { + if ($transport['failure_transport']) { + $failureTransports[] = $transport['failure_transport']; + $failureTransportsByName[$name] = $transport['failure_transport']; + } elseif ($config['failure_transport']) { + $failureTransportsByName[$name] = $config['failure_transport']; + } + } + + $senderAliases = []; + $transportRetryReferences = []; + $transportRateLimiterReferences = []; + foreach ($config['transports'] as $name => $transport) { + $serializerId = $transport['serializer'] ?? 'messenger.default_serializer'; + $transportDefinition = (new Definition(TransportInterface::class)) + ->setFactory([new Reference('messenger.transport_factory'), 'createTransport']) + ->setArguments([$transport['dsn'], $transport['options'] + ['transport_name' => $name], new Reference($serializerId)]) + ->addTag('messenger.receiver', [ + 'alias' => $name, + 'is_failure_transport' => \in_array($name, $failureTransports, true), + ] + ) + ; + $container->setDefinition($transportId = 'messenger.transport.'.$name, $transportDefinition); + $senderAliases[$name] = $transportId; + + if (null !== $transport['retry_strategy']['service']) { + $transportRetryReferences[$name] = new Reference($transport['retry_strategy']['service']); + } else { + $retryServiceId = sprintf('messenger.retry.multiplier_retry_strategy.%s', $name); + $retryDefinition = new ChildDefinition('messenger.retry.abstract_multiplier_retry_strategy'); + $retryDefinition + ->replaceArgument(0, $transport['retry_strategy']['max_retries']) + ->replaceArgument(1, $transport['retry_strategy']['delay']) + ->replaceArgument(2, $transport['retry_strategy']['multiplier']) + ->replaceArgument(3, $transport['retry_strategy']['max_delay']) + ->replaceArgument(4, $transport['retry_strategy']['jitter']); + $container->setDefinition($retryServiceId, $retryDefinition); + + $transportRetryReferences[$name] = new Reference($retryServiceId); + } + + if ($transport['rate_limiter']) { + if (!interface_exists(LimiterInterface::class)) { + throw new LogicException('Rate limiter cannot be used within Messenger as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".'); + } + + $transportRateLimiterReferences[$name] = new Reference('limiter.'.$transport['rate_limiter']); + } + } + + $senderReferences = []; + // alias => service_id + foreach ($senderAliases as $alias => $serviceId) { + $senderReferences[$alias] = new Reference($serviceId); + } + // service_id => service_id + foreach ($senderAliases as $serviceId) { + $senderReferences[$serviceId] = new Reference($serviceId); + } + + foreach ($config['transports'] as $name => $transport) { + if ($transport['failure_transport']) { + if (!isset($senderReferences[$transport['failure_transport']])) { + throw new LogicException(sprintf('Invalid Messenger configuration: the failure transport "%s" is not a valid transport or service id.', $transport['failure_transport'])); + } + } + } + + $failureTransportReferencesByTransportName = array_map(fn ($failureTransportName) => $senderReferences[$failureTransportName], $failureTransportsByName); + + $messageToSendersMapping = []; + foreach ($config['routing'] as $message => $messageConfiguration) { + if ('*' !== $message && !class_exists($message) && !interface_exists($message, false) && !preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++\*$/', $message)) { + if (str_contains($message, '*')) { + throw new LogicException(sprintf('Invalid Messenger routing configuration: invalid namespace "%s" wildcard.', $message)); + } + + throw new LogicException(sprintf('Invalid Messenger routing configuration: class or interface "%s" not found.', $message)); + } + + // make sure senderAliases contains all senders + foreach ($messageConfiguration['senders'] as $sender) { + if (!isset($senderReferences[$sender])) { + throw new LogicException(sprintf('Invalid Messenger routing configuration: the "%s" class is being routed to a sender called "%s". This is not a valid transport or service id.', $message, $sender)); + } + } + + $messageToSendersMapping[$message] = $messageConfiguration['senders']; + } + + $sendersServiceLocator = ServiceLocatorTagPass::register($container, $senderReferences); + + $container->getDefinition('messenger.senders_locator') + ->replaceArgument(0, $messageToSendersMapping) + ->replaceArgument(1, $sendersServiceLocator) + ; + + $container->getDefinition('messenger.retry.send_failed_message_for_retry_listener') + ->replaceArgument(0, $sendersServiceLocator) + ; + + $container->getDefinition('messenger.retry_strategy_locator') + ->replaceArgument(0, $transportRetryReferences); + + if (!$transportRateLimiterReferences) { + $container->removeDefinition('messenger.rate_limiter_locator'); + } else { + $container->getDefinition('messenger.rate_limiter_locator') + ->replaceArgument(0, $transportRateLimiterReferences); + } + + if (\count($failureTransports) > 0) { + if ($this->hasConsole()) { + $container->getDefinition('console.command.messenger_failed_messages_retry') + ->replaceArgument(0, $config['failure_transport']); + $container->getDefinition('console.command.messenger_failed_messages_show') + ->replaceArgument(0, $config['failure_transport']); + $container->getDefinition('console.command.messenger_failed_messages_remove') + ->replaceArgument(0, $config['failure_transport']); + } + + $failureTransportsByTransportNameServiceLocator = ServiceLocatorTagPass::register($container, $failureTransportReferencesByTransportName); + $container->getDefinition('messenger.failure.send_failed_message_to_failure_transport_listener') + ->replaceArgument(0, $failureTransportsByTransportNameServiceLocator); + } else { + $container->removeDefinition('messenger.failure.send_failed_message_to_failure_transport_listener'); + $container->removeDefinition('console.command.messenger_failed_messages_retry'); + $container->removeDefinition('console.command.messenger_failed_messages_show'); + $container->removeDefinition('console.command.messenger_failed_messages_remove'); + } + + if (!$container->hasDefinition('console.command.messenger_consume_messages')) { + $container->removeDefinition('messenger.listener.reset_services'); + } + } + + private function registerCacheConfiguration(array $config, ContainerBuilder $container): void + { + $version = new Parameter('container.build_id'); + $container->getDefinition('cache.adapter.apcu')->replaceArgument(2, $version); + $container->getDefinition('cache.adapter.system')->replaceArgument(2, $version); + $container->getDefinition('cache.adapter.filesystem')->replaceArgument(2, $config['directory']); + + if (isset($config['prefix_seed'])) { + $container->setParameter('cache.prefix.seed', $config['prefix_seed']); + } + if ($container->hasParameter('cache.prefix.seed')) { + // Inline any env vars referenced in the parameter + $container->setParameter('cache.prefix.seed', $container->resolveEnvPlaceholders($container->getParameter('cache.prefix.seed'), true)); + } + foreach (['psr6', 'redis', 'memcached', 'doctrine_dbal', 'pdo'] as $name) { + if (isset($config[$name = 'default_'.$name.'_provider'])) { + $container->setAlias('cache.'.$name, new Alias(CachePoolPass::getServiceProvider($container, $config[$name]), false)); + } + } + foreach (['app', 'system'] as $name) { + $config['pools']['cache.'.$name] = [ + 'adapters' => [$config[$name]], + 'public' => true, + 'tags' => false, + ]; + } + foreach ($config['pools'] as $name => $pool) { + $pool['adapters'] = $pool['adapters'] ?: ['cache.app']; + + $isRedisTagAware = ['cache.adapter.redis_tag_aware'] === $pool['adapters']; + foreach ($pool['adapters'] as $provider => $adapter) { + if (($config['pools'][$adapter]['adapters'] ?? null) === ['cache.adapter.redis_tag_aware']) { + $isRedisTagAware = true; + } elseif ($config['pools'][$adapter]['tags'] ?? false) { + $pool['adapters'][$provider] = $adapter = '.'.$adapter.'.inner'; + } + } + + if (1 === \count($pool['adapters'])) { + if (!isset($pool['provider']) && !\is_int($provider)) { + $pool['provider'] = $provider; + } + $definition = new ChildDefinition($adapter); + } else { + $definition = new Definition(ChainAdapter::class, [$pool['adapters'], 0]); + $pool['reset'] = 'reset'; + } + + if ($isRedisTagAware && 'cache.app' === $name) { + $container->setAlias('cache.app.taggable', $name); + $definition->addTag('cache.taggable', ['pool' => $name]); + } elseif ($isRedisTagAware) { + $tagAwareId = $name; + $container->setAlias('.'.$name.'.inner', $name); + $definition->addTag('cache.taggable', ['pool' => $name]); + } elseif ($pool['tags']) { + if (true !== $pool['tags'] && ($config['pools'][$pool['tags']]['tags'] ?? false)) { + $pool['tags'] = '.'.$pool['tags'].'.inner'; + } + $container->register($name, TagAwareAdapter::class) + ->addArgument(new Reference('.'.$name.'.inner')) + ->addArgument(true !== $pool['tags'] ? new Reference($pool['tags']) : null) + ->addMethodCall('setLogger', [new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]) + ->setPublic($pool['public']) + ->addTag('cache.taggable', ['pool' => $name]) + ->addTag('monolog.logger', ['channel' => 'cache']); + + $pool['name'] = $tagAwareId = $name; + $pool['public'] = false; + $name = '.'.$name.'.inner'; + } elseif (!\in_array($name, ['cache.app', 'cache.system'], true)) { + $tagAwareId = '.'.$name.'.taggable'; + $container->register($tagAwareId, TagAwareAdapter::class) + ->addArgument(new Reference($name)) + ->addTag('cache.taggable', ['pool' => $name]) + ; + } + + if (!\in_array($name, ['cache.app', 'cache.system'], true)) { + $container->registerAliasForArgument($tagAwareId, TagAwareCacheInterface::class, $pool['name'] ?? $name); + $container->registerAliasForArgument($name, CacheInterface::class, $pool['name'] ?? $name); + $container->registerAliasForArgument($name, CacheItemPoolInterface::class, $pool['name'] ?? $name); + } + + $definition->setPublic($pool['public']); + unset($pool['adapters'], $pool['public'], $pool['tags']); + + $definition->addTag('cache.pool', $pool); + $container->setDefinition($name, $definition); + } + + if (class_exists(PropertyAccessor::class)) { + $propertyAccessDefinition = $container->register('cache.property_access', AdapterInterface::class); + + if (!$container->getParameter('kernel.debug')) { + $propertyAccessDefinition->setFactory([PropertyAccessor::class, 'createCache']); + $propertyAccessDefinition->setArguments(['', 0, $version, new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]); + $propertyAccessDefinition->addTag('cache.pool', ['clearer' => 'cache.system_clearer']); + $propertyAccessDefinition->addTag('monolog.logger', ['channel' => 'cache']); + } else { + $propertyAccessDefinition->setClass(ArrayAdapter::class); + $propertyAccessDefinition->setArguments([0, false]); + } + } + } + + private function registerHttpClientConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('http_client.php'); + + $options = $config['default_options'] ?? []; + $rateLimiter = $options['rate_limiter'] ?? null; + unset($options['rate_limiter']); + $retryOptions = $options['retry_failed'] ?? ['enabled' => false]; + unset($options['retry_failed']); + $defaultUriTemplateVars = $options['vars'] ?? []; + unset($options['vars']); + $container->getDefinition('http_client.transport')->setArguments([$options, $config['max_host_connections'] ?? 6]); + + if (!class_exists(PingWebhookMessageHandler::class)) { + $container->removeDefinition('http_client.messenger.ping_webhook_handler'); + } + + if (!$hasPsr18 = ContainerBuilder::willBeAvailable('psr/http-client', ClientInterface::class, ['symfony/framework-bundle', 'symfony/http-client'])) { + $container->removeDefinition('psr18.http_client'); + $container->removeAlias(ClientInterface::class); + } + + if (!$hasHttplug = ContainerBuilder::willBeAvailable('php-http/httplug', HttpAsyncClient::class, ['symfony/framework-bundle', 'symfony/http-client'])) { + $container->removeDefinition('httplug.http_client'); + $container->removeAlias(HttpAsyncClient::class); + $container->removeAlias(HttpClient::class); + } + + if (null !== $rateLimiter) { + $this->registerThrottlingHttpClient($rateLimiter, 'http_client', $container); + } + + if ($this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) { + $this->registerRetryableHttpClient($retryOptions, 'http_client', $container); + } + + if (ContainerBuilder::willBeAvailable('guzzlehttp/uri-template', \GuzzleHttp\UriTemplate\UriTemplate::class, [])) { + $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.guzzle'); + } elseif (ContainerBuilder::willBeAvailable('rize/uri-template', \Rize\UriTemplate::class, [])) { + $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.rize'); + } + + $container + ->getDefinition('http_client.uri_template') + ->setArgument(2, $defaultUriTemplateVars); + + foreach ($config['scoped_clients'] as $name => $scopeConfig) { + if ($container->has($name)) { + throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name)); + } + + $scope = $scopeConfig['scope'] ?? null; + unset($scopeConfig['scope']); + $rateLimiter = $scopeConfig['rate_limiter'] ?? null; + unset($scopeConfig['rate_limiter']); + $retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false]; + unset($scopeConfig['retry_failed']); + + if (null === $scope) { + $baseUri = $scopeConfig['base_uri']; + unset($scopeConfig['base_uri']); + + $container->register($name, ScopingHttpClient::class) + ->setFactory([ScopingHttpClient::class, 'forBaseUri']) + ->setArguments([new Reference('http_client.transport'), $baseUri, $scopeConfig]) + ->addTag('http_client.client') + ; + } else { + $container->register($name, ScopingHttpClient::class) + ->setArguments([new Reference('http_client.transport'), [$scope => $scopeConfig], $scope]) + ->addTag('http_client.client') + ; + } + + if (null !== $rateLimiter) { + $this->registerThrottlingHttpClient($rateLimiter, $name, $container); + } + + if ($this->readConfigEnabled('http_client.scoped_clients.'.$name.'.retry_failed', $container, $retryOptions)) { + $this->registerRetryableHttpClient($retryOptions, $name, $container); + } + + $container + ->register($name.'.uri_template', UriTemplateHttpClient::class) + ->setDecoratedService($name, null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10) + ->setArguments([ + new Reference($name.'.uri_template.inner'), + new Reference('http_client.uri_template_expander', ContainerInterface::NULL_ON_INVALID_REFERENCE), + $defaultUriTemplateVars, + ]); + + $container->registerAliasForArgument($name, HttpClientInterface::class); + + if ($hasPsr18) { + $container->setDefinition('psr18.'.$name, new ChildDefinition('psr18.http_client')) + ->replaceArgument(0, new Reference($name)); + + $container->registerAliasForArgument('psr18.'.$name, ClientInterface::class, $name); + } + + if ($hasHttplug) { + $container->setDefinition('httplug.'.$name, new ChildDefinition('httplug.http_client')) + ->replaceArgument(0, new Reference($name)); + + $container->registerAliasForArgument('httplug.'.$name, HttpAsyncClient::class, $name); + } + } + + if ($responseFactoryId = $config['mock_response_factory'] ?? null) { + $container->register('http_client.mock_client', MockHttpClient::class) + ->setDecoratedService('http_client.transport', null, -10) // lower priority than TraceableHttpClient (5) + ->setArguments([new Reference($responseFactoryId)]); + } + } + + private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void + { + if (!class_exists(ThrottlingHttpClient::class)) { + throw new LogicException('Rate limiter support cannot be enabled as version 7.1+ of the HttpClient component is required.'); + } + + if (!$this->isInitializedConfigEnabled('rate_limiter')) { + throw new LogicException('Rate limiter cannot be used within HttpClient as the RateLimiter component is not enabled.'); + } + + $container->register($name.'.throttling.limiter', LimiterInterface::class) + ->setFactory([new Reference('limiter.'.$rateLimiter), 'create']); + + $container + ->register($name.'.throttling', ThrottlingHttpClient::class) + ->setDecoratedService($name, null, 15) // higher priority than RetryableHttpClient (10) + ->setArguments([new Reference($name.'.throttling.inner'), new Reference($name.'.throttling.limiter')]); + } + + private function registerRetryableHttpClient(array $options, string $name, ContainerBuilder $container): void + { + if (null !== $options['retry_strategy']) { + $retryStrategy = new Reference($options['retry_strategy']); + } else { + $retryStrategy = new ChildDefinition('http_client.abstract_retry_strategy'); + $codes = []; + foreach ($options['http_codes'] as $code => $codeOptions) { + if ($codeOptions['methods']) { + $codes[$code] = $codeOptions['methods']; + } else { + $codes[] = $code; + } + } + + $retryStrategy + ->replaceArgument(0, $codes ?: GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES) + ->replaceArgument(1, $options['delay']) + ->replaceArgument(2, $options['multiplier']) + ->replaceArgument(3, $options['max_delay']) + ->replaceArgument(4, $options['jitter']); + $container->setDefinition($name.'.retry_strategy', $retryStrategy); + + $retryStrategy = new Reference($name.'.retry_strategy'); + } + + $container + ->register($name.'.retryable', RetryableHttpClient::class) + ->setDecoratedService($name, null, 10) // higher priority than TraceableHttpClient (5) + ->setArguments([new Reference($name.'.retryable.inner'), $retryStrategy, $options['max_retries'], new Reference('logger')]) + ->addTag('monolog.logger', ['channel' => 'http_client']); + } + + private function registerMailerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $webhookEnabled): void + { + if (!class_exists(Mailer::class)) { + throw new LogicException('Mailer support cannot be enabled as the component is not installed. Try running "composer require symfony/mailer".'); + } + + $loader->load('mailer.php'); + $loader->load('mailer_transports.php'); + if (!\count($config['transports']) && null === $config['dsn']) { + $config['dsn'] = 'smtp://null'; + } + $transports = $config['dsn'] ? ['main' => $config['dsn']] : $config['transports']; + $container->getDefinition('mailer.transports')->setArgument(0, $transports); + $container->getDefinition('mailer.default_transport')->setArgument(0, current($transports)); + + $mailer = $container->getDefinition('mailer.mailer'); + if (false === $messageBus = $config['message_bus']) { + $mailer->replaceArgument(1, null); + } else { + $mailer->replaceArgument(1, $messageBus ? new Reference($messageBus) : new Reference('messenger.default_bus', ContainerInterface::NULL_ON_INVALID_REFERENCE)); + } + + $classToServices = [ + MailerBridge\Azure\Transport\AzureTransportFactory::class => 'mailer.transport_factory.azure', + MailerBridge\Brevo\Transport\BrevoTransportFactory::class => 'mailer.transport_factory.brevo', + MailerBridge\Google\Transport\GmailTransportFactory::class => 'mailer.transport_factory.gmail', + MailerBridge\Infobip\Transport\InfobipTransportFactory::class => 'mailer.transport_factory.infobip', + MailerBridge\MailerSend\Transport\MailerSendTransportFactory::class => 'mailer.transport_factory.mailersend', + MailerBridge\Mailgun\Transport\MailgunTransportFactory::class => 'mailer.transport_factory.mailgun', + MailerBridge\Mailjet\Transport\MailjetTransportFactory::class => 'mailer.transport_factory.mailjet', + MailerBridge\MailPace\Transport\MailPaceTransportFactory::class => 'mailer.transport_factory.mailpace', + MailerBridge\Mailchimp\Transport\MandrillTransportFactory::class => 'mailer.transport_factory.mailchimp', + MailerBridge\Postmark\Transport\PostmarkTransportFactory::class => 'mailer.transport_factory.postmark', + MailerBridge\Resend\Transport\ResendTransportFactory::class => 'mailer.transport_factory.resend', + MailerBridge\Scaleway\Transport\ScalewayTransportFactory::class => 'mailer.transport_factory.scaleway', + MailerBridge\Sendgrid\Transport\SendgridTransportFactory::class => 'mailer.transport_factory.sendgrid', + MailerBridge\Amazon\Transport\SesTransportFactory::class => 'mailer.transport_factory.amazon', + ]; + + foreach ($classToServices as $class => $service) { + $package = substr($service, \strlen('mailer.transport_factory.')); + + if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-mailer', 'gmail' === $package ? 'google' : $package), $class, ['symfony/framework-bundle', 'symfony/mailer'])) { + $container->removeDefinition($service); + } + } + + if ($webhookEnabled) { + $webhookRequestParsers = [ + MailerBridge\Brevo\Webhook\BrevoRequestParser::class => 'mailer.webhook.request_parser.brevo', + MailerBridge\MailerSend\Webhook\MailerSendRequestParser::class => 'mailer.webhook.request_parser.mailersend', + MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun', + MailerBridge\Mailjet\Webhook\MailjetRequestParser::class => 'mailer.webhook.request_parser.mailjet', + MailerBridge\Postmark\Webhook\PostmarkRequestParser::class => 'mailer.webhook.request_parser.postmark', + MailerBridge\Resend\Webhook\ResendRequestParser::class => 'mailer.webhook.request_parser.resend', + MailerBridge\Sendgrid\Webhook\SendgridRequestParser::class => 'mailer.webhook.request_parser.sendgrid', + ]; + + foreach ($webhookRequestParsers as $class => $service) { + $package = substr($service, \strlen('mailer.webhook.request_parser.')); + + if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-mailer', 'gmail' === $package ? 'google' : $package), $class, ['symfony/framework-bundle', 'symfony/mailer'])) { + $container->removeDefinition($service); + } + } + } + + $envelopeListener = $container->getDefinition('mailer.envelope_listener'); + $envelopeListener->setArgument(0, $config['envelope']['sender'] ?? null); + $envelopeListener->setArgument(1, $config['envelope']['recipients'] ?? null); + + if ($config['headers']) { + $headers = new Definition(Headers::class); + foreach ($config['headers'] as $name => $data) { + $value = $data['value']; + if (\in_array(strtolower($name), ['from', 'to', 'cc', 'bcc', 'reply-to'])) { + $value = (array) $value; + } + $headers->addMethodCall('addHeader', [$name, $value]); + } + $messageListener = $container->getDefinition('mailer.message_listener'); + $messageListener->setArgument(0, $headers); + } else { + $container->removeDefinition('mailer.message_listener'); + } + + if (!class_exists(MessengerTransportListener::class)) { + $container->removeDefinition('mailer.messenger_transport_listener'); + } + + if ($webhookEnabled) { + $loader->load('mailer_webhook.php'); + } + } + + private function registerNotifierConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $webhookEnabled): void + { + if (!class_exists(Notifier::class)) { + throw new LogicException('Notifier support cannot be enabled as the component is not installed. Try running "composer require symfony/notifier".'); + } + + $loader->load('notifier.php'); + $loader->load('notifier_transports.php'); + + if ($config['chatter_transports']) { + $container->getDefinition('chatter.transports')->setArgument(0, $config['chatter_transports']); + } else { + $container->removeDefinition('chatter'); + $container->removeAlias(ChatterInterface::class); + } + if ($config['texter_transports']) { + $container->getDefinition('texter.transports')->setArgument(0, $config['texter_transports']); + } else { + $container->removeDefinition('texter'); + $container->removeAlias(TexterInterface::class); + } + + if ($this->isInitializedConfigEnabled('mailer')) { + $sender = $container->getDefinition('mailer.envelope_listener')->getArgument(0); + $container->getDefinition('notifier.channel.email')->setArgument(2, $sender); + } else { + $container->removeDefinition('notifier.channel.email'); + } + + foreach (['texter', 'chatter', 'notifier.channel.chat', 'notifier.channel.email', 'notifier.channel.sms'] as $serviceId) { + if (!$container->hasDefinition($serviceId)) { + continue; + } + + if (false === $messageBus = $config['message_bus']) { + $container->getDefinition($serviceId)->replaceArgument(1, null); + } else { + $container->getDefinition($serviceId)->replaceArgument(1, $messageBus ? new Reference($messageBus) : new Reference('messenger.default_bus', ContainerInterface::NULL_ON_INVALID_REFERENCE)); + } + } + + if ($this->isInitializedConfigEnabled('messenger')) { + if ($config['notification_on_failed_messages']) { + $container->getDefinition('notifier.failed_message_listener')->addTag('kernel.event_subscriber'); + } + + // as we have a bus, the channels don't need the transports + $container->getDefinition('notifier.channel.chat')->setArgument(0, null); + if ($container->hasDefinition('notifier.channel.email')) { + $container->getDefinition('notifier.channel.email')->setArgument(0, null); + } + $container->getDefinition('notifier.channel.sms')->setArgument(0, null); + $container->getDefinition('notifier.channel.push')->setArgument(0, null); + } + + $container->getDefinition('notifier.channel_policy')->setArgument(0, $config['channel_policy']); + + $container->registerForAutoconfiguration(NotifierTransportFactoryInterface::class) + ->addTag('chatter.transport_factory'); + + $container->registerForAutoconfiguration(NotifierTransportFactoryInterface::class) + ->addTag('texter.transport_factory'); + + $classToServices = [ + NotifierBridge\AllMySms\AllMySmsTransportFactory::class => 'notifier.transport_factory.all-my-sms', + NotifierBridge\AmazonSns\AmazonSnsTransportFactory::class => 'notifier.transport_factory.amazon-sns', + NotifierBridge\Bandwidth\BandwidthTransportFactory::class => 'notifier.transport_factory.bandwidth', + NotifierBridge\Bluesky\BlueskyTransportFactory::class => 'notifier.transport_factory.bluesky', + NotifierBridge\Brevo\BrevoTransportFactory::class => 'notifier.transport_factory.brevo', + NotifierBridge\Chatwork\ChatworkTransportFactory::class => 'notifier.transport_factory.chatwork', + NotifierBridge\Clickatell\ClickatellTransportFactory::class => 'notifier.transport_factory.clickatell', + NotifierBridge\ClickSend\ClickSendTransportFactory::class => 'notifier.transport_factory.click-send', + NotifierBridge\ContactEveryone\ContactEveryoneTransportFactory::class => 'notifier.transport_factory.contact-everyone', + NotifierBridge\Discord\DiscordTransportFactory::class => 'notifier.transport_factory.discord', + NotifierBridge\Engagespot\EngagespotTransportFactory::class => 'notifier.transport_factory.engagespot', + NotifierBridge\Esendex\EsendexTransportFactory::class => 'notifier.transport_factory.esendex', + NotifierBridge\Expo\ExpoTransportFactory::class => 'notifier.transport_factory.expo', + NotifierBridge\FakeChat\FakeChatTransportFactory::class => 'notifier.transport_factory.fake-chat', + NotifierBridge\FakeSms\FakeSmsTransportFactory::class => 'notifier.transport_factory.fake-sms', + NotifierBridge\Firebase\FirebaseTransportFactory::class => 'notifier.transport_factory.firebase', + NotifierBridge\FortySixElks\FortySixElksTransportFactory::class => 'notifier.transport_factory.forty-six-elks', + NotifierBridge\FreeMobile\FreeMobileTransportFactory::class => 'notifier.transport_factory.free-mobile', + NotifierBridge\GatewayApi\GatewayApiTransportFactory::class => 'notifier.transport_factory.gateway-api', + NotifierBridge\Gitter\GitterTransportFactory::class => 'notifier.transport_factory.gitter', + NotifierBridge\GoIp\GoIpTransportFactory::class => 'notifier.transport_factory.go-ip', + NotifierBridge\GoogleChat\GoogleChatTransportFactory::class => 'notifier.transport_factory.google-chat', + NotifierBridge\Infobip\InfobipTransportFactory::class => 'notifier.transport_factory.infobip', + NotifierBridge\Iqsms\IqsmsTransportFactory::class => 'notifier.transport_factory.iqsms', + NotifierBridge\Isendpro\IsendproTransportFactory::class => 'notifier.transport_factory.isendpro', + NotifierBridge\KazInfoTeh\KazInfoTehTransportFactory::class => 'notifier.transport_factory.kaz-info-teh', + NotifierBridge\LightSms\LightSmsTransportFactory::class => 'notifier.transport_factory.light-sms', + NotifierBridge\LineNotify\LineNotifyTransportFactory::class => 'notifier.transport_factory.line-notify', + NotifierBridge\LinkedIn\LinkedInTransportFactory::class => 'notifier.transport_factory.linked-in', + NotifierBridge\Mailjet\MailjetTransportFactory::class => 'notifier.transport_factory.mailjet', + NotifierBridge\Mastodon\MastodonTransportFactory::class => 'notifier.transport_factory.mastodon', + NotifierBridge\Mattermost\MattermostTransportFactory::class => 'notifier.transport_factory.mattermost', + NotifierBridge\Mercure\MercureTransportFactory::class => 'notifier.transport_factory.mercure', + NotifierBridge\MessageBird\MessageBirdTransportFactory::class => 'notifier.transport_factory.message-bird', + NotifierBridge\MessageMedia\MessageMediaTransportFactory::class => 'notifier.transport_factory.message-media', + NotifierBridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class => 'notifier.transport_factory.microsoft-teams', + NotifierBridge\Mobyt\MobytTransportFactory::class => 'notifier.transport_factory.mobyt', + NotifierBridge\Novu\NovuTransportFactory::class => 'notifier.transport_factory.novu', + NotifierBridge\Ntfy\NtfyTransportFactory::class => 'notifier.transport_factory.ntfy', + NotifierBridge\Octopush\OctopushTransportFactory::class => 'notifier.transport_factory.octopush', + NotifierBridge\OneSignal\OneSignalTransportFactory::class => 'notifier.transport_factory.one-signal', + NotifierBridge\OrangeSms\OrangeSmsTransportFactory::class => 'notifier.transport_factory.orange-sms', + NotifierBridge\OvhCloud\OvhCloudTransportFactory::class => 'notifier.transport_factory.ovh-cloud', + NotifierBridge\PagerDuty\PagerDutyTransportFactory::class => 'notifier.transport_factory.pager-duty', + NotifierBridge\Plivo\PlivoTransportFactory::class => 'notifier.transport_factory.plivo', + NotifierBridge\Pushover\PushoverTransportFactory::class => 'notifier.transport_factory.pushover', + NotifierBridge\Pushy\PushyTransportFactory::class => 'notifier.transport_factory.pushy', + NotifierBridge\Redlink\RedlinkTransportFactory::class => 'notifier.transport_factory.redlink', + NotifierBridge\RingCentral\RingCentralTransportFactory::class => 'notifier.transport_factory.ring-central', + NotifierBridge\RocketChat\RocketChatTransportFactory::class => 'notifier.transport_factory.rocket-chat', + NotifierBridge\Sendberry\SendberryTransportFactory::class => 'notifier.transport_factory.sendberry', + NotifierBridge\SimpleTextin\SimpleTextinTransportFactory::class => 'notifier.transport_factory.simple-textin', + NotifierBridge\Sevenio\SevenIoTransportFactory::class => 'notifier.transport_factory.sevenio', + NotifierBridge\Sinch\SinchTransportFactory::class => 'notifier.transport_factory.sinch', + NotifierBridge\Slack\SlackTransportFactory::class => 'notifier.transport_factory.slack', + NotifierBridge\Sms77\Sms77TransportFactory::class => 'notifier.transport_factory.sms77', + NotifierBridge\Smsapi\SmsapiTransportFactory::class => 'notifier.transport_factory.smsapi', + NotifierBridge\SmsBiuras\SmsBiurasTransportFactory::class => 'notifier.transport_factory.sms-biuras', + NotifierBridge\Smsbox\SmsboxTransportFactory::class => 'notifier.transport_factory.smsbox', + NotifierBridge\Smsc\SmscTransportFactory::class => 'notifier.transport_factory.smsc', + NotifierBridge\SmsFactor\SmsFactorTransportFactory::class => 'notifier.transport_factory.sms-factor', + NotifierBridge\Smsmode\SmsmodeTransportFactory::class => 'notifier.transport_factory.smsmode', + NotifierBridge\SmsSluzba\SmsSluzbaTransportFactory::class => 'notifier.transport_factory.sms-sluzba', + NotifierBridge\Smsense\SmsenseTransportFactory::class => 'notifier.transport_factory.smsense', + NotifierBridge\SpotHit\SpotHitTransportFactory::class => 'notifier.transport_factory.spot-hit', + NotifierBridge\Telegram\TelegramTransportFactory::class => 'notifier.transport_factory.telegram', + NotifierBridge\Telnyx\TelnyxTransportFactory::class => 'notifier.transport_factory.telnyx', + NotifierBridge\Termii\TermiiTransportFactory::class => 'notifier.transport_factory.termii', + NotifierBridge\TurboSms\TurboSmsTransportFactory::class => 'notifier.transport_factory.turbo-sms', + NotifierBridge\Twilio\TwilioTransportFactory::class => 'notifier.transport_factory.twilio', + NotifierBridge\Twitter\TwitterTransportFactory::class => 'notifier.transport_factory.twitter', + NotifierBridge\Unifonic\UnifonicTransportFactory::class => 'notifier.transport_factory.unifonic', + NotifierBridge\Vonage\VonageTransportFactory::class => 'notifier.transport_factory.vonage', + NotifierBridge\Yunpian\YunpianTransportFactory::class => 'notifier.transport_factory.yunpian', + NotifierBridge\Zendesk\ZendeskTransportFactory::class => 'notifier.transport_factory.zendesk', + NotifierBridge\Zulip\ZulipTransportFactory::class => 'notifier.transport_factory.zulip', + ]; + + $parentPackages = ['symfony/framework-bundle', 'symfony/notifier']; + + foreach ($classToServices as $class => $service) { + $package = substr($service, \strlen('notifier.transport_factory.')); + + if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-notifier', $package), $class, $parentPackages)) { + $container->removeDefinition($service); + } + } + + if (ContainerBuilder::willBeAvailable('symfony/mercure-notifier', NotifierBridge\Mercure\MercureTransportFactory::class, $parentPackages) && ContainerBuilder::willBeAvailable('symfony/mercure-bundle', MercureBundle::class, $parentPackages) && \in_array(MercureBundle::class, $container->getParameter('kernel.bundles'), true)) { + $container->getDefinition($classToServices[NotifierBridge\Mercure\MercureTransportFactory::class]) + ->replaceArgument(0, new Reference(HubRegistry::class)) + ->replaceArgument(1, new Reference('event_dispatcher', ContainerBuilder::NULL_ON_INVALID_REFERENCE)) + ->addArgument(new Reference('http_client', ContainerBuilder::NULL_ON_INVALID_REFERENCE)); + } elseif (ContainerBuilder::willBeAvailable('symfony/mercure-notifier', NotifierBridge\Mercure\MercureTransportFactory::class, $parentPackages)) { + $container->removeDefinition($classToServices[NotifierBridge\Mercure\MercureTransportFactory::class]); + } + + if (ContainerBuilder::willBeAvailable('symfony/fake-chat-notifier', NotifierBridge\FakeChat\FakeChatTransportFactory::class, ['symfony/framework-bundle', 'symfony/notifier', 'symfony/mailer'])) { + $container->getDefinition($classToServices[NotifierBridge\FakeChat\FakeChatTransportFactory::class]) + ->replaceArgument(0, new Reference('mailer')) + ->replaceArgument(1, new Reference('logger')) + ->addArgument(new Reference('event_dispatcher', ContainerBuilder::NULL_ON_INVALID_REFERENCE)) + ->addArgument(new Reference('http_client', ContainerBuilder::NULL_ON_INVALID_REFERENCE)); + } + + if (ContainerBuilder::willBeAvailable('symfony/fake-sms-notifier', NotifierBridge\FakeSms\FakeSmsTransportFactory::class, ['symfony/framework-bundle', 'symfony/notifier', 'symfony/mailer'])) { + $container->getDefinition($classToServices[NotifierBridge\FakeSms\FakeSmsTransportFactory::class]) + ->replaceArgument(0, new Reference('mailer')) + ->replaceArgument(1, new Reference('logger')) + ->addArgument(new Reference('event_dispatcher', ContainerBuilder::NULL_ON_INVALID_REFERENCE)) + ->addArgument(new Reference('http_client', ContainerBuilder::NULL_ON_INVALID_REFERENCE)); + } + + if (isset($config['admin_recipients'])) { + $notifier = $container->getDefinition('notifier'); + foreach ($config['admin_recipients'] as $i => $recipient) { + $id = 'notifier.admin_recipient.'.$i; + $container->setDefinition($id, new Definition(Recipient::class, [$recipient['email'], $recipient['phone']])); + $notifier->addMethodCall('addAdminRecipient', [new Reference($id)]); + } + } + + if ($webhookEnabled) { + $loader->load('notifier_webhook.php'); + + $webhookRequestParsers = [ + NotifierBridge\Twilio\Webhook\TwilioRequestParser::class => 'notifier.webhook.request_parser.twilio', + NotifierBridge\Vonage\Webhook\VonageRequestParser::class => 'notifier.webhook.request_parser.vonage', + ]; + + foreach ($webhookRequestParsers as $class => $service) { + $package = substr($service, \strlen('notifier.webhook.request_parser.')); + + if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-notifier', $package), $class, ['symfony/framework-bundle', 'symfony/notifier'])) { + $container->removeDefinition($service); + } + } + } + } + + private function registerWebhookConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!class_exists(WebhookController::class)) { + throw new LogicException('Webhook support cannot be enabled as the component is not installed. Try running "composer require symfony/webhook".'); + } + + $loader->load('webhook.php'); + + $parsers = []; + foreach ($config['routing'] as $type => $cfg) { + $parsers[$type] = [ + 'parser' => new Reference($cfg['service']), + 'secret' => $cfg['secret'], + ]; + } + + $controller = $container->getDefinition('webhook.controller'); + $controller->replaceArgument(0, $parsers); + $controller->replaceArgument(1, new Reference($config['message_bus'])); + } + + private function registerRemoteEventConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!class_exists(RemoteEvent::class)) { + throw new LogicException('RemoteEvent support cannot be enabled as the component is not installed. Try running "composer require symfony/remote-event".'); + } + + $loader->load('remote_event.php'); + } + + private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('rate_limiter.php'); + + foreach ($config['limiters'] as $name => $limiterConfig) { + // default configuration (when used by other DI extensions) + $limiterConfig += ['lock_factory' => 'lock.factory', 'cache_pool' => 'cache.rate_limiter']; + + $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')) + ->addTag('rate_limiter', ['name' => $name]); + + if (null !== $limiterConfig['lock_factory']) { + if (!interface_exists(LockInterface::class)) { + throw new LogicException(sprintf('Rate limiter "%s" requires the Lock component to be installed. Try running "composer require symfony/lock".', $name)); + } + + if (!$this->isInitializedConfigEnabled('lock')) { + throw new LogicException(sprintf('Rate limiter "%s" requires the Lock component to be configured.', $name)); + } + + $limiter->replaceArgument(2, new Reference($limiterConfig['lock_factory'])); + } + unset($limiterConfig['lock_factory']); + + if (null === $storageId = $limiterConfig['storage_service'] ?? null) { + $container->register($storageId = 'limiter.storage.'.$name, CacheStorage::class)->addArgument(new Reference($limiterConfig['cache_pool'])); + } + + $limiter->replaceArgument(1, new Reference($storageId)); + unset($limiterConfig['storage_service'], $limiterConfig['cache_pool']); + + $limiterConfig['id'] = $name; + $limiter->replaceArgument(0, $limiterConfig); + + $container->registerAliasForArgument($limiterId, RateLimiterFactory::class, $name.'.limiter'); + } + } + + private function registerUidConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('uid.php'); + + $container->getDefinition('uuid.factory') + ->setArguments([ + $config['default_uuid_version'], + $config['time_based_uuid_version'], + $config['name_based_uuid_version'], + UuidV4::class, + $config['time_based_uuid_node'] ?? null, + $config['name_based_uuid_namespace'] ?? null, + ]) + ; + + if (isset($config['name_based_uuid_namespace'])) { + $container->getDefinition('name_based_uuid.factory') + ->setArguments([$config['name_based_uuid_namespace']]); + } + } + + private function registerHtmlSanitizerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('html_sanitizer.php'); + + foreach ($config['sanitizers'] as $sanitizerName => $sanitizerConfig) { + $configId = 'html_sanitizer.config.'.$sanitizerName; + $def = $container->register($configId, HtmlSanitizerConfig::class); + + // Base + if ($sanitizerConfig['allow_safe_elements']) { + $def->addMethodCall('allowSafeElements', [], true); + } + + if ($sanitizerConfig['allow_static_elements']) { + $def->addMethodCall('allowStaticElements', [], true); + } + + // Configures elements + foreach ($sanitizerConfig['allow_elements'] as $element => $attributes) { + $def->addMethodCall('allowElement', [$element, $attributes], true); + } + + foreach ($sanitizerConfig['block_elements'] as $element) { + $def->addMethodCall('blockElement', [$element], true); + } + + foreach ($sanitizerConfig['drop_elements'] as $element) { + $def->addMethodCall('dropElement', [$element], true); + } + + // Configures attributes + foreach ($sanitizerConfig['allow_attributes'] as $attribute => $elements) { + $def->addMethodCall('allowAttribute', [$attribute, $elements], true); + } + + foreach ($sanitizerConfig['drop_attributes'] as $attribute => $elements) { + $def->addMethodCall('dropAttribute', [$attribute, $elements], true); + } + + // Force attributes + foreach ($sanitizerConfig['force_attributes'] as $element => $attributes) { + foreach ($attributes as $attrName => $attrValue) { + $def->addMethodCall('forceAttribute', [$element, $attrName, $attrValue], true); + } + } + + // Settings + $def->addMethodCall('forceHttpsUrls', [$sanitizerConfig['force_https_urls']], true); + if ($sanitizerConfig['allowed_link_schemes']) { + $def->addMethodCall('allowLinkSchemes', [$sanitizerConfig['allowed_link_schemes']], true); + } + $def->addMethodCall('allowLinkHosts', [$sanitizerConfig['allowed_link_hosts']], true); + $def->addMethodCall('allowRelativeLinks', [$sanitizerConfig['allow_relative_links']], true); + if ($sanitizerConfig['allowed_media_schemes']) { + $def->addMethodCall('allowMediaSchemes', [$sanitizerConfig['allowed_media_schemes']], true); + } + $def->addMethodCall('allowMediaHosts', [$sanitizerConfig['allowed_media_hosts']], true); + $def->addMethodCall('allowRelativeMedias', [$sanitizerConfig['allow_relative_medias']], true); + + // Custom attribute sanitizers + foreach ($sanitizerConfig['with_attribute_sanitizers'] as $serviceName) { + $def->addMethodCall('withAttributeSanitizer', [new Reference($serviceName)], true); + } + + foreach ($sanitizerConfig['without_attribute_sanitizers'] as $serviceName) { + $def->addMethodCall('withoutAttributeSanitizer', [new Reference($serviceName)], true); + } + + if ($sanitizerConfig['max_input_length']) { + $def->addMethodCall('withMaxInputLength', [$sanitizerConfig['max_input_length']], true); + } + + // Create the sanitizer and link its config + $sanitizerId = 'html_sanitizer.sanitizer.'.$sanitizerName; + $container->register($sanitizerId, HtmlSanitizer::class) + ->addTag('html_sanitizer', ['sanitizer' => $sanitizerName]) + ->addArgument(new Reference($configId)); + + if ('default' !== $sanitizerName) { + $container->registerAliasForArgument($sanitizerId, HtmlSanitizerInterface::class, $sanitizerName); + } + } + } + + private function registerFeatureFlagConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('feature_flag.php'); + + $container->registerForAutoconfiguration(FeatureRegistryInterface::class) + ->addTag('feature_flag.feature_registry') + ; + + $container->registerAttributeForAutoconfiguration(AsFeature::class, + static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { + $featureName = $attribute->name; + + if ($reflector instanceof \ReflectionClass) { + $className = $reflector->getName(); + $method = $attribute->method; + + $featureName ??= $className; + } else { + $className = $reflector->getDeclaringClass()->getName(); + if (null !== $attribute->method && $reflector->getName() !== $attribute->method) { + throw new \LogicException(sprintf('Using the #[%s(method: %s)] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s)', AsFeature::class, $attribute->method, $className)); + } + + $method = $reflector->getName(); + $featureName ??= "{$className}::{$method}"; + } + + $definition->addTag('feature_flag.feature', [ + 'feature' => $featureName, + 'method' => $method, + ]); + }, + ); + + if (ContainerBuilder::willBeAvailable('symfony/routing', Router::class, ['symfony/framework-bundle', 'symfony/routing'])) { + $loader->load('feature_flag_routing.php'); + } + } + + private function resolveTrustedHeaders(array $headers): int + { + $trustedHeaders = 0; + + foreach ($headers as $h) { + $trustedHeaders |= match ($h) { + 'forwarded' => Request::HEADER_FORWARDED, + 'x-forwarded-for' => Request::HEADER_X_FORWARDED_FOR, + 'x-forwarded-host' => Request::HEADER_X_FORWARDED_HOST, + 'x-forwarded-proto' => Request::HEADER_X_FORWARDED_PROTO, + 'x-forwarded-port' => Request::HEADER_X_FORWARDED_PORT, + 'x-forwarded-prefix' => Request::HEADER_X_FORWARDED_PREFIX, + default => 0, + }; + } + + return $trustedHeaders; + } + + public function getXsdValidationBasePath(): string|false + { + return \dirname(__DIR__).'/Resources/config/schema'; + } + + public function getNamespace(): string + { + return 'http://symfony.com/schema/dic/symfony'; + } + + protected function isConfigEnabled(ContainerBuilder $container, array $config): bool + { + throw new \LogicException('To prevent using outdated configuration, you must use the "readConfigEnabled" method instead.'); + } + + private function isInitializedConfigEnabled(string $path): bool + { + if (isset($this->configsEnabled[$path])) { + return $this->configsEnabled[$path]; + } + + throw new LogicException(sprintf('Can not read config enabled at "%s" because it has not been initialized.', $path)); + } + + private function readConfigEnabled(string $path, ContainerBuilder $container, array $config): bool + { + return $this->configsEnabled[$path] ??= parent::isConfigEnabled($container, $config); + } + + private function writeConfigEnabled(string $path, bool $value, array &$config): void + { + if (isset($this->configsEnabled[$path])) { + throw new LogicException('Can not change config enabled because it has already been read.'); + } + + $this->configsEnabled[$path] = $value; + $config['enabled'] = $value; + } + + private function getPublicDirectory(ContainerBuilder $container): string + { + $projectDir = $container->getParameter('kernel.project_dir'); + $defaultPublicDir = $projectDir.'/public'; + + $composerFilePath = $projectDir.'/composer.json'; + + if (!file_exists($composerFilePath)) { + return $defaultPublicDir; + } + + $container->addResource(new FileResource($composerFilePath)); + $composerConfig = json_decode(file_get_contents($composerFilePath), true); + + return isset($composerConfig['extra']['public-dir']) ? $projectDir.'/'.$composerConfig['extra']['public-dir'] : $defaultPublicDir; + } +} From dc4a8dd264d1c0a2a4eb573e5e65229cf77a7ff1 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Thu, 29 Feb 2024 22:40:55 +0100 Subject: [PATCH 17/46] cs --- .../DependencyInjection/Compiler/FeatureFlagPass.php | 7 ++++--- .../Resources/config/feature_flag_routing.php | 1 - .../FeatureFlag/DataCollector/FeatureFlagDataCollector.php | 2 +- src/Symfony/Component/FeatureFlag/FeatureRegistry.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php index ff7fee78b9cb7..bb3b322a0f058 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -37,7 +37,7 @@ public function process(ContainerBuilder $container): void foreach ($tags as $tag) { $featureName = ($tag['feature'] ?? '') ?: $className; - if (array_key_exists($featureName, $features)) { + if (\array_key_exists($featureName, $features)) { throw new \RuntimeException(sprintf('Feature "%s" already defined.', $featureName)); } @@ -47,7 +47,7 @@ public function process(ContainerBuilder $container): void } $features[$featureName] = $container->setDefinition( - ".feature_flag.feature", + '.feature_flag.feature', (new Definition(\Closure::class)) ->setLazy(true) ->setFactory([\Closure::class, 'fromCallable']) @@ -74,7 +74,8 @@ public function process(ContainerBuilder $container): void ; } } - private function getServiceClass(ContainerBuilder $container, string $serviceId): string|null + + private function getServiceClass(ContainerBuilder $container, string $serviceId): ?string { while (true) { $definition = $container->findDefinition($serviceId); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php index e97bbf2c8eb40..9f181103ee5d3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php @@ -21,7 +21,6 @@ ]) ->tag('routing.expression_language_function', ['function' => 'is_feature_enabled']) - ->set('feature_flag.routing_expression_language_function.get_value', \Closure::class) ->factory([\Closure::class, 'fromCallable']) ->args([ diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index bb35698e837b5..55af0ef1b546c 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -26,7 +26,7 @@ public function __construct( ) { } - public function collect(Request $request, Response $response, \Throwable $exception = null): void + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void { } diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php index 5952c2b4c9487..549b99345734c 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -24,7 +24,7 @@ public function __construct(private readonly array $features) public function has(string $id): bool { - return array_key_exists($id, $this->features); + return \array_key_exists($id, $this->features); } public function get(string $id): callable From 911cdc9f2ef1defb0efc37c2eaaa30cf34735b95 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 1 Mar 2024 10:01:01 +0100 Subject: [PATCH 18/46] add Neirda24 as co-author Co-authored-by: Adrien Roches From 86f584ba90fcab9e64870a74444171614e093b67 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 1 Mar 2024 10:39:43 +0100 Subject: [PATCH 19/46] add ContainerInterface dependency --- src/Symfony/Component/FeatureFlag/composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json index ccda1320f7ec2..eba02519b62cc 100644 --- a/src/Symfony/Component/FeatureFlag/composer.json +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -16,7 +16,8 @@ } ], "require": { - "php": ">=8.2" + "php": ">=8.2", + "psr/container": "^1.1|^2.0" }, "autoload": { "psr-4": { "Symfony\\Component\\FeatureFlag\\": "" }, From 70bb7336c50c9f19a955f17c32fd55022b5b15a8 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 1 Mar 2024 10:47:27 +0100 Subject: [PATCH 20/46] fix exception namespace --- .../Component/FeatureFlag/Exception/RuntimeException.php | 2 -- src/Symfony/Component/FeatureFlag/composer.json | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php b/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php index 7dcd8e1cef009..df719e0e22714 100644 --- a/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php +++ b/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php @@ -11,8 +11,6 @@ namespace Symfony\Component\FeatureFlag\Exception; -use Symfony\Component\Workflow\Exception\ExceptionInterface; - class RuntimeException extends \RuntimeException implements ExceptionInterface { } diff --git a/src/Symfony/Component/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json index eba02519b62cc..e06a8a53823e7 100644 --- a/src/Symfony/Component/FeatureFlag/composer.json +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -19,6 +19,9 @@ "php": ">=8.2", "psr/container": "^1.1|^2.0" }, + "require-dev": { + "symfony/http-kernel": "^7.1" + }, "autoload": { "psr-4": { "Symfony\\Component\\FeatureFlag\\": "" }, "exclude-from-classmap": [ From e1c2f481784916ee8650083ebb28db5803bc0be2 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 1 Mar 2024 11:07:27 +0100 Subject: [PATCH 21/46] fix namespace again --- src/Symfony/Component/FeatureFlag/composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json index e06a8a53823e7..97e44d6b54879 100644 --- a/src/Symfony/Component/FeatureFlag/composer.json +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -20,7 +20,8 @@ "psr/container": "^1.1|^2.0" }, "require-dev": { - "symfony/http-kernel": "^7.1" + "symfony/http-kernel": "^7.1", + "symfony/service-contracts": "^2.5|^3" }, "autoload": { "psr-4": { "Symfony\\Component\\FeatureFlag\\": "" }, From e9456986fbc216996254905e8ee62d4ec445015b Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 1 Mar 2024 11:30:35 +0100 Subject: [PATCH 22/46] remove merge file --- .../FrameworkExtension.php.orig | 3203 ----------------- 1 file changed, 3203 deletions(-) delete mode 100644 src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig deleted file mode 100644 index b1a0af9cf41b7..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php.orig +++ /dev/null @@ -1,3203 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bundle\FrameworkBundle\DependencyInjection; - -use Composer\InstalledVersions; -use Http\Client\HttpAsyncClient; -use Http\Client\HttpClient; -use phpDocumentor\Reflection\DocBlockFactoryInterface; -use phpDocumentor\Reflection\Types\ContextFactory; -use PhpParser\Parser; -use PHPStan\PhpDocParser\Parser\PhpDocParser; -use Psr\Cache\CacheItemPoolInterface; -use Psr\Clock\ClockInterface as PsrClockInterface; -use Psr\Container\ContainerInterface as PsrContainerInterface; -use Psr\Http\Client\ClientInterface; -use Psr\Log\LoggerAwareInterface; -use Symfony\Bridge\Monolog\Processor\DebugProcessor; -use Symfony\Bridge\Twig\Extension\CsrfExtension; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; -use Symfony\Bundle\FullStack; -use Symfony\Bundle\MercureBundle\MercureBundle; -use Symfony\Component\Asset\PackageInterface; -use Symfony\Component\AssetMapper\AssetMapper; -use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; -use Symfony\Component\BrowserKit\AbstractBrowser; -use Symfony\Component\Cache\Adapter\AdapterInterface; -use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Component\Cache\Adapter\ChainAdapter; -use Symfony\Component\Cache\Adapter\TagAwareAdapter; -use Symfony\Component\Cache\DependencyInjection\CachePoolPass; -use Symfony\Component\Cache\Marshaller\MarshallerInterface; -use Symfony\Component\Cache\ResettableInterface; -use Symfony\Component\Clock\ClockInterface; -use Symfony\Component\Config\Definition\ConfigurationInterface; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\Config\Resource\DirectoryResource; -use Symfony\Component\Config\Resource\FileResource; -use Symfony\Component\Config\ResourceCheckerInterface; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\DataCollector\CommandDataCollector; -use Symfony\Component\Console\Debug\CliRequest; -use Symfony\Component\Console\Messenger\RunCommandMessageHandler; -use Symfony\Component\DependencyInjection\Alias; -use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; -use Symfony\Component\DependencyInjection\ChildDefinition; -use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\EnvVarLoaderInterface; -use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; -use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; -use Symfony\Component\DependencyInjection\Exception\LogicException; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; -use Symfony\Component\DependencyInjection\Parameter; -use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\DependencyInjection\ServiceLocator; -use Symfony\Component\Dotenv\Command\DebugCommand; -use Symfony\Component\EventDispatcher\Attribute\AsEventListener; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\ExpressionLanguage\ExpressionLanguage; -use Symfony\Component\FeatureFlag\Attribute\AsFeature; -use Symfony\Component\FeatureFlag\FeatureChecker; -use Symfony\Component\FeatureFlag\FeatureRegistryInterface; -use Symfony\Component\Finder\Finder; -use Symfony\Component\Finder\Glob; -use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension; -use Symfony\Component\Form\Form; -use Symfony\Component\Form\FormTypeExtensionInterface; -use Symfony\Component\Form\FormTypeGuesserInterface; -use Symfony\Component\Form\FormTypeInterface; -use Symfony\Component\HtmlSanitizer\HtmlSanitizer; -use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; -use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; -use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; -use Symfony\Component\HttpClient\RetryableHttpClient; -use Symfony\Component\HttpClient\ScopingHttpClient; -use Symfony\Component\HttpClient\ThrottlingHttpClient; -use Symfony\Component\HttpClient\UriTemplateHttpClient; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Attribute\AsController; -use Symfony\Component\HttpKernel\Attribute\AsTargetedValueResolver; -use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; -use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; -use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; -use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; -use Symfony\Component\HttpKernel\Log\DebugLoggerConfigurator; -use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Lock\LockInterface; -use Symfony\Component\Lock\PersistingStoreInterface; -use Symfony\Component\Lock\Store\StoreFactory; -use Symfony\Component\Mailer\Bridge as MailerBridge; -use Symfony\Component\Mailer\Command\MailerTestCommand; -use Symfony\Component\Mailer\EventListener\MessengerTransportListener; -use Symfony\Component\Mailer\Mailer; -use Symfony\Component\Mercure\HubRegistry; -use Symfony\Component\Messenger\Attribute\AsMessageHandler; -use Symfony\Component\Messenger\Bridge as MessengerBridge; -use Symfony\Component\Messenger\Handler\BatchHandlerInterface; -use Symfony\Component\Messenger\MessageBus; -use Symfony\Component\Messenger\MessageBusInterface; -use Symfony\Component\Messenger\Middleware\RouterContextMiddleware; -use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; -use Symfony\Component\Messenger\Transport\TransportFactoryInterface as MessengerTransportFactoryInterface; -use Symfony\Component\Messenger\Transport\TransportInterface; -use Symfony\Component\Mime\Header\Headers; -use Symfony\Component\Mime\MimeTypeGuesserInterface; -use Symfony\Component\Mime\MimeTypes; -use Symfony\Component\Notifier\Bridge as NotifierBridge; -use Symfony\Component\Notifier\ChatterInterface; -use Symfony\Component\Notifier\Notifier; -use Symfony\Component\Notifier\Recipient\Recipient; -use Symfony\Component\Notifier\TexterInterface; -use Symfony\Component\Notifier\Transport\TransportFactoryInterface as NotifierTransportFactoryInterface; -use Symfony\Component\Process\Messenger\RunProcessMessageHandler; -use Symfony\Component\PropertyAccess\PropertyAccessor; -use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; -use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; -use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; -use Symfony\Component\RateLimiter\LimiterInterface; -use Symfony\Component\RateLimiter\RateLimiterFactory; -use Symfony\Component\RateLimiter\Storage\CacheStorage; -use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; -use Symfony\Component\RemoteEvent\RemoteEvent; -use Symfony\Component\Routing\Router; -use Symfony\Component\Scheduler\Attribute\AsCronTask; -use Symfony\Component\Scheduler\Attribute\AsPeriodicTask; -use Symfony\Component\Scheduler\Attribute\AsSchedule; -use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; -use Symfony\Component\Security\Core\AuthenticationEvents; -use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; -use Symfony\Component\Semaphore\PersistingStoreInterface as SemaphoreStoreInterface; -use Symfony\Component\Semaphore\Semaphore; -use Symfony\Component\Semaphore\SemaphoreFactory; -use Symfony\Component\Semaphore\Store\StoreFactory as SemaphoreStoreFactory; -use Symfony\Component\Serializer\Encoder\DecoderInterface; -use Symfony\Component\Serializer\Encoder\EncoderInterface; -use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; -use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader; -use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Stopwatch\Stopwatch; -use Symfony\Component\String\LazyString; -use Symfony\Component\String\Slugger\SluggerInterface; -use Symfony\Component\Translation\Bridge as TranslationBridge; -use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; -use Symfony\Component\Translation\Extractor\PhpAstExtractor; -use Symfony\Component\Translation\LocaleSwitcher; -use Symfony\Component\Translation\PseudoLocalizationTranslator; -use Symfony\Component\Translation\Translator; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; -use Symfony\Component\Uid\Factory\UuidFactory; -use Symfony\Component\Uid\UuidV4; -use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider; -use Symfony\Component\Validator\ConstraintValidatorInterface; -use Symfony\Component\Validator\GroupProviderInterface; -use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; -use Symfony\Component\Validator\ObjectInitializerInterface; -use Symfony\Component\Validator\Validation; -use Symfony\Component\Webhook\Controller\WebhookController; -use Symfony\Component\WebLink\HttpHeaderSerializer; -use Symfony\Component\Workflow; -use Symfony\Component\Workflow\WorkflowInterface; -use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand; -use Symfony\Component\Yaml\Yaml; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\CallbackInterface; -use Symfony\Contracts\Cache\TagAwareCacheInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\Service\ResetInterface; -use Symfony\Contracts\Service\ServiceSubscriberInterface; -use Symfony\Contracts\Translation\LocaleAwareInterface; - -/** - * Process the configuration and prepare the dependency injection container with - * parameters and services. - */ -class FrameworkExtension extends Extension -{ - private array $configsEnabled = []; - - /** - * Responds to the app.config configuration parameter. - * - * @throws LogicException - */ - public function load(array $configs, ContainerBuilder $container): void - { - $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); - - if (class_exists(InstalledVersions::class) && InstalledVersions::isInstalled('symfony/symfony') && 'symfony/symfony' !== (InstalledVersions::getRootPackage()['name'] ?? '')) { - throw new \LogicException('Requiring the "symfony/symfony" package is unsupported; replace it with standalone components instead.'); - } - - $loader->load('web.php'); - $loader->load('services.php'); - $loader->load('fragment_renderer.php'); - $loader->load('error_renderer.php'); - - if (!ContainerBuilder::willBeAvailable('symfony/clock', ClockInterface::class, ['symfony/framework-bundle'])) { - $container->removeDefinition('clock'); - $container->removeAlias(ClockInterface::class); - $container->removeAlias(PsrClockInterface::class); - } - - $container->registerAliasForArgument('parameter_bag', PsrContainerInterface::class); - - $loader->load('process.php'); - - if (!class_exists(RunProcessMessageHandler::class)) { - $container->removeDefinition('process.messenger.process_message_handler'); - } - - if ($this->hasConsole()) { - $loader->load('console.php'); - - if (!class_exists(BaseXliffLintCommand::class)) { - $container->removeDefinition('console.command.xliff_lint'); - } - if (!class_exists(BaseYamlLintCommand::class)) { - $container->removeDefinition('console.command.yaml_lint'); - } - - if (!class_exists(DebugCommand::class)) { - $container->removeDefinition('console.command.dotenv_debug'); - } - - if (!class_exists(RunCommandMessageHandler::class)) { - $container->removeDefinition('console.messenger.application'); - $container->removeDefinition('console.messenger.execute_command_handler'); - } - } - - // Load Cache configuration first as it is used by other components - $loader->load('cache.php'); - - $configuration = $this->getConfiguration($configs, $container); - $config = $this->processConfiguration($configuration, $configs); - - // warmup config enabled - $this->readConfigEnabled('translator', $container, $config['translator']); - $this->readConfigEnabled('property_access', $container, $config['property_access']); - $this->readConfigEnabled('profiler', $container, $config['profiler']); - $this->readConfigEnabled('workflows', $container, $config['workflows']); - $this->readConfigEnabled('feature_flag', $container, $config['feature_flag']); - - // A translator must always be registered (as support is included by - // default in the Form and Validator component). If disabled, an identity - // translator will be used and everything will still work as expected. - if ($this->readConfigEnabled('translator', $container, $config['translator']) || $this->readConfigEnabled('form', $container, $config['form']) || $this->readConfigEnabled('validation', $container, $config['validation'])) { - if (!class_exists(Translator::class) && $this->readConfigEnabled('translator', $container, $config['translator'])) { - throw new LogicException('Translation support cannot be enabled as the Translation component is not installed. Try running "composer require symfony/translation".'); - } - - if (class_exists(Translator::class)) { - $loader->load('identity_translator.php'); - } - } - - $container->getDefinition('locale_listener')->replaceArgument(3, $config['set_locale_from_accept_language']); - $container->getDefinition('response_listener')->replaceArgument(1, $config['set_content_language_from_locale']); - $container->getDefinition('http_kernel')->replaceArgument(4, $config['handle_all_throwables'] ?? false); - - // If the slugger is used but the String component is not available, we should throw an error - if (!ContainerBuilder::willBeAvailable('symfony/string', SluggerInterface::class, ['symfony/framework-bundle'])) { - $container->register('slugger', SluggerInterface::class) - ->addError('You cannot use the "slugger" service since the String component is not installed. Try running "composer require symfony/string".'); - } else { - if (!ContainerBuilder::willBeAvailable('symfony/translation', LocaleAwareInterface::class, ['symfony/framework-bundle'])) { - $container->register('slugger', SluggerInterface::class) - ->addError('You cannot use the "slugger" service since the Translation contracts are not installed. Try running "composer require symfony/translation".'); - } - - if (!\extension_loaded('intl') && !\defined('PHPUNIT_COMPOSER_INSTALL')) { - trigger_deprecation('', '', 'Please install the "intl" PHP extension for best performance.'); - } - } - - if (isset($config['secret'])) { - $container->setParameter('kernel.secret', $config['secret']); - } - - $container->setParameter('kernel.http_method_override', $config['http_method_override']); - $container->setParameter('kernel.trust_x_sendfile_type_header', $config['trust_x_sendfile_type_header']); - $container->setParameter('kernel.trusted_hosts', $config['trusted_hosts']); - $container->setParameter('kernel.default_locale', $config['default_locale']); - $container->setParameter('kernel.enabled_locales', $config['enabled_locales']); - $container->setParameter('kernel.error_controller', $config['error_controller']); - - if (($config['trusted_proxies'] ?? false) && ($config['trusted_headers'] ?? false)) { - $container->setParameter('kernel.trusted_proxies', $config['trusted_proxies']); - $container->setParameter('kernel.trusted_headers', $this->resolveTrustedHeaders($config['trusted_headers'])); - } - - if (!$container->hasParameter('debug.file_link_format')) { - $container->setParameter('debug.file_link_format', $config['ide']); - } - - if (!empty($config['test'])) { - $loader->load('test.php'); - - if (!class_exists(AbstractBrowser::class)) { - $container->removeDefinition('test.client'); - } - } - - if ($this->readConfigEnabled('request', $container, $config['request'])) { - $this->registerRequestConfiguration($config['request'], $container, $loader); - } - - if ($this->readConfigEnabled('assets', $container, $config['assets'])) { - if (!class_exists(\Symfony\Component\Asset\Package::class)) { - throw new LogicException('Asset support cannot be enabled as the Asset component is not installed. Try running "composer require symfony/asset".'); - } - - $this->registerAssetsConfiguration($config['assets'], $container, $loader); - } - - if ($this->readConfigEnabled('asset_mapper', $container, $config['asset_mapper'])) { - if (!class_exists(AssetMapper::class)) { - throw new LogicException('AssetMapper support cannot be enabled as the AssetMapper component is not installed. Try running "composer require symfony/asset-mapper".'); - } - - $this->registerAssetMapperConfiguration($config['asset_mapper'], $container, $loader, $this->readConfigEnabled('assets', $container, $config['assets'])); - } else { - $container->removeDefinition('cache.asset_mapper'); - } - - if ($this->readConfigEnabled('http_client', $container, $config['http_client'])) { - $this->readConfigEnabled('rate_limiter', $container, $config['rate_limiter']); // makes sure that isInitializedConfigEnabled() will work - $this->registerHttpClientConfiguration($config['http_client'], $container, $loader); - } - - if ($this->readConfigEnabled('mailer', $container, $config['mailer'])) { - $this->registerMailerConfiguration($config['mailer'], $container, $loader, $this->readConfigEnabled('webhook', $container, $config['webhook'])); - - if (!$this->hasConsole() || !class_exists(MailerTestCommand::class)) { - $container->removeDefinition('console.command.mailer_test'); - } - } - - $propertyInfoEnabled = $this->readConfigEnabled('property_info', $container, $config['property_info']); - $this->registerHttpCacheConfiguration($config['http_cache'], $container, $config['http_method_override']); - $this->registerEsiConfiguration($config['esi'], $container, $loader); - $this->registerSsiConfiguration($config['ssi'], $container, $loader); - $this->registerFragmentsConfiguration($config['fragments'], $container, $loader); - $this->registerTranslatorConfiguration($config['translator'], $container, $loader, $config['default_locale'], $config['enabled_locales']); - $this->registerWorkflowConfiguration($config['workflows'], $container, $loader); - $this->registerDebugConfiguration($config['php_errors'], $container, $loader); - $this->registerRouterConfiguration($config['router'], $container, $loader, $config['enabled_locales']); - $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); - $this->registerSecretsConfiguration($config['secrets'], $container, $loader); - - $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); - - if ($this->readConfigEnabled('serializer', $container, $config['serializer'])) { - if (!class_exists(Serializer::class)) { - throw new LogicException('Serializer support cannot be enabled as the Serializer component is not installed. Try running "composer require symfony/serializer-pack".'); - } - - $this->registerSerializerConfiguration($config['serializer'], $container, $loader); - } else { - $container->getDefinition('argument_resolver.request_payload') - ->setArguments([]) - ->addError('You can neither use "#[MapRequestPayload]" nor "#[MapQueryString]" since the Serializer component is not ' - .(class_exists(Serializer::class) ? 'enabled. Try setting "framework.serializer.enabled" to true.' : 'installed. Try running "composer require symfony/serializer-pack".') - ) - ->addTag('container.error') - ->clearTag('kernel.event_subscriber'); - - $container->removeDefinition('console.command.serializer_debug'); - } - - if ($this->readConfigEnabled('type_info', $container, $config['type_info'])) { - $this->registerTypeInfoConfiguration($container, $loader); - } - - if ($propertyInfoEnabled) { - $this->registerPropertyInfoConfiguration($container, $loader); - } - - if ($this->readConfigEnabled('lock', $container, $config['lock'])) { - $this->registerLockConfiguration($config['lock'], $container, $loader); - } - - if ($this->readConfigEnabled('semaphore', $container, $config['semaphore'])) { - $this->registerSemaphoreConfiguration($config['semaphore'], $container, $loader); - } - - if ($this->readConfigEnabled('rate_limiter', $container, $config['rate_limiter'])) { - if (!interface_exists(LimiterInterface::class)) { - throw new LogicException('Rate limiter support cannot be enabled as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".'); - } - - $this->registerRateLimiterConfiguration($config['rate_limiter'], $container, $loader); - } - - if ($this->readConfigEnabled('web_link', $container, $config['web_link'])) { - if (!class_exists(HttpHeaderSerializer::class)) { - throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".'); - } - - $loader->load('web_link.php'); - } - - if ($this->readConfigEnabled('uid', $container, $config['uid'])) { - if (!class_exists(UuidFactory::class)) { - throw new LogicException('Uid support cannot be enabled as the Uid component is not installed. Try running "composer require symfony/uid".'); - } - - $this->registerUidConfiguration($config['uid'], $container, $loader); - } else { - $container->removeDefinition('argument_resolver.uid'); - } - - // register cache before session so both can share the connection services - $this->registerCacheConfiguration($config['cache'], $container); - - if ($this->readConfigEnabled('session', $container, $config['session'])) { - if (!\extension_loaded('session')) { - throw new LogicException('Session support cannot be enabled as the session extension is not installed. See https://php.net/session.installation for instructions.'); - } - - $this->registerSessionConfiguration($config['session'], $container, $loader); - if (!empty($config['test'])) { - // test listener will replace the existing session listener - // as we are aliasing to avoid duplicated registered events - $container->setAlias('session_listener', 'test.session.listener'); - } - } elseif (!empty($config['test'])) { - $container->removeDefinition('test.session.listener'); - } - - // csrf depends on session being registered - if (null === $config['csrf_protection']['enabled']) { - $this->writeConfigEnabled('csrf_protection', $this->readConfigEnabled('session', $container, $config['session']) && !class_exists(FullStack::class) && ContainerBuilder::willBeAvailable('symfony/security-csrf', CsrfTokenManagerInterface::class, ['symfony/framework-bundle']), $config['csrf_protection']); - } - $this->registerSecurityCsrfConfiguration($config['csrf_protection'], $container, $loader); - - // form depends on csrf being registered - if ($this->readConfigEnabled('form', $container, $config['form'])) { - if (!class_exists(Form::class)) { - throw new LogicException('Form support cannot be enabled as the Form component is not installed. Try running "composer require symfony/form".'); - } - - $this->registerFormConfiguration($config, $container, $loader); - - if (ContainerBuilder::willBeAvailable('symfony/validator', Validation::class, ['symfony/framework-bundle', 'symfony/form'])) { - $this->writeConfigEnabled('validation', true, $config['validation']); - } else { - $container->setParameter('validator.translation_domain', 'validators'); - - $container->removeDefinition('form.type_extension.form.validator'); - $container->removeDefinition('form.type_guesser.validator'); - } - if (!$this->readConfigEnabled('html_sanitizer', $container, $config['html_sanitizer']) || !class_exists(TextTypeHtmlSanitizerExtension::class)) { - $container->removeDefinition('form.type_extension.form.html_sanitizer'); - } - } else { - $container->removeDefinition('console.command.form_debug'); - } - - // validation depends on form, annotations being registered - $this->registerValidationConfiguration($config['validation'], $container, $loader, $propertyInfoEnabled); - - $messengerEnabled = $this->readConfigEnabled('messenger', $container, $config['messenger']); - - if ($this->readConfigEnabled('scheduler', $container, $config['scheduler'])) { - if (!$messengerEnabled) { - throw new LogicException('Scheduler support cannot be enabled as the Messenger component is not '.(interface_exists(MessageBusInterface::class) ? 'enabled.' : 'installed. Try running "composer require symfony/messenger".')); - } - $this->registerSchedulerConfiguration($config['scheduler'], $container, $loader); - } else { - $container->removeDefinition('cache.scheduler'); - $container->removeDefinition('console.command.scheduler_debug'); - } - - // messenger depends on validation being registered - if ($messengerEnabled) { - $this->registerMessengerConfiguration($config['messenger'], $container, $loader, $this->readConfigEnabled('validation', $container, $config['validation'])); - } else { - $container->removeDefinition('console.command.messenger_consume_messages'); - $container->removeDefinition('console.command.messenger_stats'); - $container->removeDefinition('console.command.messenger_debug'); - $container->removeDefinition('console.command.messenger_stop_workers'); - $container->removeDefinition('console.command.messenger_setup_transports'); - $container->removeDefinition('console.command.messenger_failed_messages_retry'); - $container->removeDefinition('console.command.messenger_failed_messages_show'); - $container->removeDefinition('console.command.messenger_failed_messages_remove'); - $container->removeDefinition('cache.messenger.restart_workers_signal'); - - if ($container->hasDefinition('messenger.transport.amqp.factory') && !class_exists(MessengerBridge\Amqp\Transport\AmqpTransportFactory::class)) { - if (class_exists(\Symfony\Component\Messenger\Transport\AmqpExt\AmqpTransportFactory::class)) { - $container->getDefinition('messenger.transport.amqp.factory') - ->setClass(\Symfony\Component\Messenger\Transport\AmqpExt\AmqpTransportFactory::class) - ->addTag('messenger.transport_factory'); - } else { - $container->removeDefinition('messenger.transport.amqp.factory'); - } - } - - if ($container->hasDefinition('messenger.transport.redis.factory') && !class_exists(MessengerBridge\Redis\Transport\RedisTransportFactory::class)) { - if (class_exists(\Symfony\Component\Messenger\Transport\RedisExt\RedisTransportFactory::class)) { - $container->getDefinition('messenger.transport.redis.factory') - ->setClass(\Symfony\Component\Messenger\Transport\RedisExt\RedisTransportFactory::class) - ->addTag('messenger.transport_factory'); - } else { - $container->removeDefinition('messenger.transport.redis.factory'); - } - } - } - - // notifier depends on messenger, mailer being registered - if ($this->readConfigEnabled('notifier', $container, $config['notifier'])) { - $this->registerNotifierConfiguration($config['notifier'], $container, $loader, $this->readConfigEnabled('webhook', $container, $config['webhook'])); - } - - // profiler depends on form, validation, translation, messenger, mailer, http-client, notifier, serializer being registered - $this->registerProfilerConfiguration($config['profiler'], $container, $loader); - - if ($this->readConfigEnabled('webhook', $container, $config['webhook'])) { - $this->registerWebhookConfiguration($config['webhook'], $container, $loader); - - // If Webhook is installed but the HttpClient or Serializer components are not available, we should throw an error - if (!$this->readConfigEnabled('http_client', $container, $config['http_client'])) { - $container->getDefinition('webhook.transport') - ->setArguments([]) - ->addError('You cannot use the "webhook transport" service since the HttpClient component is not ' - .(class_exists(ScopingHttpClient::class) ? 'enabled. Try setting "framework.http_client.enabled" to true.' : 'installed. Try running "composer require symfony/http-client".') - ) - ->addTag('container.error'); - } - if (!$this->readConfigEnabled('serializer', $container, $config['serializer'])) { - $container->getDefinition('webhook.body_configurator.json') - ->setArguments([]) - ->addError('You cannot use the "webhook transport" service since the Serializer component is not ' - .(class_exists(Serializer::class) ? 'enabled. Try setting "framework.serializer.enabled" to true.' : 'installed. Try running "composer require symfony/serializer-pack".') - ) - ->addTag('container.error'); - } - } - - if ($this->readConfigEnabled('remote-event', $container, $config['remote-event'])) { - $this->registerRemoteEventConfiguration($config['remote-event'], $container, $loader); - } - - if ($this->readConfigEnabled('html_sanitizer', $container, $config['html_sanitizer'])) { - if (!class_exists(HtmlSanitizerConfig::class)) { - throw new LogicException('HtmlSanitizer support cannot be enabled as the HtmlSanitizer component is not installed. Try running "composer require symfony/html-sanitizer".'); - } - - $this->registerHtmlSanitizerConfiguration($config['html_sanitizer'], $container, $loader); - } - - if ($this->readConfigEnabled('feature_flag', $container, $config['feature_flag'])) { - if (!class_exists(FeatureChecker::class)) { - throw new LogicException('FeatureFlag support cannot be enabled as the FeatureFlag component is not installed. Try running "composer require symfony/feature-flag".'); - } - $this->registerFeatureFlagConfiguration($config['feature_flag'], $container, $loader); - } - - if (ContainerBuilder::willBeAvailable('symfony/mime', MimeTypes::class, ['symfony/framework-bundle'])) { - $loader->load('mime_type.php'); - } - - $container->registerForAutoconfiguration(PackageInterface::class) - ->addTag('assets.package'); - $container->registerForAutoconfiguration(AssetCompilerInterface::class) - ->addTag('asset_mapper.compiler'); - $container->registerForAutoconfiguration(Command::class) - ->addTag('console.command'); - $container->registerForAutoconfiguration(ResourceCheckerInterface::class) - ->addTag('config_cache.resource_checker'); - $container->registerForAutoconfiguration(EnvVarLoaderInterface::class) - ->addTag('container.env_var_loader'); - $container->registerForAutoconfiguration(EnvVarProcessorInterface::class) - ->addTag('container.env_var_processor'); - $container->registerForAutoconfiguration(CallbackInterface::class) - ->addTag('container.reversible'); - $container->registerForAutoconfiguration(ServiceLocator::class) - ->addTag('container.service_locator'); - $container->registerForAutoconfiguration(ServiceSubscriberInterface::class) - ->addTag('container.service_subscriber'); - $container->registerForAutoconfiguration(ValueResolverInterface::class) - ->addTag('controller.argument_value_resolver'); - $container->registerForAutoconfiguration(AbstractController::class) - ->addTag('controller.service_arguments'); - $container->registerForAutoconfiguration(DataCollectorInterface::class) - ->addTag('data_collector'); - $container->registerForAutoconfiguration(FormTypeInterface::class) - ->addTag('form.type'); - $container->registerForAutoconfiguration(FormTypeGuesserInterface::class) - ->addTag('form.type_guesser'); - $container->registerForAutoconfiguration(FormTypeExtensionInterface::class) - ->addTag('form.type_extension'); - $container->registerForAutoconfiguration(CacheClearerInterface::class) - ->addTag('kernel.cache_clearer'); - $container->registerForAutoconfiguration(CacheWarmerInterface::class) - ->addTag('kernel.cache_warmer'); - $container->registerForAutoconfiguration(EventDispatcherInterface::class) - ->addTag('event_dispatcher.dispatcher'); - $container->registerForAutoconfiguration(EventSubscriberInterface::class) - ->addTag('kernel.event_subscriber'); - $container->registerForAutoconfiguration(LocaleAwareInterface::class) - ->addTag('kernel.locale_aware'); - $container->registerForAutoconfiguration(ResetInterface::class) - ->addTag('kernel.reset', ['method' => 'reset']); - - if (!interface_exists(MarshallerInterface::class)) { - $container->registerForAutoconfiguration(ResettableInterface::class) - ->addTag('kernel.reset', ['method' => 'reset']); - } - - $container->registerForAutoconfiguration(PropertyListExtractorInterface::class) - ->addTag('property_info.list_extractor'); - $container->registerForAutoconfiguration(PropertyTypeExtractorInterface::class) - ->addTag('property_info.type_extractor'); - $container->registerForAutoconfiguration(PropertyDescriptionExtractorInterface::class) - ->addTag('property_info.description_extractor'); - $container->registerForAutoconfiguration(PropertyAccessExtractorInterface::class) - ->addTag('property_info.access_extractor'); - $container->registerForAutoconfiguration(PropertyInitializableExtractorInterface::class) - ->addTag('property_info.initializable_extractor'); - $container->registerForAutoconfiguration(EncoderInterface::class) - ->addTag('serializer.encoder'); - $container->registerForAutoconfiguration(DecoderInterface::class) - ->addTag('serializer.encoder'); - $container->registerForAutoconfiguration(NormalizerInterface::class) - ->addTag('serializer.normalizer'); - $container->registerForAutoconfiguration(DenormalizerInterface::class) - ->addTag('serializer.normalizer'); - $container->registerForAutoconfiguration(ConstraintValidatorInterface::class) - ->addTag('validator.constraint_validator'); - $container->registerForAutoconfiguration(GroupProviderInterface::class) - ->addTag('validator.group_provider'); - $container->registerForAutoconfiguration(ObjectInitializerInterface::class) - ->addTag('validator.initializer'); - $container->registerForAutoconfiguration(BatchHandlerInterface::class) - ->addTag('messenger.message_handler'); - $container->registerForAutoconfiguration(MessengerTransportFactoryInterface::class) - ->addTag('messenger.transport_factory'); - $container->registerForAutoconfiguration(MimeTypeGuesserInterface::class) - ->addTag('mime.mime_type_guesser'); - $container->registerForAutoconfiguration(LoggerAwareInterface::class) - ->addMethodCall('setLogger', [new Reference('logger')]); - - $container->registerAttributeForAutoconfiguration(AsEventListener::class, static function (ChildDefinition $definition, AsEventListener $attribute, \ReflectionClass|\ReflectionMethod $reflector) { - $tagAttributes = get_object_vars($attribute); - if ($reflector instanceof \ReflectionMethod) { - if (isset($tagAttributes['method'])) { - throw new LogicException(sprintf('AsEventListener attribute cannot declare a method on "%s::%s()".', $reflector->class, $reflector->name)); - } - $tagAttributes['method'] = $reflector->getName(); - } - $definition->addTag('kernel.event_listener', $tagAttributes); - }); - $container->registerAttributeForAutoconfiguration(AsController::class, static function (ChildDefinition $definition, AsController $attribute): void { - $definition->addTag('controller.service_arguments'); - }); - $container->registerAttributeForAutoconfiguration(AsRemoteEventConsumer::class, static function (ChildDefinition $definition, AsRemoteEventConsumer $attribute): void { - $definition->addTag('remote_event.consumer', ['consumer' => $attribute->name]); - }); - $container->registerAttributeForAutoconfiguration(AsMessageHandler::class, static function (ChildDefinition $definition, AsMessageHandler $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { - $tagAttributes = get_object_vars($attribute); - $tagAttributes['from_transport'] = $tagAttributes['fromTransport']; - unset($tagAttributes['fromTransport']); - if ($reflector instanceof \ReflectionMethod) { - if (isset($tagAttributes['method'])) { - throw new LogicException(sprintf('AsMessageHandler attribute cannot declare a method on "%s::%s()".', $reflector->class, $reflector->name)); - } - $tagAttributes['method'] = $reflector->getName(); - } - $definition->addTag('messenger.message_handler', $tagAttributes); - }); - $container->registerAttributeForAutoconfiguration(AsTargetedValueResolver::class, static function (ChildDefinition $definition, AsTargetedValueResolver $attribute): void { - $definition->addTag('controller.targeted_value_resolver', $attribute->name ? ['name' => $attribute->name] : []); - }); - $container->registerAttributeForAutoconfiguration(AsSchedule::class, static function (ChildDefinition $definition, AsSchedule $attribute): void { - $definition->addTag('scheduler.schedule_provider', ['name' => $attribute->name]); - }); - foreach ([AsPeriodicTask::class, AsCronTask::class] as $taskAttributeClass) { - $container->registerAttributeForAutoconfiguration( - $taskAttributeClass, - static function (ChildDefinition $definition, AsPeriodicTask|AsCronTask $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { - $tagAttributes = get_object_vars($attribute) + [ - 'trigger' => match ($attribute::class) { - AsPeriodicTask::class => 'every', - AsCronTask::class => 'cron', - }, - ]; - if ($reflector instanceof \ReflectionMethod) { - if (isset($tagAttributes['method'])) { - throw new LogicException(sprintf('"%s" attribute cannot declare a method on "%s::%s()".', $attribute::class, $reflector->class, $reflector->name)); - } - $tagAttributes['method'] = $reflector->getName(); - } - $definition->addTag('scheduler.task', $tagAttributes); - } - ); - } - - if (!$container->getParameter('kernel.debug')) { - // remove tagged iterator argument for resource checkers - $container->getDefinition('config_cache_factory')->setArguments([]); - } - - if (!$config['disallow_search_engine_index'] ?? false) { - $container->removeDefinition('disallow_search_engine_index_response_listener'); - } - - $container->registerForAutoconfiguration(RouteLoaderInterface::class) - ->addTag('routing.route_loader'); - - $container->setParameter('container.behavior_describing_tags', [ - 'container.do_not_inline', - 'container.service_locator', - 'container.service_subscriber', - 'kernel.event_subscriber', - 'kernel.event_listener', - 'kernel.locale_aware', - 'kernel.reset', - ]); - } - - public function getConfiguration(array $config, ContainerBuilder $container): ?ConfigurationInterface - { - return new Configuration($container->getParameter('kernel.debug')); - } - - protected function hasConsole(): bool - { - return class_exists(Application::class); - } - - private function registerFormConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('form.php'); - - if (null === $config['form']['csrf_protection']['enabled']) { - $this->writeConfigEnabled('form.csrf_protection', $config['csrf_protection']['enabled'], $config['form']['csrf_protection']); - } - - if ($this->readConfigEnabled('form.csrf_protection', $container, $config['form']['csrf_protection'])) { - if (!$container->hasDefinition('security.csrf.token_generator')) { - throw new \LogicException('To use form CSRF protection, "framework.csrf_protection" must be enabled.'); - } - - $loader->load('form_csrf.php'); - - $container->setParameter('form.type_extension.csrf.enabled', true); - $container->setParameter('form.type_extension.csrf.field_name', $config['form']['csrf_protection']['field_name']); - } else { - $container->setParameter('form.type_extension.csrf.enabled', false); - } - - if (!ContainerBuilder::willBeAvailable('symfony/translation', Translator::class, ['symfony/framework-bundle', 'symfony/form'])) { - $container->removeDefinition('form.type_extension.upload.validator'); - } - } - - private function registerHttpCacheConfiguration(array $config, ContainerBuilder $container, bool $httpMethodOverride): void - { - $options = $config; - unset($options['enabled']); - - if (!$options['private_headers']) { - unset($options['private_headers']); - } - - if (!$options['skip_response_headers']) { - unset($options['skip_response_headers']); - } - - $container->getDefinition('http_cache') - ->setPublic($config['enabled']) - ->replaceArgument(3, $options); - - if ($httpMethodOverride) { - $container->getDefinition('http_cache') - ->addArgument((new Definition('void')) - ->setFactory([Request::class, 'enableHttpMethodParameterOverride']) - ); - } - } - - private function registerEsiConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$this->readConfigEnabled('esi', $container, $config)) { - $container->removeDefinition('fragment.renderer.esi'); - - return; - } - - $loader->load('esi.php'); - } - - private function registerSsiConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$this->readConfigEnabled('ssi', $container, $config)) { - $container->removeDefinition('fragment.renderer.ssi'); - - return; - } - - $loader->load('ssi.php'); - } - - private function registerFragmentsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$this->readConfigEnabled('fragments', $container, $config)) { - $container->removeDefinition('fragment.renderer.hinclude'); - - return; - } - - $container->setParameter('fragment.renderer.hinclude.global_template', $config['hinclude_default_template']); - - $loader->load('fragment_listener.php'); - $container->setParameter('fragment.path', $config['path']); - } - - private function registerProfilerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$this->readConfigEnabled('profiler', $container, $config)) { - // this is needed for the WebProfiler to work even if the profiler is disabled - $container->setParameter('data_collector.templates', []); - - return; - } - - $loader->load('profiling.php'); - $loader->load('collectors.php'); - $loader->load('cache_debug.php'); - - if ($this->isInitializedConfigEnabled('form')) { - $loader->load('form_debug.php'); - } - - if ($this->isInitializedConfigEnabled('validation')) { - $loader->load('validator_debug.php'); - } - - if ($this->isInitializedConfigEnabled('translator')) { - $loader->load('translation_debug.php'); - - $container->getDefinition('translator.data_collector')->setDecoratedService('translator'); - } - - if ($this->isInitializedConfigEnabled('messenger')) { - $loader->load('messenger_debug.php'); - } - - if ($this->isInitializedConfigEnabled('mailer')) { - $loader->load('mailer_debug.php'); - } - - if ($this->isInitializedConfigEnabled('workflows')) { - $loader->load('workflow_debug.php'); - } - - if ($this->isInitializedConfigEnabled('http_client')) { - $loader->load('http_client_debug.php'); - } - - if ($this->isInitializedConfigEnabled('notifier')) { - $loader->load('notifier_debug.php'); - } - - if ($this->isInitializedConfigEnabled('serializer') && $config['collect_serializer_data']) { - $loader->load('serializer_debug.php'); - } - - if ($this->isInitializedConfigEnabled('feature_flag')) { - $loader->load('feature_flag_debug.php'); - } - - $container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']); - $container->setParameter('profiler_listener.only_main_requests', $config['only_main_requests']); - - // Choose storage class based on the DSN - [$class] = explode(':', $config['dsn'], 2); - if ('file' !== $class) { - throw new \LogicException(sprintf('Driver "%s" is not supported for the profiler.', $class)); - } - - $container->setParameter('profiler.storage.dsn', $config['dsn']); - - $container->getDefinition('profiler') - ->addArgument($config['collect']) - ->addTag('kernel.reset', ['method' => 'reset']); - - $container->getDefinition('profiler_listener') - ->addArgument($config['collect_parameter']); - - if (!$container->getParameter('kernel.debug') || !class_exists(CliRequest::class) || !$container->has('debug.stopwatch')) { - $container->removeDefinition('console_profiler_listener'); - } - - if (!class_exists(CommandDataCollector::class)) { - $container->removeDefinition('.data_collector.command'); - } - } - - private function registerWorkflowConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$config['enabled']) { - $container->removeDefinition('console.command.workflow_dump'); - - return; - } - - if (!class_exists(Workflow\Workflow::class)) { - throw new LogicException('Workflow support cannot be enabled as the Workflow component is not installed. Try running "composer require symfony/workflow".'); - } - - $loader->load('workflow.php'); - - $registryDefinition = $container->getDefinition('workflow.registry'); - - foreach ($config['workflows'] as $name => $workflow) { - $type = $workflow['type']; - $workflowId = sprintf('%s.%s', $type, $name); - - // Process Metadata (workflow + places (transition is done in the "create transition" block)) - $metadataStoreDefinition = new Definition(Workflow\Metadata\InMemoryMetadataStore::class, [[], [], null]); - if ($workflow['metadata']) { - $metadataStoreDefinition->replaceArgument(0, $workflow['metadata']); - } - $placesMetadata = []; - foreach ($workflow['places'] as $place) { - if ($place['metadata']) { - $placesMetadata[$place['name']] = $place['metadata']; - } - } - if ($placesMetadata) { - $metadataStoreDefinition->replaceArgument(1, $placesMetadata); - } - - // Create transitions - $transitions = []; - $guardsConfiguration = []; - $transitionsMetadataDefinition = new Definition(\SplObjectStorage::class); - // Global transition counter per workflow - $transitionCounter = 0; - foreach ($workflow['transitions'] as $transition) { - if ('workflow' === $type) { - $transitionDefinition = new Definition(Workflow\Transition::class, [$transition['name'], $transition['from'], $transition['to']]); - $transitionId = sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); - $container->setDefinition($transitionId, $transitionDefinition); - $transitions[] = new Reference($transitionId); - if (isset($transition['guard'])) { - $configuration = new Definition(Workflow\EventListener\GuardExpression::class); - $configuration->addArgument(new Reference($transitionId)); - $configuration->addArgument($transition['guard']); - $eventName = sprintf('workflow.%s.guard.%s', $name, $transition['name']); - $guardsConfiguration[$eventName][] = $configuration; - } - if ($transition['metadata']) { - $transitionsMetadataDefinition->addMethodCall('attach', [ - new Reference($transitionId), - $transition['metadata'], - ]); - } - } elseif ('state_machine' === $type) { - foreach ($transition['from'] as $from) { - foreach ($transition['to'] as $to) { - $transitionDefinition = new Definition(Workflow\Transition::class, [$transition['name'], $from, $to]); - $transitionId = sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); - $container->setDefinition($transitionId, $transitionDefinition); - $transitions[] = new Reference($transitionId); - if (isset($transition['guard'])) { - $configuration = new Definition(Workflow\EventListener\GuardExpression::class); - $configuration->addArgument(new Reference($transitionId)); - $configuration->addArgument($transition['guard']); - $eventName = sprintf('workflow.%s.guard.%s', $name, $transition['name']); - $guardsConfiguration[$eventName][] = $configuration; - } - if ($transition['metadata']) { - $transitionsMetadataDefinition->addMethodCall('attach', [ - new Reference($transitionId), - $transition['metadata'], - ]); - } - } - } - } - } - $metadataStoreDefinition->replaceArgument(2, $transitionsMetadataDefinition); - $container->setDefinition(sprintf('%s.metadata_store', $workflowId), $metadataStoreDefinition); - - // Create places - $places = array_column($workflow['places'], 'name'); - $initialMarking = $workflow['initial_marking'] ?? []; - - // Create a Definition - $definitionDefinition = new Definition(Workflow\Definition::class); - $definitionDefinition->addArgument($places); - $definitionDefinition->addArgument($transitions); - $definitionDefinition->addArgument($initialMarking); - $definitionDefinition->addArgument(new Reference(sprintf('%s.metadata_store', $workflowId))); - - // Create MarkingStore - $markingStoreDefinition = null; - if (isset($workflow['marking_store']['type']) || isset($workflow['marking_store']['property'])) { - $markingStoreDefinition = new ChildDefinition('workflow.marking_store.method'); - $markingStoreDefinition->setArguments([ - 'state_machine' === $type, // single state - $workflow['marking_store']['property'] ?? 'marking', - ]); - } elseif (isset($workflow['marking_store']['service'])) { - $markingStoreDefinition = new Reference($workflow['marking_store']['service']); - } - - // Create Workflow - $workflowDefinition = new ChildDefinition(sprintf('%s.abstract', $type)); - $workflowDefinition->replaceArgument(0, new Reference(sprintf('%s.definition', $workflowId))); - $workflowDefinition->replaceArgument(1, $markingStoreDefinition); - $workflowDefinition->replaceArgument(3, $name); - $workflowDefinition->replaceArgument(4, $workflow['events_to_dispatch']); - - $workflowDefinition->addTag('workflow', ['name' => $name]); - if ('workflow' === $type) { - $workflowDefinition->addTag('workflow.workflow', ['name' => $name]); - } elseif ('state_machine' === $type) { - $workflowDefinition->addTag('workflow.state_machine', ['name' => $name]); - } - - // Store to container - $container->setDefinition($workflowId, $workflowDefinition); - $container->setDefinition(sprintf('%s.definition', $workflowId), $definitionDefinition); - $container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name.'.'.$type); - $container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name); - - // Validate Workflow - if ('state_machine' === $workflow['type']) { - $validator = new Workflow\Validator\StateMachineValidator(); - } else { - $validator = new Workflow\Validator\WorkflowValidator(); - } - - $trs = array_map(fn (Reference $ref): Workflow\Transition => $container->get((string) $ref), $transitions); - $realDefinition = new Workflow\Definition($places, $trs, $initialMarking); - $validator->validate($realDefinition, $name); - - // Add workflow to Registry - if ($workflow['supports']) { - foreach ($workflow['supports'] as $supportedClassName) { - $strategyDefinition = new Definition(Workflow\SupportStrategy\InstanceOfSupportStrategy::class, [$supportedClassName]); - $registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), $strategyDefinition]); - } - } elseif (isset($workflow['support_strategy'])) { - $registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), new Reference($workflow['support_strategy'])]); - } - - // Enable the AuditTrail - if ($workflow['audit_trail']['enabled']) { - $listener = new Definition(Workflow\EventListener\AuditTrailListener::class); - $listener->addTag('monolog.logger', ['channel' => 'workflow']); - $listener->addTag('kernel.event_listener', ['event' => sprintf('workflow.%s.leave', $name), 'method' => 'onLeave']); - $listener->addTag('kernel.event_listener', ['event' => sprintf('workflow.%s.transition', $name), 'method' => 'onTransition']); - $listener->addTag('kernel.event_listener', ['event' => sprintf('workflow.%s.enter', $name), 'method' => 'onEnter']); - $listener->addArgument(new Reference('logger')); - $container->setDefinition(sprintf('.%s.listener.audit_trail', $workflowId), $listener); - } - - // Add Guard Listener - if ($guardsConfiguration) { - if (!class_exists(ExpressionLanguage::class)) { - throw new LogicException('Cannot guard workflows as the ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'); - } - - if (!class_exists(AuthenticationEvents::class)) { - throw new LogicException('Cannot guard workflows as the Security component is not installed. Try running "composer require symfony/security-core".'); - } - - $guard = new Definition(Workflow\EventListener\GuardListener::class); - - $guard->setArguments([ - $guardsConfiguration, - new Reference('workflow.security.expression_language'), - new Reference('security.token_storage'), - new Reference('security.authorization_checker'), - new Reference('security.authentication.trust_resolver'), - new Reference('security.role_hierarchy'), - new Reference('validator', ContainerInterface::NULL_ON_INVALID_REFERENCE), - ]); - foreach ($guardsConfiguration as $eventName => $config) { - $guard->addTag('kernel.event_listener', ['event' => $eventName, 'method' => 'onTransition']); - } - - $container->setDefinition(sprintf('.%s.listener.guard', $workflowId), $guard); - $container->setParameter('workflow.has_guard_listeners', true); - } - } - - $listenerAttributes = [ - Workflow\Attribute\AsAnnounceListener::class, - Workflow\Attribute\AsCompletedListener::class, - Workflow\Attribute\AsEnterListener::class, - Workflow\Attribute\AsEnteredListener::class, - Workflow\Attribute\AsGuardListener::class, - Workflow\Attribute\AsLeaveListener::class, - Workflow\Attribute\AsTransitionListener::class, - ]; - - foreach ($listenerAttributes as $attribute) { - $container->registerAttributeForAutoconfiguration($attribute, static function (ChildDefinition $definition, AsEventListener $attribute, \ReflectionClass|\ReflectionMethod $reflector) { - $tagAttributes = get_object_vars($attribute); - if ($reflector instanceof \ReflectionMethod) { - if (isset($tagAttributes['method'])) { - throw new LogicException(sprintf('"%s" attribute cannot declare a method on "%s::%s()".', $attribute::class, $reflector->class, $reflector->name)); - } - $tagAttributes['method'] = $reflector->getName(); - } - $definition->addTag('kernel.event_listener', $tagAttributes); - }); - } - } - - private function registerDebugConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('debug_prod.php'); - - $debug = $container->getParameter('kernel.debug'); - - if (class_exists(Stopwatch::class)) { - $container->register('debug.stopwatch', Stopwatch::class) - ->addArgument(true) - ->setPublic($debug) - ->addTag('kernel.reset', ['method' => 'reset']); - $container->setAlias(Stopwatch::class, new Alias('debug.stopwatch', false)); - } - - if ($debug && !$container->hasParameter('debug.container.dump')) { - $container->setParameter('debug.container.dump', '%kernel.build_dir%/%kernel.container_class%.xml'); - } - - if ($debug && class_exists(Stopwatch::class)) { - $loader->load('debug.php'); - } - - $definition = $container->findDefinition('debug.error_handler_configurator'); - - if (false === $config['log']) { - $definition->replaceArgument(0, null); - } elseif (true !== $config['log']) { - $definition->replaceArgument(1, $config['log']); - } - - if (!$config['throw']) { - $container->setParameter('debug.error_handler.throw_at', 0); - } - - if ($debug && class_exists(DebugProcessor::class)) { - $definition = new Definition(DebugProcessor::class); - $definition->addArgument(new Reference('.virtual_request_stack')); - $definition->addTag('kernel.reset', ['method' => 'reset']); - $container->setDefinition('debug.log_processor', $definition); - - $container->register('debug.debug_logger_configurator', DebugLoggerConfigurator::class) - ->setArguments([new Reference('debug.log_processor'), '%kernel.runtime_mode.web%']); - } - } - - private function registerRouterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, array $enabledLocales = []): void - { - if (!$this->readConfigEnabled('router', $container, $config)) { - $container->removeDefinition('console.command.router_debug'); - $container->removeDefinition('console.command.router_match'); - $container->removeDefinition('messenger.middleware.router_context'); - - return; - } - if (!class_exists(RouterContextMiddleware::class)) { - $container->removeDefinition('messenger.middleware.router_context'); - } - - $loader->load('routing.php'); - - if ($config['utf8']) { - $container->getDefinition('routing.loader')->replaceArgument(1, ['utf8' => true]); - } - - if ($enabledLocales) { - $enabledLocales = implode('|', array_map('preg_quote', $enabledLocales)); - $container->getDefinition('routing.loader')->replaceArgument(2, ['_locale' => $enabledLocales]); - } - - if (!ContainerBuilder::willBeAvailable('symfony/expression-language', ExpressionLanguage::class, ['symfony/framework-bundle', 'symfony/routing'])) { - $container->removeDefinition('router.expression_language_provider'); - } - - $container->setParameter('router.resource', $config['resource']); - $container->setParameter('router.cache_dir', $config['cache_dir']); - $router = $container->findDefinition('router.default'); - $argument = $router->getArgument(2); - $argument['strict_requirements'] = $config['strict_requirements']; - if (isset($config['type'])) { - $argument['resource_type'] = $config['type']; - } - $router->replaceArgument(2, $argument); - - $container->setParameter('request_listener.http_port', $config['http_port']); - $container->setParameter('request_listener.https_port', $config['https_port']); - - if (null !== $config['default_uri']) { - $container->getDefinition('router.request_context') - ->replaceArgument(0, $config['default_uri']); - } - } - - private function registerSessionConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('session.php'); - - // session storage - $container->setAlias('session.storage.factory', $config['storage_factory_id']); - - $options = ['cache_limiter' => '0']; - foreach (['name', 'cookie_lifetime', 'cookie_path', 'cookie_domain', 'cookie_secure', 'cookie_httponly', 'cookie_samesite', 'use_cookies', 'gc_maxlifetime', 'gc_probability', 'gc_divisor', 'sid_length', 'sid_bits_per_character'] as $key) { - if (isset($config[$key])) { - $options[$key] = $config[$key]; - } - } - - if ('auto' === ($options['cookie_secure'] ?? null)) { - $container->getDefinition('session.storage.factory.native')->replaceArgument(3, true); - $container->getDefinition('session.storage.factory.php_bridge')->replaceArgument(2, true); - } - - $container->setParameter('session.storage.options', $options); - - // session handler (the internal callback registered with PHP session management) - if (null === ($config['handler_id'] ?? $config['save_path'] ?? null)) { - $config['save_path'] = null; - $container->setAlias('session.handler', 'session.handler.native'); - } else { - $config['handler_id'] ??= 'session.handler.native_file'; - - if (!\array_key_exists('save_path', $config)) { - $config['save_path'] = '%kernel.cache_dir%/sessions'; - } - $container->resolveEnvPlaceholders($config['handler_id'], null, $usedEnvs); - - if ($usedEnvs || preg_match('#^[a-z]++://#', $config['handler_id'])) { - $id = '.cache_connection.'.ContainerBuilder::hash($config['handler_id']); - - $container->getDefinition('session.abstract_handler') - ->replaceArgument(0, $container->hasDefinition($id) ? new Reference($id) : $config['handler_id']); - - $container->setAlias('session.handler', 'session.abstract_handler'); - } else { - $container->setAlias('session.handler', $config['handler_id']); - } - } - - $container->setParameter('session.save_path', $config['save_path']); - - $container->setParameter('session.metadata.update_threshold', $config['metadata_update_threshold']); - } - - private function registerRequestConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if ($config['formats']) { - $loader->load('request.php'); - - $listener = $container->getDefinition('request.add_request_formats_listener'); - $listener->replaceArgument(0, $config['formats']); - } - } - - private function registerAssetsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('assets.php'); - - if ($config['version_strategy']) { - $defaultVersion = new Reference($config['version_strategy']); - } else { - $defaultVersion = $this->createVersion($container, $config['version'], $config['version_format'], $config['json_manifest_path'], '_default', $config['strict_mode']); - } - - $defaultPackage = $this->createPackageDefinition($config['base_path'], $config['base_urls'], $defaultVersion); - $container->setDefinition('assets._default_package', $defaultPackage); - - foreach ($config['packages'] as $name => $package) { - if (null !== $package['version_strategy']) { - $version = new Reference($package['version_strategy']); - } elseif (!\array_key_exists('version', $package) && null === $package['json_manifest_path']) { - // if neither version nor json_manifest_path are specified, use the default - $version = $defaultVersion; - } else { - // let format fallback to main version_format - $format = $package['version_format'] ?: $config['version_format']; - $version = $package['version'] ?? null; - $version = $this->createVersion($container, $version, $format, $package['json_manifest_path'], $name, $package['strict_mode']); - } - - $packageDefinition = $this->createPackageDefinition($package['base_path'], $package['base_urls'], $version) - ->addTag('assets.package', ['package' => $name]); - $container->setDefinition('assets._package_'.$name, $packageDefinition); - $container->registerAliasForArgument('assets._package_'.$name, PackageInterface::class, $name.'.package'); - } - } - - private function registerAssetMapperConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $assetEnabled): void - { - $loader->load('asset_mapper.php'); - - if (!$assetEnabled) { - $container->removeDefinition('asset_mapper.asset_package'); - } - - $paths = $config['paths']; - foreach ($container->getParameter('kernel.bundles_metadata') as $name => $bundle) { - if ($container->fileExists($dir = $bundle['path'].'/Resources/public') || $container->fileExists($dir = $bundle['path'].'/public')) { - $paths[$dir] = sprintf('bundles/%s', preg_replace('/bundle$/', '', strtolower($name))); - } - } - $excludedPathPatterns = []; - foreach ($config['excluded_patterns'] as $path) { - $excludedPathPatterns[] = Glob::toRegex($path, true, false); - } - - $container->getDefinition('asset_mapper.repository') - ->setArgument(0, $paths) - ->setArgument(2, $excludedPathPatterns) - ->setArgument(3, $config['exclude_dotfiles']); - - $container->getDefinition('asset_mapper.public_assets_path_resolver') - ->setArgument(0, $config['public_prefix']); - - $publicDirectory = $this->getPublicDirectory($container); - $publicAssetsDirectory = rtrim($publicDirectory.'/'.ltrim($config['public_prefix'], '/'), '/'); - $container->getDefinition('asset_mapper.local_public_assets_filesystem') - ->setArgument(0, $publicDirectory) - ; - - $container->getDefinition('asset_mapper.compiled_asset_mapper_config_reader') - ->setArgument(0, $publicAssetsDirectory); - - if (!$config['server']) { - $container->removeDefinition('asset_mapper.dev_server_subscriber'); - } else { - $container->getDefinition('asset_mapper.dev_server_subscriber') - ->setArgument(1, $config['public_prefix']) - ->setArgument(2, $config['extensions']); - } - - $container->getDefinition('asset_mapper.compiler.css_asset_url_compiler') - ->setArgument(0, $config['missing_import_mode']); - - $container->getDefinition('asset_mapper.compiler.javascript_import_path_compiler') - ->setArgument(1, $config['missing_import_mode']); - - $container - ->getDefinition('asset_mapper.importmap.remote_package_storage') - ->replaceArgument(0, $config['vendor_dir']) - ; - $container - ->getDefinition('asset_mapper.mapped_asset_factory') - ->replaceArgument(2, $config['vendor_dir']) - ; - - $container - ->getDefinition('asset_mapper.importmap.config_reader') - ->replaceArgument(0, $config['importmap_path']) - ; - - $container - ->getDefinition('asset_mapper.importmap.renderer') - ->replaceArgument(3, $config['importmap_polyfill']) - ->replaceArgument(4, $config['importmap_script_attributes']) - ; - } - - /** - * Returns a definition for an asset package. - */ - private function createPackageDefinition(?string $basePath, array $baseUrls, Reference $version): Definition - { - if ($basePath && $baseUrls) { - throw new \LogicException('An asset package cannot have base URLs and base paths.'); - } - - $package = new ChildDefinition($baseUrls ? 'assets.url_package' : 'assets.path_package'); - $package - ->replaceArgument(0, $baseUrls ?: $basePath) - ->replaceArgument(1, $version) - ; - - return $package; - } - - private function createVersion(ContainerBuilder $container, ?string $version, ?string $format, ?string $jsonManifestPath, string $name, bool $strictMode): Reference - { - // Configuration prevents $version and $jsonManifestPath from being set - if (null !== $version) { - $def = new ChildDefinition('assets.static_version_strategy'); - $def - ->replaceArgument(0, $version) - ->replaceArgument(1, $format) - ; - $container->setDefinition('assets._version_'.$name, $def); - - return new Reference('assets._version_'.$name); - } - - if (null !== $jsonManifestPath) { - $def = new ChildDefinition('assets.json_manifest_version_strategy'); - $def->replaceArgument(0, $jsonManifestPath); - $def->replaceArgument(2, $strictMode); - $container->setDefinition('assets._version_'.$name, $def); - - return new Reference('assets._version_'.$name); - } - - return new Reference('assets.empty_version_strategy'); - } - - private function registerTranslatorConfiguration(array $config, ContainerBuilder $container, LoaderInterface $loader, string $defaultLocale, array $enabledLocales): void - { - if (!$this->readConfigEnabled('translator', $container, $config)) { - $container->removeDefinition('console.command.translation_debug'); - $container->removeDefinition('console.command.translation_extract'); - $container->removeDefinition('console.command.translation_pull'); - $container->removeDefinition('console.command.translation_push'); - - return; - } - - $loader->load('translation.php'); - - if (!ContainerBuilder::willBeAvailable('symfony/translation', LocaleSwitcher::class, ['symfony/framework-bundle'])) { - $container->removeDefinition('translation.locale_switcher'); - } - - // don't use ContainerBuilder::willBeAvailable() as these are not needed in production - if (interface_exists(Parser::class) && class_exists(PhpAstExtractor::class)) { - $container->removeDefinition('translation.extractor.php'); - } else { - $container->removeDefinition('translation.extractor.php_ast'); - } - - $loader->load('translation_providers.php'); - - // Use the "real" translator instead of the identity default - $container->setAlias('translator', 'translator.default')->setPublic(true); - $container->setAlias('translator.formatter', new Alias($config['formatter'], false)); - $translator = $container->findDefinition('translator.default'); - $translator->addMethodCall('setFallbackLocales', [$config['fallbacks'] ?: [$defaultLocale]]); - - $defaultOptions = $translator->getArgument(4); - $defaultOptions['cache_dir'] = $config['cache_dir']; - $translator->setArgument(4, $defaultOptions); - $translator->setArgument(5, $enabledLocales); - - $container->setParameter('translator.logging', $config['logging']); - $container->setParameter('translator.default_path', $config['default_path']); - - // Discover translation directories - $dirs = []; - $transPaths = []; - $nonExistingDirs = []; - if (ContainerBuilder::willBeAvailable('symfony/validator', Validation::class, ['symfony/framework-bundle', 'symfony/translation'])) { - $r = new \ReflectionClass(Validation::class); - - $dirs[] = $transPaths[] = \dirname($r->getFileName()).'/Resources/translations'; - } - if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/translation'])) { - $r = new \ReflectionClass(Form::class); - - $dirs[] = $transPaths[] = \dirname($r->getFileName()).'/Resources/translations'; - } - if (ContainerBuilder::willBeAvailable('symfony/security-core', AuthenticationException::class, ['symfony/framework-bundle', 'symfony/translation'])) { - $r = new \ReflectionClass(AuthenticationException::class); - - $dirs[] = $transPaths[] = \dirname($r->getFileName(), 2).'/Resources/translations'; - } - $defaultDir = $container->getParameterBag()->resolveValue($config['default_path']); - foreach ($container->getParameter('kernel.bundles_metadata') as $name => $bundle) { - if ($container->fileExists($dir = $bundle['path'].'/Resources/translations') || $container->fileExists($dir = $bundle['path'].'/translations')) { - $dirs[] = $dir; - } else { - $nonExistingDirs[] = $dir; - } - } - - foreach ($config['paths'] as $dir) { - if ($container->fileExists($dir)) { - $dirs[] = $transPaths[] = $dir; - } else { - throw new \UnexpectedValueException(sprintf('"%s" defined in translator.paths does not exist or is not a directory.', $dir)); - } - } - - if ($container->hasDefinition('console.command.translation_debug')) { - $container->getDefinition('console.command.translation_debug')->replaceArgument(5, $transPaths); - } - - if ($container->hasDefinition('console.command.translation_extract')) { - $container->getDefinition('console.command.translation_extract')->replaceArgument(6, $transPaths); - } - - if (null === $defaultDir) { - // allow null - } elseif ($container->fileExists($defaultDir)) { - $dirs[] = $defaultDir; - } else { - $nonExistingDirs[] = $defaultDir; - } - - // Register translation resources - if ($dirs) { - $files = []; - - foreach ($dirs as $dir) { - $finder = Finder::create() - ->followLinks() - ->files() - ->filter(fn (\SplFileInfo $file) => 2 <= substr_count($file->getBasename(), '.') && preg_match('/\.\w+$/', $file->getBasename())) - ->in($dir) - ->sortByName() - ; - foreach ($finder as $file) { - $fileNameParts = explode('.', basename($file)); - $locale = $fileNameParts[\count($fileNameParts) - 2]; - if (!isset($files[$locale])) { - $files[$locale] = []; - } - - $files[$locale][] = (string) $file; - } - } - - $projectDir = $container->getParameter('kernel.project_dir'); - - $options = array_merge( - $translator->getArgument(4), - [ - 'resource_files' => $files, - 'scanned_directories' => $scannedDirectories = array_merge($dirs, $nonExistingDirs), - 'cache_vary' => [ - 'scanned_directories' => array_map(fn ($dir) => str_starts_with($dir, $projectDir.'/') ? substr($dir, 1 + \strlen($projectDir)) : $dir, $scannedDirectories), - ], - ] - ); - - $translator->replaceArgument(4, $options); - } - - if ($config['pseudo_localization']['enabled']) { - $options = $config['pseudo_localization']; - unset($options['enabled']); - - $container - ->register('translator.pseudo', PseudoLocalizationTranslator::class) - ->setDecoratedService('translator', null, -1) // Lower priority than "translator.data_collector" - ->setArguments([ - new Reference('translator.pseudo.inner'), - $options, - ]); - } - - $classToServices = [ - TranslationBridge\Crowdin\CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', - TranslationBridge\Loco\LocoProviderFactory::class => 'translation.provider_factory.loco', - TranslationBridge\Lokalise\LokaliseProviderFactory::class => 'translation.provider_factory.lokalise', - TranslationBridge\Phrase\PhraseProviderFactory::class => 'translation.provider_factory.phrase', - ]; - - $parentPackages = ['symfony/framework-bundle', 'symfony/translation', 'symfony/http-client']; - - foreach ($classToServices as $class => $service) { - $package = substr($service, \strlen('translation.provider_factory.')); - - if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable(sprintf('symfony/%s-translation-provider', $package), $class, $parentPackages)) { - $container->removeDefinition($service); - } - } - - if (!$config['providers']) { - return; - } - - $locales = $enabledLocales; - - foreach ($config['providers'] as $provider) { - if ($provider['locales']) { - $locales += $provider['locales']; - } - } - - $locales = array_unique($locales); - - $container->getDefinition('console.command.translation_pull') - ->replaceArgument(4, array_merge($transPaths, [$config['default_path']])) - ->replaceArgument(5, $locales) - ; - - $container->getDefinition('console.command.translation_push') - ->replaceArgument(2, array_merge($transPaths, [$config['default_path']])) - ->replaceArgument(3, $locales) - ; - - $container->getDefinition('translation.provider_collection_factory') - ->replaceArgument(1, $locales) - ; - - $container->getDefinition('translation.provider_collection')->setArgument(0, $config['providers']); - } - - private function registerValidationConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $propertyInfoEnabled): void - { - if (!$this->readConfigEnabled('validation', $container, $config)) { - $container->removeDefinition('console.command.validator_debug'); - - return; - } - - if (!class_exists(Validation::class)) { - throw new LogicException('Validation support cannot be enabled as the Validator component is not installed. Try running "composer require symfony/validator".'); - } - - if (!isset($config['email_validation_mode'])) { - $config['email_validation_mode'] = 'loose'; - } - - $loader->load('validator.php'); - - $validatorBuilder = $container->getDefinition('validator.builder'); - - $container->setParameter('validator.translation_domain', $config['translation_domain']); - - $files = ['xml' => [], 'yml' => []]; - $this->registerValidatorMapping($container, $config, $files); - - if (!empty($files['xml'])) { - $validatorBuilder->addMethodCall('addXmlMappings', [$files['xml']]); - } - - if (!empty($files['yml'])) { - $validatorBuilder->addMethodCall('addYamlMappings', [$files['yml']]); - } - - $definition = $container->findDefinition('validator.email'); - $definition->replaceArgument(0, $config['email_validation_mode']); - - if (\array_key_exists('enable_attributes', $config) && $config['enable_attributes']) { - $validatorBuilder->addMethodCall('enableAttributeMapping'); - } - - if (\array_key_exists('static_method', $config) && $config['static_method']) { - foreach ($config['static_method'] as $methodName) { - $validatorBuilder->addMethodCall('addMethodMapping', [$methodName]); - } - } - - if (!$container->getParameter('kernel.debug')) { - $validatorBuilder->addMethodCall('setMappingCache', [new Reference('validator.mapping.cache.adapter')]); - } - - $container->setParameter('validator.auto_mapping', $config['auto_mapping']); - if (!$propertyInfoEnabled || !class_exists(PropertyInfoLoader::class)) { - $container->removeDefinition('validator.property_info_loader'); - } - - $container - ->getDefinition('validator.not_compromised_password') - ->setArgument(2, $config['not_compromised_password']['enabled']) - ->setArgument(3, $config['not_compromised_password']['endpoint']) - ; - - if (!class_exists(ExpressionLanguage::class)) { - $container->removeDefinition('validator.expression_language'); - $container->removeDefinition('validator.expression_language_provider'); - } elseif (!class_exists(ExpressionLanguageProvider::class)) { - $container->removeDefinition('validator.expression_language_provider'); - } - } - - private function registerValidatorMapping(ContainerBuilder $container, array $config, array &$files): void - { - $fileRecorder = function ($extension, $path) use (&$files) { - $files['yaml' === $extension ? 'yml' : $extension][] = $path; - }; - - if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) { - $reflClass = new \ReflectionClass(Form::class); - $fileRecorder('xml', \dirname($reflClass->getFileName()).'/Resources/config/validation.xml'); - } - - foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) { - $configDir = is_dir($bundle['path'].'/Resources/config') ? $bundle['path'].'/Resources/config' : $bundle['path'].'/config'; - - if ( - $container->fileExists($file = $configDir.'/validation.yaml', false) - || $container->fileExists($file = $configDir.'/validation.yml', false) - ) { - $fileRecorder('yml', $file); - } - - if ($container->fileExists($file = $configDir.'/validation.xml', false)) { - $fileRecorder('xml', $file); - } - - if ($container->fileExists($dir = $configDir.'/validation', '/^$/')) { - $this->registerMappingFilesFromDir($dir, $fileRecorder); - } - } - - $projectDir = $container->getParameter('kernel.project_dir'); - if ($container->fileExists($dir = $projectDir.'/config/validator', '/^$/')) { - $this->registerMappingFilesFromDir($dir, $fileRecorder); - } - - $this->registerMappingFilesFromConfig($container, $config, $fileRecorder); - } - - private function registerMappingFilesFromDir(string $dir, callable $fileRecorder): void - { - foreach (Finder::create()->followLinks()->files()->in($dir)->name('/\.(xml|ya?ml)$/')->sortByName() as $file) { - $fileRecorder($file->getExtension(), $file->getRealPath()); - } - } - - private function registerMappingFilesFromConfig(ContainerBuilder $container, array $config, callable $fileRecorder): void - { - foreach ($config['mapping']['paths'] as $path) { - if (is_dir($path)) { - $this->registerMappingFilesFromDir($path, $fileRecorder); - $container->addResource(new DirectoryResource($path, '/^$/')); - } elseif ($container->fileExists($path, false)) { - if (!preg_match('/\.(xml|ya?ml)$/', $path, $matches)) { - throw new \RuntimeException(sprintf('Unsupported mapping type in "%s", supported types are XML & Yaml.', $path)); - } - $fileRecorder($matches[1], $path); - } else { - throw new \RuntimeException(sprintf('Could not open file or directory "%s".', $path)); - } - } - } - - private function registerPropertyAccessConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$this->readConfigEnabled('property_access', $container, $config)) { - return; - } - - $loader->load('property_access.php'); - - $magicMethods = PropertyAccessor::DISALLOW_MAGIC_METHODS; - $magicMethods |= $config['magic_call'] ? PropertyAccessor::MAGIC_CALL : 0; - $magicMethods |= $config['magic_get'] ? PropertyAccessor::MAGIC_GET : 0; - $magicMethods |= $config['magic_set'] ? PropertyAccessor::MAGIC_SET : 0; - - $throw = PropertyAccessor::DO_NOT_THROW; - $throw |= $config['throw_exception_on_invalid_index'] ? PropertyAccessor::THROW_ON_INVALID_INDEX : 0; - $throw |= $config['throw_exception_on_invalid_property_path'] ? PropertyAccessor::THROW_ON_INVALID_PROPERTY_PATH : 0; - - $container - ->getDefinition('property_accessor') - ->replaceArgument(0, $magicMethods) - ->replaceArgument(1, $throw) - ->replaceArgument(3, new Reference(PropertyReadInfoExtractorInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) - ->replaceArgument(4, new Reference(PropertyWriteInfoExtractorInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) - ; - } - - private function registerSecretsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$this->readConfigEnabled('secrets', $container, $config)) { - $container->removeDefinition('console.command.secrets_set'); - $container->removeDefinition('console.command.secrets_list'); - $container->removeDefinition('console.command.secrets_reveal'); - $container->removeDefinition('console.command.secrets_remove'); - $container->removeDefinition('console.command.secrets_generate_key'); - $container->removeDefinition('console.command.secrets_decrypt_to_local'); - $container->removeDefinition('console.command.secrets_encrypt_from_local'); - - return; - } - - $loader->load('secrets.php'); - - $container->getDefinition('secrets.vault')->replaceArgument(0, $config['vault_directory']); - - if ($config['local_dotenv_file']) { - $container->getDefinition('secrets.local_vault')->replaceArgument(0, $config['local_dotenv_file']); - } else { - $container->removeDefinition('secrets.local_vault'); - } - - if ($config['decryption_env_var']) { - if (!preg_match('/^(?:[-.\w\\\\]*+:)*+\w++$/', $config['decryption_env_var'])) { - throw new InvalidArgumentException(sprintf('Invalid value "%s" set as "decryption_env_var": only "word" characters are allowed.', $config['decryption_env_var'])); - } - - if (ContainerBuilder::willBeAvailable('symfony/string', LazyString::class, ['symfony/framework-bundle'])) { - $container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']); - } else { - $container->getDefinition('secrets.vault')->replaceArgument(1, "%env({$config['decryption_env_var']})%"); - $container->removeDefinition('secrets.decryption_key'); - } - } else { - $container->getDefinition('secrets.vault')->replaceArgument(1, null); - $container->removeDefinition('secrets.decryption_key'); - } - } - - private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!$this->readConfigEnabled('csrf_protection', $container, $config)) { - return; - } - - if (!class_exists(\Symfony\Component\Security\Csrf\CsrfToken::class)) { - throw new LogicException('CSRF support cannot be enabled as the Security CSRF component is not installed. Try running "composer require symfony/security-csrf".'); - } - - if (!$this->isInitializedConfigEnabled('session')) { - throw new \LogicException('CSRF protection needs sessions to be enabled.'); - } - - // Enable services for CSRF protection (even without forms) - $loader->load('security_csrf.php'); - - if (!class_exists(CsrfExtension::class)) { - $container->removeDefinition('twig.extension.security_csrf'); - } - } - - private function registerSerializerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('serializer.php'); - - $chainLoader = $container->getDefinition('serializer.mapping.chain_loader'); - - if (!$this->isInitializedConfigEnabled('property_access')) { - $container->removeAlias('serializer.property_accessor'); - $container->removeDefinition('serializer.normalizer.object'); - } - - if (!class_exists(Yaml::class)) { - $container->removeDefinition('serializer.encoder.yaml'); - } - - if (!$this->isInitializedConfigEnabled('property_access')) { - $container->removeDefinition('serializer.denormalizer.unwrapping'); - } - - if (!class_exists(Headers::class)) { - $container->removeDefinition('serializer.normalizer.mime_message'); - } - - if ($container->getParameter('kernel.debug')) { - $container->removeDefinition('serializer.mapping.cache_class_metadata_factory'); - } - - if (!$this->readConfigEnabled('translator', $container, $config)) { - $container->removeDefinition('serializer.normalizer.translatable'); - } - - $serializerLoaders = []; - if (isset($config['enable_attributes']) && $config['enable_attributes']) { - $attributeLoader = new Definition(AttributeLoader::class); - - $serializerLoaders[] = $attributeLoader; - } - - $fileRecorder = function ($extension, $path) use (&$serializerLoaders) { - $definition = new Definition(\in_array($extension, ['yaml', 'yml']) ? YamlFileLoader::class : XmlFileLoader::class, [$path]); - $serializerLoaders[] = $definition; - }; - - foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) { - $configDir = is_dir($bundle['path'].'/Resources/config') ? $bundle['path'].'/Resources/config' : $bundle['path'].'/config'; - - if ($container->fileExists($file = $configDir.'/serialization.xml', false)) { - $fileRecorder('xml', $file); - } - - if ( - $container->fileExists($file = $configDir.'/serialization.yaml', false) - || $container->fileExists($file = $configDir.'/serialization.yml', false) - ) { - $fileRecorder('yml', $file); - } - - if ($container->fileExists($dir = $configDir.'/serialization', '/^$/')) { - $this->registerMappingFilesFromDir($dir, $fileRecorder); - } - } - - $projectDir = $container->getParameter('kernel.project_dir'); - if ($container->fileExists($dir = $projectDir.'/config/serializer', '/^$/')) { - $this->registerMappingFilesFromDir($dir, $fileRecorder); - } - - $this->registerMappingFilesFromConfig($container, $config, $fileRecorder); - - $chainLoader->replaceArgument(0, $serializerLoaders); - $container->getDefinition('serializer.mapping.cache_warmer')->replaceArgument(0, $serializerLoaders); - - if (isset($config['name_converter']) && $config['name_converter']) { - $container->getDefinition('serializer.name_converter.metadata_aware')->setArgument(1, new Reference($config['name_converter'])); - } - - $defaultContext = $config['default_context'] ?? []; - - if ($defaultContext) { - $container->setParameter('serializer.default_context', $defaultContext); - } - - if (isset($config['circular_reference_handler']) && $config['circular_reference_handler']) { - $arguments = $container->getDefinition('serializer.normalizer.object')->getArguments(); - $context = ($arguments[6] ?? $defaultContext) + ['circular_reference_handler' => new Reference($config['circular_reference_handler'])]; - $container->getDefinition('serializer.normalizer.object')->setArgument(5, null); - $container->getDefinition('serializer.normalizer.object')->setArgument(6, $context); - } - - if ($config['max_depth_handler'] ?? false) { - $arguments = $container->getDefinition('serializer.normalizer.object')->getArguments(); - $context = ($arguments[6] ?? $defaultContext) + ['max_depth_handler' => new Reference($config['max_depth_handler'])]; - $container->getDefinition('serializer.normalizer.object')->setArgument(6, $context); - } - } - - private function registerPropertyInfoConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!interface_exists(PropertyInfoExtractorInterface::class)) { - throw new LogicException('PropertyInfo support cannot be enabled as the PropertyInfo component is not installed. Try running "composer require symfony/property-info".'); - } - - $loader->load('property_info.php'); - - if ( - ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/property-info']) - && ContainerBuilder::willBeAvailable('phpdocumentor/type-resolver', ContextFactory::class, ['symfony/framework-bundle', 'symfony/property-info']) - ) { - $definition = $container->register('property_info.phpstan_extractor', PhpStanExtractor::class); - $definition->addTag('property_info.type_extractor', ['priority' => -1000]); - } - - if (ContainerBuilder::willBeAvailable('phpdocumentor/reflection-docblock', DocBlockFactoryInterface::class, ['symfony/framework-bundle', 'symfony/property-info'], true)) { - $definition = $container->register('property_info.php_doc_extractor', PhpDocExtractor::class); - $definition->addTag('property_info.description_extractor', ['priority' => -1000]); - $definition->addTag('property_info.type_extractor', ['priority' => -1001]); - } - - if ($container->getParameter('kernel.debug')) { - $container->removeDefinition('property_info.cache'); - } - } - - private function registerTypeInfoConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!class_exists(Type::class)) { - throw new LogicException('TypeInfo support cannot be enabled as the TypeInfo component is not installed. Try running "composer require symfony/type-info".'); - } - - $loader->load('type_info.php'); - - if (ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/type-info'])) { - $container->register('type_info.resolver.string', StringTypeResolver::class); - - /** @var ServiceLocatorArgument $resolversLocator */ - $resolversLocator = $container->getDefinition('type_info.resolver')->getArgument(0); - $resolversLocator->setValues($resolversLocator->getValues() + [ - 'string' => new Reference('type_info.resolver.string'), - ]); - } - } - - private function registerLockConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('lock.php'); - - foreach ($config['resources'] as $resourceName => $resourceStores) { - if (0 === \count($resourceStores)) { - continue; - } - - // Generate stores - $storeDefinitions = []; - foreach ($resourceStores as $resourceStore) { - $storeDsn = $container->resolveEnvPlaceholders($resourceStore, null, $usedEnvs); - $storeDefinition = new Definition(PersistingStoreInterface::class); - $storeDefinition - ->setFactory([StoreFactory::class, 'createStore']) - ->setArguments([$resourceStore]) - ->addTag('lock.store'); - - $container->setDefinition($storeDefinitionId = '.lock.'.$resourceName.'.store.'.$container->hash($storeDsn), $storeDefinition); - - $storeDefinition = new Reference($storeDefinitionId); - - $storeDefinitions[] = $storeDefinition; - } - - // Wrap array of stores with CombinedStore - if (\count($storeDefinitions) > 1) { - $combinedDefinition = new ChildDefinition('lock.store.combined.abstract'); - $combinedDefinition->replaceArgument(0, $storeDefinitions); - $container->setDefinition($storeDefinitionId = '.lock.'.$resourceName.'.store.'.$container->hash($resourceStores), $combinedDefinition); - } - - // Generate factories for each resource - $factoryDefinition = new ChildDefinition('lock.factory.abstract'); - $factoryDefinition->replaceArgument(0, new Reference($storeDefinitionId)); - $container->setDefinition('lock.'.$resourceName.'.factory', $factoryDefinition); - - // provide alias for default resource - if ('default' === $resourceName) { - $container->setAlias('lock.factory', new Alias('lock.'.$resourceName.'.factory', false)); - $container->setAlias(LockFactory::class, new Alias('lock.factory', false)); - } else { - $container->registerAliasForArgument('lock.'.$resourceName.'.factory', LockFactory::class, $resourceName.'.lock.factory'); - } - } - } - - private function registerSemaphoreConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('semaphore.php'); - - foreach ($config['resources'] as $resourceName => $resourceStore) { - $storeDsn = $container->resolveEnvPlaceholders($resourceStore, null, $usedEnvs); - $storeDefinition = new Definition(SemaphoreStoreInterface::class); - $storeDefinition->setFactory([SemaphoreStoreFactory::class, 'createStore']); - $storeDefinition->setArguments([$resourceStore]); - - $container->setDefinition($storeDefinitionId = '.semaphore.'.$resourceName.'.store.'.$container->hash($storeDsn), $storeDefinition); - - // Generate factories for each resource - $factoryDefinition = new ChildDefinition('semaphore.factory.abstract'); - $factoryDefinition->replaceArgument(0, new Reference($storeDefinitionId)); - $container->setDefinition('semaphore.'.$resourceName.'.factory', $factoryDefinition); - - // Generate services for semaphore instances - $semaphoreDefinition = new Definition(Semaphore::class); - $semaphoreDefinition->setFactory([new Reference('semaphore.'.$resourceName.'.factory'), 'createSemaphore']); - $semaphoreDefinition->setArguments([$resourceName]); - - // provide alias for default resource - if ('default' === $resourceName) { - $container->setAlias('semaphore.factory', new Alias('semaphore.'.$resourceName.'.factory', false)); - $container->setAlias(SemaphoreFactory::class, new Alias('semaphore.factory', false)); - } else { - $container->registerAliasForArgument('semaphore.'.$resourceName.'.factory', SemaphoreFactory::class, $resourceName.'.semaphore.factory'); - } - } - } - - private function registerSchedulerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!class_exists(SchedulerTransportFactory::class)) { - throw new LogicException('Scheduler support cannot be enabled as the Scheduler component is not installed. Try running "composer require symfony/scheduler".'); - } - - $loader->load('scheduler.php'); - - if (!$this->hasConsole()) { - $container->removeDefinition('console.command.scheduler_debug'); - } - } - - private function registerMessengerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $validationEnabled): void - { - if (!interface_exists(MessageBusInterface::class)) { - throw new LogicException('Messenger support cannot be enabled as the Messenger component is not installed. Try running "composer require symfony/messenger".'); - } - - if (!$this->hasConsole()) { - $container->removeDefinition('console.command.messenger_stats'); - } - - $loader->load('messenger.php'); - - if (!interface_exists(DenormalizerInterface::class)) { - $container->removeDefinition('serializer.normalizer.flatten_exception'); - } - - if (ContainerBuilder::willBeAvailable('symfony/amqp-messenger', MessengerBridge\Amqp\Transport\AmqpTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { - $container->getDefinition('messenger.transport.amqp.factory')->addTag('messenger.transport_factory'); - } - - if (ContainerBuilder::willBeAvailable('symfony/redis-messenger', MessengerBridge\Redis\Transport\RedisTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { - $container->getDefinition('messenger.transport.redis.factory')->addTag('messenger.transport_factory'); - } - - if (ContainerBuilder::willBeAvailable('symfony/amazon-sqs-messenger', MessengerBridge\AmazonSqs\Transport\AmazonSqsTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { - $container->getDefinition('messenger.transport.sqs.factory')->addTag('messenger.transport_factory'); - } - - if (ContainerBuilder::willBeAvailable('symfony/beanstalkd-messenger', MessengerBridge\Beanstalkd\Transport\BeanstalkdTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { - $container->getDefinition('messenger.transport.beanstalkd.factory')->addTag('messenger.transport_factory'); - } - - if ($config['stop_worker_on_signals'] && $this->hasConsole()) { - $container->getDefinition('console.command.messenger_consume_messages') - ->replaceArgument(8, $config['stop_worker_on_signals']); - $container->getDefinition('console.command.messenger_failed_messages_retry') - ->replaceArgument(6, $config['stop_worker_on_signals']); - } - - if (null === $config['default_bus'] && 1 === \count($config['buses'])) { - $config['default_bus'] = key($config['buses']); - } - - $defaultMiddleware = [ - 'before' => [ - ['id' => 'add_bus_name_stamp_middleware'], - ['id' => 'reject_redelivered_message_middleware'], - ['id' => 'dispatch_after_current_bus'], - ['id' => 'failed_message_processing_middleware'], - ], - 'after' => [ - ['id' => 'send_message'], - ['id' => 'handle_message'], - ], - ]; - foreach ($config['buses'] as $busId => $bus) { - $middleware = $bus['middleware']; - - if ($bus['default_middleware']['enabled']) { - $defaultMiddleware['after'][0]['arguments'] = [$bus['default_middleware']['allow_no_senders']]; - $defaultMiddleware['after'][1]['arguments'] = [$bus['default_middleware']['allow_no_handlers']]; - - // argument to add_bus_name_stamp_middleware - $defaultMiddleware['before'][0]['arguments'] = [$busId]; - - $middleware = array_merge($defaultMiddleware['before'], $middleware, $defaultMiddleware['after']); - } - - foreach ($middleware as $middlewareItem) { - if (!$validationEnabled && \in_array($middlewareItem['id'], ['validation', 'messenger.middleware.validation'], true)) { - throw new LogicException('The Validation middleware is only available when the Validator component is installed and enabled. Try running "composer require symfony/validator".'); - } - } - - if ($container->getParameter('kernel.debug') && class_exists(Stopwatch::class)) { - array_unshift($middleware, ['id' => 'traceable', 'arguments' => [$busId]]); - } - - $container->setParameter($busId.'.middleware', $middleware); - $container->register($busId, MessageBus::class)->addArgument([])->addTag('messenger.bus'); - - if ($busId === $config['default_bus']) { - $container->setAlias('messenger.default_bus', $busId)->setPublic(true); - $container->setAlias(MessageBusInterface::class, $busId); - } else { - $container->registerAliasForArgument($busId, MessageBusInterface::class); - } - } - - if (empty($config['transports'])) { - $container->removeDefinition('messenger.transport.symfony_serializer'); - $container->removeDefinition('messenger.transport.amqp.factory'); - $container->removeDefinition('messenger.transport.redis.factory'); - $container->removeDefinition('messenger.transport.sqs.factory'); - $container->removeDefinition('messenger.transport.beanstalkd.factory'); - $container->removeAlias(SerializerInterface::class); - } else { - $container->getDefinition('messenger.transport.symfony_serializer') - ->replaceArgument(1, $config['serializer']['symfony_serializer']['format']) - ->replaceArgument(2, $config['serializer']['symfony_serializer']['context']); - $container->setAlias('messenger.default_serializer', $config['serializer']['default_serializer']); - } - - $failureTransports = []; - if ($config['failure_transport']) { - if (!isset($config['transports'][$config['failure_transport']])) { - throw new LogicException(sprintf('Invalid Messenger configuration: the failure transport "%s" is not a valid transport or service id.', $config['failure_transport'])); - } - - $container->setAlias('messenger.failure_transports.default', 'messenger.transport.'.$config['failure_transport']); - $failureTransports[] = $config['failure_transport']; - } - - $failureTransportsByName = []; - foreach ($config['transports'] as $name => $transport) { - if ($transport['failure_transport']) { - $failureTransports[] = $transport['failure_transport']; - $failureTransportsByName[$name] = $transport['failure_transport']; - } elseif ($config['failure_transport']) { - $failureTransportsByName[$name] = $config['failure_transport']; - } - } - - $senderAliases = []; - $transportRetryReferences = []; - $transportRateLimiterReferences = []; - foreach ($config['transports'] as $name => $transport) { - $serializerId = $transport['serializer'] ?? 'messenger.default_serializer'; - $transportDefinition = (new Definition(TransportInterface::class)) - ->setFactory([new Reference('messenger.transport_factory'), 'createTransport']) - ->setArguments([$transport['dsn'], $transport['options'] + ['transport_name' => $name], new Reference($serializerId)]) - ->addTag('messenger.receiver', [ - 'alias' => $name, - 'is_failure_transport' => \in_array($name, $failureTransports, true), - ] - ) - ; - $container->setDefinition($transportId = 'messenger.transport.'.$name, $transportDefinition); - $senderAliases[$name] = $transportId; - - if (null !== $transport['retry_strategy']['service']) { - $transportRetryReferences[$name] = new Reference($transport['retry_strategy']['service']); - } else { - $retryServiceId = sprintf('messenger.retry.multiplier_retry_strategy.%s', $name); - $retryDefinition = new ChildDefinition('messenger.retry.abstract_multiplier_retry_strategy'); - $retryDefinition - ->replaceArgument(0, $transport['retry_strategy']['max_retries']) - ->replaceArgument(1, $transport['retry_strategy']['delay']) - ->replaceArgument(2, $transport['retry_strategy']['multiplier']) - ->replaceArgument(3, $transport['retry_strategy']['max_delay']) - ->replaceArgument(4, $transport['retry_strategy']['jitter']); - $container->setDefinition($retryServiceId, $retryDefinition); - - $transportRetryReferences[$name] = new Reference($retryServiceId); - } - - if ($transport['rate_limiter']) { - if (!interface_exists(LimiterInterface::class)) { - throw new LogicException('Rate limiter cannot be used within Messenger as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".'); - } - - $transportRateLimiterReferences[$name] = new Reference('limiter.'.$transport['rate_limiter']); - } - } - - $senderReferences = []; - // alias => service_id - foreach ($senderAliases as $alias => $serviceId) { - $senderReferences[$alias] = new Reference($serviceId); - } - // service_id => service_id - foreach ($senderAliases as $serviceId) { - $senderReferences[$serviceId] = new Reference($serviceId); - } - - foreach ($config['transports'] as $name => $transport) { - if ($transport['failure_transport']) { - if (!isset($senderReferences[$transport['failure_transport']])) { - throw new LogicException(sprintf('Invalid Messenger configuration: the failure transport "%s" is not a valid transport or service id.', $transport['failure_transport'])); - } - } - } - - $failureTransportReferencesByTransportName = array_map(fn ($failureTransportName) => $senderReferences[$failureTransportName], $failureTransportsByName); - - $messageToSendersMapping = []; - foreach ($config['routing'] as $message => $messageConfiguration) { - if ('*' !== $message && !class_exists($message) && !interface_exists($message, false) && !preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\\)++\*$/', $message)) { - if (str_contains($message, '*')) { - throw new LogicException(sprintf('Invalid Messenger routing configuration: invalid namespace "%s" wildcard.', $message)); - } - - throw new LogicException(sprintf('Invalid Messenger routing configuration: class or interface "%s" not found.', $message)); - } - - // make sure senderAliases contains all senders - foreach ($messageConfiguration['senders'] as $sender) { - if (!isset($senderReferences[$sender])) { - throw new LogicException(sprintf('Invalid Messenger routing configuration: the "%s" class is being routed to a sender called "%s". This is not a valid transport or service id.', $message, $sender)); - } - } - - $messageToSendersMapping[$message] = $messageConfiguration['senders']; - } - - $sendersServiceLocator = ServiceLocatorTagPass::register($container, $senderReferences); - - $container->getDefinition('messenger.senders_locator') - ->replaceArgument(0, $messageToSendersMapping) - ->replaceArgument(1, $sendersServiceLocator) - ; - - $container->getDefinition('messenger.retry.send_failed_message_for_retry_listener') - ->replaceArgument(0, $sendersServiceLocator) - ; - - $container->getDefinition('messenger.retry_strategy_locator') - ->replaceArgument(0, $transportRetryReferences); - - if (!$transportRateLimiterReferences) { - $container->removeDefinition('messenger.rate_limiter_locator'); - } else { - $container->getDefinition('messenger.rate_limiter_locator') - ->replaceArgument(0, $transportRateLimiterReferences); - } - - if (\count($failureTransports) > 0) { - if ($this->hasConsole()) { - $container->getDefinition('console.command.messenger_failed_messages_retry') - ->replaceArgument(0, $config['failure_transport']); - $container->getDefinition('console.command.messenger_failed_messages_show') - ->replaceArgument(0, $config['failure_transport']); - $container->getDefinition('console.command.messenger_failed_messages_remove') - ->replaceArgument(0, $config['failure_transport']); - } - - $failureTransportsByTransportNameServiceLocator = ServiceLocatorTagPass::register($container, $failureTransportReferencesByTransportName); - $container->getDefinition('messenger.failure.send_failed_message_to_failure_transport_listener') - ->replaceArgument(0, $failureTransportsByTransportNameServiceLocator); - } else { - $container->removeDefinition('messenger.failure.send_failed_message_to_failure_transport_listener'); - $container->removeDefinition('console.command.messenger_failed_messages_retry'); - $container->removeDefinition('console.command.messenger_failed_messages_show'); - $container->removeDefinition('console.command.messenger_failed_messages_remove'); - } - - if (!$container->hasDefinition('console.command.messenger_consume_messages')) { - $container->removeDefinition('messenger.listener.reset_services'); - } - } - - private function registerCacheConfiguration(array $config, ContainerBuilder $container): void - { - $version = new Parameter('container.build_id'); - $container->getDefinition('cache.adapter.apcu')->replaceArgument(2, $version); - $container->getDefinition('cache.adapter.system')->replaceArgument(2, $version); - $container->getDefinition('cache.adapter.filesystem')->replaceArgument(2, $config['directory']); - - if (isset($config['prefix_seed'])) { - $container->setParameter('cache.prefix.seed', $config['prefix_seed']); - } - if ($container->hasParameter('cache.prefix.seed')) { - // Inline any env vars referenced in the parameter - $container->setParameter('cache.prefix.seed', $container->resolveEnvPlaceholders($container->getParameter('cache.prefix.seed'), true)); - } - foreach (['psr6', 'redis', 'memcached', 'doctrine_dbal', 'pdo'] as $name) { - if (isset($config[$name = 'default_'.$name.'_provider'])) { - $container->setAlias('cache.'.$name, new Alias(CachePoolPass::getServiceProvider($container, $config[$name]), false)); - } - } - foreach (['app', 'system'] as $name) { - $config['pools']['cache.'.$name] = [ - 'adapters' => [$config[$name]], - 'public' => true, - 'tags' => false, - ]; - } - foreach ($config['pools'] as $name => $pool) { - $pool['adapters'] = $pool['adapters'] ?: ['cache.app']; - - $isRedisTagAware = ['cache.adapter.redis_tag_aware'] === $pool['adapters']; - foreach ($pool['adapters'] as $provider => $adapter) { - if (($config['pools'][$adapter]['adapters'] ?? null) === ['cache.adapter.redis_tag_aware']) { - $isRedisTagAware = true; - } elseif ($config['pools'][$adapter]['tags'] ?? false) { - $pool['adapters'][$provider] = $adapter = '.'.$adapter.'.inner'; - } - } - - if (1 === \count($pool['adapters'])) { - if (!isset($pool['provider']) && !\is_int($provider)) { - $pool['provider'] = $provider; - } - $definition = new ChildDefinition($adapter); - } else { - $definition = new Definition(ChainAdapter::class, [$pool['adapters'], 0]); - $pool['reset'] = 'reset'; - } - - if ($isRedisTagAware && 'cache.app' === $name) { - $container->setAlias('cache.app.taggable', $name); - $definition->addTag('cache.taggable', ['pool' => $name]); - } elseif ($isRedisTagAware) { - $tagAwareId = $name; - $container->setAlias('.'.$name.'.inner', $name); - $definition->addTag('cache.taggable', ['pool' => $name]); - } elseif ($pool['tags']) { - if (true !== $pool['tags'] && ($config['pools'][$pool['tags']]['tags'] ?? false)) { - $pool['tags'] = '.'.$pool['tags'].'.inner'; - } - $container->register($name, TagAwareAdapter::class) - ->addArgument(new Reference('.'.$name.'.inner')) - ->addArgument(true !== $pool['tags'] ? new Reference($pool['tags']) : null) - ->addMethodCall('setLogger', [new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]) - ->setPublic($pool['public']) - ->addTag('cache.taggable', ['pool' => $name]) - ->addTag('monolog.logger', ['channel' => 'cache']); - - $pool['name'] = $tagAwareId = $name; - $pool['public'] = false; - $name = '.'.$name.'.inner'; - } elseif (!\in_array($name, ['cache.app', 'cache.system'], true)) { - $tagAwareId = '.'.$name.'.taggable'; - $container->register($tagAwareId, TagAwareAdapter::class) - ->addArgument(new Reference($name)) - ->addTag('cache.taggable', ['pool' => $name]) - ; - } - - if (!\in_array($name, ['cache.app', 'cache.system'], true)) { - $container->registerAliasForArgument($tagAwareId, TagAwareCacheInterface::class, $pool['name'] ?? $name); - $container->registerAliasForArgument($name, CacheInterface::class, $pool['name'] ?? $name); - $container->registerAliasForArgument($name, CacheItemPoolInterface::class, $pool['name'] ?? $name); - } - - $definition->setPublic($pool['public']); - unset($pool['adapters'], $pool['public'], $pool['tags']); - - $definition->addTag('cache.pool', $pool); - $container->setDefinition($name, $definition); - } - - if (class_exists(PropertyAccessor::class)) { - $propertyAccessDefinition = $container->register('cache.property_access', AdapterInterface::class); - - if (!$container->getParameter('kernel.debug')) { - $propertyAccessDefinition->setFactory([PropertyAccessor::class, 'createCache']); - $propertyAccessDefinition->setArguments(['', 0, $version, new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]); - $propertyAccessDefinition->addTag('cache.pool', ['clearer' => 'cache.system_clearer']); - $propertyAccessDefinition->addTag('monolog.logger', ['channel' => 'cache']); - } else { - $propertyAccessDefinition->setClass(ArrayAdapter::class); - $propertyAccessDefinition->setArguments([0, false]); - } - } - } - - private function registerHttpClientConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('http_client.php'); - - $options = $config['default_options'] ?? []; - $rateLimiter = $options['rate_limiter'] ?? null; - unset($options['rate_limiter']); - $retryOptions = $options['retry_failed'] ?? ['enabled' => false]; - unset($options['retry_failed']); - $defaultUriTemplateVars = $options['vars'] ?? []; - unset($options['vars']); - $container->getDefinition('http_client.transport')->setArguments([$options, $config['max_host_connections'] ?? 6]); - - if (!class_exists(PingWebhookMessageHandler::class)) { - $container->removeDefinition('http_client.messenger.ping_webhook_handler'); - } - - if (!$hasPsr18 = ContainerBuilder::willBeAvailable('psr/http-client', ClientInterface::class, ['symfony/framework-bundle', 'symfony/http-client'])) { - $container->removeDefinition('psr18.http_client'); - $container->removeAlias(ClientInterface::class); - } - - if (!$hasHttplug = ContainerBuilder::willBeAvailable('php-http/httplug', HttpAsyncClient::class, ['symfony/framework-bundle', 'symfony/http-client'])) { - $container->removeDefinition('httplug.http_client'); - $container->removeAlias(HttpAsyncClient::class); - $container->removeAlias(HttpClient::class); - } - - if (null !== $rateLimiter) { - $this->registerThrottlingHttpClient($rateLimiter, 'http_client', $container); - } - - if ($this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) { - $this->registerRetryableHttpClient($retryOptions, 'http_client', $container); - } - - if (ContainerBuilder::willBeAvailable('guzzlehttp/uri-template', \GuzzleHttp\UriTemplate\UriTemplate::class, [])) { - $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.guzzle'); - } elseif (ContainerBuilder::willBeAvailable('rize/uri-template', \Rize\UriTemplate::class, [])) { - $container->setAlias('http_client.uri_template_expander', 'http_client.uri_template_expander.rize'); - } - - $container - ->getDefinition('http_client.uri_template') - ->setArgument(2, $defaultUriTemplateVars); - - foreach ($config['scoped_clients'] as $name => $scopeConfig) { - if ($container->has($name)) { - throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name)); - } - - $scope = $scopeConfig['scope'] ?? null; - unset($scopeConfig['scope']); - $rateLimiter = $scopeConfig['rate_limiter'] ?? null; - unset($scopeConfig['rate_limiter']); - $retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false]; - unset($scopeConfig['retry_failed']); - - if (null === $scope) { - $baseUri = $scopeConfig['base_uri']; - unset($scopeConfig['base_uri']); - - $container->register($name, ScopingHttpClient::class) - ->setFactory([ScopingHttpClient::class, 'forBaseUri']) - ->setArguments([new Reference('http_client.transport'), $baseUri, $scopeConfig]) - ->addTag('http_client.client') - ; - } else { - $container->register($name, ScopingHttpClient::class) - ->setArguments([new Reference('http_client.transport'), [$scope => $scopeConfig], $scope]) - ->addTag('http_client.client') - ; - } - - if (null !== $rateLimiter) { - $this->registerThrottlingHttpClient($rateLimiter, $name, $container); - } - - if ($this->readConfigEnabled('http_client.scoped_clients.'.$name.'.retry_failed', $container, $retryOptions)) { - $this->registerRetryableHttpClient($retryOptions, $name, $container); - } - - $container - ->register($name.'.uri_template', UriTemplateHttpClient::class) - ->setDecoratedService($name, null, 7) // Between TraceableHttpClient (5) and RetryableHttpClient (10) - ->setArguments([ - new Reference($name.'.uri_template.inner'), - new Reference('http_client.uri_template_expander', ContainerInterface::NULL_ON_INVALID_REFERENCE), - $defaultUriTemplateVars, - ]); - - $container->registerAliasForArgument($name, HttpClientInterface::class); - - if ($hasPsr18) { - $container->setDefinition('psr18.'.$name, new ChildDefinition('psr18.http_client')) - ->replaceArgument(0, new Reference($name)); - - $container->registerAliasForArgument('psr18.'.$name, ClientInterface::class, $name); - } - - if ($hasHttplug) { - $container->setDefinition('httplug.'.$name, new ChildDefinition('httplug.http_client')) - ->replaceArgument(0, new Reference($name)); - - $container->registerAliasForArgument('httplug.'.$name, HttpAsyncClient::class, $name); - } - } - - if ($responseFactoryId = $config['mock_response_factory'] ?? null) { - $container->register('http_client.mock_client', MockHttpClient::class) - ->setDecoratedService('http_client.transport', null, -10) // lower priority than TraceableHttpClient (5) - ->setArguments([new Reference($responseFactoryId)]); - } - } - - private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void - { - if (!class_exists(ThrottlingHttpClient::class)) { - throw new LogicException('Rate limiter support cannot be enabled as version 7.1+ of the HttpClient component is required.'); - } - - if (!$this->isInitializedConfigEnabled('rate_limiter')) { - throw new LogicException('Rate limiter cannot be used within HttpClient as the RateLimiter component is not enabled.'); - } - - $container->register($name.'.throttling.limiter', LimiterInterface::class) - ->setFactory([new Reference('limiter.'.$rateLimiter), 'create']); - - $container - ->register($name.'.throttling', ThrottlingHttpClient::class) - ->setDecoratedService($name, null, 15) // higher priority than RetryableHttpClient (10) - ->setArguments([new Reference($name.'.throttling.inner'), new Reference($name.'.throttling.limiter')]); - } - - private function registerRetryableHttpClient(array $options, string $name, ContainerBuilder $container): void - { - if (null !== $options['retry_strategy']) { - $retryStrategy = new Reference($options['retry_strategy']); - } else { - $retryStrategy = new ChildDefinition('http_client.abstract_retry_strategy'); - $codes = []; - foreach ($options['http_codes'] as $code => $codeOptions) { - if ($codeOptions['methods']) { - $codes[$code] = $codeOptions['methods']; - } else { - $codes[] = $code; - } - } - - $retryStrategy - ->replaceArgument(0, $codes ?: GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES) - ->replaceArgument(1, $options['delay']) - ->replaceArgument(2, $options['multiplier']) - ->replaceArgument(3, $options['max_delay']) - ->replaceArgument(4, $options['jitter']); - $container->setDefinition($name.'.retry_strategy', $retryStrategy); - - $retryStrategy = new Reference($name.'.retry_strategy'); - } - - $container - ->register($name.'.retryable', RetryableHttpClient::class) - ->setDecoratedService($name, null, 10) // higher priority than TraceableHttpClient (5) - ->setArguments([new Reference($name.'.retryable.inner'), $retryStrategy, $options['max_retries'], new Reference('logger')]) - ->addTag('monolog.logger', ['channel' => 'http_client']); - } - - private function registerMailerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $webhookEnabled): void - { - if (!class_exists(Mailer::class)) { - throw new LogicException('Mailer support cannot be enabled as the component is not installed. Try running "composer require symfony/mailer".'); - } - - $loader->load('mailer.php'); - $loader->load('mailer_transports.php'); - if (!\count($config['transports']) && null === $config['dsn']) { - $config['dsn'] = 'smtp://null'; - } - $transports = $config['dsn'] ? ['main' => $config['dsn']] : $config['transports']; - $container->getDefinition('mailer.transports')->setArgument(0, $transports); - $container->getDefinition('mailer.default_transport')->setArgument(0, current($transports)); - - $mailer = $container->getDefinition('mailer.mailer'); - if (false === $messageBus = $config['message_bus']) { - $mailer->replaceArgument(1, null); - } else { - $mailer->replaceArgument(1, $messageBus ? new Reference($messageBus) : new Reference('messenger.default_bus', ContainerInterface::NULL_ON_INVALID_REFERENCE)); - } - - $classToServices = [ - MailerBridge\Azure\Transport\AzureTransportFactory::class => 'mailer.transport_factory.azure', - MailerBridge\Brevo\Transport\BrevoTransportFactory::class => 'mailer.transport_factory.brevo', - MailerBridge\Google\Transport\GmailTransportFactory::class => 'mailer.transport_factory.gmail', - MailerBridge\Infobip\Transport\InfobipTransportFactory::class => 'mailer.transport_factory.infobip', - MailerBridge\MailerSend\Transport\MailerSendTransportFactory::class => 'mailer.transport_factory.mailersend', - MailerBridge\Mailgun\Transport\MailgunTransportFactory::class => 'mailer.transport_factory.mailgun', - MailerBridge\Mailjet\Transport\MailjetTransportFactory::class => 'mailer.transport_factory.mailjet', - MailerBridge\MailPace\Transport\MailPaceTransportFactory::class => 'mailer.transport_factory.mailpace', - MailerBridge\Mailchimp\Transport\MandrillTransportFactory::class => 'mailer.transport_factory.mailchimp', - MailerBridge\Postmark\Transport\PostmarkTransportFactory::class => 'mailer.transport_factory.postmark', - MailerBridge\Resend\Transport\ResendTransportFactory::class => 'mailer.transport_factory.resend', - MailerBridge\Scaleway\Transport\ScalewayTransportFactory::class => 'mailer.transport_factory.scaleway', - MailerBridge\Sendgrid\Transport\SendgridTransportFactory::class => 'mailer.transport_factory.sendgrid', - MailerBridge\Amazon\Transport\SesTransportFactory::class => 'mailer.transport_factory.amazon', - ]; - - foreach ($classToServices as $class => $service) { - $package = substr($service, \strlen('mailer.transport_factory.')); - - if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-mailer', 'gmail' === $package ? 'google' : $package), $class, ['symfony/framework-bundle', 'symfony/mailer'])) { - $container->removeDefinition($service); - } - } - - if ($webhookEnabled) { - $webhookRequestParsers = [ - MailerBridge\Brevo\Webhook\BrevoRequestParser::class => 'mailer.webhook.request_parser.brevo', - MailerBridge\MailerSend\Webhook\MailerSendRequestParser::class => 'mailer.webhook.request_parser.mailersend', - MailerBridge\Mailgun\Webhook\MailgunRequestParser::class => 'mailer.webhook.request_parser.mailgun', - MailerBridge\Mailjet\Webhook\MailjetRequestParser::class => 'mailer.webhook.request_parser.mailjet', - MailerBridge\Postmark\Webhook\PostmarkRequestParser::class => 'mailer.webhook.request_parser.postmark', - MailerBridge\Resend\Webhook\ResendRequestParser::class => 'mailer.webhook.request_parser.resend', - MailerBridge\Sendgrid\Webhook\SendgridRequestParser::class => 'mailer.webhook.request_parser.sendgrid', - ]; - - foreach ($webhookRequestParsers as $class => $service) { - $package = substr($service, \strlen('mailer.webhook.request_parser.')); - - if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-mailer', 'gmail' === $package ? 'google' : $package), $class, ['symfony/framework-bundle', 'symfony/mailer'])) { - $container->removeDefinition($service); - } - } - } - - $envelopeListener = $container->getDefinition('mailer.envelope_listener'); - $envelopeListener->setArgument(0, $config['envelope']['sender'] ?? null); - $envelopeListener->setArgument(1, $config['envelope']['recipients'] ?? null); - - if ($config['headers']) { - $headers = new Definition(Headers::class); - foreach ($config['headers'] as $name => $data) { - $value = $data['value']; - if (\in_array(strtolower($name), ['from', 'to', 'cc', 'bcc', 'reply-to'])) { - $value = (array) $value; - } - $headers->addMethodCall('addHeader', [$name, $value]); - } - $messageListener = $container->getDefinition('mailer.message_listener'); - $messageListener->setArgument(0, $headers); - } else { - $container->removeDefinition('mailer.message_listener'); - } - - if (!class_exists(MessengerTransportListener::class)) { - $container->removeDefinition('mailer.messenger_transport_listener'); - } - - if ($webhookEnabled) { - $loader->load('mailer_webhook.php'); - } - } - - private function registerNotifierConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $webhookEnabled): void - { - if (!class_exists(Notifier::class)) { - throw new LogicException('Notifier support cannot be enabled as the component is not installed. Try running "composer require symfony/notifier".'); - } - - $loader->load('notifier.php'); - $loader->load('notifier_transports.php'); - - if ($config['chatter_transports']) { - $container->getDefinition('chatter.transports')->setArgument(0, $config['chatter_transports']); - } else { - $container->removeDefinition('chatter'); - $container->removeAlias(ChatterInterface::class); - } - if ($config['texter_transports']) { - $container->getDefinition('texter.transports')->setArgument(0, $config['texter_transports']); - } else { - $container->removeDefinition('texter'); - $container->removeAlias(TexterInterface::class); - } - - if ($this->isInitializedConfigEnabled('mailer')) { - $sender = $container->getDefinition('mailer.envelope_listener')->getArgument(0); - $container->getDefinition('notifier.channel.email')->setArgument(2, $sender); - } else { - $container->removeDefinition('notifier.channel.email'); - } - - foreach (['texter', 'chatter', 'notifier.channel.chat', 'notifier.channel.email', 'notifier.channel.sms'] as $serviceId) { - if (!$container->hasDefinition($serviceId)) { - continue; - } - - if (false === $messageBus = $config['message_bus']) { - $container->getDefinition($serviceId)->replaceArgument(1, null); - } else { - $container->getDefinition($serviceId)->replaceArgument(1, $messageBus ? new Reference($messageBus) : new Reference('messenger.default_bus', ContainerInterface::NULL_ON_INVALID_REFERENCE)); - } - } - - if ($this->isInitializedConfigEnabled('messenger')) { - if ($config['notification_on_failed_messages']) { - $container->getDefinition('notifier.failed_message_listener')->addTag('kernel.event_subscriber'); - } - - // as we have a bus, the channels don't need the transports - $container->getDefinition('notifier.channel.chat')->setArgument(0, null); - if ($container->hasDefinition('notifier.channel.email')) { - $container->getDefinition('notifier.channel.email')->setArgument(0, null); - } - $container->getDefinition('notifier.channel.sms')->setArgument(0, null); - $container->getDefinition('notifier.channel.push')->setArgument(0, null); - } - - $container->getDefinition('notifier.channel_policy')->setArgument(0, $config['channel_policy']); - - $container->registerForAutoconfiguration(NotifierTransportFactoryInterface::class) - ->addTag('chatter.transport_factory'); - - $container->registerForAutoconfiguration(NotifierTransportFactoryInterface::class) - ->addTag('texter.transport_factory'); - - $classToServices = [ - NotifierBridge\AllMySms\AllMySmsTransportFactory::class => 'notifier.transport_factory.all-my-sms', - NotifierBridge\AmazonSns\AmazonSnsTransportFactory::class => 'notifier.transport_factory.amazon-sns', - NotifierBridge\Bandwidth\BandwidthTransportFactory::class => 'notifier.transport_factory.bandwidth', - NotifierBridge\Bluesky\BlueskyTransportFactory::class => 'notifier.transport_factory.bluesky', - NotifierBridge\Brevo\BrevoTransportFactory::class => 'notifier.transport_factory.brevo', - NotifierBridge\Chatwork\ChatworkTransportFactory::class => 'notifier.transport_factory.chatwork', - NotifierBridge\Clickatell\ClickatellTransportFactory::class => 'notifier.transport_factory.clickatell', - NotifierBridge\ClickSend\ClickSendTransportFactory::class => 'notifier.transport_factory.click-send', - NotifierBridge\ContactEveryone\ContactEveryoneTransportFactory::class => 'notifier.transport_factory.contact-everyone', - NotifierBridge\Discord\DiscordTransportFactory::class => 'notifier.transport_factory.discord', - NotifierBridge\Engagespot\EngagespotTransportFactory::class => 'notifier.transport_factory.engagespot', - NotifierBridge\Esendex\EsendexTransportFactory::class => 'notifier.transport_factory.esendex', - NotifierBridge\Expo\ExpoTransportFactory::class => 'notifier.transport_factory.expo', - NotifierBridge\FakeChat\FakeChatTransportFactory::class => 'notifier.transport_factory.fake-chat', - NotifierBridge\FakeSms\FakeSmsTransportFactory::class => 'notifier.transport_factory.fake-sms', - NotifierBridge\Firebase\FirebaseTransportFactory::class => 'notifier.transport_factory.firebase', - NotifierBridge\FortySixElks\FortySixElksTransportFactory::class => 'notifier.transport_factory.forty-six-elks', - NotifierBridge\FreeMobile\FreeMobileTransportFactory::class => 'notifier.transport_factory.free-mobile', - NotifierBridge\GatewayApi\GatewayApiTransportFactory::class => 'notifier.transport_factory.gateway-api', - NotifierBridge\Gitter\GitterTransportFactory::class => 'notifier.transport_factory.gitter', - NotifierBridge\GoIp\GoIpTransportFactory::class => 'notifier.transport_factory.go-ip', - NotifierBridge\GoogleChat\GoogleChatTransportFactory::class => 'notifier.transport_factory.google-chat', - NotifierBridge\Infobip\InfobipTransportFactory::class => 'notifier.transport_factory.infobip', - NotifierBridge\Iqsms\IqsmsTransportFactory::class => 'notifier.transport_factory.iqsms', - NotifierBridge\Isendpro\IsendproTransportFactory::class => 'notifier.transport_factory.isendpro', - NotifierBridge\KazInfoTeh\KazInfoTehTransportFactory::class => 'notifier.transport_factory.kaz-info-teh', - NotifierBridge\LightSms\LightSmsTransportFactory::class => 'notifier.transport_factory.light-sms', - NotifierBridge\LineNotify\LineNotifyTransportFactory::class => 'notifier.transport_factory.line-notify', - NotifierBridge\LinkedIn\LinkedInTransportFactory::class => 'notifier.transport_factory.linked-in', - NotifierBridge\Mailjet\MailjetTransportFactory::class => 'notifier.transport_factory.mailjet', - NotifierBridge\Mastodon\MastodonTransportFactory::class => 'notifier.transport_factory.mastodon', - NotifierBridge\Mattermost\MattermostTransportFactory::class => 'notifier.transport_factory.mattermost', - NotifierBridge\Mercure\MercureTransportFactory::class => 'notifier.transport_factory.mercure', - NotifierBridge\MessageBird\MessageBirdTransportFactory::class => 'notifier.transport_factory.message-bird', - NotifierBridge\MessageMedia\MessageMediaTransportFactory::class => 'notifier.transport_factory.message-media', - NotifierBridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class => 'notifier.transport_factory.microsoft-teams', - NotifierBridge\Mobyt\MobytTransportFactory::class => 'notifier.transport_factory.mobyt', - NotifierBridge\Novu\NovuTransportFactory::class => 'notifier.transport_factory.novu', - NotifierBridge\Ntfy\NtfyTransportFactory::class => 'notifier.transport_factory.ntfy', - NotifierBridge\Octopush\OctopushTransportFactory::class => 'notifier.transport_factory.octopush', - NotifierBridge\OneSignal\OneSignalTransportFactory::class => 'notifier.transport_factory.one-signal', - NotifierBridge\OrangeSms\OrangeSmsTransportFactory::class => 'notifier.transport_factory.orange-sms', - NotifierBridge\OvhCloud\OvhCloudTransportFactory::class => 'notifier.transport_factory.ovh-cloud', - NotifierBridge\PagerDuty\PagerDutyTransportFactory::class => 'notifier.transport_factory.pager-duty', - NotifierBridge\Plivo\PlivoTransportFactory::class => 'notifier.transport_factory.plivo', - NotifierBridge\Pushover\PushoverTransportFactory::class => 'notifier.transport_factory.pushover', - NotifierBridge\Pushy\PushyTransportFactory::class => 'notifier.transport_factory.pushy', - NotifierBridge\Redlink\RedlinkTransportFactory::class => 'notifier.transport_factory.redlink', - NotifierBridge\RingCentral\RingCentralTransportFactory::class => 'notifier.transport_factory.ring-central', - NotifierBridge\RocketChat\RocketChatTransportFactory::class => 'notifier.transport_factory.rocket-chat', - NotifierBridge\Sendberry\SendberryTransportFactory::class => 'notifier.transport_factory.sendberry', - NotifierBridge\SimpleTextin\SimpleTextinTransportFactory::class => 'notifier.transport_factory.simple-textin', - NotifierBridge\Sevenio\SevenIoTransportFactory::class => 'notifier.transport_factory.sevenio', - NotifierBridge\Sinch\SinchTransportFactory::class => 'notifier.transport_factory.sinch', - NotifierBridge\Slack\SlackTransportFactory::class => 'notifier.transport_factory.slack', - NotifierBridge\Sms77\Sms77TransportFactory::class => 'notifier.transport_factory.sms77', - NotifierBridge\Smsapi\SmsapiTransportFactory::class => 'notifier.transport_factory.smsapi', - NotifierBridge\SmsBiuras\SmsBiurasTransportFactory::class => 'notifier.transport_factory.sms-biuras', - NotifierBridge\Smsbox\SmsboxTransportFactory::class => 'notifier.transport_factory.smsbox', - NotifierBridge\Smsc\SmscTransportFactory::class => 'notifier.transport_factory.smsc', - NotifierBridge\SmsFactor\SmsFactorTransportFactory::class => 'notifier.transport_factory.sms-factor', - NotifierBridge\Smsmode\SmsmodeTransportFactory::class => 'notifier.transport_factory.smsmode', - NotifierBridge\SmsSluzba\SmsSluzbaTransportFactory::class => 'notifier.transport_factory.sms-sluzba', - NotifierBridge\Smsense\SmsenseTransportFactory::class => 'notifier.transport_factory.smsense', - NotifierBridge\SpotHit\SpotHitTransportFactory::class => 'notifier.transport_factory.spot-hit', - NotifierBridge\Telegram\TelegramTransportFactory::class => 'notifier.transport_factory.telegram', - NotifierBridge\Telnyx\TelnyxTransportFactory::class => 'notifier.transport_factory.telnyx', - NotifierBridge\Termii\TermiiTransportFactory::class => 'notifier.transport_factory.termii', - NotifierBridge\TurboSms\TurboSmsTransportFactory::class => 'notifier.transport_factory.turbo-sms', - NotifierBridge\Twilio\TwilioTransportFactory::class => 'notifier.transport_factory.twilio', - NotifierBridge\Twitter\TwitterTransportFactory::class => 'notifier.transport_factory.twitter', - NotifierBridge\Unifonic\UnifonicTransportFactory::class => 'notifier.transport_factory.unifonic', - NotifierBridge\Vonage\VonageTransportFactory::class => 'notifier.transport_factory.vonage', - NotifierBridge\Yunpian\YunpianTransportFactory::class => 'notifier.transport_factory.yunpian', - NotifierBridge\Zendesk\ZendeskTransportFactory::class => 'notifier.transport_factory.zendesk', - NotifierBridge\Zulip\ZulipTransportFactory::class => 'notifier.transport_factory.zulip', - ]; - - $parentPackages = ['symfony/framework-bundle', 'symfony/notifier']; - - foreach ($classToServices as $class => $service) { - $package = substr($service, \strlen('notifier.transport_factory.')); - - if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-notifier', $package), $class, $parentPackages)) { - $container->removeDefinition($service); - } - } - - if (ContainerBuilder::willBeAvailable('symfony/mercure-notifier', NotifierBridge\Mercure\MercureTransportFactory::class, $parentPackages) && ContainerBuilder::willBeAvailable('symfony/mercure-bundle', MercureBundle::class, $parentPackages) && \in_array(MercureBundle::class, $container->getParameter('kernel.bundles'), true)) { - $container->getDefinition($classToServices[NotifierBridge\Mercure\MercureTransportFactory::class]) - ->replaceArgument(0, new Reference(HubRegistry::class)) - ->replaceArgument(1, new Reference('event_dispatcher', ContainerBuilder::NULL_ON_INVALID_REFERENCE)) - ->addArgument(new Reference('http_client', ContainerBuilder::NULL_ON_INVALID_REFERENCE)); - } elseif (ContainerBuilder::willBeAvailable('symfony/mercure-notifier', NotifierBridge\Mercure\MercureTransportFactory::class, $parentPackages)) { - $container->removeDefinition($classToServices[NotifierBridge\Mercure\MercureTransportFactory::class]); - } - - if (ContainerBuilder::willBeAvailable('symfony/fake-chat-notifier', NotifierBridge\FakeChat\FakeChatTransportFactory::class, ['symfony/framework-bundle', 'symfony/notifier', 'symfony/mailer'])) { - $container->getDefinition($classToServices[NotifierBridge\FakeChat\FakeChatTransportFactory::class]) - ->replaceArgument(0, new Reference('mailer')) - ->replaceArgument(1, new Reference('logger')) - ->addArgument(new Reference('event_dispatcher', ContainerBuilder::NULL_ON_INVALID_REFERENCE)) - ->addArgument(new Reference('http_client', ContainerBuilder::NULL_ON_INVALID_REFERENCE)); - } - - if (ContainerBuilder::willBeAvailable('symfony/fake-sms-notifier', NotifierBridge\FakeSms\FakeSmsTransportFactory::class, ['symfony/framework-bundle', 'symfony/notifier', 'symfony/mailer'])) { - $container->getDefinition($classToServices[NotifierBridge\FakeSms\FakeSmsTransportFactory::class]) - ->replaceArgument(0, new Reference('mailer')) - ->replaceArgument(1, new Reference('logger')) - ->addArgument(new Reference('event_dispatcher', ContainerBuilder::NULL_ON_INVALID_REFERENCE)) - ->addArgument(new Reference('http_client', ContainerBuilder::NULL_ON_INVALID_REFERENCE)); - } - - if (isset($config['admin_recipients'])) { - $notifier = $container->getDefinition('notifier'); - foreach ($config['admin_recipients'] as $i => $recipient) { - $id = 'notifier.admin_recipient.'.$i; - $container->setDefinition($id, new Definition(Recipient::class, [$recipient['email'], $recipient['phone']])); - $notifier->addMethodCall('addAdminRecipient', [new Reference($id)]); - } - } - - if ($webhookEnabled) { - $loader->load('notifier_webhook.php'); - - $webhookRequestParsers = [ - NotifierBridge\Twilio\Webhook\TwilioRequestParser::class => 'notifier.webhook.request_parser.twilio', - NotifierBridge\Vonage\Webhook\VonageRequestParser::class => 'notifier.webhook.request_parser.vonage', - ]; - - foreach ($webhookRequestParsers as $class => $service) { - $package = substr($service, \strlen('notifier.webhook.request_parser.')); - - if (!ContainerBuilder::willBeAvailable(sprintf('symfony/%s-notifier', $package), $class, ['symfony/framework-bundle', 'symfony/notifier'])) { - $container->removeDefinition($service); - } - } - } - } - - private function registerWebhookConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!class_exists(WebhookController::class)) { - throw new LogicException('Webhook support cannot be enabled as the component is not installed. Try running "composer require symfony/webhook".'); - } - - $loader->load('webhook.php'); - - $parsers = []; - foreach ($config['routing'] as $type => $cfg) { - $parsers[$type] = [ - 'parser' => new Reference($cfg['service']), - 'secret' => $cfg['secret'], - ]; - } - - $controller = $container->getDefinition('webhook.controller'); - $controller->replaceArgument(0, $parsers); - $controller->replaceArgument(1, new Reference($config['message_bus'])); - } - - private function registerRemoteEventConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - if (!class_exists(RemoteEvent::class)) { - throw new LogicException('RemoteEvent support cannot be enabled as the component is not installed. Try running "composer require symfony/remote-event".'); - } - - $loader->load('remote_event.php'); - } - - private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('rate_limiter.php'); - - foreach ($config['limiters'] as $name => $limiterConfig) { - // default configuration (when used by other DI extensions) - $limiterConfig += ['lock_factory' => 'lock.factory', 'cache_pool' => 'cache.rate_limiter']; - - $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')) - ->addTag('rate_limiter', ['name' => $name]); - - if (null !== $limiterConfig['lock_factory']) { - if (!interface_exists(LockInterface::class)) { - throw new LogicException(sprintf('Rate limiter "%s" requires the Lock component to be installed. Try running "composer require symfony/lock".', $name)); - } - - if (!$this->isInitializedConfigEnabled('lock')) { - throw new LogicException(sprintf('Rate limiter "%s" requires the Lock component to be configured.', $name)); - } - - $limiter->replaceArgument(2, new Reference($limiterConfig['lock_factory'])); - } - unset($limiterConfig['lock_factory']); - - if (null === $storageId = $limiterConfig['storage_service'] ?? null) { - $container->register($storageId = 'limiter.storage.'.$name, CacheStorage::class)->addArgument(new Reference($limiterConfig['cache_pool'])); - } - - $limiter->replaceArgument(1, new Reference($storageId)); - unset($limiterConfig['storage_service'], $limiterConfig['cache_pool']); - - $limiterConfig['id'] = $name; - $limiter->replaceArgument(0, $limiterConfig); - - $container->registerAliasForArgument($limiterId, RateLimiterFactory::class, $name.'.limiter'); - } - } - - private function registerUidConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('uid.php'); - - $container->getDefinition('uuid.factory') - ->setArguments([ - $config['default_uuid_version'], - $config['time_based_uuid_version'], - $config['name_based_uuid_version'], - UuidV4::class, - $config['time_based_uuid_node'] ?? null, - $config['name_based_uuid_namespace'] ?? null, - ]) - ; - - if (isset($config['name_based_uuid_namespace'])) { - $container->getDefinition('name_based_uuid.factory') - ->setArguments([$config['name_based_uuid_namespace']]); - } - } - - private function registerHtmlSanitizerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('html_sanitizer.php'); - - foreach ($config['sanitizers'] as $sanitizerName => $sanitizerConfig) { - $configId = 'html_sanitizer.config.'.$sanitizerName; - $def = $container->register($configId, HtmlSanitizerConfig::class); - - // Base - if ($sanitizerConfig['allow_safe_elements']) { - $def->addMethodCall('allowSafeElements', [], true); - } - - if ($sanitizerConfig['allow_static_elements']) { - $def->addMethodCall('allowStaticElements', [], true); - } - - // Configures elements - foreach ($sanitizerConfig['allow_elements'] as $element => $attributes) { - $def->addMethodCall('allowElement', [$element, $attributes], true); - } - - foreach ($sanitizerConfig['block_elements'] as $element) { - $def->addMethodCall('blockElement', [$element], true); - } - - foreach ($sanitizerConfig['drop_elements'] as $element) { - $def->addMethodCall('dropElement', [$element], true); - } - - // Configures attributes - foreach ($sanitizerConfig['allow_attributes'] as $attribute => $elements) { - $def->addMethodCall('allowAttribute', [$attribute, $elements], true); - } - - foreach ($sanitizerConfig['drop_attributes'] as $attribute => $elements) { - $def->addMethodCall('dropAttribute', [$attribute, $elements], true); - } - - // Force attributes - foreach ($sanitizerConfig['force_attributes'] as $element => $attributes) { - foreach ($attributes as $attrName => $attrValue) { - $def->addMethodCall('forceAttribute', [$element, $attrName, $attrValue], true); - } - } - - // Settings - $def->addMethodCall('forceHttpsUrls', [$sanitizerConfig['force_https_urls']], true); - if ($sanitizerConfig['allowed_link_schemes']) { - $def->addMethodCall('allowLinkSchemes', [$sanitizerConfig['allowed_link_schemes']], true); - } - $def->addMethodCall('allowLinkHosts', [$sanitizerConfig['allowed_link_hosts']], true); - $def->addMethodCall('allowRelativeLinks', [$sanitizerConfig['allow_relative_links']], true); - if ($sanitizerConfig['allowed_media_schemes']) { - $def->addMethodCall('allowMediaSchemes', [$sanitizerConfig['allowed_media_schemes']], true); - } - $def->addMethodCall('allowMediaHosts', [$sanitizerConfig['allowed_media_hosts']], true); - $def->addMethodCall('allowRelativeMedias', [$sanitizerConfig['allow_relative_medias']], true); - - // Custom attribute sanitizers - foreach ($sanitizerConfig['with_attribute_sanitizers'] as $serviceName) { - $def->addMethodCall('withAttributeSanitizer', [new Reference($serviceName)], true); - } - - foreach ($sanitizerConfig['without_attribute_sanitizers'] as $serviceName) { - $def->addMethodCall('withoutAttributeSanitizer', [new Reference($serviceName)], true); - } - - if ($sanitizerConfig['max_input_length']) { - $def->addMethodCall('withMaxInputLength', [$sanitizerConfig['max_input_length']], true); - } - - // Create the sanitizer and link its config - $sanitizerId = 'html_sanitizer.sanitizer.'.$sanitizerName; - $container->register($sanitizerId, HtmlSanitizer::class) - ->addTag('html_sanitizer', ['sanitizer' => $sanitizerName]) - ->addArgument(new Reference($configId)); - - if ('default' !== $sanitizerName) { - $container->registerAliasForArgument($sanitizerId, HtmlSanitizerInterface::class, $sanitizerName); - } - } - } - - private function registerFeatureFlagConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void - { - $loader->load('feature_flag.php'); - - $container->registerForAutoconfiguration(FeatureRegistryInterface::class) - ->addTag('feature_flag.feature_registry') - ; - - $container->registerAttributeForAutoconfiguration(AsFeature::class, - static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { - $featureName = $attribute->name; - - if ($reflector instanceof \ReflectionClass) { - $className = $reflector->getName(); - $method = $attribute->method; - - $featureName ??= $className; - } else { - $className = $reflector->getDeclaringClass()->getName(); - if (null !== $attribute->method && $reflector->getName() !== $attribute->method) { - throw new \LogicException(sprintf('Using the #[%s(method: %s)] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s)', AsFeature::class, $attribute->method, $className)); - } - - $method = $reflector->getName(); - $featureName ??= "{$className}::{$method}"; - } - - $definition->addTag('feature_flag.feature', [ - 'feature' => $featureName, - 'method' => $method, - ]); - }, - ); - - if (ContainerBuilder::willBeAvailable('symfony/routing', Router::class, ['symfony/framework-bundle', 'symfony/routing'])) { - $loader->load('feature_flag_routing.php'); - } - } - - private function resolveTrustedHeaders(array $headers): int - { - $trustedHeaders = 0; - - foreach ($headers as $h) { - $trustedHeaders |= match ($h) { - 'forwarded' => Request::HEADER_FORWARDED, - 'x-forwarded-for' => Request::HEADER_X_FORWARDED_FOR, - 'x-forwarded-host' => Request::HEADER_X_FORWARDED_HOST, - 'x-forwarded-proto' => Request::HEADER_X_FORWARDED_PROTO, - 'x-forwarded-port' => Request::HEADER_X_FORWARDED_PORT, - 'x-forwarded-prefix' => Request::HEADER_X_FORWARDED_PREFIX, - default => 0, - }; - } - - return $trustedHeaders; - } - - public function getXsdValidationBasePath(): string|false - { - return \dirname(__DIR__).'/Resources/config/schema'; - } - - public function getNamespace(): string - { - return 'http://symfony.com/schema/dic/symfony'; - } - - protected function isConfigEnabled(ContainerBuilder $container, array $config): bool - { - throw new \LogicException('To prevent using outdated configuration, you must use the "readConfigEnabled" method instead.'); - } - - private function isInitializedConfigEnabled(string $path): bool - { - if (isset($this->configsEnabled[$path])) { - return $this->configsEnabled[$path]; - } - - throw new LogicException(sprintf('Can not read config enabled at "%s" because it has not been initialized.', $path)); - } - - private function readConfigEnabled(string $path, ContainerBuilder $container, array $config): bool - { - return $this->configsEnabled[$path] ??= parent::isConfigEnabled($container, $config); - } - - private function writeConfigEnabled(string $path, bool $value, array &$config): void - { - if (isset($this->configsEnabled[$path])) { - throw new LogicException('Can not change config enabled because it has already been read.'); - } - - $this->configsEnabled[$path] = $value; - $config['enabled'] = $value; - } - - private function getPublicDirectory(ContainerBuilder $container): string - { - $projectDir = $container->getParameter('kernel.project_dir'); - $defaultPublicDir = $projectDir.'/public'; - - $composerFilePath = $projectDir.'/composer.json'; - - if (!file_exists($composerFilePath)) { - return $defaultPublicDir; - } - - $container->addResource(new FileResource($composerFilePath)); - $composerConfig = json_decode(file_get_contents($composerFilePath), true); - - return isset($composerConfig['extra']['public-dir']) ? $projectDir.'/'.$composerConfig['extra']['public-dir'] : $defaultPublicDir; - } -} From 09fe65509ed2df3770f0ae68f0b276f07f86ce14 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 5 Apr 2024 21:59:44 +0200 Subject: [PATCH 23/46] Fix bundle and add details in profiler Co-authored-by: Adrien Roches --- .../Resources/config/feature_flag.php | 1 - .../views/Collector/feature_flag.html.twig | 170 ++++++++++-------- .../FeatureFlagDataCollector.php | 50 ++++-- .../Debug/TraceableFeatureChecker.php | 36 +++- .../Component/FeatureFlag/FeatureRegistry.php | 8 +- .../FeatureFlag/FeatureRegistryInterface.php | 9 +- src/Symfony/Component/FeatureFlag/README.md | 9 +- .../Component/FeatureFlag/composer.json | 4 +- 8 files changed, 181 insertions(+), 106 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php index ce2d76abd7578..0e52c75d5129a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php @@ -28,7 +28,6 @@ ->set('feature_flag.feature_checker', FeatureChecker::class) ->args([ '$featureRegistry' => service('feature_flag.feature_registry'), - '$default' => false, ]) ->alias(FeatureCheckerInterface::class, 'feature_flag.feature_checker') ; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig index 075b8ff94277d..7f7c479694548 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig @@ -3,20 +3,22 @@ {% block page_title 'Feature Flag' %} {% block toolbar %} - {% if 0 < collector.features|length %} + {% if 0 < collector.resolvedValues|length %} {% set icon %} Feature Flag - {{ collector.features|length }} + {{ collector.resolvedValues|length }} {% endset %} {% set text %}
- {% for feature_name, data in collector.features|filter(d => d.is_enabled is not null) %} + {% for feature_name, checks in collector.checks %}
{{ loop.first ? 'Resolved features' : '' }} - - {{ feature_name }} - + {% for check in checks %} + + {{ feature_name }} + + {% endfor %}
{% endfor %} @@ -28,7 +30,7 @@ {% endblock %} {% block menu %} - + Feature Flag Feature Flag @@ -37,27 +39,6 @@ {% block head %} {{ parent() }} {% endblock %} {% block panel %} -

Features

- {% if collector.features|length > 0 %} - - - - - - - - - - {% for feature_name, data in collector.features %} - {% set result = data['is_enabled'] is not null ? (data['is_enabled'] ? 'enabled' : 'disabled') : 'unknown' %} - - - - - - {% endfor %} - -
FeatureResolved valueEnabled
{{ feature_name }} - {% if data['value'].value is not null %} - {{ profiler_dump(data['value'], maxDepth=2) }} - {% else %} - Not resolved - {% endif %} - - {% if data['is_enabled'] is not null %} - - {{ result|capitalize }} - - {% else %} - - Not resolved - - {% endif %} -
- {% else %} -
-

No features checked

+

Feature Flag

+
+
+

Resolved {{ collector.resolvedValues|length }}

+ +
+ {% if collector.resolvedValues|length > 0 %} + + + + + + + + + {% for feature_name, value in collector.resolvedValues %} + + + + + {% endfor %} + +
Feature nameEnabled
+ {{ feature_name }} + + {% for check in collector.checks[feature_name] %} + {% set context_id = 'context-' ~ loop.parent.loop.index ~ '-' ~ loop.index %} +
+ {% set result = check['is_enabled'] ? 'enabled' : 'disabled' %} + {{ result|capitalize }} + +
+ +
+ + + + + + + + + + + + + +
Resolved{{ profiler_dump(value, maxDepth=2) }}
Expected{{ profiler_dump(check['expected_value'], maxDepth=2) }}
Calls{{ check['calls'] }}
+
+ {% else %} + Not resolved + {% endfor %} + +
+ {% else %} +
+

No features checked

+
+ {% endif %} +
- {% endif %} +
+

Not resolved {{ collector.notResolved|length }}

+ +
+ {% if collector.notResolved|length > 0 %} + + + + + + + + {% for feature_name in collector.notResolved %} + + + + {% endfor %} + +
Feature name
+ {{ feature_name }} +
+ {% else %} +
+

All features resolved

+
+ {% endif %} +
+
+
{% endblock %} diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index 55af0ef1b546c..93cdbe735cdf5 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\VarDumper\Cloner\Data; final class FeatureFlagDataCollector extends DataCollector implements LateDataCollectorInterface { @@ -32,21 +33,48 @@ public function collect(Request $request, Response $response, ?\Throwable $excep public function lateCollect(): void { - $checks = $this->featureChecker->getChecks(); - $values = $this->featureChecker->getValues(); - - $this->data['features'] = []; - foreach ($this->featureRegistry->getNames() as $featureName) { - $this->data['features'][$featureName] = [ - 'is_enabled' => $checks[$featureName] ?? null, - 'value' => $this->cloneVar($values[$featureName] ?? null), - ]; + $this->data['resolvedValues'] = []; + foreach ($this->featureChecker->getResolvedValues() as $featureName => $resolvedValue) { + $this->data['resolvedValues'][$featureName] = $this->cloneVar($resolvedValue); } + + $this->data['checks'] = []; + foreach ($this->featureChecker->getChecks() as $featureName => $checks) { + $this->data['checks'][$featureName] = array_map( + fn (array $check): array => [ + 'expected_value' => $this->cloneVar($check['expectedValue']), + 'is_enabled' => $check['isEnabled'], + 'calls' => $check['calls'], + ], + $checks, + ); + } + + $this->data['not_resolved'] = array_diff($this->featureRegistry->getNames(), array_keys($this->data['resolvedValues'])); + } + + /** + * @return array + */ + public function getResolvedValues(): array + { + return $this->data['resolvedValues'] ?? []; + } + + /** + * @return array + */ + public function getChecks(): array + { + return $this->data['checks'] ?? []; } - public function getFeatures(): array + /** + * @return list + */ + public function getNotResolved(): array { - return $this->data['features'] ?? []; + return $this->data['not_resolved'] ?? []; } public function getName(): string diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 5b258b1ab33d9..8c592431224a6 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -15,10 +15,12 @@ final class TraceableFeatureChecker implements FeatureCheckerInterface { - /** @var array */ + /** @var array> */ private array $checks = []; /** @var array */ - private array $values = []; + private array $resolvedValues = []; + /** @var array */ + private array $expectedValues = []; public function __construct( private readonly FeatureCheckerInterface $decorated, @@ -27,10 +29,24 @@ public function __construct( public function isEnabled(string $featureName, mixed $expectedValue = true): bool { - $isEnabled = $this->checks[$featureName] = $this->decorated->isEnabled($featureName, $expectedValue); - // Force logging value. It has no cost since value is cached by decorated FeatureChecker. + $isEnabled = $this->decorated->isEnabled($featureName, $expectedValue); + + // Check duplicates + $this->expectedValues[$featureName] ??= []; + if (false !== ($i = array_search($expectedValue, $this->expectedValues[$featureName] ?? [], true))) { + $this->checks[$featureName][$i]['calls']++; + + return $isEnabled; + } + $this->expectedValues[$featureName] ??= []; + $this->expectedValues[$featureName][] = $expectedValue; + + // Force logging value. It has no cost since value is cached by the decorated FeatureChecker. $this->getValue($featureName); + $this->checks[$featureName] ??= []; + $this->checks[$featureName][] = ['expectedValue' => $expectedValue, 'isEnabled' => $isEnabled, 'calls' => 1]; + return $isEnabled; } @@ -41,16 +57,22 @@ public function isDisabled(string $featureName, mixed $expectedValue = true): bo public function getValue(string $featureName): mixed { - return $this->values[$featureName] = $this->decorated->getValue($featureName); + return $this->resolvedValues[$featureName] = $this->decorated->getValue($featureName); } + /** + * @return array> + */ public function getChecks(): array { return $this->checks; } - public function getValues(): array + /** + * @return array + */ + public function getResolvedValues(): array { - return $this->values; + return $this->resolvedValues; } } diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php index 549b99345734c..dcb0a9223549f 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -22,14 +22,14 @@ public function __construct(private readonly array $features) { } - public function has(string $id): bool + public function has(string $featureName): bool { - return \array_key_exists($id, $this->features); + return \array_key_exists($featureName, $this->features); } - public function get(string $id): callable + public function get(string $featureName): callable { - return $this->features[$id] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found.', $id)); + return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found.', $featureName)); } public function getNames(): array diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php index 6a41294943d89..3f097dfa5d54a 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php @@ -11,20 +11,19 @@ namespace Symfony\Component\FeatureFlag; -use Psr\Container\ContainerInterface; use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; -interface FeatureRegistryInterface extends ContainerInterface +interface FeatureRegistryInterface { - public function has(string $id): bool; + public function has(string $featureName): bool; /** * @throws FeatureNotFoundException When the feature is not registered */ - public function get(string $id): callable; + public function get(string $featureName): callable; /** - * @return array An array of all registered feature names + * @return list A list of all registered feature names */ public function getNames(): array; } diff --git a/src/Symfony/Component/FeatureFlag/README.md b/src/Symfony/Component/FeatureFlag/README.md index 0cb878bae681a..fbf54ff42a739 100644 --- a/src/Symfony/Component/FeatureFlag/README.md +++ b/src/Symfony/Component/FeatureFlag/README.md @@ -2,11 +2,12 @@ FeatureFlag Component ===================== The FeatureFlag component allows you to split the code execution flow by -enabling some features depending on context. +enabling features depending on context. -It provides a service that checks if a feature is enabled. A feature is a -callable which returns a value compared to the expected one to determine is the -feature is enabled (mostly a boolean but not limited to). +It provides a service that checks if a feature is enabled. Each feature is +defined by a callable function that returns a value. +The feature is enabled if the value matches the expected one (mostly a boolean +but not limited to). **This Component is experimental**. [Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) diff --git a/src/Symfony/Component/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json index 97e44d6b54879..82d4da80f0b9b 100644 --- a/src/Symfony/Component/FeatureFlag/composer.json +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -1,8 +1,8 @@ { "name": "symfony/feature-flag", "type": "library", - "description": "Provide a feature flag mechanism.", - "keywords": [], + "description": "Provides a feature flag mechanism", + "keywords": ["feature", "flag", "flags", "toggle"], "homepage": "https://symfony.com", "license": "MIT", "authors": [ From 28763a0c0422fc480f7451859dd831b680122824 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 5 Apr 2024 22:58:34 +0200 Subject: [PATCH 24/46] Add Twig functions to UndefinedCallableHandler::FUNCTION_COMPONENTS --- src/Symfony/Bridge/Twig/UndefinedCallableHandler.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php index 16421eaf504d4..ee8a3778be666 100644 --- a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php +++ b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php @@ -80,6 +80,8 @@ class UndefinedCallableHandler 'workflow_marked_places' => 'workflow', 'workflow_metadata' => 'workflow', 'workflow_transition_blockers' => 'workflow', + 'get_feature_value' => 'feature-flag', + 'is_feature_enabled' => 'feature-flag', ]; private const FULL_STACK_ENABLE = [ From 1b1f3fd9aa2ebbb13e8023f7b04c411680b72bfe Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 5 Apr 2024 23:05:18 +0200 Subject: [PATCH 25/46] cs --- .../Component/FeatureFlag/Debug/TraceableFeatureChecker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 8c592431224a6..871b03924ae63 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -34,7 +34,7 @@ public function isEnabled(string $featureName, mixed $expectedValue = true): boo // Check duplicates $this->expectedValues[$featureName] ??= []; if (false !== ($i = array_search($expectedValue, $this->expectedValues[$featureName] ?? [], true))) { - $this->checks[$featureName][$i]['calls']++; + ++$this->checks[$featureName][$i]['calls']; return $isEnabled; } From 66f83b66a46072f5ea7f6cf72c2c4793e525a158 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 5 Apr 2024 23:30:55 +0200 Subject: [PATCH 26/46] rebase --- .../FrameworkBundle/DependencyInjection/FrameworkExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 9e7e104fb61f1..2e0d7d9838677 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -3463,7 +3463,7 @@ static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionC } else { $className = $reflector->getDeclaringClass()->getName(); if (null !== $attribute->method && $reflector->getName() !== $attribute->method) { - throw new \LogicException(sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s).', AsFeature::class, $attribute->method, $className)); + throw new \LogicException(sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s)', AsFeature::class, $attribute->method, $className)); } $method = $reflector->getName(); From 55d47fcdeb127290931874203d667acfe33e7dde Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 5 Apr 2024 23:37:26 +0200 Subject: [PATCH 27/46] review --- .../FrameworkBundle/DependencyInjection/FrameworkExtension.php | 2 +- .../Component/FeatureFlag/Debug/TraceableFeatureChecker.php | 1 - src/Symfony/Component/FeatureFlag/FeatureRegistry.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2e0d7d9838677..9e7e104fb61f1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -3463,7 +3463,7 @@ static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionC } else { $className = $reflector->getDeclaringClass()->getName(); if (null !== $attribute->method && $reflector->getName() !== $attribute->method) { - throw new \LogicException(sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s)', AsFeature::class, $attribute->method, $className)); + throw new \LogicException(sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s).', AsFeature::class, $attribute->method, $className)); } $method = $reflector->getName(); diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 871b03924ae63..b53e1e6b53ba0 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -38,7 +38,6 @@ public function isEnabled(string $featureName, mixed $expectedValue = true): boo return $isEnabled; } - $this->expectedValues[$featureName] ??= []; $this->expectedValues[$featureName][] = $expectedValue; // Force logging value. It has no cost since value is cached by the decorated FeatureChecker. diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php index dcb0a9223549f..320f60b508584 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -29,7 +29,7 @@ public function has(string $featureName): bool public function get(string $featureName): callable { - return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found.', $featureName)); + return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found. Available features: %s.', $featureName, implode(', ', $this->getNames()))); } public function getNames(): array From e0dea4c20e6ae59d40ebce051868550941a78d7c Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 5 Apr 2024 23:39:16 +0200 Subject: [PATCH 28/46] cs --- src/Symfony/Component/FeatureFlag/FeatureRegistry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php index 320f60b508584..1581eaefc88c0 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/FeatureRegistry.php @@ -29,7 +29,7 @@ public function has(string $featureName): bool public function get(string $featureName): callable { - return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found. Available features: %s.', $featureName, implode(', ', $this->getNames()))); + return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found. Available features: "%s".', $featureName, implode(', ', $this->getNames()))); } public function getNames(): array From 07a7d484ba213b882bae1e99ac2ade2219f0dd4c Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 5 Apr 2024 23:58:00 +0200 Subject: [PATCH 29/46] tests --- .../FeatureFlagDataCollector.php | 2 +- .../FeatureFlagDataCollectorTest.php | 40 ++++++++++++++----- .../Debug/TraceableFeatureCheckerTest.php | 6 +-- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index 93cdbe735cdf5..e0489fdd98df2 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -50,7 +50,7 @@ public function lateCollect(): void ); } - $this->data['not_resolved'] = array_diff($this->featureRegistry->getNames(), array_keys($this->data['resolvedValues'])); + $this->data['not_resolved'] = array_values(array_diff($this->featureRegistry->getNames(), array_keys($this->data['resolvedValues']))); } /** diff --git a/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php index 1b2151d210383..cee61c68546dc 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php @@ -32,27 +32,47 @@ public function testLateCollect() $traceableFeatureChecker->isEnabled('feature_true'); $traceableFeatureChecker->isEnabled('feature_integer', 1); - $this->assertSame([], $dataCollector->getFeatures()); + $this->assertSame([], $dataCollector->getChecks()); $dataCollector->lateCollect(); - $data = array_map(fn ($a) => array_merge($a, ['value' => $a['value']->getValue()]), $dataCollector->getFeatures()); + $data = array_map(fn ($v) => $v->getValue(), $dataCollector->getResolvedValues()); + $this->assertSame( + [ + 'feature_true' => true, + 'feature_integer' => 42, + ], + $data, + ); + + $data = array_map( + fn ($checks) => array_map(function ($a) { + $a['expected_value'] = $a['expected_value']->getValue(); + + return $a; + }, $checks), + $dataCollector->getChecks(), + ); $this->assertSame( [ 'feature_true' => [ - 'is_enabled' => true, - 'value' => true, + [ + 'expected_value' => true, + 'is_enabled' => true, + 'calls' => 1, + ], ], 'feature_integer' => [ - 'is_enabled' => false, - 'value' => 42, - ], - 'feature_random' => [ - 'is_enabled' => null, - 'value' => null, + [ + 'expected_value' => 1, + 'is_enabled' => false, + 'calls' => 1, + ], ], ], $data, ); + + $this->assertSame(['feature_random'], $dataCollector->getNotResolved()); } } diff --git a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php index 1a27b49ab5e7b..6a42f4093ba95 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php @@ -32,8 +32,8 @@ public function testTraces() $this->assertSame( [ - 'feature_true' => true, - 'feature_integer' => false, + 'feature_true' => [['expectedValue' => true, 'isEnabled' => true, 'calls' => 1]], + 'feature_integer' => [['expectedValue' => 1, 'isEnabled' => false, 'calls' => 1]], ], $traceableFeatureChecker->getChecks(), ); @@ -42,7 +42,7 @@ public function testTraces() 'feature_true' => true, 'feature_integer' => 42, ], - $traceableFeatureChecker->getValues(), + $traceableFeatureChecker->getResolvedValues(), ); } } From c7630e2f11b50699f095d870d87ab065006de56e Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Sat, 6 Apr 2024 00:05:07 +0200 Subject: [PATCH 30/46] psalm --- .../Component/FeatureFlag/Debug/TraceableFeatureChecker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index b53e1e6b53ba0..6152706a221c1 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -60,7 +60,7 @@ public function getValue(string $featureName): mixed } /** - * @return array> + * @return array> */ public function getChecks(): array { From 583244241899ac5e7d6ecb95744de11aef6911b2 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 12 Apr 2024 11:55:35 +0200 Subject: [PATCH 31/46] readme --- src/Symfony/Component/FeatureFlag/README.md | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/Symfony/Component/FeatureFlag/README.md b/src/Symfony/Component/FeatureFlag/README.md index fbf54ff42a739..58478a1331750 100644 --- a/src/Symfony/Component/FeatureFlag/README.md +++ b/src/Symfony/Component/FeatureFlag/README.md @@ -14,6 +14,53 @@ but not limited to). are not covered by Symfony's [Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). +Getting Started +--------------- + +```bash +composer require symfony/feature-flag +``` + +```php +use Symfony\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\FeatureRegistry; + +// Declare features +final class XmasFeature +{ + public function __invoke(): bool + { + return date('m-d') === '12-25'; + } +} + +$features = new FeatureRegistry([ + 'weekend' => fn() => date('N') >= 6, + 'xmas' => new XmasFeature(), // could be any callable + 'universe' => fn() => 42, + 'random' => fn() => random_int(1, 3), +]; + +// Create the feature checker +$featureChecker = new FeatureChecker($features); + +// Check if a feature is enabled +$featureChecker->isEnabled('weekend'); // returns true on weekend +$featureChecker->isDisabled('weekend'); // returns true from monday to friday + +// Check a not existing feature +$featureChecker->isEnabled('not_a_feature'); // returns false + +// Check if a feature is enabled using an expected value +$featureChecker->isEnabled('universe'); // returns false +$featureChecker->isEnabled('universe', 7); // returns false +$featureChecker->isEnabled('universe', 42); // returns true + +// Retrieve a feature value +$featureChecker->getValue('random'); // returns 1, 2 or 3 +$featureChecker->getValue('random'); // returns the same value as above +``` + Resources --------- From 75b95fb2af9ac6245b5f2a4ce06e1403301a64c0 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 12 Apr 2024 16:14:15 +0200 Subject: [PATCH 32/46] add functional tests --- .../Compiler/FeatureFlagPass.php | 2 +- .../Fixtures/FeatureFlag/ClassFeature.php | 23 ++++++ .../FeatureFlag/ClassMethodFeature.php | 23 ++++++ .../FeatureFlag/DifferentMethodFeature.php | 23 ++++++ .../FeatureFlag/InvalidMethodFeature.php | 23 ++++++ .../Fixtures/FeatureFlag/MethodFeature.php | 29 ++++++++ .../Fixtures/FeatureFlag/NamedFeature.php | 23 ++++++ .../Tests/Functional/FeatureFlagTest.php | 73 +++++++++++++++++++ .../Functional/app/FeatureFlag/bundles.php | 18 +++++ .../Functional/app/FeatureFlag/config.yml | 17 +++++ .../config_with_different_method.yml | 11 +++ .../app/FeatureFlag/config_with_duplicate.yml | 15 ++++ .../config_with_invalid_method.yml | 11 +++ 13 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassFeature.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassMethodFeature.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/DifferentMethodFeature.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodFeature.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/MethodFeature.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/NamedFeature.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/bundles.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config.yml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_different_method.yml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_duplicate.yml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method.yml diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php index bb3b322a0f058..0cfc3cd1717b2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -43,7 +43,7 @@ public function process(ContainerBuilder $container): void $method = $tag['method'] ?? '__invoke'; if (!$r->hasMethod($method)) { - throw new \RuntimeException(sprintf('Invalid feature strategy "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); + throw new \RuntimeException(sprintf('Invalid feature method "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); } $features[$featureName] = $container->setDefinition( diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassFeature.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassFeature.php new file mode 100644 index 0000000000000..20d570eb65941 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassFeature.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag; + +use Symfony\Component\FeatureFlag\Attribute\AsFeature; + +#[AsFeature] +class ClassFeature +{ + public function __invoke(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassMethodFeature.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassMethodFeature.php new file mode 100644 index 0000000000000..7c0ca99fae809 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/ClassMethodFeature.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag; + +use Symfony\Component\FeatureFlag\Attribute\AsFeature; + +#[AsFeature(method: 'resolve')] +class ClassMethodFeature +{ + public function resolve(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/DifferentMethodFeature.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/DifferentMethodFeature.php new file mode 100644 index 0000000000000..39eed742252b5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/DifferentMethodFeature.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag; + +use Symfony\Component\FeatureFlag\Attribute\AsFeature; + +class DifferentMethodFeature +{ + #[AsFeature(method: 'different')] + public function resolve(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodFeature.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodFeature.php new file mode 100644 index 0000000000000..42bd22af86161 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodFeature.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag; + +use Symfony\Component\FeatureFlag\Attribute\AsFeature; + +#[AsFeature(method: 'invalid_method')] +class InvalidMethodFeature +{ + public function resolve(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/MethodFeature.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/MethodFeature.php new file mode 100644 index 0000000000000..f4802c92a3c17 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/MethodFeature.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag; + +use Symfony\Component\FeatureFlag\Attribute\AsFeature; + +class MethodFeature +{ + #[AsFeature(name: 'method_string')] + public function string(): string + { + return 'green'; + } + + #[AsFeature(name: 'method_int')] + public function int(): int + { + return 42; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/NamedFeature.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/NamedFeature.php new file mode 100644 index 0000000000000..618932f90af46 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/NamedFeature.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag; + +use Symfony\Component\FeatureFlag\Attribute\AsFeature; + +#[AsFeature(name: 'custom_name')] +class NamedFeature +{ + public function __invoke(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php new file mode 100644 index 0000000000000..d2f96b4973d39 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.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\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassFeature; +use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassMethodFeature; +use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\NamedFeature; +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; + +class FeatureFlagTest extends AbstractWebTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + self::deleteTmpDir(); + } + + public function testFeatureFlagAssertions() + { + static::bootKernel(['test_case' => 'FeatureFlag', 'root_config' => 'config.yml']); + /** @var FeatureCheckerInterface $featureChecker */ + $featureChecker = static::getContainer()->get('feature_flag.feature_checker'); + + // With default behavior + $this->assertTrue($featureChecker->isEnabled(ClassFeature::class)); + $this->assertTrue($featureChecker->isEnabled(ClassMethodFeature::class)); + + // With a custom name + $this->assertTrue($featureChecker->isEnabled('custom_name')); + $this->assertFalse($featureChecker->isEnabled(NamedFeature::class)); + + // With an unknown feature + $this->assertFalse($featureChecker->isEnabled('unknown')); + + // Get values + $this->assertSame('green', $featureChecker->getValue('method_string')); + $this->assertSame(42, $featureChecker->getValue('method_int')); + } + + public function testFeatureFlagAssertionsWithInvalidMethod() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid feature method "Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\InvalidMethodFeature": method "Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\InvalidMethodFeature::invalid_method()" does not exist.'); + + static::bootKernel(['test_case' => 'FeatureFlag', 'root_config' => 'config_with_invalid_method.yml']); + } + + public function testFeatureFlagAssertionsWithDifferentMethod() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Using the #[Symfony\Component\FeatureFlag\Attribute\AsFeature(method: "different")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\DifferentMethodFeature).'); + + static::bootKernel(['test_case' => 'FeatureFlag', 'root_config' => 'config_with_different_method.yml']); + } + + public function testFeatureFlagAssertionsWithDuplicate() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Feature "Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassFeature" already defined.'); + + static::bootKernel(['test_case' => 'FeatureFlag', 'root_config' => 'config_with_duplicate.yml']); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/bundles.php new file mode 100644 index 0000000000000..15ff182c6fed5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/bundles.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\TestBundle; + +return [ + new FrameworkBundle(), + new TestBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config.yml new file mode 100644 index 0000000000000..c0bc7665812a2 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config.yml @@ -0,0 +1,17 @@ +imports: + - { resource: ../config/default.yml } + +framework: + http_method_override: false + feature_flag: + enabled: true + +services: + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassFeature: + autoconfigure: true + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassMethodFeature: + autoconfigure: true + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\MethodFeature: + autoconfigure: true + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\NamedFeature: + autoconfigure: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_different_method.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_different_method.yml new file mode 100644 index 0000000000000..364caaa2bbe47 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_different_method.yml @@ -0,0 +1,11 @@ +imports: + - { resource: ../config/default.yml } + +framework: + http_method_override: false + feature_flag: + enabled: true + +services: + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\DifferentMethodFeature: + autoconfigure: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_duplicate.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_duplicate.yml new file mode 100644 index 0000000000000..6f44afd12020f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_duplicate.yml @@ -0,0 +1,15 @@ +imports: + - { resource: ../config/default.yml } + +framework: + http_method_override: false + feature_flag: + enabled: true + +services: + feature.first: + class: 'Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassFeature' + autoconfigure: true + feature.second: + class: 'Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassFeature' + autoconfigure: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method.yml new file mode 100644 index 0000000000000..8e780b9449dd7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method.yml @@ -0,0 +1,11 @@ +imports: + - { resource: ../config/default.yml } + +framework: + http_method_override: false + feature_flag: + enabled: true + +services: + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\InvalidMethodFeature: + autoconfigure: true From 3d51ae603be81a2d284165fdb17db46b1ec5cfa1 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 16 Apr 2024 15:52:14 +0200 Subject: [PATCH 33/46] fix framework bundle test --- .../Tests/DependencyInjection/ConfigurationTest.php | 3 ++- src/Symfony/Bundle/FrameworkBundle/composer.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 48da7e052799d..ee50bfc43d9c9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -20,6 +20,7 @@ use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\FeatureFlag\FeatureChecker; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\JsonStreamer\JsonStreamWriter; @@ -1015,7 +1016,7 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'enabled' => !class_exists(FullStack::class) && class_exists(JsonStreamWriter::class), ], 'feature_flag' => [ - 'enabled' => false, + 'enabled' => !class_exists(FullStack::class) && class_exists(FeatureChecker::class), ], ]; } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 2ecedbc45660e..6c07c21288bde 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -47,6 +47,7 @@ "symfony/polyfill-intl-icu": "~1.0", "symfony/form": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", + "symfony/feature-flag": "^7.1", "symfony/html-sanitizer": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", "symfony/lock": "^6.4|^7.0", From 11c1227123ce9e6bd9040541c9d7cde87d3a3a76 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 8 Oct 2024 11:36:11 +0200 Subject: [PATCH 34/46] [FeatureFlag] Use providers --- .../Twig/Extension/FeatureFlagExtension.php | 4 +- .../Twig/Extension/FeatureFlagRuntime.php | 14 ++-- .../Bridge/Twig/UndefinedCallableHandler.php | 4 +- .../Compiler/FeatureFlagPass.php | 2 +- .../Compiler/UnusedTagsPass.php | 1 + .../FrameworkExtension.php | 6 +- .../Resources/config/feature_flag.php | 17 ++-- .../Resources/config/feature_flag_debug.php | 2 +- .../Resources/config/feature_flag_routing.php | 4 +- .../views/Collector/feature_flag.html.twig | 10 ++- .../Component/FeatureFlag/CHANGELOG.md | 2 +- .../FeatureFlagDataCollector.php | 9 ++- .../Debug/TraceableFeatureChecker.php | 5 -- .../Exception/FeatureNotFoundException.php | 16 ---- .../Component/FeatureFlag/FeatureChecker.php | 15 +--- .../FeatureFlag/FeatureCheckerInterface.php | 6 -- .../FeatureFlag/Provider/ChainProvider.php | 55 +++++++++++++ .../InMemoryProvider.php} | 15 ++-- .../ProviderInterface.php} | 12 ++- src/Symfony/Component/FeatureFlag/README.md | 7 +- .../FeatureFlagDataCollectorTest.php | 12 ++- .../Debug/TraceableFeatureCheckerTest.php | 4 +- .../FeatureFlag/Tests/FeatureCheckerTest.php | 34 ++++---- .../FeatureFlag/Tests/FeatureRegistryTest.php | 54 ------------- .../Tests/Provider/ChainProviderTests.php | 78 +++++++++++++++++++ .../Tests/Provider/InMemoryProviderTests.php | 63 +++++++++++++++ .../Component/FeatureFlag/composer.json | 4 +- 27 files changed, 287 insertions(+), 168 deletions(-) delete mode 100644 src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php create mode 100644 src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php rename src/Symfony/Component/FeatureFlag/{FeatureRegistry.php => Provider/InMemoryProvider.php} (53%) rename src/Symfony/Component/FeatureFlag/{FeatureRegistryInterface.php => Provider/ProviderInterface.php} (51%) delete mode 100644 src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php create mode 100644 src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php create mode 100644 src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php index 652a83687444b..2ce0ff217d2bf 100644 --- a/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php @@ -19,8 +19,8 @@ final class FeatureFlagExtension extends AbstractExtension public function getFunctions(): array { return [ - new TwigFunction('is_feature_enabled', [FeatureFlagRuntime::class, 'isFeatureEnabled']), - new TwigFunction('get_feature_value', [FeatureFlagRuntime::class, 'getFeatureValue']), + new TwigFunction('feature_is_enabled', [FeatureFlagRuntime::class, 'isEnabled']), + new TwigFunction('feature_get_value', [FeatureFlagRuntime::class, 'getValue']), ]; } } diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php index ca8f1ffedd500..ea5948d7320bc 100644 --- a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php @@ -15,25 +15,25 @@ final class FeatureFlagRuntime { - public function __construct(private readonly ?FeatureCheckerInterface $featureEnabledChecker = null) + public function __construct(private readonly ?FeatureCheckerInterface $featureChecker = null) { } - public function isFeatureEnabled(string $featureName, mixed $expectedValue = true): bool + public function isEnabled(string $featureName, mixed $expectedValue = true): bool { - if (null === $this->featureEnabledChecker) { + if (null === $this->featureChecker) { throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); } - return $this->featureEnabledChecker->isEnabled($featureName, $expectedValue); + return $this->featureChecker->isEnabled($featureName, $expectedValue); } - public function getFeatureValue(string $featureName): mixed + public function getValue(string $featureName): mixed { - if (null === $this->featureEnabledChecker) { + if (null === $this->featureChecker) { throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); } - return $this->featureEnabledChecker->getValue($featureName); + return $this->featureChecker->getValue($featureName); } } diff --git a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php index ee8a3778be666..b29766582a0bb 100644 --- a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php +++ b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php @@ -60,6 +60,8 @@ class UndefinedCallableHandler 'field_choices' => 'form', 'logout_url' => 'security-http', 'logout_path' => 'security-http', + 'feature_get_value' => 'feature-flag', + 'feature_is_enabled' => 'feature-flag', 'is_granted' => 'security-core', 'is_granted_for_user' => 'security-core', 'impersonation_path' => 'security-http', @@ -80,8 +82,6 @@ class UndefinedCallableHandler 'workflow_marked_places' => 'workflow', 'workflow_metadata' => 'workflow', 'workflow_transition_blockers' => 'workflow', - 'get_feature_value' => 'feature-flag', - 'is_feature_enabled' => 'feature-flag', ]; private const FULL_STACK_ENABLE = [ diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php index 0cfc3cd1717b2..0905902dbb8fd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -56,7 +56,7 @@ public function process(ContainerBuilder $container): void } } - $container->getDefinition('feature_flag.feature_registry') + $container->getDefinition('feature_flag.provider.in_memory') ->setArgument('$features', $features) ; diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 5591cf59c8c76..d206a64137407 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -50,6 +50,7 @@ class UnusedTagsPass implements CompilerPassInterface 'event_dispatcher.dispatcher', 'feature_flag.feature', 'feature_flag.feature_checker', + 'feature_flag.provider', 'form.type', 'form.type_extension', 'form.type_guesser', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 9e7e104fb61f1..3644f4e8b0e08 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -81,7 +81,7 @@ use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\FeatureFlag\Attribute\AsFeature; use Symfony\Component\FeatureFlag\FeatureChecker; -use Symfony\Component\FeatureFlag\FeatureRegistryInterface; +use Symfony\Component\FeatureFlag\Provider\ProviderInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Glob; @@ -3447,8 +3447,8 @@ private function registerFeatureFlagConfiguration(array $config, ContainerBuilde { $loader->load('feature_flag.php'); - $container->registerForAutoconfiguration(FeatureRegistryInterface::class) - ->addTag('feature_flag.feature_registry') + $container->registerForAutoconfiguration(ProviderInterface::class) + ->addTag('feature_flag.provider') ; $container->registerAttributeForAutoconfiguration(AsFeature::class, diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php index 0e52c75d5129a..d5de01616113b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php @@ -13,21 +13,28 @@ use Symfony\Component\FeatureFlag\FeatureChecker; use Symfony\Component\FeatureFlag\FeatureCheckerInterface; -use Symfony\Component\FeatureFlag\FeatureRegistry; -use Symfony\Component\FeatureFlag\FeatureRegistryInterface; +use Symfony\Component\FeatureFlag\Provider\ChainProvider; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; +use Symfony\Component\FeatureFlag\Provider\ProviderInterface; return static function (ContainerConfigurator $container) { $container->services() - ->set('feature_flag.feature_registry', FeatureRegistry::class) + ->set('feature_flag.provider.in_memory', InMemoryProvider::class) ->args([ '$features' => abstract_arg('Defined in FeatureFlagPass.'), ]) - ->alias(FeatureRegistryInterface::class, 'feature_flag.feature_registry') + ->tag('feature_flag.provider') + + ->set('feature_flag.provider', ChainProvider::class) + ->args([ + '$providers' => tagged_iterator('feature_flag.provider'), + ]) + ->alias(ProviderInterface::class, 'feature_flag.provider') ->set('feature_flag.feature_checker', FeatureChecker::class) ->args([ - '$featureRegistry' => service('feature_flag.feature_registry'), + '$provider' => service('feature_flag.provider'), ]) ->alias(FeatureCheckerInterface::class, 'feature_flag.feature_checker') ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php index 492fb4c18c5b3..4f0a5a894c919 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_debug.php @@ -25,7 +25,7 @@ ->set('feature_flag.data_collector', FeatureFlagDataCollector::class) ->args([ - '$featureRegistry' => service('feature_flag.feature_registry'), + '$provider' => service('feature_flag.provider'), '$featureChecker' => service('debug.feature_flag.feature_checker'), ]) ->tag('data_collector', ['template' => '@WebProfiler/Collector/feature_flag.html.twig', 'id' => 'feature_flag']) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php index 9f181103ee5d3..ccf021e05620f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.php @@ -19,14 +19,14 @@ ->args([ [service('feature_flag.feature_checker'), 'isEnabled'], ]) - ->tag('routing.expression_language_function', ['function' => 'is_feature_enabled']) + ->tag('routing.expression_language_function', ['function' => 'feature_is_enabled']) ->set('feature_flag.routing_expression_language_function.get_value', \Closure::class) ->factory([\Closure::class, 'fromCallable']) ->args([ [service('feature_flag.feature_checker'), 'getValue'], ]) - ->tag('routing.expression_language_function', ['function' => 'get_feature_value']) + ->tag('routing.expression_language_function', ['function' => 'feature_get_value']) ->get('feature_flag.feature_checker') ->tag('routing.condition_service', ['alias' => 'feature']) diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig index 7f7c479694548..5bec4215c4dd6 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig @@ -79,24 +79,28 @@ {% for check in collector.checks[feature_name] %} {% set context_id = 'context-' ~ loop.parent.loop.index ~ '-' ~ loop.index %}
- {% set result = check['is_enabled'] ? 'enabled' : 'disabled' %} + {% set result = check.is_enabled ? 'enabled' : 'disabled' %} {{ result|capitalize }}
+ + + + - + - +
Found{{ profiler_dump(check.found) }}
Resolved {{ profiler_dump(value, maxDepth=2) }}
Expected{{ profiler_dump(check['expected_value'], maxDepth=2) }}{{ profiler_dump(check.expected_value, maxDepth=2) }}
Calls{{ check['calls'] }}{{ check.calls }}
diff --git a/src/Symfony/Component/FeatureFlag/CHANGELOG.md b/src/Symfony/Component/FeatureFlag/CHANGELOG.md index 6eb821cdebc51..f2f5402e3dd96 100644 --- a/src/Symfony/Component/FeatureFlag/CHANGELOG.md +++ b/src/Symfony/Component/FeatureFlag/CHANGELOG.md @@ -1,7 +1,7 @@ CHANGELOG ========= -7.1 +7.2 --- * Add the component as experimental diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index e0489fdd98df2..e0d22b7cb4d36 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -12,7 +12,7 @@ namespace Symfony\Component\FeatureFlag\DataCollector; use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; -use Symfony\Component\FeatureFlag\FeatureRegistryInterface; +use Symfony\Component\FeatureFlag\Provider\ProviderInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; @@ -22,7 +22,7 @@ final class FeatureFlagDataCollector extends DataCollector implements LateDataCollectorInterface { public function __construct( - private readonly FeatureRegistryInterface $featureRegistry, + private readonly ProviderInterface $provider, private readonly TraceableFeatureChecker $featureChecker, ) { } @@ -42,6 +42,7 @@ public function lateCollect(): void foreach ($this->featureChecker->getChecks() as $featureName => $checks) { $this->data['checks'][$featureName] = array_map( fn (array $check): array => [ + 'found' => $this->cloneVar($this->provider->has($featureName)), 'expected_value' => $this->cloneVar($check['expectedValue']), 'is_enabled' => $check['isEnabled'], 'calls' => $check['calls'], @@ -50,7 +51,7 @@ public function lateCollect(): void ); } - $this->data['not_resolved'] = array_values(array_diff($this->featureRegistry->getNames(), array_keys($this->data['resolvedValues']))); + $this->data['not_resolved'] = array_values(array_diff($this->provider->getNames(), array_keys($this->data['resolvedValues']))); } /** @@ -62,7 +63,7 @@ public function getResolvedValues(): array } /** - * @return array + * @return array */ public function getChecks(): array { diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 6152706a221c1..7d3da1984b8fa 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -49,11 +49,6 @@ public function isEnabled(string $featureName, mixed $expectedValue = true): boo return $isEnabled; } - public function isDisabled(string $featureName, mixed $expectedValue = true): bool - { - return !$this->isEnabled($featureName, $expectedValue); - } - public function getValue(string $featureName): mixed { return $this->resolvedValues[$featureName] = $this->decorated->getValue($featureName); diff --git a/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php b/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php deleted file mode 100644 index 821b1b1d00ecd..0000000000000 --- a/src/Symfony/Component/FeatureFlag/Exception/FeatureNotFoundException.php +++ /dev/null @@ -1,16 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\FeatureFlag\Exception; - -class FeatureNotFoundException extends RuntimeException -{ -} diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index ee4da1ac81e46..2a67562940868 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -11,31 +11,24 @@ namespace Symfony\Component\FeatureFlag; +use Symfony\Component\FeatureFlag\Provider\ProviderInterface; + final class FeatureChecker implements FeatureCheckerInterface { private array $cache = []; public function __construct( - private readonly FeatureRegistryInterface $featureRegistry, + private readonly ProviderInterface $provider, ) { } public function isEnabled(string $featureName, mixed $expectedValue = true): bool { - if (!$this->featureRegistry->has($featureName)) { - return false; - } - return $this->getValue($featureName) === $expectedValue; } - public function isDisabled(string $featureName, mixed $expectedValue = true): bool - { - return !$this->isEnabled($featureName, $expectedValue); - } - public function getValue(string $featureName): mixed { - return $this->cache[$featureName] ??= $this->featureRegistry->get($featureName)(); + return $this->cache[$featureName] ??= $this->provider->get($featureName)(); } } diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php index 0ff14b7bb9ce1..e9ba98b7c306a 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php @@ -19,11 +19,5 @@ interface FeatureCheckerInterface */ public function isEnabled(string $featureName, mixed $expectedValue = true): bool; - /** - * @param string $featureName the name of the feature to check - * @param mixed $expectedValue comparison value required to determine if the feature is disabled - */ - public function isDisabled(string $featureName, mixed $expectedValue = true): bool; - public function getValue(string $featureName): mixed; } diff --git a/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php new file mode 100644 index 0000000000000..6b4e325c4f9fb --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Provider; + +final class ChainProvider implements ProviderInterface +{ + public function __construct( + /** @var list */ + private readonly iterable $providers = [], + ) { + } + + public function has(string $featureName): bool + { + foreach ($this->providers as $provider) { + if ($provider->has($featureName)) { + return true; + } + } + + return false; + } + + public function get(string $featureName): \Closure + { + foreach ($this->providers as $provider) { + if ($provider->has($featureName)) { + return $provider->get($featureName); + } + } + + return fn() => false; + } + + public function getNames(): array + { + $names = []; + foreach ($this->providers as $provider) { + foreach ($provider->getNames() as $name) { + $names[$name] = true; + } + } + + return array_keys($names); + } +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php similarity index 53% rename from src/Symfony/Component/FeatureFlag/FeatureRegistry.php rename to src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php index 1581eaefc88c0..85a0e77af1ad9 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistry.php +++ b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php @@ -9,17 +9,18 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\FeatureFlag; +namespace Symfony\Component\FeatureFlag\Provider; -use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; +use Symfony\Component\FeatureFlag\ArgumentResolver\ArgumentResolver; -final class FeatureRegistry implements FeatureRegistryInterface +final class InMemoryProvider implements ProviderInterface { /** * @param array $features */ - public function __construct(private readonly array $features) - { + public function __construct( + private readonly array $features, + ) { } public function has(string $featureName): bool @@ -27,9 +28,9 @@ public function has(string $featureName): bool return \array_key_exists($featureName, $this->features); } - public function get(string $featureName): callable + public function get(string $featureName): \Closure { - return $this->features[$featureName] ?? throw new FeatureNotFoundException(sprintf('Feature "%s" not found. Available features: "%s".', $featureName, implode(', ', $this->getNames()))); + return $this->features[$featureName] ?? fn() => false; } public function getNames(): array diff --git a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php similarity index 51% rename from src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php rename to src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php index 3f097dfa5d54a..da4e9771dc432 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureRegistryInterface.php +++ b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php @@ -9,21 +9,19 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\FeatureFlag; +namespace Symfony\Component\FeatureFlag\Provider; -use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; - -interface FeatureRegistryInterface +interface ProviderInterface { public function has(string $featureName): bool; /** - * @throws FeatureNotFoundException When the feature is not registered + * @return \Closure(): mixed */ - public function get(string $featureName): callable; + public function get(string $featureName): \Closure; /** - * @return list A list of all registered feature names + * @return list */ public function getNames(): array; } diff --git a/src/Symfony/Component/FeatureFlag/README.md b/src/Symfony/Component/FeatureFlag/README.md index 58478a1331750..ffd7750da4986 100644 --- a/src/Symfony/Component/FeatureFlag/README.md +++ b/src/Symfony/Component/FeatureFlag/README.md @@ -23,7 +23,7 @@ composer require symfony/feature-flag ```php use Symfony\Component\FeatureFlag\FeatureChecker; -use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; // Declare features final class XmasFeature @@ -34,7 +34,7 @@ final class XmasFeature } } -$features = new FeatureRegistry([ +$provider = new InMemoryProvider([ 'weekend' => fn() => date('N') >= 6, 'xmas' => new XmasFeature(), // could be any callable 'universe' => fn() => 42, @@ -42,11 +42,10 @@ $features = new FeatureRegistry([ ]; // Create the feature checker -$featureChecker = new FeatureChecker($features); +$featureChecker = new FeatureChecker($provider); // Check if a feature is enabled $featureChecker->isEnabled('weekend'); // returns true on weekend -$featureChecker->isDisabled('weekend'); // returns true from monday to friday // Check a not existing feature $featureChecker->isEnabled('not_a_feature'); // returns false diff --git a/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php index cee61c68546dc..4793bb566e4fb 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php @@ -15,13 +15,14 @@ use Symfony\Component\FeatureFlag\DataCollector\FeatureFlagDataCollector; use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; use Symfony\Component\FeatureFlag\FeatureChecker; -use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; +use Symfony\Component\VarDumper\Cloner\Data; class FeatureFlagDataCollectorTest extends TestCase { public function testLateCollect() { - $featureRegistry = new FeatureRegistry([ + $featureRegistry = new InMemoryProvider([ 'feature_true' => fn () => true, 'feature_integer' => fn () => 42, 'feature_random' => fn () => random_int(1, 42), @@ -36,7 +37,7 @@ public function testLateCollect() $dataCollector->lateCollect(); - $data = array_map(fn ($v) => $v->getValue(), $dataCollector->getResolvedValues()); + $data = array_map(fn (Data $v): mixed => $v->getValue(), $dataCollector->getResolvedValues()); $this->assertSame( [ 'feature_true' => true, @@ -46,7 +47,8 @@ public function testLateCollect() ); $data = array_map( - fn ($checks) => array_map(function ($a) { + fn ($checks) => array_map(function (array $a): array { + $a['found'] = $a['found']->getValue(); $a['expected_value'] = $a['expected_value']->getValue(); return $a; @@ -57,6 +59,7 @@ public function testLateCollect() [ 'feature_true' => [ [ + 'found' => true, 'expected_value' => true, 'is_enabled' => true, 'calls' => 1, @@ -64,6 +67,7 @@ public function testLateCollect() ], 'feature_integer' => [ [ + 'found' => true, 'expected_value' => 1, 'is_enabled' => false, 'calls' => 1, diff --git a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php index 6a42f4093ba95..fabfb37863532 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php @@ -14,13 +14,13 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; use Symfony\Component\FeatureFlag\FeatureChecker; -use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; class TraceableFeatureCheckerTest extends TestCase { public function testTraces() { - $featureChecker = new FeatureChecker(new FeatureRegistry([ + $featureChecker = new FeatureChecker(new InMemoryProvider([ 'feature_true' => fn () => true, 'feature_integer' => fn () => 42, 'feature_random' => fn () => random_int(1, 42), diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php index e2fbdccbb8cb4..1ed0d92116746 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php @@ -12,9 +12,8 @@ namespace Symfony\Component\FeatureFlag\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; use Symfony\Component\FeatureFlag\FeatureChecker; -use Symfony\Component\FeatureFlag\FeatureRegistry; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; class FeatureCheckerTest extends TestCase { @@ -22,7 +21,7 @@ class FeatureCheckerTest extends TestCase protected function setUp(): void { - $this->featureChecker = new FeatureChecker(new FeatureRegistry([ + $this->featureChecker = new FeatureChecker(new InMemoryProvider([ 'feature_true' => fn () => true, 'feature_false' => fn () => false, 'feature_integer' => fn () => 42, @@ -40,43 +39,40 @@ public function testGetValue() public function testGetValueOnNotFound() { - $this->expectException(FeatureNotFoundException::class); - $this->expectExceptionMessage('Feature "unknown_feature" not found.'); - - $this->featureChecker->getValue('unknown_feature'); + $this->assertFalse($this->featureChecker->getValue('unknown_feature')); } /** * @dataProvider provideIsEnabled */ - public function testIsEnabled(bool $expectedResult, string $featureName) + public function testIsEnabled(string $featureName, bool $expectedResult) { $this->assertSame($expectedResult, $this->featureChecker->isEnabled($featureName)); } public static function provideIsEnabled() { - yield '"true" without expected value' => [true, 'feature_true']; - yield '"false" without expected value' => [false, 'feature_false']; - yield 'an integer without expected value' => [false, 'feature_integer']; - yield 'an unknown feature' => [false, 'unknown_feature']; + yield '"true" without expected value' => ['feature_true', true]; + yield '"false" without expected value' => ['feature_false', false]; + yield 'an integer without expected value' => ['feature_integer', false]; + yield 'an unknown feature' => ['unknown_feature', false]; } /** * @dataProvider providesEnabledComparedToAnExpectedValue */ - public function testIsEnabledComparedToAnExpectedValue(bool $expectedResult, string $featureName, mixed $expectedValue) + public function testIsEnabledComparedToAnExpectedValue(string $featureName, mixed $expectedValue, bool $expectedResult) { $this->assertSame($expectedResult, $this->featureChecker->isEnabled($featureName, $expectedValue)); } public static function providesEnabledComparedToAnExpectedValue() { - yield '"true" and the same expected value' => [true, 'feature_true', true]; - yield '"true" and a different expected value' => [false, 'feature_true', false]; - yield '"false" and the same expected value' => [false, 'feature_false', true]; - yield '"false" and a different expected value' => [true, 'feature_false', false]; - yield 'an integer and the same expected value' => [true, 'feature_integer', 42]; - yield 'an integer and a different expected value' => [false, 'feature_integer', 1]; + yield '"true" and the same expected value' => ['feature_true', true, true]; + yield '"true" and a different expected value' => ['feature_true', false, false]; + yield '"false" and the same expected value' => ['feature_false', true, false]; + yield '"false" and a different expected value' => ['feature_false', false, true]; + yield 'an integer and the same expected value' => ['feature_integer', 42, true]; + yield 'an integer and a different expected value' => ['feature_integer', 1, false]; } } diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php deleted file mode 100644 index 1051d225df311..0000000000000 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureRegistryTest.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\FeatureFlag\Tests; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\FeatureFlag\Exception\FeatureNotFoundException; -use Symfony\Component\FeatureFlag\FeatureRegistry; - -class FeatureRegistryTest extends TestCase -{ - private FeatureRegistry $featureRegistry; - - protected function setUp(): void - { - $this->featureRegistry = new FeatureRegistry([ - 'first_feature' => fn () => true, - 'second_feature' => fn () => false, - ]); - } - - public function testHas() - { - $this->assertTrue($this->featureRegistry->has('first_feature')); - $this->assertTrue($this->featureRegistry->has('second_feature')); - $this->assertFalse($this->featureRegistry->has('unknown_feature')); - } - - public function testGet() - { - $this->assertIsCallable($this->featureRegistry->get('first_feature')); - } - - public function testGetNotFound() - { - $this->expectException(FeatureNotFoundException::class); - $this->expectExceptionMessage('Feature "unknown_feature" not found.'); - - $this->featureRegistry->get('unknown_feature'); - } - - public function testGetNames() - { - $this->assertSame(['first_feature', 'second_feature'], $this->featureRegistry->getNames()); - } -} diff --git a/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php b/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php new file mode 100644 index 0000000000000..6549f4fbba756 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\Provider\ChainProvider; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; + +class ChainProviderTests extends TestCase +{ + private ChainProvider $provider; + + protected function setUp(): void + { + $this->provider = new ChainProvider([ + new InMemoryProvider([ + 'first' => fn () => true, + ]), + new InMemoryProvider([ + 'second' => fn () => true, + ]), + new InMemoryProvider([ + 'exception' => fn () => throw new \LogicException('Should not be called.'), + ]), + ]); + } + + public function testHas() + { + $this->assertTrue($this->provider->has('first')); + $this->assertTrue($this->provider->has('second')); + $this->assertTrue($this->provider->has('exception')); + $this->assertFalse($this->provider->has('unknown')); + } + + public function testGet() + { + $feature = $this->provider->get('first'); + + $this->assertIsCallable($feature); + $this->assertTrue($feature()); + } + + public function testGetFallback() + { + $feature = $this->provider->get('second'); + + $this->assertIsCallable($feature); + $this->assertTrue($feature()); + } + + public function testGetLazy() + { + $this->assertIsCallable($this->provider->get('exception')); + } + + public function testGetNotFound() + { + $feature = $this->provider->get('unknown'); + + $this->assertIsCallable($feature); + $this->assertFalse($feature()); + } + + public function testGetNames() + { + $this->assertSame(['first', 'second', 'exception'], $this->provider->getNames()); + } +} diff --git a/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php b/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php new file mode 100644 index 0000000000000..e8e9e10df1ef3 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; + +class InMemoryProviderTests extends TestCase +{ + private InMemoryProvider $provider; + + protected function setUp(): void + { + $this->provider = new InMemoryProvider([ + 'first' => fn () => true, + 'second' => fn () => false, + 'exception' => fn () => throw new \LogicException('Should not be called.'), + ]); + } + + public function testHas() + { + $this->assertTrue($this->provider->has('first')); + $this->assertTrue($this->provider->has('second')); + $this->assertTrue($this->provider->has('exception')); + $this->assertFalse($this->provider->has('unknown')); + } + + public function testGet() + { + $feature = $this->provider->get('first'); + + $this->assertIsCallable($feature); + $this->assertTrue($feature()); + } + + public function testGetLazy() + { + $this->assertIsCallable($this->provider->get('exception')); + } + + public function testGetNotFound() + { + $feature = $this->provider->get('unknown'); + + $this->assertIsCallable($feature); + $this->assertFalse($feature()); + } + + public function testGetNames() + { + $this->assertSame(['first', 'second', 'exception'], $this->provider->getNames()); + } +} diff --git a/src/Symfony/Component/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json index 82d4da80f0b9b..3edfafe53f2e6 100644 --- a/src/Symfony/Component/FeatureFlag/composer.json +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -20,8 +20,8 @@ "psr/container": "^1.1|^2.0" }, "require-dev": { - "symfony/http-kernel": "^7.1", - "symfony/service-contracts": "^2.5|^3" + "symfony/http-kernel": "^7.2", + "symfony/var-dumper": "^7.2" }, "autoload": { "psr-4": { "Symfony\\Component\\FeatureFlag\\": "" }, From 1643936b6eea2d73b8a7c54ce43ef508698c00ec Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 9 Oct 2024 23:47:26 +0200 Subject: [PATCH 35/46] cs --- src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php | 4 ++-- .../DependencyInjection/Compiler/FeatureFlagPass.php | 6 +++--- .../DependencyInjection/FrameworkExtension.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php index ea5948d7320bc..6a5a7ae950aa5 100644 --- a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php @@ -22,7 +22,7 @@ public function __construct(private readonly ?FeatureCheckerInterface $featureCh public function isEnabled(string $featureName, mixed $expectedValue = true): bool { if (null === $this->featureChecker) { - throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); + throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); } return $this->featureChecker->isEnabled($featureName, $expectedValue); @@ -31,7 +31,7 @@ public function isEnabled(string $featureName, mixed $expectedValue = true): boo public function getValue(string $featureName): mixed { if (null === $this->featureChecker) { - throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); + throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); } return $this->featureChecker->getValue($featureName); diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php index 0905902dbb8fd..3389a62864433 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -32,18 +32,18 @@ public function process(ContainerBuilder $container): void $r = $container->getReflectionClass($className); if (null === $r) { - throw new \RuntimeException(sprintf('Invalid service "%s": class "%s" does not exist.', $serviceId, $className)); + throw new \RuntimeException(\sprintf('Invalid service "%s": class "%s" does not exist.', $serviceId, $className)); } foreach ($tags as $tag) { $featureName = ($tag['feature'] ?? '') ?: $className; if (\array_key_exists($featureName, $features)) { - throw new \RuntimeException(sprintf('Feature "%s" already defined.', $featureName)); + throw new \RuntimeException(\sprintf('Feature "%s" already defined.', $featureName)); } $method = $tag['method'] ?? '__invoke'; if (!$r->hasMethod($method)) { - throw new \RuntimeException(sprintf('Invalid feature method "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); + throw new \RuntimeException(\sprintf('Invalid feature method "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); } $features[$featureName] = $container->setDefinition( diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 3644f4e8b0e08..c0caa6911275a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -3463,7 +3463,7 @@ static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionC } else { $className = $reflector->getDeclaringClass()->getName(); if (null !== $attribute->method && $reflector->getName() !== $attribute->method) { - throw new \LogicException(sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s).', AsFeature::class, $attribute->method, $className)); + throw new \LogicException(\sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s).', AsFeature::class, $attribute->method, $className)); } $method = $reflector->getName(); From 6378c629ce0a1bdba7f39cd7f1afe141bcd9f6f6 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 11 Oct 2024 15:53:22 +0200 Subject: [PATCH 36/46] review --- .../Bridge/Twig/Extension/FeatureFlagRuntime.php | 13 +++---------- src/Symfony/Bundle/FrameworkBundle/composer.json | 2 +- .../Component/FeatureFlag/Attribute/AsFeature.php | 2 ++ .../DataCollector/FeatureFlagDataCollector.php | 3 +++ .../FeatureFlag/Debug/TraceableFeatureChecker.php | 3 +++ .../FeatureFlag/Exception/ExceptionInterface.php | 3 +++ .../FeatureFlag/Exception/RuntimeException.php | 3 +++ .../Component/FeatureFlag/FeatureChecker.php | 3 +++ .../FeatureFlag/FeatureCheckerInterface.php | 3 +++ .../FeatureFlag/Provider/ChainProvider.php | 3 +++ .../FeatureFlag/Provider/InMemoryProvider.php | 3 +++ .../FeatureFlag/Provider/ProviderInterface.php | 3 +++ src/Symfony/Component/FeatureFlag/composer.json | 2 +- 13 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php index 6a5a7ae950aa5..4c955085f3e77 100644 --- a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php @@ -15,25 +15,18 @@ final class FeatureFlagRuntime { - public function __construct(private readonly ?FeatureCheckerInterface $featureChecker = null) - { + public function __construct( + private readonly FeatureCheckerInterface $featureChecker + ) { } public function isEnabled(string $featureName, mixed $expectedValue = true): bool { - if (null === $this->featureChecker) { - throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); - } - return $this->featureChecker->isEnabled($featureName, $expectedValue); } public function getValue(string $featureName): mixed { - if (null === $this->featureChecker) { - throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); - } - return $this->featureChecker->getValue($featureName); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 6c07c21288bde..be5e48c4a8dca 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -28,7 +28,7 @@ "symfony/http-foundation": "^7.3", "symfony/http-kernel": "^7.2", "symfony/polyfill-mbstring": "~1.0", - "symfony/filesystem": "^7.1", + "symfony/filesystem": "^7.2", "symfony/finder": "^6.4|^7.0", "symfony/routing": "^6.4|^7.0" }, diff --git a/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.php b/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.php index 7143d6f93c441..fbcde6cb91f25 100644 --- a/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.php +++ b/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.php @@ -13,6 +13,8 @@ /** * Service tag to autoconfigure feature flags. + * + * @experimental */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class AsFeature diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index e0d22b7cb4d36..82a3e13d820c4 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -19,6 +19,9 @@ use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; use Symfony\Component\VarDumper\Cloner\Data; +/** + * @experimental + */ final class FeatureFlagDataCollector extends DataCollector implements LateDataCollectorInterface { public function __construct( diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 7d3da1984b8fa..06d943d8f4d4d 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -13,6 +13,9 @@ use Symfony\Component\FeatureFlag\FeatureCheckerInterface; +/** + * @experimental + */ final class TraceableFeatureChecker implements FeatureCheckerInterface { /** @var array> */ diff --git a/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php b/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php index 85911ef5c83d2..706cfe9890d36 100644 --- a/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php +++ b/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php @@ -11,6 +11,9 @@ namespace Symfony\Component\FeatureFlag\Exception; +/** + * @experimental + */ interface ExceptionInterface extends \Throwable { } diff --git a/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php b/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php index df719e0e22714..f4345416351fc 100644 --- a/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php +++ b/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php @@ -11,6 +11,9 @@ namespace Symfony\Component\FeatureFlag\Exception; +/** + * @experimental + */ class RuntimeException extends \RuntimeException implements ExceptionInterface { } diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 2a67562940868..3bbcba405db1e 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -13,6 +13,9 @@ use Symfony\Component\FeatureFlag\Provider\ProviderInterface; +/** + * @experimental + */ final class FeatureChecker implements FeatureCheckerInterface { private array $cache = []; diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php index e9ba98b7c306a..998903e4026b5 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php @@ -11,6 +11,9 @@ namespace Symfony\Component\FeatureFlag; +/** + * @experimental + */ interface FeatureCheckerInterface { /** diff --git a/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php index 6b4e325c4f9fb..329c1b4e1ba32 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php +++ b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php @@ -11,6 +11,9 @@ namespace Symfony\Component\FeatureFlag\Provider; +/** + * @experimental + */ final class ChainProvider implements ProviderInterface { public function __construct( diff --git a/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php index 85a0e77af1ad9..32df05503f7c1 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php +++ b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php @@ -13,6 +13,9 @@ use Symfony\Component\FeatureFlag\ArgumentResolver\ArgumentResolver; +/** + * @experimental + */ final class InMemoryProvider implements ProviderInterface { /** diff --git a/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php index da4e9771dc432..35cb431a68e9d 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php +++ b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php @@ -11,6 +11,9 @@ namespace Symfony\Component\FeatureFlag\Provider; +/** + * @experimental + */ interface ProviderInterface { public function has(string $featureName): bool; diff --git a/src/Symfony/Component/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json index 3edfafe53f2e6..6f4ce45d56e25 100644 --- a/src/Symfony/Component/FeatureFlag/composer.json +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -2,7 +2,7 @@ "name": "symfony/feature-flag", "type": "library", "description": "Provides a feature flag mechanism", - "keywords": ["feature", "flag", "flags", "toggle"], + "keywords": ["feature", "flag", "flags", "toggle", "ab test", "rollout", "canary", "blue green", "kill switch"], "homepage": "https://symfony.com", "license": "MIT", "authors": [ From fe338c1e5221074861e9399b80bd7a61059f52b4 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 16 Oct 2024 10:15:55 +0200 Subject: [PATCH 37/46] fix bad rebase --- .../FrameworkExtension.php | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c0caa6911275a..87ab12f7bc022 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -3482,25 +3482,6 @@ static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionC } } - private function resolveTrustedHeaders(array $headers): int - { - $trustedHeaders = 0; - - foreach ($headers as $h) { - $trustedHeaders |= match ($h) { - 'forwarded' => Request::HEADER_FORWARDED, - 'x-forwarded-for' => Request::HEADER_X_FORWARDED_FOR, - 'x-forwarded-host' => Request::HEADER_X_FORWARDED_HOST, - 'x-forwarded-proto' => Request::HEADER_X_FORWARDED_PROTO, - 'x-forwarded-port' => Request::HEADER_X_FORWARDED_PORT, - 'x-forwarded-prefix' => Request::HEADER_X_FORWARDED_PREFIX, - default => 0, - }; - } - - return $trustedHeaders; - } - public function getXsdValidationBasePath(): string|false { return \dirname(__DIR__).'/Resources/config/schema'; From f8c30416750a2c0cb9ca83096f1252b3301a0afb Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Thu, 17 Oct 2024 13:37:00 +0200 Subject: [PATCH 38/46] update duplicate feature message --- .../DependencyInjection/Compiler/FeatureFlagPass.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php index 3389a62864433..5944ef362d561 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -38,7 +38,7 @@ public function process(ContainerBuilder $container): void foreach ($tags as $tag) { $featureName = ($tag['feature'] ?? '') ?: $className; if (\array_key_exists($featureName, $features)) { - throw new \RuntimeException(\sprintf('Feature "%s" already defined.', $featureName)); + throw new \RuntimeException(\sprintf('Feature "%s" already defined in the "feature_flag.provider.in_memory" provider.', $featureName)); } $method = $tag['method'] ?? '__invoke'; From 0706557168b4d29d957c5605214c27c194c06c0d Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 5 Nov 2024 16:51:52 +0100 Subject: [PATCH 39/46] remove expected value --- .../Twig/Extension/FeatureFlagRuntime.php | 6 +- .../Tests/Functional/FeatureFlagTest.php | 2 +- .../Bundle/FrameworkBundle/composer.json | 2 +- .../Compiler/ExtensionPass.php | 6 ++ .../Resources/config/feature_flag.php | 2 +- .../views/Collector/feature_flag.html.twig | 86 +++++++++---------- .../FeatureFlagDataCollector.php | 39 +++------ .../Debug/TraceableFeatureChecker.php | 38 +++----- .../Component/FeatureFlag/FeatureChecker.php | 4 +- .../FeatureFlag/FeatureCheckerInterface.php | 6 +- .../FeatureFlag/Provider/ChainProvider.php | 2 +- .../FeatureFlag/Provider/InMemoryProvider.php | 4 +- .../FeatureFlagDataCollectorTest.php | 55 ++++++------ .../Debug/TraceableFeatureCheckerTest.php | 17 ++-- .../FeatureFlag/Tests/FeatureCheckerTest.php | 29 ++----- 15 files changed, 121 insertions(+), 177 deletions(-) diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php index 4c955085f3e77..f075dc5e7433b 100644 --- a/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php @@ -16,13 +16,13 @@ final class FeatureFlagRuntime { public function __construct( - private readonly FeatureCheckerInterface $featureChecker + private readonly FeatureCheckerInterface $featureChecker, ) { } - public function isEnabled(string $featureName, mixed $expectedValue = true): bool + public function isEnabled(string $featureName): bool { - return $this->featureChecker->isEnabled($featureName, $expectedValue); + return $this->featureChecker->isEnabled($featureName); } public function getValue(string $featureName): mixed diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php index d2f96b4973d39..a5daabd561364 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php @@ -66,7 +66,7 @@ public function testFeatureFlagAssertionsWithDifferentMethod() public function testFeatureFlagAssertionsWithDuplicate() { $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Feature "Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassFeature" already defined.'); + $this->expectExceptionMessage('Feature "Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\ClassFeature" already defined in the "feature_flag.provider.in_memory" provider.'); static::bootKernel(['test_case' => 'FeatureFlag', 'root_config' => 'config_with_duplicate.yml']); } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index be5e48c4a8dca..b4e11ea30d3f5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -47,7 +47,7 @@ "symfony/polyfill-intl-icu": "~1.0", "symfony/form": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", - "symfony/feature-flag": "^7.1", + "symfony/feature-flag": "^7.2", "symfony/html-sanitizer": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", "symfony/lock": "^6.4|^7.0", diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php index b21e4f37ece2b..8f6131f6ecb36 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php @@ -54,6 +54,12 @@ public function process(ContainerBuilder $container): void $container->removeDefinition('twig.runtime.importmap'); } + if (!$container->has('feature_flag.feature_checker')) { + // edge case where FeatureFlag is installed, but not enabled + $container->removeDefinition('twig.extension.feature_flag'); + $container->removeDefinition('twig.runtime.feature_flag'); + } + $viewDir = \dirname((new \ReflectionClass(\Symfony\Bridge\Twig\Extension\FormExtension::class))->getFileName(), 2).'/Resources/views'; $templateIterator = $container->getDefinition('twig.template_iterator'); $templatePaths = $templateIterator->getArgument(1); diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php index bbfd4b704c002..82845febc33c4 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flag.php @@ -18,7 +18,7 @@ $container->services() ->set('twig.runtime.feature_flag', FeatureFlagRuntime::class) - ->args([service('feature_flag.feature_checker')->nullOnInvalid()]) + ->args([service('feature_flag.feature_checker')]) ->tag('twig.runtime') ->set('twig.extension.feature_flag', FeatureFlagExtension::class) diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig index 5bec4215c4dd6..9f1ec97a21ad5 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig @@ -3,22 +3,26 @@ {% block page_title 'Feature Flag' %} {% block toolbar %} - {% if 0 < collector.resolvedValues|length %} + {% if 0 < collector.resolved|length %} {% set icon %} Feature Flag - {{ collector.resolvedValues|length }} + {{ collector.resolved|length }} {% endset %} {% set text %}
- {% for feature_name, checks in collector.checks %} + {% set color_map = { + 'not_found': 'gray', + 'resolved': 'green', + 'disabled': 'red', + 'enabled': 'green', + } %} + {% for feature_name, info in collector.resolved %}
- {{ loop.first ? 'Resolved features' : '' }} - {% for check in checks %} - - {{ feature_name }} - - {% endfor %} + {{ feature_name }} + + {{ info.status|capitalize|replace({'_': ' '}) }} +
{% endfor %} @@ -30,7 +34,7 @@ {% endblock %} {% block menu %} - + Feature Flag Feature Flag @@ -45,6 +49,12 @@ text-align: center; min-width: 100px; } + .badge.badge-not_found { + background-color: var(--gray-500); + } + .badge.badge-resolved { + background-color: var(--green-500); + } .badge.badge-enabled { background-color: var(--green-500); } @@ -58,10 +68,10 @@

Feature Flag

-

Resolved {{ collector.resolvedValues|length }}

+

Resolved {{ collector.resolved|length }}

- {% if collector.resolvedValues|length > 0 %} + {% if collector.resolved|length > 0 %} @@ -70,44 +80,30 @@ - {% for feature_name, value in collector.resolvedValues %} + {% for feature_name, info in collector.resolved %} {% endfor %} @@ -115,7 +111,7 @@
{{ feature_name }} - {% for check in collector.checks[feature_name] %} - {% set context_id = 'context-' ~ loop.parent.loop.index ~ '-' ~ loop.index %} -
- {% set result = check.is_enabled ? 'enabled' : 'disabled' %} - {{ result|capitalize }} - -
- -
- - - - - - - - - - - - - - - - - -
Found{{ profiler_dump(check.found) }}
Resolved{{ profiler_dump(value, maxDepth=2) }}
Expected{{ profiler_dump(check.expected_value, maxDepth=2) }}
Calls{{ check.calls }}
-
- {% else %} - Not resolved - {% endfor %} + {% set context_id = 'context-' ~ loop.index %} +
+ {{ info.status|capitalize|replace({'_': ' '}) }} + +
+
+ + + + + + + + + +
Value{{ profiler_dump(info.value, maxDepth=2) }}
Calls{{ info.calls }}
+
{% else %}
-

No features checked

+

No features resolved

{% endif %}
diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index 82a3e13d820c4..1a85d7974fea5 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -36,41 +36,24 @@ public function collect(Request $request, Response $response, ?\Throwable $excep public function lateCollect(): void { - $this->data['resolvedValues'] = []; - foreach ($this->featureChecker->getResolvedValues() as $featureName => $resolvedValue) { - $this->data['resolvedValues'][$featureName] = $this->cloneVar($resolvedValue); + $this->data['resolved'] = []; + foreach ($this->featureChecker->getResolvedValues() as $featureName => $info) { + $this->data['resolved'][$featureName] = [ + 'status' => $this->provider->has($featureName) ? $info['status'] : 'not_found', + 'value' => $this->cloneVar($info['value']), + 'calls' => $info['calls'], + ]; } - $this->data['checks'] = []; - foreach ($this->featureChecker->getChecks() as $featureName => $checks) { - $this->data['checks'][$featureName] = array_map( - fn (array $check): array => [ - 'found' => $this->cloneVar($this->provider->has($featureName)), - 'expected_value' => $this->cloneVar($check['expectedValue']), - 'is_enabled' => $check['isEnabled'], - 'calls' => $check['calls'], - ], - $checks, - ); - } - - $this->data['not_resolved'] = array_values(array_diff($this->provider->getNames(), array_keys($this->data['resolvedValues']))); - } - - /** - * @return array - */ - public function getResolvedValues(): array - { - return $this->data['resolvedValues'] ?? []; + $this->data['not_resolved'] = array_values(array_diff($this->provider->getNames(), array_keys($this->data['resolved']))); } /** - * @return array + * @return array */ - public function getChecks(): array + public function getResolved(): array { - return $this->data['checks'] ?? []; + return $this->data['resolved'] ?? []; } /** diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 06d943d8f4d4d..3ed3ea3561c8c 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -18,51 +18,37 @@ */ final class TraceableFeatureChecker implements FeatureCheckerInterface { - /** @var array> */ - private array $checks = []; - /** @var array */ + /** @var array */ private array $resolvedValues = []; - /** @var array */ - private array $expectedValues = []; public function __construct( private readonly FeatureCheckerInterface $decorated, ) { } - public function isEnabled(string $featureName, mixed $expectedValue = true): bool + public function isEnabled(string $featureName): bool { - $isEnabled = $this->decorated->isEnabled($featureName, $expectedValue); - - // Check duplicates - $this->expectedValues[$featureName] ??= []; - if (false !== ($i = array_search($expectedValue, $this->expectedValues[$featureName] ?? [], true))) { - ++$this->checks[$featureName][$i]['calls']; - - return $isEnabled; - } - $this->expectedValues[$featureName][] = $expectedValue; + $isEnabled = $this->decorated->isEnabled($featureName); // Force logging value. It has no cost since value is cached by the decorated FeatureChecker. $this->getValue($featureName); - $this->checks[$featureName] ??= []; - $this->checks[$featureName][] = ['expectedValue' => $expectedValue, 'isEnabled' => $isEnabled, 'calls' => 1]; + $this->resolvedValues[$featureName]['status'] = $isEnabled ? 'enabled' : 'disabled'; return $isEnabled; } public function getValue(string $featureName): mixed { - return $this->resolvedValues[$featureName] = $this->decorated->getValue($featureName); - } + $this->resolvedValues[$featureName] ??= [ + 'status' => 'resolved', + 'value' => $this->decorated->getValue($featureName), + 'calls' => 0, + ]; - /** - * @return array> - */ - public function getChecks(): array - { - return $this->checks; + ++$this->resolvedValues[$featureName]['calls']; + + return $this->decorated->getValue($featureName); } /** diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 3bbcba405db1e..7ff26846a9412 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -25,9 +25,9 @@ public function __construct( ) { } - public function isEnabled(string $featureName, mixed $expectedValue = true): bool + public function isEnabled(string $featureName): bool { - return $this->getValue($featureName) === $expectedValue; + return true === $this->getValue($featureName); } public function getValue(string $featureName): mixed diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php index 998903e4026b5..9cbbbafec0e0a 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php @@ -16,11 +16,7 @@ */ interface FeatureCheckerInterface { - /** - * @param string $featureName the name of the feature to check - * @param mixed $expectedValue comparison value required to determine if the feature is enabled - */ - public function isEnabled(string $featureName, mixed $expectedValue = true): bool; + public function isEnabled(string $featureName): bool; public function getValue(string $featureName): mixed; } diff --git a/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php index 329c1b4e1ba32..f89426fe7c48e 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php +++ b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php @@ -41,7 +41,7 @@ public function get(string $featureName): \Closure } } - return fn() => false; + return fn () => false; } public function getNames(): array diff --git a/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php index 32df05503f7c1..e081b2c11bf00 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php +++ b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php @@ -11,8 +11,6 @@ namespace Symfony\Component\FeatureFlag\Provider; -use Symfony\Component\FeatureFlag\ArgumentResolver\ArgumentResolver; - /** * @experimental */ @@ -33,7 +31,7 @@ public function has(string $featureName): bool public function get(string $featureName): \Closure { - return $this->features[$featureName] ?? fn() => false; + return $this->features[$featureName] ?? fn () => false; } public function getNames(): array diff --git a/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php index 4793bb566e4fb..fc880a052abd0 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php @@ -16,7 +16,6 @@ use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker; use Symfony\Component\FeatureFlag\FeatureChecker; use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; -use Symfony\Component\VarDumper\Cloner\Data; class FeatureFlagDataCollectorTest extends TestCase { @@ -24,6 +23,7 @@ public function testLateCollect() { $featureRegistry = new InMemoryProvider([ 'feature_true' => fn () => true, + 'feature_false' => fn () => false, 'feature_integer' => fn () => 42, 'feature_random' => fn () => random_int(1, 42), ]); @@ -31,47 +31,44 @@ public function testLateCollect() $dataCollector = new FeatureFlagDataCollector($featureRegistry, $traceableFeatureChecker); $traceableFeatureChecker->isEnabled('feature_true'); - $traceableFeatureChecker->isEnabled('feature_integer', 1); + $traceableFeatureChecker->isEnabled('feature_false'); + $traceableFeatureChecker->isEnabled('feature_unknown'); + $traceableFeatureChecker->getValue('feature_integer'); + $traceableFeatureChecker->getValue('feature_integer'); - $this->assertSame([], $dataCollector->getChecks()); + $this->assertSame([], $dataCollector->getResolved()); $dataCollector->lateCollect(); - $data = array_map(fn (Data $v): mixed => $v->getValue(), $dataCollector->getResolvedValues()); - $this->assertSame( - [ - 'feature_true' => true, - 'feature_integer' => 42, - ], - $data, - ); - $data = array_map( - fn ($checks) => array_map(function (array $a): array { - $a['found'] = $a['found']->getValue(); - $a['expected_value'] = $a['expected_value']->getValue(); + function (array $a): array { + $a['value'] = $a['value']->getValue(); return $a; - }, $checks), - $dataCollector->getChecks(), + }, + $dataCollector->getResolved(), ); $this->assertSame( [ 'feature_true' => [ - [ - 'found' => true, - 'expected_value' => true, - 'is_enabled' => true, - 'calls' => 1, - ], + 'status' => 'enabled', + 'value' => true, + 'calls' => 1, + ], + 'feature_false' => [ + 'status' => 'disabled', + 'value' => false, + 'calls' => 1, + ], + 'feature_unknown' => [ + 'status' => 'not_found', + 'value' => false, + 'calls' => 1, ], 'feature_integer' => [ - [ - 'found' => true, - 'expected_value' => 1, - 'is_enabled' => false, - 'calls' => 1, - ], + 'status' => 'resolved', + 'value' => 42, + 'calls' => 2, ], ], $data, diff --git a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php index fabfb37863532..59fb9e94c5ea9 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php @@ -22,25 +22,22 @@ public function testTraces() { $featureChecker = new FeatureChecker(new InMemoryProvider([ 'feature_true' => fn () => true, + 'feature_false' => fn () => false, 'feature_integer' => fn () => 42, 'feature_random' => fn () => random_int(1, 42), ])); $traceableFeatureChecker = new TraceableFeatureChecker($featureChecker); $this->assertTrue($traceableFeatureChecker->isEnabled('feature_true')); - $this->assertFalse($traceableFeatureChecker->isEnabled('feature_integer', 1)); + $this->assertFalse($traceableFeatureChecker->isEnabled('feature_false')); + $this->assertSame(42, $traceableFeatureChecker->getValue('feature_integer')); + $this->assertSame(42, $traceableFeatureChecker->getValue('feature_integer')); $this->assertSame( [ - 'feature_true' => [['expectedValue' => true, 'isEnabled' => true, 'calls' => 1]], - 'feature_integer' => [['expectedValue' => 1, 'isEnabled' => false, 'calls' => 1]], - ], - $traceableFeatureChecker->getChecks(), - ); - $this->assertSame( - [ - 'feature_true' => true, - 'feature_integer' => 42, + 'feature_true' => ['status' => 'enabled', 'value' => true, 'calls' => 1], + 'feature_false' => ['status' => 'disabled', 'value' => false, 'calls' => 1], + 'feature_integer' => ['status' => 'resolved', 'value' => 42, 'calls' => 2], ], $traceableFeatureChecker->getResolvedValues(), ); diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php index 1ed0d92116746..fd5e4d0018e04 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php @@ -32,7 +32,10 @@ protected function setUp(): void public function testGetValue() { $this->assertSame(42, $this->featureChecker->getValue('feature_integer')); + } + public function testGetValueCache() + { $this->assertIsInt($value = $this->featureChecker->getValue('feature_random')); $this->assertSame($value, $this->featureChecker->getValue('feature_random')); } @@ -50,29 +53,11 @@ public function testIsEnabled(string $featureName, bool $expectedResult) $this->assertSame($expectedResult, $this->featureChecker->isEnabled($featureName)); } - public static function provideIsEnabled() + public static function provideIsEnabled(): iterable { - yield '"true" without expected value' => ['feature_true', true]; - yield '"false" without expected value' => ['feature_false', false]; - yield 'an integer without expected value' => ['feature_integer', false]; + yield '"true"' => ['feature_true', true]; + yield '"false"' => ['feature_false', false]; + yield 'an integer' => ['feature_integer', false]; yield 'an unknown feature' => ['unknown_feature', false]; } - - /** - * @dataProvider providesEnabledComparedToAnExpectedValue - */ - public function testIsEnabledComparedToAnExpectedValue(string $featureName, mixed $expectedValue, bool $expectedResult) - { - $this->assertSame($expectedResult, $this->featureChecker->isEnabled($featureName, $expectedValue)); - } - - public static function providesEnabledComparedToAnExpectedValue() - { - yield '"true" and the same expected value' => ['feature_true', true, true]; - yield '"true" and a different expected value' => ['feature_true', false, false]; - yield '"false" and the same expected value' => ['feature_false', true, false]; - yield '"false" and a different expected value' => ['feature_false', false, true]; - yield 'an integer and the same expected value' => ['feature_integer', 42, true]; - yield 'an integer and a different expected value' => ['feature_integer', 1, false]; - } } From 1b20951594f251c25c064f5186f288bbdc069913 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 6 Nov 2024 10:53:10 +0100 Subject: [PATCH 40/46] replace ProviderInterface::has by a nullable ProviderInterface::get --- .../views/Collector/feature_flag.html.twig | 4 ++-- .../Resources/views/Icon/feature-flag.svg | 5 +++++ .../FeatureFlagDataCollector.php | 2 +- .../Component/FeatureFlag/FeatureChecker.php | 8 +++++++- .../FeatureFlag/Provider/ChainProvider.php | 19 ++++--------------- .../FeatureFlag/Provider/InMemoryProvider.php | 9 ++------- .../Provider/ProviderInterface.php | 6 ++---- .../Tests/Provider/ChainProviderTests.php | 15 +++------------ .../Tests/Provider/InMemoryProviderTests.php | 18 ++++++------------ 9 files changed, 32 insertions(+), 54 deletions(-) create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/feature-flag.svg diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig index 9f1ec97a21ad5..3386bb2676e1c 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig @@ -5,7 +5,7 @@ {% block toolbar %} {% if 0 < collector.resolved|length %} {% set icon %} - Feature Flag + {{ source('@WebProfiler/Icon/feature-flag.svg') }} {{ collector.resolved|length }} {% endset %} @@ -35,7 +35,7 @@ {% block menu %} - Feature Flag + {{ source('@WebProfiler/Icon/feature-flag.svg') }} Feature Flag {% endblock %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/feature-flag.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/feature-flag.svg new file mode 100644 index 0000000000000..47fa304b95dd0 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/feature-flag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php index 1a85d7974fea5..48b4f39e9ba11 100644 --- a/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -39,7 +39,7 @@ public function lateCollect(): void $this->data['resolved'] = []; foreach ($this->featureChecker->getResolvedValues() as $featureName => $info) { $this->data['resolved'][$featureName] = [ - 'status' => $this->provider->has($featureName) ? $info['status'] : 'not_found', + 'status' => $this->provider->get($featureName) ? $info['status'] : 'not_found', 'value' => $this->cloneVar($info['value']), 'calls' => $info['calls'], ]; diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 7ff26846a9412..76cec2b465609 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -32,6 +32,12 @@ public function isEnabled(string $featureName): bool public function getValue(string $featureName): mixed { - return $this->cache[$featureName] ??= $this->provider->get($featureName)(); + if (isset($this->cache[$featureName])) { + return $this->cache[$featureName]; + } + + $feature = $this->provider->get($featureName) ?? fn () => false; + + return $this->cache[$featureName] = $feature(); } } diff --git a/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php index f89426fe7c48e..c9d435d2088cb 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php +++ b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php @@ -22,26 +22,15 @@ public function __construct( ) { } - public function has(string $featureName): bool + public function get(string $featureName): ?\Closure { foreach ($this->providers as $provider) { - if ($provider->has($featureName)) { - return true; + if ($feature = $provider->get($featureName)) { + return $feature; } } - return false; - } - - public function get(string $featureName): \Closure - { - foreach ($this->providers as $provider) { - if ($provider->has($featureName)) { - return $provider->get($featureName); - } - } - - return fn () => false; + return null; } public function getNames(): array diff --git a/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php index e081b2c11bf00..75da6bf186583 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php +++ b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php @@ -24,14 +24,9 @@ public function __construct( ) { } - public function has(string $featureName): bool + public function get(string $featureName): ?\Closure { - return \array_key_exists($featureName, $this->features); - } - - public function get(string $featureName): \Closure - { - return $this->features[$featureName] ?? fn () => false; + return $this->features[$featureName] ?? null; } public function getNames(): array diff --git a/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php index 35cb431a68e9d..5fa60186a16f7 100644 --- a/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php +++ b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php @@ -16,12 +16,10 @@ */ interface ProviderInterface { - public function has(string $featureName): bool; - /** - * @return \Closure(): mixed + * @return ?\Closure(): mixed */ - public function get(string $featureName): \Closure; + public function get(string $featureName): ?\Closure; /** * @return list diff --git a/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php b/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php index 6549f4fbba756..26a2c5f74bc25 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php +++ b/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php @@ -26,7 +26,7 @@ protected function setUp(): void 'first' => fn () => true, ]), new InMemoryProvider([ - 'second' => fn () => true, + 'second' => fn () => 42, ]), new InMemoryProvider([ 'exception' => fn () => throw new \LogicException('Should not be called.'), @@ -34,14 +34,6 @@ protected function setUp(): void ]); } - public function testHas() - { - $this->assertTrue($this->provider->has('first')); - $this->assertTrue($this->provider->has('second')); - $this->assertTrue($this->provider->has('exception')); - $this->assertFalse($this->provider->has('unknown')); - } - public function testGet() { $feature = $this->provider->get('first'); @@ -55,7 +47,7 @@ public function testGetFallback() $feature = $this->provider->get('second'); $this->assertIsCallable($feature); - $this->assertTrue($feature()); + $this->assertSame(42, $feature()); } public function testGetLazy() @@ -67,8 +59,7 @@ public function testGetNotFound() { $feature = $this->provider->get('unknown'); - $this->assertIsCallable($feature); - $this->assertFalse($feature()); + $this->assertNull($feature); } public function testGetNames() diff --git a/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php b/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php index e8e9e10df1ef3..d3ad0d18b08a9 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php +++ b/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php @@ -22,25 +22,20 @@ protected function setUp(): void { $this->provider = new InMemoryProvider([ 'first' => fn () => true, - 'second' => fn () => false, + 'second' => fn () => 42, 'exception' => fn () => throw new \LogicException('Should not be called.'), ]); } - public function testHas() - { - $this->assertTrue($this->provider->has('first')); - $this->assertTrue($this->provider->has('second')); - $this->assertTrue($this->provider->has('exception')); - $this->assertFalse($this->provider->has('unknown')); - } - public function testGet() { $feature = $this->provider->get('first'); - $this->assertIsCallable($feature); $this->assertTrue($feature()); + + $feature = $this->provider->get('second'); + $this->assertIsCallable($feature); + $this->assertSame(42, $feature()); } public function testGetLazy() @@ -52,8 +47,7 @@ public function testGetNotFound() { $feature = $this->provider->get('unknown'); - $this->assertIsCallable($feature); - $this->assertFalse($feature()); + $this->assertNull($feature); } public function testGetNames() From d3033bfdccd5e3e4d26a96a4e53c087cc607a035 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 6 Nov 2024 11:05:09 +0100 Subject: [PATCH 41/46] update README.md --- src/Symfony/Component/FeatureFlag/README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/README.md b/src/Symfony/Component/FeatureFlag/README.md index ffd7750da4986..d5459afb14d49 100644 --- a/src/Symfony/Component/FeatureFlag/README.md +++ b/src/Symfony/Component/FeatureFlag/README.md @@ -50,11 +50,6 @@ $featureChecker->isEnabled('weekend'); // returns true on weekend // Check a not existing feature $featureChecker->isEnabled('not_a_feature'); // returns false -// Check if a feature is enabled using an expected value -$featureChecker->isEnabled('universe'); // returns false -$featureChecker->isEnabled('universe', 7); // returns false -$featureChecker->isEnabled('universe', 42); // returns true - // Retrieve a feature value $featureChecker->getValue('random'); // returns 1, 2 or 3 $featureChecker->getValue('random'); // returns the same value as above From 20b994cc471259ed4b482bce1d650fb4a209b64c Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Wed, 6 Nov 2024 11:25:43 +0100 Subject: [PATCH 42/46] remove useless exceptions --- .../Exception/ExceptionInterface.php | 19 ------------------- .../Exception/RuntimeException.php | 19 ------------------- 2 files changed, 38 deletions(-) delete mode 100644 src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php delete mode 100644 src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php diff --git a/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php b/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php deleted file mode 100644 index 706cfe9890d36..0000000000000 --- a/src/Symfony/Component/FeatureFlag/Exception/ExceptionInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\FeatureFlag\Exception; - -/** - * @experimental - */ -interface ExceptionInterface extends \Throwable -{ -} diff --git a/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php b/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php deleted file mode 100644 index f4345416351fc..0000000000000 --- a/src/Symfony/Component/FeatureFlag/Exception/RuntimeException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\FeatureFlag\Exception; - -/** - * @experimental - */ -class RuntimeException extends \RuntimeException implements ExceptionInterface -{ -} From 4fc09812a325b53be20bd4d7e4b501642b076274 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 7 Jan 2025 12:00:28 +0100 Subject: [PATCH 43/46] add .github folder --- .../.github/PULL_REQUEST_TEMPLATE.md | 8 ++++++++ .../.github/workflows/close-pull-request.yml | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/Symfony/Component/FeatureFlag/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/Symfony/Component/FeatureFlag/.github/workflows/close-pull-request.yml diff --git a/src/Symfony/Component/FeatureFlag/.github/PULL_REQUEST_TEMPLATE.md b/src/Symfony/Component/FeatureFlag/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..4689c4dad430e --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Symfony/Component/FeatureFlag/.github/workflows/close-pull-request.yml b/src/Symfony/Component/FeatureFlag/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000000000..e55b47817e69a --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! From a042334c85f42a834bc608164c46f45f8407e530 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Tue, 7 Jan 2025 14:19:02 +0100 Subject: [PATCH 44/46] review --- .../Compiler/FeatureFlagPass.php | 3 +++ .../InvalidMethodVisibilityFeature.php | 23 +++++++++++++++++++ .../Tests/Functional/FeatureFlagTest.php | 8 +++++++ .../config_with_invalid_method_visibility.yml | 11 +++++++++ .../Debug/TraceableFeatureChecker.php | 6 +++-- .../Component/FeatureFlag/FeatureChecker.php | 2 +- src/Symfony/Component/FeatureFlag/LICENSE | 2 +- 7 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodVisibilityFeature.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method_visibility.yml diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php index 5944ef362d561..62d5af6a95dc5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -45,6 +45,9 @@ public function process(ContainerBuilder $container): void if (!$r->hasMethod($method)) { throw new \RuntimeException(\sprintf('Invalid feature method "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method)); } + if (!$r->getMethod($method)->isPublic()) { + throw new \RuntimeException(\sprintf('Invalid feature method "%s": method "%s::%s()" must be public.', $serviceId, $r->getName(), $method)); + } $features[$featureName] = $container->setDefinition( '.feature_flag.feature', diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodVisibilityFeature.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodVisibilityFeature.php new file mode 100644 index 0000000000000..004721f69bf45 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/FeatureFlag/InvalidMethodVisibilityFeature.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag; + +use Symfony\Component\FeatureFlag\Attribute\AsFeature; + +#[AsFeature(method: 'resolve')] +class InvalidMethodVisibilityFeature +{ + protected function resolve(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php index a5daabd561364..f7cdb08fa5cf3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php @@ -55,6 +55,14 @@ public function testFeatureFlagAssertionsWithInvalidMethod() static::bootKernel(['test_case' => 'FeatureFlag', 'root_config' => 'config_with_invalid_method.yml']); } + public function testFeatureFlagAssertionsWithInvalidMethodVisibility() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid feature method "Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\InvalidMethodVisibilityFeature": method "Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\InvalidMethodVisibilityFeature::resolve()" must be public.'); + + static::bootKernel(['test_case' => 'FeatureFlag', 'root_config' => 'config_with_invalid_method_visibility.yml']); + } + public function testFeatureFlagAssertionsWithDifferentMethod() { $this->expectException(\LogicException::class); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method_visibility.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method_visibility.yml new file mode 100644 index 0000000000000..2cb5decd766e8 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/FeatureFlag/config_with_invalid_method_visibility.yml @@ -0,0 +1,11 @@ +imports: + - { resource: ../config/default.yml } + +framework: + http_method_override: false + feature_flag: + enabled: true + +services: + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\FeatureFlag\InvalidMethodVisibilityFeature: + autoconfigure: true diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php index 3ed3ea3561c8c..b908275b42d10 100644 --- a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php @@ -40,15 +40,17 @@ public function isEnabled(string $featureName): bool public function getValue(string $featureName): mixed { + $value = $this->decorated->getValue($featureName); + $this->resolvedValues[$featureName] ??= [ 'status' => 'resolved', - 'value' => $this->decorated->getValue($featureName), + 'value' => $value, 'calls' => 0, ]; ++$this->resolvedValues[$featureName]['calls']; - return $this->decorated->getValue($featureName); + return $value; } /** diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 76cec2b465609..7d18d702a0dfc 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -32,7 +32,7 @@ public function isEnabled(string $featureName): bool public function getValue(string $featureName): mixed { - if (isset($this->cache[$featureName])) { + if (array_key_exists($featureName, $this->cache)) { return $this->cache[$featureName]; } diff --git a/src/Symfony/Component/FeatureFlag/LICENSE b/src/Symfony/Component/FeatureFlag/LICENSE index e374a5c8339d3..bc38d714ef697 100644 --- a/src/Symfony/Component/FeatureFlag/LICENSE +++ b/src/Symfony/Component/FeatureFlag/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024-present Fabien Potencier +Copyright (c) 2025-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 From 2f490f8d262e91036bdc29ddfba5cf2946d4701e Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Fri, 2 May 2025 22:31:19 +0200 Subject: [PATCH 45/46] fix rebase --- .../FrameworkBundle/DependencyInjection/FrameworkExtension.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 87ab12f7bc022..cfb26c7ff3809 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -172,6 +172,7 @@ use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; use Symfony\Component\RemoteEvent\RemoteEvent; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Router; use Symfony\Component\Scheduler\Attribute\AsCronTask; use Symfony\Component\Scheduler\Attribute\AsPeriodicTask; use Symfony\Component\Scheduler\Attribute\AsSchedule; From 802cd2c0a699acd13374b9eb942ad832b0e1bdf4 Mon Sep 17 00:00:00 2001 From: "hubert.lenoir" Date: Mon, 5 May 2025 10:49:57 +0200 Subject: [PATCH 46/46] add ResetInterface --- src/Symfony/Component/FeatureFlag/FeatureChecker.php | 8 +++++++- .../FeatureFlag/Tests/FeatureCheckerTest.php | 12 ++++++++++++ src/Symfony/Component/FeatureFlag/composer.json | 3 ++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php index 7d18d702a0dfc..6544e4078857d 100644 --- a/src/Symfony/Component/FeatureFlag/FeatureChecker.php +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -12,11 +12,12 @@ namespace Symfony\Component\FeatureFlag; use Symfony\Component\FeatureFlag\Provider\ProviderInterface; +use Symfony\Contracts\Service\ResetInterface; /** * @experimental */ -final class FeatureChecker implements FeatureCheckerInterface +final class FeatureChecker implements FeatureCheckerInterface, ResetInterface { private array $cache = []; @@ -40,4 +41,9 @@ public function getValue(string $featureName): mixed return $this->cache[$featureName] = $feature(); } + + public function reset(): void + { + $this->cache = []; + } } diff --git a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php index fd5e4d0018e04..0e4435fd0b82c 100644 --- a/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.php @@ -18,6 +18,7 @@ class FeatureCheckerTest extends TestCase { private FeatureChecker $featureChecker; + private int $counter = 0; protected function setUp(): void { @@ -26,6 +27,7 @@ protected function setUp(): void 'feature_false' => fn () => false, 'feature_integer' => fn () => 42, 'feature_random' => fn () => random_int(1, 42), + 'feature_counter' => fn () => ++$this->counter, ])); } @@ -60,4 +62,14 @@ public static function provideIsEnabled(): iterable yield 'an integer' => ['feature_integer', false]; yield 'an unknown feature' => ['unknown_feature', false]; } + + public function testReset() + { + $this->assertSame(1, $this->featureChecker->getValue('feature_counter')); + $this->assertSame(1, $this->featureChecker->getValue('feature_counter')); + + $this->featureChecker->reset(); + + $this->assertSame(2, $this->featureChecker->getValue('feature_counter')); + } } diff --git a/src/Symfony/Component/FeatureFlag/composer.json b/src/Symfony/Component/FeatureFlag/composer.json index 6f4ce45d56e25..68be858f22635 100644 --- a/src/Symfony/Component/FeatureFlag/composer.json +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -17,7 +17,8 @@ ], "require": { "php": ">=8.2", - "psr/container": "^1.1|^2.0" + "psr/container": "^1.1|^2.0", + "symfony/service-contracts": "^2.5|^3" }, "require-dev": { "symfony/http-kernel": "^7.2",