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..2ce0ff217d2bf --- /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('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 new file mode 100644 index 0000000000000..f075dc5e7433b --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php @@ -0,0 +1,32 @@ + + * + * 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 $featureChecker, + ) { + } + + public function isEnabled(string $featureName): bool + { + return $this->featureChecker->isEnabled($featureName); + } + + public function getValue(string $featureName): mixed + { + return $this->featureChecker->getValue($featureName); + } +} diff --git a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php index 16421eaf504d4..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', 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..62d5af6a95dc5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagPass.php @@ -0,0 +1,95 @@ + + * + * 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 in the "feature_flag.provider.in_memory" provider.', $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)); + } + 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', + (new Definition(\Closure::class)) + ->setLazy(true) + ->setFactory([\Closure::class, 'fromCallable']) + ->setArguments([[new Reference($serviceId), $method]]), + ); + } + } + + $container->getDefinition('feature_flag.provider.in_memory') + ->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 + { + 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..d206a64137407 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -48,6 +48,9 @@ class UnusedTagsPass implements CompilerPassInterface 'controller.targeted_value_resolver', 'data_collector', '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/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..cfb26c7ff3809 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\Provider\ProviderInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Glob; @@ -169,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; @@ -310,6 +314,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 +643,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 +1023,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 +3444,45 @@ private function registerHtmlSanitizerConfiguration(array $config, ContainerBuil } } + private function registerFeatureFlagConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('feature_flag.php'); + + $container->registerForAutoconfiguration(ProviderInterface::class) + ->addTag('feature_flag.provider') + ; + + $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'); + } + } + 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..d5de01616113b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag.php @@ -0,0 +1,41 @@ + + * + * 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\Provider\ChainProvider; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; +use Symfony\Component\FeatureFlag\Provider\ProviderInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flag.provider.in_memory', InMemoryProvider::class) + ->args([ + '$features' => abstract_arg('Defined in FeatureFlagPass.'), + ]) + ->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([ + '$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 new file mode 100644 index 0000000000000..4f0a5a894c919 --- /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([ + '$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 new file mode 100644 index 0000000000000..ccf021e05620f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flag_routing.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; + +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' => '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' => 'feature_get_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..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; @@ -1014,6 +1015,9 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'json_streamer' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(JsonStreamWriter::class), ], + 'feature_flag' => [ + 'enabled' => !class_exists(FullStack::class) && class_exists(FeatureChecker::class), + ], ]; } 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/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/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..f7cdb08fa5cf3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/FeatureFlagTest.php @@ -0,0 +1,81 @@ + + * + * 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 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); + $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 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/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 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/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 2ecedbc45660e..b4e11ea30d3f5 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" }, @@ -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.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/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..82845febc33c4 --- /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')]) + ->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..3386bb2676e1c --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flag.html.twig @@ -0,0 +1,148 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block page_title 'Feature Flag' %} + +{% block toolbar %} + {% if 0 < collector.resolved|length %} + {% set icon %} + {{ source('@WebProfiler/Icon/feature-flag.svg') }} +
+ {% endset %} + + {% set text %} + + {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + {{ source('@WebProfiler/Icon/feature-flag.svg') }} + Feature Flag + +{% endblock %} + +{% block head %} + {{ parent() }} + +{% endblock %} + +{% block panel %} +Feature name | +Enabled | +||||
---|---|---|---|---|---|
+ {{ feature_name }} + | +
+ {% set context_id = 'context-' ~ loop.index %}
+
+ {{ info.status|capitalize|replace({'_': ' '}) }}
+
+
+
+
+
+
|
+
No features resolved
+Feature name | +
---|
+ {{ feature_name }} + | +
All features resolved
+