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') }} + {{ collector.resolved|length }} + {% endset %} + + {% set text %} +
+ {% set color_map = { + 'not_found': 'gray', + 'resolved': 'green', + 'disabled': 'red', + 'enabled': 'green', + } %} + {% for feature_name, info in collector.resolved %} +
+ {{ feature_name }} + + {{ info.status|capitalize|replace({'_': ' '}) }} + +
+
+ {% endfor %} +
+ {% 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 Flag

+
+
+

Resolved {{ collector.resolved|length }}

+ +
+ {% if collector.resolved|length > 0 %} + + + + + + + + + {% for feature_name, info in collector.resolved %} + + + + + {% endfor %} + +
Feature nameEnabled
+ {{ feature_name }} + + {% set context_id = 'context-' ~ loop.index %} +
+ {{ info.status|capitalize|replace({'_': ' '}) }} + +
+ +
+ + + + + + + + + +
Value{{ profiler_dump(info.value, maxDepth=2) }}
Calls{{ info.calls }}
+
+
+ {% else %} +
+

No features resolved

+
+ {% 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/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/.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/.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! 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..fbcde6cb91f25 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Attribute/AsFeature.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\FeatureFlag\Attribute; + +/** + * Service tag to autoconfigure feature flags. + * + * @experimental + */ +#[\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..f2f5402e3dd96 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +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 new file mode 100644 index 0000000000000..48b4f39e9ba11 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/DataCollector/FeatureFlagDataCollector.php @@ -0,0 +1,71 @@ + + * + * 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\Debug\TraceableFeatureChecker; +use Symfony\Component\FeatureFlag\Provider\ProviderInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\VarDumper\Cloner\Data; + +/** + * @experimental + */ +final class FeatureFlagDataCollector extends DataCollector implements LateDataCollectorInterface +{ + public function __construct( + private readonly ProviderInterface $provider, + private readonly TraceableFeatureChecker $featureChecker, + ) { + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + } + + public function lateCollect(): void + { + $this->data['resolved'] = []; + foreach ($this->featureChecker->getResolvedValues() as $featureName => $info) { + $this->data['resolved'][$featureName] = [ + 'status' => $this->provider->get($featureName) ? $info['status'] : 'not_found', + 'value' => $this->cloneVar($info['value']), + 'calls' => $info['calls'], + ]; + } + + $this->data['not_resolved'] = array_values(array_diff($this->provider->getNames(), array_keys($this->data['resolved']))); + } + + /** + * @return array + */ + public function getResolved(): array + { + return $this->data['resolved'] ?? []; + } + + /** + * @return list + */ + public function getNotResolved(): array + { + return $this->data['not_resolved'] ?? []; + } + + public function getName(): string + { + return 'feature_flag'; + } +} diff --git a/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.php new file mode 100644 index 0000000000000..b908275b42d10 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Debug/TraceableFeatureChecker.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\Debug; + +use Symfony\Component\FeatureFlag\FeatureCheckerInterface; + +/** + * @experimental + */ +final class TraceableFeatureChecker implements FeatureCheckerInterface +{ + /** @var array */ + private array $resolvedValues = []; + + public function __construct( + private readonly FeatureCheckerInterface $decorated, + ) { + } + + public function isEnabled(string $featureName): bool + { + $isEnabled = $this->decorated->isEnabled($featureName); + + // Force logging value. It has no cost since value is cached by the decorated FeatureChecker. + $this->getValue($featureName); + + $this->resolvedValues[$featureName]['status'] = $isEnabled ? 'enabled' : 'disabled'; + + return $isEnabled; + } + + public function getValue(string $featureName): mixed + { + $value = $this->decorated->getValue($featureName); + + $this->resolvedValues[$featureName] ??= [ + 'status' => 'resolved', + 'value' => $value, + 'calls' => 0, + ]; + + ++$this->resolvedValues[$featureName]['calls']; + + return $value; + } + + /** + * @return array + */ + public function getResolvedValues(): array + { + return $this->resolvedValues; + } +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureChecker.php b/src/Symfony/Component/FeatureFlag/FeatureChecker.php new file mode 100644 index 0000000000000..6544e4078857d --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/FeatureChecker.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag; + +use Symfony\Component\FeatureFlag\Provider\ProviderInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * @experimental + */ +final class FeatureChecker implements FeatureCheckerInterface, ResetInterface +{ + private array $cache = []; + + public function __construct( + private readonly ProviderInterface $provider, + ) { + } + + public function isEnabled(string $featureName): bool + { + return true === $this->getValue($featureName); + } + + public function getValue(string $featureName): mixed + { + if (array_key_exists($featureName, $this->cache)) { + return $this->cache[$featureName]; + } + + $feature = $this->provider->get($featureName) ?? fn () => false; + + return $this->cache[$featureName] = $feature(); + } + + public function reset(): void + { + $this->cache = []; + } +} diff --git a/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.php new file mode 100644 index 0000000000000..9cbbbafec0e0a --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/FeatureCheckerInterface.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; + +/** + * @experimental + */ +interface FeatureCheckerInterface +{ + public function isEnabled(string $featureName): bool; + + public function getValue(string $featureName): mixed; +} diff --git a/src/Symfony/Component/FeatureFlag/LICENSE b/src/Symfony/Component/FeatureFlag/LICENSE new file mode 100644 index 0000000000000..bc38d714ef697 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/LICENSE @@ -0,0 +1,19 @@ +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 +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/Provider/ChainProvider.php b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.php new file mode 100644 index 0000000000000..c9d435d2088cb --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Provider/ChainProvider.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\Provider; + +/** + * @experimental + */ +final class ChainProvider implements ProviderInterface +{ + public function __construct( + /** @var list */ + private readonly iterable $providers = [], + ) { + } + + public function get(string $featureName): ?\Closure + { + foreach ($this->providers as $provider) { + if ($feature = $provider->get($featureName)) { + return $feature; + } + } + + return null; + } + + 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/Provider/InMemoryProvider.php b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php new file mode 100644 index 0000000000000..75da6bf186583 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Provider/InMemoryProvider.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Provider; + +/** + * @experimental + */ +final class InMemoryProvider implements ProviderInterface +{ + /** + * @param array $features + */ + public function __construct( + private readonly array $features, + ) { + } + + public function get(string $featureName): ?\Closure + { + return $this->features[$featureName] ?? null; + } + + public function getNames(): array + { + return array_keys($this->features); + } +} diff --git a/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php new file mode 100644 index 0000000000000..5fa60186a16f7 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Provider/ProviderInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlag\Provider; + +/** + * @experimental + */ +interface ProviderInterface +{ + /** + * @return ?\Closure(): mixed + */ + public function get(string $featureName): ?\Closure; + + /** + * @return list + */ + public function getNames(): array; +} diff --git a/src/Symfony/Component/FeatureFlag/README.md b/src/Symfony/Component/FeatureFlag/README.md new file mode 100644 index 0000000000000..d5459afb14d49 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/README.md @@ -0,0 +1,64 @@ +FeatureFlag Component +===================== + +The FeatureFlag component allows you to split the code execution flow by +enabling features depending on context. + +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) +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\Provider\InMemoryProvider; + +// Declare features +final class XmasFeature +{ + public function __invoke(): bool + { + return date('m-d') === '12-25'; + } +} + +$provider = new InMemoryProvider([ + '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($provider); + +// Check if a feature is enabled +$featureChecker->isEnabled('weekend'); // returns true on weekend + +// Check a not existing feature +$featureChecker->isEnabled('not_a_feature'); // returns false + +// Retrieve a feature value +$featureChecker->getValue('random'); // returns 1, 2 or 3 +$featureChecker->getValue('random'); // returns the same value as above +``` + +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/Tests/DataCollector/FeatureFlagDataCollectorTest.php b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.php new file mode 100644 index 0000000000000..fc880a052abd0 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/DataCollector/FeatureFlagDataCollectorTest.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\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\Provider\InMemoryProvider; + +class FeatureFlagDataCollectorTest extends TestCase +{ + 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), + ]); + $traceableFeatureChecker = new TraceableFeatureChecker(new FeatureChecker($featureRegistry)); + $dataCollector = new FeatureFlagDataCollector($featureRegistry, $traceableFeatureChecker); + + $traceableFeatureChecker->isEnabled('feature_true'); + $traceableFeatureChecker->isEnabled('feature_false'); + $traceableFeatureChecker->isEnabled('feature_unknown'); + $traceableFeatureChecker->getValue('feature_integer'); + $traceableFeatureChecker->getValue('feature_integer'); + + $this->assertSame([], $dataCollector->getResolved()); + + $dataCollector->lateCollect(); + + $data = array_map( + function (array $a): array { + $a['value'] = $a['value']->getValue(); + + return $a; + }, + $dataCollector->getResolved(), + ); + $this->assertSame( + [ + 'feature_true' => [ + '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' => [ + 'status' => 'resolved', + 'value' => 42, + 'calls' => 2, + ], + ], + $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 new file mode 100644 index 0000000000000..59fb9e94c5ea9 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/Debug/TraceableFeatureCheckerTest.php @@ -0,0 +1,45 @@ + + * + * 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\Provider\InMemoryProvider; + +class TraceableFeatureCheckerTest extends TestCase +{ + 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_false')); + $this->assertSame(42, $traceableFeatureChecker->getValue('feature_integer')); + $this->assertSame(42, $traceableFeatureChecker->getValue('feature_integer')); + + $this->assertSame( + [ + '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 new file mode 100644 index 0000000000000..0e4435fd0b82c --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/FeatureCheckerTest.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\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlag\FeatureChecker; +use Symfony\Component\FeatureFlag\Provider\InMemoryProvider; + +class FeatureCheckerTest extends TestCase +{ + private FeatureChecker $featureChecker; + private int $counter = 0; + + protected function setUp(): void + { + $this->featureChecker = new FeatureChecker(new InMemoryProvider([ + 'feature_true' => fn () => true, + 'feature_false' => fn () => false, + 'feature_integer' => fn () => 42, + 'feature_random' => fn () => random_int(1, 42), + 'feature_counter' => fn () => ++$this->counter, + ])); + } + + 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')); + } + + public function testGetValueOnNotFound() + { + $this->assertFalse($this->featureChecker->getValue('unknown_feature')); + } + + /** + * @dataProvider provideIsEnabled + */ + public function testIsEnabled(string $featureName, bool $expectedResult) + { + $this->assertSame($expectedResult, $this->featureChecker->isEnabled($featureName)); + } + + public static function provideIsEnabled(): iterable + { + yield '"true"' => ['feature_true', true]; + yield '"false"' => ['feature_false', false]; + 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/Tests/Provider/ChainProviderTests.php b/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php new file mode 100644 index 0000000000000..26a2c5f74bc25 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/Provider/ChainProviderTests.php @@ -0,0 +1,69 @@ + + * + * 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 () => 42, + ]), + new InMemoryProvider([ + 'exception' => fn () => throw new \LogicException('Should not be called.'), + ]), + ]); + } + + 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->assertSame(42, $feature()); + } + + public function testGetLazy() + { + $this->assertIsCallable($this->provider->get('exception')); + } + + public function testGetNotFound() + { + $feature = $this->provider->get('unknown'); + + $this->assertNull($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..d3ad0d18b08a9 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/Tests/Provider/InMemoryProviderTests.php @@ -0,0 +1,57 @@ + + * + * 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 () => 42, + 'exception' => fn () => throw new \LogicException('Should not be called.'), + ]); + } + + 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() + { + $this->assertIsCallable($this->provider->get('exception')); + } + + public function testGetNotFound() + { + $feature = $this->provider->get('unknown'); + + $this->assertNull($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 new file mode 100644 index 0000000000000..68be858f22635 --- /dev/null +++ b/src/Symfony/Component/FeatureFlag/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/feature-flag", + "type": "library", + "description": "Provides a feature flag mechanism", + "keywords": ["feature", "flag", "flags", "toggle", "ab test", "rollout", "canary", "blue green", "kill switch"], + "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", + "psr/container": "^1.1|^2.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/http-kernel": "^7.2", + "symfony/var-dumper": "^7.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 + + +