diff --git a/composer.json b/composer.json index 3d0aa2f4f87af..a6bc86bbee229 100644 --- a/composer.json +++ b/composer.json @@ -73,6 +73,7 @@ "symfony/error-handler": "self.version", "symfony/event-dispatcher": "self.version", "symfony/expression-language": "self.version", + "symfony/feature-flags": "self.version", "symfony/filesystem": "self.version", "symfony/finder": "self.version", "symfony/form": "self.version", diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagsExtension.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagsExtension.php new file mode 100644 index 0000000000000..b0800fba2624b --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagsExtension.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 Symfony\Component\FeatureFlags\FeatureCheckerInterface; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +final class FeatureFlagsExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('is_feature_enabled', [FeatureFlagsRuntime::class, 'isFeatureEnabled']), + ]; + } +} diff --git a/src/Symfony/Bridge/Twig/Extension/FeatureFlagsRuntime.php b/src/Symfony/Bridge/Twig/Extension/FeatureFlagsRuntime.php new file mode 100644 index 0000000000000..b1637c9326ba9 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/FeatureFlagsRuntime.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\FeatureFlags\FeatureCheckerInterface; + +final class FeatureFlagsRuntime +{ + public function __construct(private readonly ?FeatureCheckerInterface $featureEnabledChecker = null) + { + } + + public function isFeatureEnabled(string $featureName): bool + { + if (null === $this->featureEnabledChecker) { + throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FeatureCheckerInterface::class, __METHOD__)); + } + + return $this->featureEnabledChecker->isEnabled($featureName); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/FeatureFlagsDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/FeatureFlagsDebugCommand.php new file mode 100644 index 0000000000000..ec5f303b0e69c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/FeatureFlagsDebugCommand.php @@ -0,0 +1,294 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\FeatureFlags\Debug\TraceableStrategy; +use Symfony\Component\FeatureFlags\Feature; +use Symfony\Component\FeatureFlags\Provider\ProviderInterface; +use Symfony\Component\FeatureFlags\Strategy\OuterStrategiesInterface; +use Symfony\Component\FeatureFlags\Strategy\OuterStrategyInterface; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; + +/** + * A console command for retrieving information about feature flags. + */ +#[AsCommand(name: 'debug:feature-flags', description: 'Display configured features and their provider for an application')] +final class FeatureFlagsDebugCommand extends Command +{ + /** @var iterable */ + private iterable $featureProviders; + + /** @param iterable $featureProviders */ + public function __construct(iterable $featureProviders) + { + parent::__construct(); + + $this->featureProviders = $featureProviders; + } + + protected function configure(): void + { + $this + ->addArgument('featureName', InputArgument::OPTIONAL, 'Feature name. If provided will display the full tree of strategies regarding that feature.') + ->setHelp(<<<'EOF' +The %command.name% command displays all configured feature flags: + + php %command.full_name% + +To get more insight for a flag, specify its name: + + php %command.full_name% my-feature +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (null === $input->getArgument('featureName')) { + return $this->listAllFeaturesPerProvider($io); + } + + return $this->detailsFeature($io, $input->getArgument('featureName')); + } + + private function detailsFeature(SymfonyStyle $io, string $debuggingFeatureName): int + { + $io->title("About \"{$debuggingFeatureName}\" flag"); + + $providerNames = []; + $featureFoundName = null; + $featureFoundProviders = []; + $candidates = []; + + foreach ($this->featureProviders as $serviceName => $featureProvider) { + $providerName = $featureProvider::class; + if ($providerName !== $serviceName) { + $providerName .= " ({$serviceName})"; + } + $providerNames[] = $providerName; + + foreach ($featureProvider->names() as $featureName) { + if ( + !\in_array($featureName, $candidates, true) + && (str_contains($featureName, $debuggingFeatureName) || \levenshtein($featureName, $debuggingFeatureName) <= \strlen($featureName) / 3) + ) { + $candidates[] = $featureName; + } + + if ($featureName !== $debuggingFeatureName) { + continue; + } + + $featureFoundName = $featureName; + $featureFoundProviders[] = $providerName; + + if (\count($featureFoundProviders) > 1) { + continue; + } + + $feature = $featureProvider->get($featureName); + $featureGetDefault = \Closure::bind(fn (): bool => $feature->default, $feature, Feature::class); + + $io + ->createTable() + ->setHorizontal() + ->setHeaders(['Name', 'Description', 'Default', 'Provider', 'Strategy Tree']) + ->addRow([ + $featureName, + \chunk_split($feature->getDescription(), 40, "\n"), + \json_encode($featureGetDefault()), + $providerName, + $this->getStrategyTreeFromFeature($feature), + ]) + ->setStyle('compact') + ->render() + ; + } + } + + if (null !== $featureFoundName) { + $this->renderDuplicateWarnings($io, $featureFoundName, $featureFoundProviders); + + return 0; + } + + $warning = \sprintf( + "\"%s\" not found in any of the following providers :\n%s", + $debuggingFeatureName, + \implode("\n", \array_map(fn (string $providerName) => ' * '.$providerName, $providerNames)), + ); + if (0 < \count($candidates)) { + $warning .= \sprintf( + "\n\nDid you mean \"%s\"?", + \implode('", "', $candidates), + ); + } + $io->warning($warning); + + return 1; + } + + private function listAllFeaturesPerProvider(SymfonyStyle $io): int + { + $io->title('Feature list grouped by their providers'); + + $order = 0; + $groupedFeatureProviders = []; + + foreach ($this->featureProviders as $serviceName => $featureProvider) { + ++$order; + + $providerName = $featureProvider::class; + if ($providerName !== $serviceName) { + $providerName .= " ({$serviceName})."; + } + $io->section("#{$order} - {$providerName}"); + + $tableHeaders = ['Name', 'Description', 'Default', 'Main Strategy']; + $tableRows = []; + + foreach ($featureProvider->names() as $featureName) { + $groupedFeatureProviders[$featureName] ??= []; + $groupedFeatureProviders[$featureName][] = $providerName; + + $feature = $featureProvider->get($featureName); + + $featureGetDefault = \Closure::bind(fn (): bool => $feature->default, $feature, Feature::class); + $featureGetStrategy = \Closure::bind(fn (): StrategyInterface => $feature->strategy, $feature, Feature::class); + + $strategy = $featureGetStrategy(); + $strategyClass = $strategy::class; + $strategyId = null; + + if ($strategy instanceof TraceableStrategy) { + $strategyGetId = \Closure::bind(fn (): string => $strategy->strategyId, $strategy, TraceableStrategy::class); + + $strategyId = $strategyGetId(); + $strategyClass = $strategy->getInnerStrategy()::class; + } + + $strategyString = $strategyClass; + if (null !== $strategyId) { + $strategyString .= " ({$strategyId})"; + } + + $rowFeatureName = $featureName; + + if (\count($groupedFeatureProviders[$featureName]) > 1) { + $rowFeatureName .= ' (⚠️ duplicated)'; + } + + $tableRows[] = [ + $rowFeatureName, + \chunk_split($feature->getDescription(), 40, "\n"), + \json_encode($featureGetDefault()), + $strategyString, + ]; + } + $io->table($tableHeaders, $tableRows); + } + + foreach ($groupedFeatureProviders as $featureName => $featureProviders) { + $this->renderDuplicateWarnings($io, $featureName, $featureProviders); + } + + return 0; + } + + private function getStrategyTreeFromFeature(Feature $feature): string + { + $featureGetStrategy = \Closure::bind(fn (): StrategyInterface => $feature->strategy, $feature, Feature::class); + + $strategyTree = $this->getStrategyTree($featureGetStrategy()); + + return $this->convertStrategyTreeToString($strategyTree); + } + + private function getStrategyTree(StrategyInterface $strategy, string|null $strategyId = null): array + { + $children = []; + + if ($strategy instanceof TraceableStrategy) { + $strategyGetId = \Closure::bind(fn (): string => $strategy->strategyId, $strategy, TraceableStrategy::class); + + return $this->getStrategyTree($strategy->getInnerStrategy(), $strategyGetId()); + } elseif ($strategy instanceof OuterStrategiesInterface) { + $children = \array_map( + fn (StrategyInterface $strategyInterface): array => $this->getStrategyTree($strategyInterface), + $strategy->getInnerStrategies() + ); + } elseif ($strategy instanceof OuterStrategyInterface) { + $children = [$this->getStrategyTree($strategy->getInnerStrategy())]; + } + + return [ + 'id' => $strategyId, + 'class' => $strategy::class, + 'children' => $children, + ]; + } + + private function convertStrategyTreeToString(array $strategyTree, int $indent = 0): string + { + $childIndicator = 'L '; + $spaces = \str_repeat(' ', $indent * \strlen($childIndicator)); + + $prefix = '' === $spaces ? '' : "{$spaces}{$childIndicator}"; + + $row = $strategyTree['class']; + + if (null !== $strategyTree['id']) { + $row .= " ({$strategyTree['id']})"; + } + + $row .= "\n"; + + foreach ($strategyTree['children'] as $child) { + $row .= $this->convertStrategyTreeToString($child, $indent + 1); + } + + return "{$prefix}{$row}"; + } + + /** + * @param list $providerNames + */ + private function renderDuplicateWarnings(SymfonyStyle $io, string $featureName, array $providerNames): void + { + $duplicatesCount = \count($providerNames) - 1; + if (0 === $duplicatesCount) { + return; + } + + $providerNames = \array_slice($providerNames, -$duplicatesCount); + + if (1 === $duplicatesCount) { + $warningMessage = \sprintf('Found 1 duplicate for "%s" feature, which will probably never be used, in those providers:', $featureName); + } else { + $warningMessage = \sprintf('Found %d duplicates for "%s" feature, which will probably never be used, in those providers:', $duplicatesCount, $featureName); + } + + $warningMessage .= "\n"; + $warningMessage .= \implode("\n", \array_map(fn (string $providerName): string => ' * '.$providerName, $providerNames)); + + $io->warning($warningMessage); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagsDebugPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagsDebugPass.php new file mode 100644 index 0000000000000..6a50d30636d6e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/FeatureFlagsDebugPass.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\FeatureFlags\Debug\TraceableFeatureChecker; +use Symfony\Component\FeatureFlags\Debug\TraceableStrategy; + +final class FeatureFlagsDebugPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->has('feature_flags.data_collector')) { + return; + } + + foreach ($container->findTaggedServiceIds('feature_flags.feature_strategy') as $serviceId => $tags) { + $container->register('debug.'.$serviceId, TraceableStrategy::class) + ->setDecoratedService($serviceId) + ->setArguments([ + '$strategy' => new Reference('.inner'), + '$strategyId' => $serviceId, + '$dataCollector' => new Reference('feature_flags.data_collector'), + ]) + ; + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 3c135d301a737..cba7bc54365f1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -48,6 +48,8 @@ class UnusedTagsPass implements CompilerPassInterface 'controller.targeted_value_resolver', 'data_collector', 'event_dispatcher.dispatcher', + 'feature_flags.feature_provider', + 'feature_flags.feature_strategy', '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 1da90f63c66fa..7650de0294d4e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -25,6 +25,7 @@ use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\FeatureFlags\FeatureCheckerInterface; use Symfony\Component\Form\Form; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; use Symfony\Component\HttpClient\HttpClient; @@ -177,6 +178,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addHtmlSanitizerSection($rootNode, $enableIfStandalone); $this->addWebhookSection($rootNode, $enableIfStandalone); $this->addRemoteEventSection($rootNode, $enableIfStandalone); + $this->addFeatureFlagsSection($rootNode, $enableIfStandalone); return $treeBuilder; } @@ -2447,4 +2449,148 @@ private function addHtmlSanitizerSection(ArrayNodeDefinition $rootNode, callable ->end() ; } + + private function addFeatureFlagsSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void + { + $knownStrategyTypes = [ + 'grant', + 'deny', + 'not', + 'date', + 'env', + 'request_header', + 'request_query', + 'request_attribute', + 'priority', + 'affirmative', + 'unanimous', + ]; + + $rootNode + ->children() + ->arrayNode('feature_flags') + ->info('FeatureFlags configuration') + ->{$enableIfStandalone('symfony/feature-flags', FeatureCheckerInterface::class)}() + ->children() + // strategies + ->arrayNode('strategies') + ->useAttributeAsKey('name', false) + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->info('Will become the service ID in the container.') + ->example('header.feature-strategy') + ->end() + ->scalarNode('type') + ->isRequired() + ->cannotBeEmpty() + ->info(sprintf('Can be one of : %s. Or a service ID.', implode(', ', $knownStrategyTypes))) + ->example('request_header') + ->end() + ->variableNode('with') + ->defaultValue([]) + ->example(['name' => 'Some-Header']) + ->info('Additional information required. Depends on type.') + ->end() + ->end() + ->beforeNormalization() + ->always() + ->then(static function (array $strategy): array { + $defaultWith = match ($strategy['type']) { + 'date' => ['since' => null, 'until' => null, 'includeSince' => false, 'includeUntil' => false], + 'not' => ['strategy' => null], + 'env', 'request_header', 'request_query', 'request_attribute' => ['name' => null], + 'priority', 'affirmative', 'unanimous' => ['strategies' => null], + default => [], + }; + + $strategy['with'] ??= []; + $strategy['with'] += $defaultWith; + + return $strategy; + }) + ->end() + ->validate() + ->always() + ->then(static function (array $strategy): array { + $validator = match ($strategy['type']) { + 'date' => static function (array $with): void { + if ('' === trim((string) $with['since'].(string) $with['until'])) { + throw new \InvalidArgumentException('Either "since" or "until" must be provided.'); + } + }, + 'not' => static function (array $with): void { + if ('' === (string) $with['strategy']) { + throw new \InvalidArgumentException('"strategy" must be provided.'); + } + }, + 'env' => static function (array $with): void { + if ('' === (string) $with['name']) { + throw new \InvalidArgumentException('"name" must be provided.'); + } + }, + 'request_header' => static function (array $with): void { + if ('' === (string) $with['name']) { + throw new \InvalidArgumentException('"name" must be provided.'); + } + }, + 'request_query' => static function (array $with): void { + if ('' === (string) $with['name']) { + throw new \InvalidArgumentException('"name" must be provided.'); + } + }, + 'request_attribute' => static function (array $with): void { + if ('' === (string) $with['name']) { + throw new \InvalidArgumentException('"name" must be provided.'); + } + }, + 'priority', 'affirmative', 'unanimous' => static function (array $with): void { + if ([] === (array) $with['strategies']) { + throw new \InvalidArgumentException('"strategies" must be provided.'); + } + }, + default => static fn (): bool => true, + }; + + $validator($strategy['with']); + + return $strategy; + }) + ->end() + ->end() + ->end() + // features + ->arrayNode('features') + ->useAttributeAsKey('name', false) + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->info('Name to be used for checking.') + ->example('my-feature') + ->end() + ->scalarNode('description')->defaultValue('')->end() + ->booleanNode('default') + ->defaultFalse() + ->treatNullLike(false) + ->isRequired() + ->info('Will be used as a fallback mechanism if the strategy return StrategyResult::Abstain.') + ->end() + ->scalarNode('strategy') + ->isRequired() + ->cannotBeEmpty() + ->example('header.feature-strategy') + ->info('Strategy to be used for this feature. Can be one of "feature_flags.strategies[].name" or a valid service id that implements StrategyInterface::class.') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 70f2bd3ea2cc0..a05f5b6f3c372 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -25,6 +25,7 @@ use Psr\Log\LoggerAwareInterface; use Symfony\Bridge\Monolog\Processor\DebugProcessor; use Symfony\Bridge\Twig\Extension\CsrfExtension; +use Symfony\Bundle\FeatureFlagsBundle\Strategy\CustomStrategy; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; use Symfony\Bundle\FullStack; @@ -52,6 +53,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Messenger\RunCommandMessageHandler; use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -69,6 +71,10 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\FeatureFlags\Feature; +use Symfony\Component\FeatureFlags\FeatureChecker; +use Symfony\Component\FeatureFlags\Provider\ProviderInterface; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Glob; use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension; @@ -138,6 +144,7 @@ use Symfony\Component\RateLimiter\Storage\CacheStorage; use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; use Symfony\Component\RemoteEvent\RemoteEvent; +use Symfony\Component\Routing\Router; use Symfony\Component\Scheduler\Attribute\AsCronTask; use Symfony\Component\Scheduler\Attribute\AsPeriodicTask; use Symfony\Component\Scheduler\Attribute\AsSchedule; @@ -255,6 +262,7 @@ public function load(array $configs, ContainerBuilder $container): void $config = $this->processConfiguration($configuration, $configs); // warmup config enabled + $this->readConfigEnabled('feature_flags', $container, $config['feature_flags']); $this->readConfigEnabled('translator', $container, $config['translator']); $this->readConfigEnabled('property_access', $container, $config['property_access']); $this->readConfigEnabled('profiler', $container, $config['profiler']); @@ -563,6 +571,13 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerHtmlSanitizerConfiguration($config['html_sanitizer'], $container, $loader); } + if ($this->readConfigEnabled('feature_flags', $container, $config['feature_flags'])) { + if (!class_exists(FeatureChecker::class)) { + throw new LogicException('FeatureFlags support cannot be enabled as the FeatureFlags component is not installed. Try running "composer require symfony/feature-flags".'); + } + $this->registerFeatureFlagsConfiguration($config['feature_flags'], $container, $loader); + } + $this->addAnnotatedClassesToCompile([ '**\\Controller\\', '**\\Entity\\', @@ -880,6 +895,10 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ $loader->load('serializer_debug.php'); } + if ($this->isInitializedConfigEnabled('feature_flags')) { + $loader->load('feature_flags_debug.php'); + } + $container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']); $container->setParameter('profiler_listener.only_main_requests', $config['only_main_requests']); @@ -2965,6 +2984,69 @@ private function registerHtmlSanitizerConfiguration(array $config, ContainerBuil } } + private function registerFeatureFlagsConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('feature_flags.php'); + + $container->registerForAutoconfiguration(ProviderInterface::class) + ->addTag('feature_flags.feature_provider') + ; + $features = []; + foreach ($config['features'] as $featureName => $featureConfig) { + $features[$featureName] = new ServiceClosureArgument((new Definition(Feature::class)) + ->setShared(false) + ->setArguments([ + $featureName, + $featureConfig['description'], + $featureConfig['default'], + new Reference($featureConfig['strategy']), + ])) + ; + } + $container->getDefinition('feature_flags.provider.lazy_in_memory') + ->setArgument('$features', $features) + ; + + $container->registerForAutoconfiguration(StrategyInterface::class) + ->addTag('feature_flags.feature_strategy') + ; + + foreach ($config['strategies'] as $strategyName => $strategyConfig) { + ['type' => $type, 'with' => $with] = $strategyConfig; + + $definition = new ChildDefinition("feature_flags.abstract_strategy.{$type}"); + $definition = match ($type) { + 'date' => $definition->setArguments([ + '$since' => new Definition(\DateTimeImmutable::class, [$with['since']]), + '$until' => new Definition(\DateTimeImmutable::class, [$with['until']]), + '$includeSince' => $with['includeSince'], + '$includeUntil' => $with['includeUntil'], + ]), + 'env' => $definition->setArguments(['$envName' => $with['name']]), + 'request_header' => $definition->setArguments(['$headerName' => $with['name']]), + 'request_query' => $definition->setArguments(['$queryParameterName' => $with['name']]), + 'request_attribute' => $definition->setArguments(['$attributeName' => $with['name']]), // Check if RequestStack class exists + 'priority', 'affirmative', 'unanimous' => $definition->setArguments([ + '$strategies' => array_map( + static fn (string $referencedStrategyName): Reference => new Reference($referencedStrategyName), // @phpstan-ignore-line + (array) $with['strategies'], + ), + ]), + 'not' => $definition->setArguments([ + '$inner' => new Reference($with['strategy']), // @phpstan-ignore-line + ]), + 'grant', 'deny', => $definition, + default => new ChildDefinition($type), + }; + + $container->setDefinition($strategyName, $definition)->addTag('feature_flags.feature_strategy'); + } + + if (ContainerBuilder::willBeAvailable('symfony/routing', Router::class, ['symfony/framework-bundle', 'symfony/routing'])) { + $loader->load('feature_flags_routing.php'); + } + } + private function resolveTrustedHeaders(array $headers): int { $trustedHeaders = 0; diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index 3da66702c584f..cec20f570715a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -17,6 +17,7 @@ use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ContainerBuilderDebugDumpPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\DataCollectorTranslatorPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ErrorLoggerCompilerPass; +use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\FeatureFlagsDebugPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\LoggingTranslatorPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ProfilerPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\RemoveUnusedSessionMarshallingHandlerPass; @@ -177,6 +178,7 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new ErrorLoggerCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); if ($container->getParameter('kernel.debug')) { + $container->addCompilerPass(new FeatureFlagsDebugPass()); $container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 2); $container->addCompilerPass(new UnusedTagsPass(), PassConfig::TYPE_AFTER_REMOVING); $container->addCompilerPass(new ContainerBuilderDebugDumpPass(), PassConfig::TYPE_BEFORE_REMOVING, -255); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags.php new file mode 100644 index 0000000000000..b3a1d19e63a7c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bundle\FrameworkBundle\Command\FeatureFlagsDebugCommand; +use Symfony\Component\FeatureFlags\FeatureChecker; +use Symfony\Component\FeatureFlags\FeatureCheckerInterface; +use Symfony\Component\FeatureFlags\FeatureCollection; +use Symfony\Component\FeatureFlags\Provider\LazyInMemoryProvider; +use Symfony\Component\FeatureFlags\Strategy\AffirmativeStrategy; +use Symfony\Component\FeatureFlags\Strategy\DateStrategy; +use Symfony\Component\FeatureFlags\Strategy\EnvStrategy; +use Symfony\Component\FeatureFlags\Strategy\GrantStrategy; +use Symfony\Component\FeatureFlags\Strategy\HttpFoundation\RequestStackAttributeStrategy; +use Symfony\Component\FeatureFlags\Strategy\HttpFoundation\RequestStackHeaderStrategy; +use Symfony\Component\FeatureFlags\Strategy\HttpFoundation\RequestStackQueryStrategy; +use Symfony\Component\FeatureFlags\Strategy\NotStrategy; +use Symfony\Component\FeatureFlags\Strategy\PriorityStrategy; +use Symfony\Component\FeatureFlags\Strategy\UnanimousStrategy; + +return static function (ContainerConfigurator $container) { + $strategyPrefix = 'feature_flags.abstract_strategy.'; + + $container->services() + + ->set('feature_flags.feature_collection', FeatureCollection::class) + ->args([ + '$providers' => tagged_iterator('feature_flags.feature_provider'), + ]) + + ->set('feature_flags.feature_checker', FeatureChecker::class) + ->args([ + '$features' => service('feature_flags.feature_collection'), + '$whenNotFound' => false, + ]) + ->alias(FeatureCheckerInterface::class, 'feature_flags.feature_checker') + + ->set('feature_flags.provider.lazy_in_memory', LazyInMemoryProvider::class) + ->args([ + '$features' => abstract_arg('Defined in FeatureFlagsExtension'), + ]) + ->tag('feature_flags.feature_provider', ['priority' => 16]) + + ->set($strategyPrefix.'grant', GrantStrategy::class)->abstract() + ->set($strategyPrefix.'not', NotStrategy::class)->abstract()->args([ + '$inner' => abstract_arg('Defined in FeatureFlagsExtension'), + ]) + ->set($strategyPrefix.'env', EnvStrategy::class)->abstract()->args([ + '$envName' => abstract_arg('Defined in FeatureFlagsExtension'), + ]) + ->set($strategyPrefix.'date', DateStrategy::class)->abstract()->args([ + '$since' => abstract_arg('Defined in FeatureFlagsExtension'), + '$until' => abstract_arg('Defined in FeatureFlagsExtension'), + '$includeSince' => abstract_arg('Defined in FeatureFlagsExtension'), + '$includeUntil' => abstract_arg('Defined in FeatureFlagsExtension'), + '$clock' => service('clock')->nullOnInvalid(), + ]) + ->set($strategyPrefix.'request_attribute', RequestStackAttributeStrategy::class)->abstract()->args([ + '$attributeName' => abstract_arg('Defined in FeatureFlagsExtension'), + ])->call('setRequestStack', [service('request_stack')->nullOnInvalid()]) + ->set($strategyPrefix.'request_header', RequestStackHeaderStrategy::class)->abstract()->args([ + '$headerName' => abstract_arg('Defined in FeatureFlagsExtension'), + ])->call('setRequestStack', [service('request_stack')->nullOnInvalid()]) + ->set($strategyPrefix.'request_query', RequestStackQueryStrategy::class)->abstract()->args([ + '$queryParameterName' => abstract_arg('Defined in FeatureFlagsExtension'), + ])->call('setRequestStack', [service('request_stack')->nullOnInvalid()]) + ->set($strategyPrefix.'priority', PriorityStrategy::class)->abstract()->args([ + '$strategies' => abstract_arg('Defined in FeatureFlagsExtension'), + ]) + ->set($strategyPrefix.'affirmative', AffirmativeStrategy::class)->abstract()->args([ + '$strategies' => abstract_arg('Defined in FeatureFlagsExtension'), + ]) + ->set($strategyPrefix.'unanimous', UnanimousStrategy::class)->abstract()->args([ + '$strategies' => abstract_arg('Defined in FeatureFlagsExtension'), + ]) + + ->set('console.command.feature_flags_debug', FeatureFlagsDebugCommand::class) + ->args([ + tagged_iterator('feature_flags.feature_provider', 'name'), + ]) + ->tag('console.command') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags_debug.php new file mode 100644 index 0000000000000..752b365d87b6b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags_debug.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\FeatureFlags\DataCollector\FeatureCheckerDataCollector; +use Symfony\Component\FeatureFlags\Debug\TraceableFeatureChecker; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flags.data_collector', FeatureCheckerDataCollector::class) + ->tag('data_collector', ['template' => '@WebProfiler/Collector/feature_flags.html.twig', 'id' => 'feature_flags']) + + + ->set('debug.feature_flags.feature_checker', TraceableFeatureChecker::class) + ->decorate('feature_flags.feature_checker') + ->args([ + '$featureChecker' => service('debug.feature_flags.feature_checker.inner'), + '$dataCollector' => service('feature_flags.data_collector'), + ]) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags_routing.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags_routing.php new file mode 100644 index 0000000000000..401298b8d34bc --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/feature_flags_routing.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\DependencyInjection\Loader\Configurator; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('feature_flags.routing.provider', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([ + [service('feature_flags.feature_checker'), 'isEnabled'], + ]) + ->tag('routing.expression_language_function', ['function' => 'isFeatureEnabled']) + + + ->get('feature_flags.feature_checker') + ->tag('routing.condition_service', ['alias' => 'feature']) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 532cf022d3c66..961f12de8978e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -45,6 +45,7 @@ + @@ -957,4 +958,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 4bb48ece61348..e6319101bd962 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -526,6 +526,50 @@ public function testEnabledLockNeedsResources() ]); } + public function testFeatureRequiresDescriptionKey() + { + self::expectException(InvalidConfigurationException::class); + self::expectExceptionMessage('The child config "default" under "framework.feature_flags.features.some-feature" must be configured: Will be used as a fallback mechanism if the strategy return StrategyResult::Abstain.'); + + $processor = new Processor(); + $configuration = new Configuration(true); + + $processor->processConfiguration($configuration, [ + 'framework' => [ + 'feature_flags' =>[ + 'features' => [ + [ + 'name' => 'some-feature', + 'strategy' => 'fake-strategy', + ], + ], + ], + ], + ]); + } + + public function testFeatureRequiresStrategyKey() + { + self::expectException(InvalidConfigurationException::class); + self::expectExceptionMessage('The child config "strategy" under "framework.feature_flags.features.some-feature" must be configured: Strategy to be used for this feature. Can be one of "feature_flags.strategies[].name" or a valid service id that implements StrategyInterface::class.'); + + $processor = new Processor(); + $configuration = new Configuration(true); + + $processor->processConfiguration($configuration, [ + 'framework' => [ + 'feature_flags' => [ + 'features' => [ + [ + 'name' => 'some-feature', + 'default' => false, + ], + ], + ], + ], + ]); + } + protected static function getBundleDefaultConfig() { return [ @@ -780,6 +824,11 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'remote-event' => [ 'enabled' => false, ], + 'feature_flags' => [ + 'enabled' => false, + 'strategies' => [], + 'features' => [], + ], ]; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/feature_flags.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/feature_flags.php new file mode 100644 index 0000000000000..10172ad21af7a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/feature_flags.php @@ -0,0 +1,67 @@ +loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'feature_flags' => [ + 'strategies' => [ + [ + 'name' => 'date.feature-strategy', + 'type' => 'date', + 'with' => ['since' => '-2 days'], + ], + [ + 'name' => 'env.feature-strategy', + 'type' => 'env', + 'with' => ['name' => 'SOME_ENV'], + ], + [ + 'name' => 'request_header.feature-strategy', + 'type' => 'request_header', + 'with' => ['name' => 'SOME-HEADER-NAME'], + ], + [ + 'name' => 'request_query.feature-strategy', + 'type' => 'request_query', + 'with' => ['name' => 'some_query_parameter'], + ], + [ + 'name' => 'request_attribute.feature-strategy', + 'type' => 'request_attribute', + 'with' => ['name' => 'some_request_attribute'], + ], + [ + 'name' => 'priority.feature-strategy', + 'type' => 'priority', + 'with' => ['strategies' => ['env.feature-strategy', 'grant.feature-strategy']], + ], + [ + 'name' => 'affirmative.feature-strategy', + 'type' => 'affirmative', + 'with' => ['strategies' => ['env.feature-strategy', 'grant.feature-strategy']], + ], + [ + 'name' => 'unanimous.feature-strategy', + 'type' => 'unanimous', + 'with' => ['strategies' => ['env.feature-strategy', 'grant.feature-strategy']], + ], + [ + 'name' => 'not.feature-strategy', + 'type' => 'not', + 'with' => ['strategy' => 'grant.feature-strategy'], + ], + [ + 'name' => 'grant.feature-strategy', + 'type' => 'grant', + ], + [ + 'name' => 'deny.feature-strategy', + 'type' => 'deny', + ], + ], + 'features' => [ + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/feature_flags.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/feature_flags.xml new file mode 100644 index 0000000000000..104f94966c7cb --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/feature_flags.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + grant.feature-strategy> + env.feature-strategy> + + + + + grant.feature-strategy> + env.feature-strategy> + + + + + grant.feature-strategy> + env.feature-strategy> + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/feature_flags.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/feature_flags.yml new file mode 100644 index 0000000000000..650b88dd94a3c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/feature_flags.yml @@ -0,0 +1,48 @@ +framework: + annotations: false + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + feature_flags: + strategies: + - name: 'date.feature-strategy' + type: 'date' + with: + since: '-2 days' + - name: 'env.feature-strategy' + type: 'env' + with: + name: 'SOME_ENV' + - name: 'request_header.feature-strategy' + type: 'request_header' + with: + name: 'SOME-HEADER-NAME' + - name: 'request_query.feature-strategy' + type: 'request_query' + with: + name: 'some_query_parameter' + - name: 'request_attribute.feature-strategy' + type: 'request_attribute' + with: + name: 'some_request_attribute' + - name: 'priority.feature-strategy' + type: 'priority' + with: + strategies: ['env.feature-strategy', 'grant.feature-strategy'] + - name: 'affirmative.feature-strategy' + type: 'affirmative' + with: + strategies: ['env.feature-strategy', 'grant.feature-strategy'] + - name: 'unanimous.feature-strategy' + type: 'unanimous' + with: + strategies: ['env.feature-strategy', 'grant.feature-strategy'] + - name: 'not.feature-strategy' + type: 'not' + with: + strategy: 'grant.feature-strategy' + - name: 'grant.feature-strategy' + type: 'grant' + - name: 'deny.feature-strategy' + type: 'deny' diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index c67d1bf56611c..cafbcafda67ed 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -44,6 +44,8 @@ use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\FeatureFlags\FeatureChecker; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; use Symfony\Component\Finder\Finder; use Symfony\Component\Form\Form; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; @@ -2289,6 +2291,45 @@ public function testWebhookWithoutSerializer() ); } + public function testFeatureFlags() + { + if (!class_exists(FeatureChecker::class)) { + $this->markTestSkipped('FeatureFlags not available.'); + } + + $container = $this->createContainerFromFile('feature_flags'); + + $expectedServiceIds = [ + 'date.feature-strategy', + 'env.feature-strategy', + 'request_header.feature-strategy', + 'request_query.feature-strategy', + 'request_attribute.feature-strategy', + 'priority.feature-strategy', + 'affirmative.feature-strategy', + 'unanimous.feature-strategy', + 'not.feature-strategy', + 'grant.feature-strategy', + 'deny.feature-strategy', + ]; + foreach ($expectedServiceIds as $expectedServiceId) { + $this->assertTrue($container->hasDefinition($expectedServiceId)); + + $serviceDefinition = $container->getDefinition($expectedServiceId); + $this->assertTrue( + $serviceDefinition->hasTag('feature_flags.feature_strategy'), + "'{$expectedServiceId}' does not have the tag.", + ); + } + + $registeredForAutoconfiguration = $container->getAutoconfiguredInstanceof(); + + self::assertArrayHasKey(StrategyInterface::class, $registeredForAutoconfiguration); + $tags = $registeredForAutoconfiguration[StrategyInterface::class]->getTags(); + + self::assertArrayHasKey('feature_flags.feature_strategy', $tags); + } + protected function createContainer(array $data = []) { return new ContainerBuilder(new EnvPlaceholderParameterBag(array_merge([ diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index e88252c3f2648..3270d1e2a6e3f 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\FeatureFlags\FeatureChecker; use Symfony\Component\Form\AbstractRendererEngine; use Symfony\Component\Form\Form; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -93,6 +94,10 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('importmap.php'); } + if ($container::willBeAvailable('symfony/feature-flags', FeatureChecker::class, ['symfony/twig-bundle'])) { + $loader->load('feature_flags.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_flags.php b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flags.php new file mode 100644 index 0000000000000..f6e1a22735481 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/feature_flags.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\FeatureFlagsExtension; +use Symfony\Bridge\Twig\Extension\FeatureFlagsRuntime; + +return static function (ContainerConfigurator $container) { + $container->services() + + ->set('twig.runtime.feature_flags', FeatureFlagsRuntime::class) + ->args([service('feature_flags.feature_checker')->nullOnInvalid()]) + ->tag('twig.runtime') + + ->set('twig.extension.feature_flags', FeatureFlagsExtension::class) + ->tag('twig.extension') + ; +}; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flags.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flags.html.twig new file mode 100644 index 0000000000000..19b5c7f8d2c4b --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/feature_flags.html.twig @@ -0,0 +1,125 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block page_title 'Feature Flags' %} + +{% block toolbar %} + {% if 0 < collector.checks|length %} + {% set icon %} + Feature Flags + {{ collector.checks|length }} + {% endset %} + + {% set text %} +
+
+ Checks + {% for check in collector.checks %} + + {{ check['feature'] }} + + {% endfor %} +
+
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + Feature Flags + Feature Flags + +{% endblock %} + +{% block head %} + {{ parent() }} + +{% endblock %} + +{% block panel %} +

Feature checks

+ {% if collector.checks|length > 0 %} + + + + + + + + + {% for check in collector.checks %} + {% set result = check['result'] ? 'grant' : 'deny' %} + + + + + {% endfor %} + +
FeatureStrategies
{{ check['feature'] }} + + {{ result|capitalize }} + {%- if check['computes'] is empty %} + (unknown) + {% elseif (check['computes']|first).result.name|lower is same as 'abstain' %} + (default) + {% endif %} + + {% for compute in check['computes'] %} +
+ {{ _self.indent(compute['level']) }} + {{ compute['result'].name }} + {{ compute['strategyId'] }} + {{ compute['strategyClass']|abbr_class }} +
+ {% endfor %} +
+ {% else %} +
+

No features checked

+
+ {% endif %} +{% endblock %} + +{% macro indent(level) %} + {%- for i in 0..level -%} {% endfor %}L +{% endmacro %} diff --git a/src/Symfony/Component/FeatureFlags/.gitattributes b/src/Symfony/Component/FeatureFlags/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/.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/FeatureFlags/.gitignore b/src/Symfony/Component/FeatureFlags/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/FeatureFlags/DataCollector/FeatureCheckerDataCollector.php b/src/Symfony/Component/FeatureFlags/DataCollector/FeatureCheckerDataCollector.php new file mode 100644 index 0000000000000..d1e6a86dbf6af --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/DataCollector/FeatureCheckerDataCollector.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\DataCollector; + +use Symfony\Component\FeatureFlags\StrategyResult; +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\Caster\ClassStub; +use Symfony\Component\VarDumper\Cloner\Data; + +/** + * @property Data|array{ + * checks: array, + * }>, + * } $data + */ +final class FeatureCheckerDataCollector extends DataCollector implements LateDataCollectorInterface +{ + /** @var \SplStack */ + private \SplStack $currentCheck; + + /** @var \SplStack */ + private \SplStack $currentCompute; + + public function __construct() + { + $this->data = ['checks' => []]; + $this->currentCheck = new \SplStack(); + $this->currentCompute = new \SplStack(); + } + + public function collect(Request $request, Response $response, \Throwable $exception = null): void + { + } + + public function collectIsEnabledStart(string $featureName): void + { + $checkId = uniqid(); + + $this->data['checks'][$checkId] = [ + 'feature' => $featureName, + 'computes' => [], + 'result' => null, + ]; + $this->currentCheck->push($checkId); + } + + /** + * @param class-string $strategyClass + */ + public function collectComputeStart(string $strategyId, string $strategyClass): void + { + $checkId = $this->currentCheck->top(); + $computeId = uniqid(); + $level = $this->currentCompute->count(); + + $this->data['checks'][$checkId]['computes'][$computeId] = [ + 'strategyId' => $strategyId, + 'strategyClass' => new ClassStub($strategyClass), + 'level' => $level, + 'result' => null, + ]; + $this->currentCompute->push($computeId); + } + + public function collectComputeStop(StrategyResult $result): void + { + $checkId = $this->currentCheck->top(); + $computeId = $this->currentCompute->pop(); + + $this->data['checks'][$checkId]['computes'][$computeId]['result'] = $result; + } + + public function collectIsEnabledStop(bool $result): void + { + $checkId = $this->currentCheck->pop(); + + $this->data['checks'][$checkId]['result'] = $result; + } + + public function getName(): string + { + return 'feature_flags'; + } + + public function reset(): void + { + $this->data = [ + 'checks' => [], + ]; + } + + public function lateCollect(): void + { + $this->data = $this->cloneVar($this->data); + } + + public function getChecks(): array|Data + { + return $this->data['checks']; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Debug/TraceableFeatureChecker.php b/src/Symfony/Component/FeatureFlags/Debug/TraceableFeatureChecker.php new file mode 100644 index 0000000000000..f79fd3de3f353 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Debug/TraceableFeatureChecker.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Debug; + +use Symfony\Component\FeatureFlags\DataCollector\FeatureCheckerDataCollector; +use Symfony\Component\FeatureFlags\FeatureCheckerInterface; + +final class TraceableFeatureChecker implements FeatureCheckerInterface +{ + public function __construct( + private readonly FeatureCheckerInterface $featureChecker, + private readonly FeatureCheckerDataCollector $dataCollector, + ) { + } + + public function isEnabled(string $featureName): bool + { + $this->dataCollector->collectIsEnabledStart($featureName); + + $result = $this->featureChecker->isEnabled($featureName); + + $this->dataCollector->collectIsEnabledStop($result); + + return $result; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Debug/TraceableStrategy.php b/src/Symfony/Component/FeatureFlags/Debug/TraceableStrategy.php new file mode 100644 index 0000000000000..5961c3d9975e6 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Debug/TraceableStrategy.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Debug; + +use Symfony\Component\FeatureFlags\DataCollector\FeatureCheckerDataCollector; +use Symfony\Component\FeatureFlags\Strategy\OuterStrategyInterface; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\StrategyResult; + +final class TraceableStrategy implements StrategyInterface, OuterStrategyInterface +{ + public function __construct( + private readonly StrategyInterface $strategy, + private readonly string $strategyId, + private readonly FeatureCheckerDataCollector $dataCollector, + ) { + } + + public function compute(): StrategyResult + { + $this->dataCollector->collectComputeStart($this->strategyId, $this->strategy::class); + + $result = $this->strategy->compute(); + + $this->dataCollector->collectComputeStop($result); + + return $result; + } + + public function getInnerStrategy(): StrategyInterface + { + return $this->strategy; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Feature.php b/src/Symfony/Component/FeatureFlags/Feature.php new file mode 100644 index 0000000000000..3505ebbb397fe --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Feature.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags; + +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; + +final class Feature +{ + public function __construct( + private readonly string $name, + private readonly string $description, + private readonly bool $default, + private readonly StrategyInterface $strategy, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): string + { + return $this->description; + } + + public function isEnabled(): bool + { + return match($this->strategy->compute()) { + StrategyResult::Grant => true, + StrategyResult::Deny => false, + StrategyResult::Abstain => $this->default, + }; + } +} diff --git a/src/Symfony/Component/FeatureFlags/FeatureChecker.php b/src/Symfony/Component/FeatureFlags/FeatureChecker.php new file mode 100644 index 0000000000000..ed1ce65fed86c --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/FeatureChecker.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags; + +final class FeatureChecker implements FeatureCheckerInterface +{ + public function __construct( + private readonly FeatureCollection $features, + private readonly bool $whenNotFound, + ) { + } + + public function isEnabled(string $featureName): bool + { + if (!$this->features->has($featureName)) { + return $this->whenNotFound; + } + + return $this->features->get($featureName)->isEnabled(); + } +} diff --git a/src/Symfony/Component/FeatureFlags/FeatureCheckerInterface.php b/src/Symfony/Component/FeatureFlags/FeatureCheckerInterface.php new file mode 100644 index 0000000000000..199f10be05f8c --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/FeatureCheckerInterface.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags; + +interface FeatureCheckerInterface +{ + public function isEnabled(string $featureName): bool; +} diff --git a/src/Symfony/Component/FeatureFlags/FeatureCollection.php b/src/Symfony/Component/FeatureFlags/FeatureCollection.php new file mode 100644 index 0000000000000..2941ac70281a9 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/FeatureCollection.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags; + +use Psr\Container\ContainerInterface; +use Symfony\Component\FeatureFlags\Provider\InMemoryProvider; +use Symfony\Component\FeatureFlags\Provider\ProviderInterface; +use function array_key_exists; +use function array_merge; + +final class FeatureCollection implements ContainerInterface +{ + /** @var array */ + private array $features = []; + + /** @var iterable */ + private iterable $providers; + + /** + * @param iterable $providers + */ + public function __construct(iterable $providers = []) + { + $this->providers = $providers; + } + + /** + * @param list $features + */ + public static function withFeatures(array $features): self + { + return new self([new InMemoryProvider($features)]); + } + + private function findFeature(string $featureName): ?Feature + { + if (array_key_exists($featureName, $this->features)) { + return $this->features[$featureName]; + } + + foreach ($this->providers as $provider) { + if (($feature = $provider->get($featureName)) !== null) { + $this->features[$feature->getName()] = $feature; + + return $feature; + } + } + + return null; + } + + public function has(string $id): bool + { + return $this->findFeature($id) !== null; + } + + /** + * @throws FeatureNotFoundException If the feature is not registered in this provider. + */ + public function get(string $id): Feature + { + return $this->findFeature($id) ?? throw new FeatureNotFoundException($id); + } + + /** + * @return list + */ + public function names(): array + { + /** @var list> $namesStackedPerProvider */ + $namesStackedPerProvider = []; + + foreach ($this->providers as $provider) { + $namesStackedPerProvider[] = $provider->names(); + } + + return array_merge(...$namesStackedPerProvider); + } +} diff --git a/src/Symfony/Component/FeatureFlags/FeatureNotFoundException.php b/src/Symfony/Component/FeatureFlags/FeatureNotFoundException.php new file mode 100644 index 0000000000000..7402661aaa2a9 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/FeatureNotFoundException.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\FeatureFlags; + +use Psr\Container\NotFoundExceptionInterface; + +class FeatureNotFoundException extends \Exception implements NotFoundExceptionInterface +{ + public function __construct(string $id) + { + parent::__construct("Feature \"$id\" not found."); + } +} diff --git a/src/Symfony/Component/FeatureFlags/LICENSE b/src/Symfony/Component/FeatureFlags/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/FeatureFlags/Provider/InMemoryProvider.php b/src/Symfony/Component/FeatureFlags/Provider/InMemoryProvider.php new file mode 100644 index 0000000000000..2836020f26d7c --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Provider/InMemoryProvider.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\FeatureFlags\Provider; + +use Symfony\Component\FeatureFlags\Feature; +use function array_keys; +use function array_reduce; + +final class InMemoryProvider implements ProviderInterface +{ + /** + * @var array $features + */ + private readonly array $features; + + /** + * @param list $features + */ + public function __construct( + array $features, + ) { + $this->features = array_reduce($features, static function (array $features, Feature $feature): array { + $features[$feature->getName()] = $feature; + + return $features; + }, []); + } + + public function get(string $featureName): ?Feature + { + return $this->features[$featureName] ?? null; + } + + public function names(): array + { + return array_keys($this->features); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Provider/LazyInMemoryProvider.php b/src/Symfony/Component/FeatureFlags/Provider/LazyInMemoryProvider.php new file mode 100644 index 0000000000000..46f94c388f0bb --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Provider/LazyInMemoryProvider.php @@ -0,0 +1,34 @@ + $features + */ + public function __construct( + private readonly array $features, + ) { + } + + public function get(string $featureName): ?Feature + { + if (!array_key_exists($featureName, $this->features)) { + return null; + } + + return ($this->features[$featureName])(); + } + + public function names(): array + { + return array_keys($this->features); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Provider/ProviderInterface.php b/src/Symfony/Component/FeatureFlags/Provider/ProviderInterface.php new file mode 100644 index 0000000000000..05dd3b602b451 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Provider/ProviderInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Provider; + +use Symfony\Component\FeatureFlags\Feature; + +interface ProviderInterface +{ + public function get(string $featureName): ?Feature; + + /** + * @return list + */ + public function names(): array; +} diff --git a/src/Symfony/Component/FeatureFlags/README.md b/src/Symfony/Component/FeatureFlags/README.md new file mode 100644 index 0000000000000..0a063dd8b54ee --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/README.md @@ -0,0 +1,77 @@ +FeatureFlags Component +====================== + +The FeatureFlags component provides TODO + +**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). + +Usage +----- + +This example implements the following logic : +* **Grants** when the `feat` parameter equals to `true` in the query string. +* **Denies** when the `feat` parameter equals to `false` in the query string. +* **Abstain** when the `feat` parameter is not found in the query string. So it + fallbacks to `false`. + +```php +isEnabled('new_feature')) { + // Use the new feature +} else { + // Use the legacy code +} +``` + +Available strategies +-------------------- + +**AffirmativeStrategy** : Takes a list of `StrategyInterface` and stops at the first `Grant`. + +**PriorityStrategy** : Takes a list of `StrategyInterface` and stops at the first non-abstain (either `Grant` or `Deny`). + +**UnanimousStrategy** : Takes a list of `StrategyInterface` and stops at the first `Deny`. Will return `Deny` if at least one found. `Grant` If at least one found and no `Deny`. `Abstain` otherwise. + +**DateStrategy** : Grant if current date is after the `$from` and before the `$until` ones. + +**DenyStrategy** : Always Denies. + +**EnvStrategy** : Will look for a truthy value in the given `$name` env variable. + +**GrantStrategy** : Always Grants. + +**NotStrategy** : Takes a `StrategyInterface` and inverts its returned value (except if abstained). + +**RequestHeaderStrategy** : Will look for a truthy value in the given `$name` header. + +**RequestQueryStrategy** : Will look for a truthy value in the given `$name` query string parameter. + +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/FeatureFlags/Strategy/AffirmativeStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/AffirmativeStrategy.php new file mode 100644 index 0000000000000..84289b7a48b13 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/AffirmativeStrategy.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\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; +use function iterator_to_array; + +final class AffirmativeStrategy implements StrategyInterface, OuterStrategiesInterface +{ + /** + * @param iterable $strategies + */ + public function __construct( + private readonly iterable $strategies, + ) { + } + + public function compute(): StrategyResult + { + $result = StrategyResult::Abstain; + foreach ($this->strategies as $strategy) { + $innerResult = $strategy->compute(); + + if (StrategyResult::Grant === $innerResult) { + return StrategyResult::Grant; + } + + if (StrategyResult::Deny === $innerResult) { + $result = StrategyResult::Deny; + } + } + + return $result; + } + + public function getInnerStrategies(): array + { + return iterator_to_array($this->strategies); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/DateStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/DateStrategy.php new file mode 100644 index 0000000000000..fab88d9670c9c --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/DateStrategy.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\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; +use Psr\Clock\ClockInterface; + +final class DateStrategy implements StrategyInterface +{ + public function __construct( + private readonly ClockInterface $clock, + private readonly \DateTimeImmutable|null $since = null, + private readonly \DateTimeImmutable|null $until = null, + private readonly bool $includeSince = true, + private readonly bool $includeUntil = true, + ) { + if (null === $this->since && null === $this->until) { + throw new \InvalidArgumentException('Either from or until must be provided.'); + } + } + + public function compute(): StrategyResult + { + $now = $this->clock->now(); + + if (null !== $this->since) { + if ($this->includeSince && $this->since > $now) { + return StrategyResult::Deny; + } + + if (!$this->includeSince && $this->since >= $now) { + return StrategyResult::Deny; + } + } + + if (null !== $this->until) { + if ($this->includeUntil && $this->until < $now) { + return StrategyResult::Deny; + } + + if (!$this->includeUntil && $this->until <= $now) { + return StrategyResult::Deny; + } + } + + return StrategyResult::Grant; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/DenyStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/DenyStrategy.php new file mode 100644 index 0000000000000..7040037037aaa --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/DenyStrategy.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\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; + +final class DenyStrategy implements StrategyInterface +{ + public function compute(): StrategyResult + { + return StrategyResult::Deny; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/EnvStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/EnvStrategy.php new file mode 100644 index 0000000000000..f08e264a1f26a --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/EnvStrategy.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; + +final class EnvStrategy implements StrategyInterface +{ + public function __construct( + private readonly string $envName, + ) { + } + + public function compute(): StrategyResult + { + $value = getenv($this->envName); + + if (false === $value) { + return StrategyResult::Abstain; + } + + return filter_var($value, \FILTER_VALIDATE_BOOL) ? StrategyResult::Grant : StrategyResult::Deny; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/GrantStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/GrantStrategy.php new file mode 100644 index 0000000000000..86c0aa1333b4d --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/GrantStrategy.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\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; + +final class GrantStrategy implements StrategyInterface +{ + public function compute(): StrategyResult + { + return StrategyResult::Grant; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackAttributeStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackAttributeStrategy.php new file mode 100644 index 0000000000000..21f433f0bcf72 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackAttributeStrategy.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\Component\FeatureFlags\Strategy\HttpFoundation; + +use Symfony\Component\FeatureFlags\StrategyResult; +use Symfony\Component\HttpFoundation\Request; + +final class RequestStackAttributeStrategy extends RequestStackStrategy +{ + public function __construct( + private readonly string $attributeName, + ) { + } + + protected function computeRequest(Request $request): StrategyResult + { + if (false === $request->attributes->has($this->attributeName)) { + return StrategyResult::Abstain; + } + + return $request->attributes->getBoolean($this->attributeName) ? StrategyResult::Grant : StrategyResult::Deny; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackHeaderStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackHeaderStrategy.php new file mode 100644 index 0000000000000..3d15b8338188a --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackHeaderStrategy.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\Component\FeatureFlags\Strategy\HttpFoundation; + +use Symfony\Component\FeatureFlags\StrategyResult; +use Symfony\Component\HttpFoundation\Request; + +final class RequestStackHeaderStrategy extends RequestStackStrategy +{ + public function __construct( + private readonly string $headerName, + ) { + } + + protected function computeRequest(Request $request): StrategyResult + { + if (false === $request->headers->has($this->headerName)) { + return StrategyResult::Abstain; + } + + return filter_var($request->headers->get($this->headerName), \FILTER_VALIDATE_BOOL) ? StrategyResult::Grant : StrategyResult::Deny; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackQueryStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackQueryStrategy.php new file mode 100644 index 0000000000000..86ee4525d31ff --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackQueryStrategy.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\Component\FeatureFlags\Strategy\HttpFoundation; + +use Symfony\Component\FeatureFlags\StrategyResult; +use Symfony\Component\HttpFoundation\Request; + +final class RequestStackQueryStrategy extends RequestStackStrategy +{ + public function __construct( + private readonly string $queryParameterName, + ) { + } + + protected function computeRequest(Request $request): StrategyResult + { + if (false === $request->query->has($this->queryParameterName)) { + return StrategyResult::Abstain; + } + + return $request->query->getBoolean($this->queryParameterName) ? StrategyResult::Grant : StrategyResult::Deny; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackStrategy.php new file mode 100644 index 0000000000000..c3c245ffe5e39 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/HttpFoundation/RequestStackStrategy.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Strategy\HttpFoundation; + +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\StrategyResult; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +abstract class RequestStackStrategy implements StrategyInterface +{ + private RequestStack|null $requestStack = null; + + public function setRequestStack(RequestStack|null $requestStack = null): void + { + $this->requestStack = $requestStack; + } + + public function compute(): StrategyResult + { + if (null === $this->requestStack) { + return StrategyResult::Abstain; + } + + $currentRequest = $this->requestStack->getCurrentRequest(); + + if (null === $currentRequest) { + return StrategyResult::Abstain; + } + + if (($result = $this->computeRequest($currentRequest)) !== StrategyResult::Abstain) { + return $result; + } + + $mainRequest = $this->requestStack->getMainRequest(); + + if (null === $mainRequest) { + return StrategyResult::Abstain; + } + + return $this->computeRequest($mainRequest); + } + + abstract protected function computeRequest(Request $request): StrategyResult; +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/NotStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/NotStrategy.php new file mode 100644 index 0000000000000..4f731f32c9e1f --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/NotStrategy.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; + +final class NotStrategy implements StrategyInterface, OuterStrategyInterface +{ + public function __construct( + private readonly StrategyInterface $inner, + ) { + } + + public function compute(): StrategyResult + { + $innerResult = $this->inner->compute(); + + return match ($innerResult) { + StrategyResult::Abstain => StrategyResult::Abstain, + StrategyResult::Grant => StrategyResult::Deny, + StrategyResult::Deny => StrategyResult::Grant, + }; + } + + public function getInnerStrategy(): StrategyInterface + { + return $this->inner; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/OuterStrategiesInterface.php b/src/Symfony/Component/FeatureFlags/Strategy/OuterStrategiesInterface.php new file mode 100644 index 0000000000000..7a498c4903400 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/OuterStrategiesInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Strategy; + +interface OuterStrategiesInterface +{ + /** + * @return list + */ + public function getInnerStrategies(): array; +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/OuterStrategyInterface.php b/src/Symfony/Component/FeatureFlags/Strategy/OuterStrategyInterface.php new file mode 100644 index 0000000000000..8858a66a2a817 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/OuterStrategyInterface.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Strategy; + +interface OuterStrategyInterface +{ + public function getInnerStrategy(): StrategyInterface; +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/PriorityStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/PriorityStrategy.php new file mode 100644 index 0000000000000..facd988f23a9f --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/PriorityStrategy.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\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; +use function iterator_to_array; + +final class PriorityStrategy implements StrategyInterface, OuterStrategiesInterface +{ + /** + * @param iterable $strategies + */ + public function __construct( + private readonly iterable $strategies, + ) { + } + + public function compute(): StrategyResult + { + $result = StrategyResult::Abstain; + foreach ($this->strategies as $strategy) { + $innerResult = $strategy->compute(); + + if (StrategyResult::Abstain !== $innerResult) { + return $innerResult; + } + } + + return $result; + } + + public function getInnerStrategies(): array + { + return iterator_to_array($this->strategies); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/RequestHeaderStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/RequestHeaderStrategy.php new file mode 100644 index 0000000000000..e4ce0b0ba5a09 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/RequestHeaderStrategy.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\Component\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; + +// TODO: make it case insensitive ? +final class RequestHeaderStrategy implements StrategyInterface +{ + public function __construct( + private readonly string $headerName, + ) { + } + + public function compute(): StrategyResult + { + if (!array_key_exists('HTTP_'.$this->headerName, $_SERVER)) { + return StrategyResult::Abstain; + } + + return filter_var($_SERVER['HTTP_'.$this->headerName], \FILTER_VALIDATE_BOOL) ? StrategyResult::Grant : StrategyResult::Deny; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/RequestQueryStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/RequestQueryStrategy.php new file mode 100644 index 0000000000000..dcb48efe46378 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/RequestQueryStrategy.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\Component\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; + +// TODO: make it case insensitive ? +final class RequestQueryStrategy implements StrategyInterface +{ + public function __construct( + private readonly string $queryParameterName, + ) { + } + + public function compute(): StrategyResult + { + if (!array_key_exists($this->queryParameterName, $_GET)) { + return StrategyResult::Abstain; + } + + return filter_var($_GET[$this->queryParameterName], \FILTER_VALIDATE_BOOL) ? StrategyResult::Grant : StrategyResult::Deny; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/StrategyInterface.php b/src/Symfony/Component/FeatureFlags/Strategy/StrategyInterface.php new file mode 100644 index 0000000000000..7624c9cdac5f5 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/StrategyInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; + +interface StrategyInterface +{ + public function compute(): StrategyResult; +} diff --git a/src/Symfony/Component/FeatureFlags/Strategy/UnanimousStrategy.php b/src/Symfony/Component/FeatureFlags/Strategy/UnanimousStrategy.php new file mode 100644 index 0000000000000..77fd618febd36 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Strategy/UnanimousStrategy.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\FeatureFlags\Strategy; + +use Symfony\Component\FeatureFlags\StrategyResult; +use function iterator_to_array; + +final class UnanimousStrategy implements StrategyInterface, OuterStrategiesInterface +{ + /** + * @param iterable $strategies + */ + public function __construct( + private readonly iterable $strategies, + ) { + } + + public function compute(): StrategyResult + { + $result = StrategyResult::Abstain; + foreach ($this->strategies as $strategy) { + $innerResult = $strategy->compute(); + + if (StrategyResult::Deny === $innerResult) { + return StrategyResult::Deny; + } + + if (StrategyResult::Grant === $innerResult) { + $result = StrategyResult::Grant; + } + } + + return $result; + } + + public function getInnerStrategies(): array + { + return iterator_to_array($this->strategies); + } +} diff --git a/src/Symfony/Component/FeatureFlags/StrategyResult.php b/src/Symfony/Component/FeatureFlags/StrategyResult.php new file mode 100644 index 0000000000000..9598b9a64c27f --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/StrategyResult.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags; + +enum StrategyResult +{ + case Grant; + case Deny; + case Abstain; +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/FeatureCheckerTest.php b/src/Symfony/Component/FeatureFlags/Tests/FeatureCheckerTest.php new file mode 100644 index 0000000000000..46e20edcd811e --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/FeatureCheckerTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlags\Feature; +use Symfony\Component\FeatureFlags\FeatureChecker; +use Symfony\Component\FeatureFlags\FeatureCollection; +use Symfony\Component\FeatureFlags\Strategy\DenyStrategy; +use Symfony\Component\FeatureFlags\Strategy\GrantStrategy; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\StrategyResult; + +/** + * @covers \Symfony\Component\FeatureFlags\FeatureChecker + * + * @uses \Symfony\Component\FeatureFlags\FeatureCollection + * @uses \Symfony\Component\FeatureFlags\Feature + * @uses \Symfony\Component\FeatureFlags\Strategy\GrantStrategy + * @uses \Symfony\Component\FeatureFlags\Strategy\DenyStrategy + */ +final class FeatureCheckerTest extends TestCase +{ + public function testItCorrectlyCheckTheFeaturesEvenIfNotFound(): void + { + $featureChecker = new FeatureChecker( + FeatureCollection::withFeatures([]), + true + ); + + self::assertTrue($featureChecker->isEnabled('not-found-1')); + + $featureChecker = new FeatureChecker( + FeatureCollection::withFeatures([ + new Feature( + name: 'fake-1', + description: 'Fake description 1', + default: true, + strategy: new GrantStrategy() + ), + new Feature( + name: 'fake-2', + description: 'Fake description 2', + default: true, + strategy: new DenyStrategy() + ), + new Feature( + name: 'fake-3', + description: 'Fake description 3', + default: false, + strategy: new class implements StrategyInterface { + public function compute(): StrategyResult + { + return StrategyResult::Abstain; + } + } + ), + ]), + false + ); + + self::assertFalse($featureChecker->isEnabled('not-found-1')); + self::assertTrue($featureChecker->isEnabled('fake-1')); + self::assertFalse($featureChecker->isEnabled('fake-2')); + self::assertFalse($featureChecker->isEnabled('fake-3')); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/FeatureCollectionTest.php b/src/Symfony/Component/FeatureFlags/Tests/FeatureCollectionTest.php new file mode 100644 index 0000000000000..6f499af4fdbf8 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/FeatureCollectionTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\FeatureFlags\Feature; +use Symfony\Component\FeatureFlags\FeatureCollection; +use Symfony\Component\FeatureFlags\FeatureNotFoundException; +use Symfony\Component\FeatureFlags\Strategy\GrantStrategy; + +/** + * @covers \Symfony\Component\FeatureFlags\FeatureCollection + * + * @uses \Symfony\Component\FeatureFlags\Feature + * @uses \Symfony\Component\FeatureFlags\Strategy\GrantStrategy + */ +final class FeatureCollectionTest extends TestCase +{ + public function testEnsureItListFeatureNames(): void + { + $featureCollection = FeatureCollection::withFeatures([ + new Feature( + name: 'fake-1', + description: 'Fake description 1', + default: true, + strategy: new GrantStrategy() + ), + new Feature( + name: 'fake-2', + description: 'Fake description 2', + default: true, + strategy: new GrantStrategy() + ), + ]); + + self::assertIsIterable($featureCollection->names()); + self::assertCount(2, $featureCollection->names()); + self::assertSame(['fake-1', 'fake-2'], $featureCollection->names()); + } + + public function testEnsureItImplementsContainerInterface(): void + { + self::assertTrue(is_a(FeatureCollection::class, ContainerInterface::class, true)); + } + + public function testItCanFindTheFeature(): void + { + $featureFake1 = new Feature( + name: 'fake-1', + description: 'Fake description 1', + default: true, + strategy: new GrantStrategy() + ); + + $featureFake2 = new Feature( + name: 'fake-2', + description: 'Fake description 2', + default: true, + strategy: new GrantStrategy() + ); + + $featureCollection = FeatureCollection::withFeatures([$featureFake1, $featureFake2]); + + self::assertTrue($featureCollection->has('fake-1')); + self::assertSame($featureFake1, $featureCollection->get('fake-1')); + + self::assertTrue($featureCollection->has('fake-2')); + self::assertSame($featureFake2, $featureCollection->get('fake-2')); + } + + public function testItThrowsWhenFeatureNotFound(): void + { + $featureCollection = FeatureCollection::withFeatures([]); + + self::assertFalse($featureCollection->has('not-found-1')); + + self::expectException(FeatureNotFoundException::class); + $featureCollection->get('not-found-1'); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/FeatureTest.php b/src/Symfony/Component/FeatureFlags/Tests/FeatureTest.php new file mode 100644 index 0000000000000..03b36d7f1e01a --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/FeatureTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlags\Feature; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\StrategyResult; + +/** + * @covers \Symfony\Component\FeatureFlags\Feature + * + * @uses \Symfony\Component\FeatureFlags\StrategyResult + */ +final class FeatureTest extends TestCase +{ + public function testItCanBeInstantiated(): void + { + new Feature( + name: 'fake', + description: 'Fake description', + default: false, + strategy: new class implements StrategyInterface { + public function compute(): StrategyResult + { + return StrategyResult::Abstain; + } + } + ); + + self::addToAssertionCount(1); + } + + public static function generateValidStrategy(): \Generator + { + // Grant + yield "grant and default 'true'" => [ + StrategyResult::Grant, + true, + true, + ]; + + yield "grant and default 'false'" => [ + StrategyResult::Grant, + false, + true, + ]; + + // Deny + yield "deny and default 'true'" => [ + StrategyResult::Deny, + true, + false, + ]; + + yield "deny and default 'false'" => [ + StrategyResult::Deny, + false, + false, + ]; + + // Abstain + yield "abstain and default 'true'" => [ + StrategyResult::Abstain, + true, + true, + ]; + + yield "abstain and default 'false'" => [ + StrategyResult::Abstain, + false, + false, + ]; + } + + /** + * @dataProvider generateValidStrategy + */ + public function testItCorrectlyComputeAndHandlesDefault(StrategyResult $strategyResult, bool $default, bool $expectedResult): void + { + $strategy = self::createMock(StrategyInterface::class); + + $strategy + ->expects(self::once()) + ->method('compute') + ->willReturn($strategyResult) + ; + + $feature = new Feature( + name: 'fake', + description: 'Fake description', + default: $default, + strategy: $strategy + ); + + self::assertSame($expectedResult, $feature->isEnabled()); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/Strategy/AbstractOuterStrategiesTestCase.php b/src/Symfony/Component/FeatureFlags/Tests/Strategy/AbstractOuterStrategiesTestCase.php new file mode 100644 index 0000000000000..12732929b5c1f --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/Strategy/AbstractOuterStrategiesTestCase.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests\Strategy; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\StrategyResult; + +abstract class AbstractOuterStrategiesTestCase extends TestCase +{ + protected static function generateStrategy(StrategyResult $strategyResult): StrategyInterface + { + return new class($strategyResult) implements StrategyInterface { + public function __construct(private StrategyResult $result) + { + } + + public function compute(): StrategyResult + { + return $this->result; + } + }; + } +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/Strategy/AffirmativeStrategyTest.php b/src/Symfony/Component/FeatureFlags/Tests/Strategy/AffirmativeStrategyTest.php new file mode 100644 index 0000000000000..c6e31c38879ec --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/Strategy/AffirmativeStrategyTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests\Strategy; + +use Symfony\Component\FeatureFlags\Strategy\AffirmativeStrategy; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\StrategyResult; + +/** + * @covers \Symfony\Component\FeatureFlags\Strategy\AffirmativeStrategy + */ +final class AffirmativeStrategyTest extends AbstractOuterStrategiesTestCase +{ + public static function generatesValidStrategies(): \Generator + { + yield 'no strategies' => [ + [], + StrategyResult::Abstain, + ]; + + yield 'if all abstain' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + ], + StrategyResult::Abstain, + ]; + + yield 'if one denies after only abstain results' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Deny), + ], + StrategyResult::Deny, + ]; + + yield 'if one grants after only abstain results' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Grant), + ], + StrategyResult::Grant, + ]; + + yield 'if one grants after at least one Deny' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Deny), + self::generateStrategy(StrategyResult::Grant), + ], + StrategyResult::Grant, + ]; + + yield 'if one denies after at least one grant' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Grant), + self::generateStrategy(StrategyResult::Deny), + ], + StrategyResult::Grant, + ]; + } + + /** + * @dataProvider generatesValidStrategies + * + * @param iterable $strategies + */ + public function testItComputesCorrectly(iterable $strategies, StrategyResult $expected): void + { + $affirmativeStrategy = new AffirmativeStrategy($strategies); + + self::assertSame($expected, $affirmativeStrategy->compute()); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/Strategy/DateStrategyTest.php b/src/Symfony/Component/FeatureFlags/Tests/Strategy/DateStrategyTest.php new file mode 100644 index 0000000000000..1d50a4ebf73e6 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/Strategy/DateStrategyTest.php @@ -0,0 +1,269 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests\Strategy; + +use PHPUnit\Framework\TestCase; +use Psr\Clock\ClockInterface; +use Symfony\Component\FeatureFlags\Strategy\DateStrategy; +use Symfony\Component\FeatureFlags\StrategyResult; + +/** + * @covers \Symfony\Component\FeatureFlags\Strategy\DateStrategy + */ +final class DateStrategyTest extends TestCase +{ + private static ClockInterface $nowClock; + + private static function generateClock(\DateTimeImmutable|null $now = null): ClockInterface + { + $now = $now ?? \DateTimeImmutable::createFromFormat('!d/m/Y', (new \DateTimeImmutable())->format('d/m/Y')); + + return new class($now) implements ClockInterface { + public function __construct(private \DateTimeImmutable $now) + { + } + + public function now(): \DateTimeImmutable + { + return $this->now; + } + }; + } + + public static function setUpBeforeClass(): void + { + self::$nowClock = self::generateClock(); + } + + public function testItRequiresAtLeastOneDate(): void + { + self::expectException(\InvalidArgumentException::class); + self::expectExceptionMessage('Either from or until must be provided.'); + + new DateStrategy(self::generateClock()); + } + + public static function generateValidDates(): \Generator + { + $now = self::generateClock()->now(); + + // from + no until + yield '[-2 days;∞' => [ + 'expected' => StrategyResult::Grant, + 'from' => $now->modify('-2 days'), + 'until' => null, + 'includeFrom' => true, + 'includeUntil' => false, + ]; + + yield ']-2 days;∞' => [ + 'expected' => StrategyResult::Grant, + 'from' => $now->modify('-2 days'), + 'until' => null, + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + yield '[now;∞' => [ + 'expected' => StrategyResult::Grant, + 'from' => $now, + 'until' => null, + 'includeFrom' => true, + 'includeUntil' => false, + ]; + + yield ']now;∞' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now, + 'until' => null, + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + yield '[+2 days;∞' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('+2 days'), + 'until' => null, + 'includeFrom' => true, + 'includeUntil' => false, + ]; + + yield ']+2 days;∞' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('+2 days'), + 'until' => null, + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + // no from + until + yield '∞;-2 days]' => [ + 'expected' => StrategyResult::Deny, + 'from' => null, + 'until' => $now->modify('-2 days'), + 'includeFrom' => false, + 'includeUntil' => true, + ]; + + yield '∞;-2 days[' => [ + 'expected' => StrategyResult::Deny, + 'from' => null, + 'until' => $now->modify('-2 days'), + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + yield '∞;now]' => [ + 'expected' => StrategyResult::Grant, + 'from' => null, + 'until' => $now, + 'includeFrom' => false, + 'includeUntil' => true, + ]; + + yield '∞;now[' => [ + 'expected' => StrategyResult::Deny, + 'from' => null, + 'until' => $now, + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + yield '∞;+2 days]' => [ + 'expected' => StrategyResult::Grant, + 'from' => null, + 'until' => $now->modify('+2 days'), + 'includeFrom' => false, + 'includeUntil' => true, + ]; + + yield '∞;+2 days[' => [ + 'expected' => StrategyResult::Grant, + 'from' => null, + 'until' => $now->modify('+2 days'), + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + // from + until + yield '[-2 days;-1 days]' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('-1 days'), + 'includeFrom' => true, + 'includeUntil' => true, + ]; + + yield '[-2 days;-1 days[' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('-1 days'), + 'includeFrom' => true, + 'includeUntil' => false, + ]; + + yield ']-2 days;-1 days[' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('-1 days'), + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + yield ']-2 days;-1 days]' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('-1 days'), + 'includeFrom' => false, + 'includeUntil' => true, + ]; + + yield '[-2 days;+3 days]' => [ + 'expected' => StrategyResult::Grant, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('+3 days'), + 'includeFrom' => true, + 'includeUntil' => true, + ]; + + yield '[-2 days;+3 days[' => [ + 'expected' => StrategyResult::Grant, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('+3 days'), + 'includeFrom' => true, + 'includeUntil' => false, + ]; + + yield ']-2 days;+3 days[' => [ + 'expected' => StrategyResult::Grant, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('+3 days'), + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + yield ']-2 days;+3 days]' => [ + 'expected' => StrategyResult::Grant, + 'from' => $now->modify('-2 days'), + 'until' => $now->modify('+3 days'), + 'includeFrom' => false, + 'includeUntil' => true, + ]; + + yield '[+1 days;+2 days]' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('+1 days'), + 'until' => $now->modify('+2 days'), + 'includeFrom' => true, + 'includeUntil' => true, + ]; + + yield '[+1 days;+2 days[' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('+1 days'), + 'until' => $now->modify('+2 days'), + 'includeFrom' => true, + 'includeUntil' => false, + ]; + + yield ']+1 days;+2 days[' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('+1 days'), + 'until' => $now->modify('+2 days'), + 'includeFrom' => false, + 'includeUntil' => false, + ]; + + yield ']+1 days;+2 days]' => [ + 'expected' => StrategyResult::Deny, + 'from' => $now->modify('+1 days'), + 'until' => $now->modify('+2 days'), + 'includeFrom' => false, + 'includeUntil' => true, + ]; + } + + /** + * @dataProvider generateValidDates + */ + public function testItComputeDatesCorrectly( + StrategyResult $expected, + \DateTimeImmutable|null $from = null, + \DateTimeImmutable|null $until = null, + bool $includeFrom = true, + bool $includeUntil = true, + ): void { + $dateStrategy = new DateStrategy(self::$nowClock, $from, $until, $includeFrom, $includeUntil); + + self::assertSame($expected, $dateStrategy->compute()); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/Strategy/PriorityStrategyTest.php b/src/Symfony/Component/FeatureFlags/Tests/Strategy/PriorityStrategyTest.php new file mode 100644 index 0000000000000..b473e61217f0e --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/Strategy/PriorityStrategyTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests\Strategy; + +use Symfony\Component\FeatureFlags\Strategy\PriorityStrategy; +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\StrategyResult; + +/** + * @covers \Symfony\Component\FeatureFlags\Strategy\PriorityStrategy + */ +final class PriorityStrategyTest extends AbstractOuterStrategiesTestCase +{ + public static function generatesValidStrategies(): \Generator + { + yield 'no strategies' => [ + [], + StrategyResult::Abstain, + ]; + + yield 'if all abstain' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + ], + StrategyResult::Abstain, + ]; + + yield 'if one denies after only abstain results' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Deny), + ], + StrategyResult::Deny, + ]; + + yield 'if one grants after only abstain results' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Grant), + ], + StrategyResult::Grant, + ]; + + yield 'if one grants after at least one Deny' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Deny), + self::generateStrategy(StrategyResult::Grant), + ], + StrategyResult::Deny, + ]; + + yield 'if one denies after at least one grant' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Grant), + self::generateStrategy(StrategyResult::Deny), + ], + StrategyResult::Grant, + ]; + } + + /** + * @dataProvider generatesValidStrategies + * + * @param iterable $strategies + */ + public function testItComputesCorrectly(iterable $strategies, StrategyResult $expected): void + { + $affirmativeStrategy = new PriorityStrategy($strategies); + + self::assertSame($expected, $affirmativeStrategy->compute()); + } +} diff --git a/src/Symfony/Component/FeatureFlags/Tests/Strategy/UnanimousStrategyTest.php b/src/Symfony/Component/FeatureFlags/Tests/Strategy/UnanimousStrategyTest.php new file mode 100644 index 0000000000000..619f5088559f0 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/Tests/Strategy/UnanimousStrategyTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\FeatureFlags\Tests\Strategy; + +use Symfony\Component\FeatureFlags\Strategy\StrategyInterface; +use Symfony\Component\FeatureFlags\Strategy\UnanimousStrategy; +use Symfony\Component\FeatureFlags\StrategyResult; + +/** + * @covers \Symfony\Component\FeatureFlags\Strategy\UnanimousStrategy + */ +final class UnanimousStrategyTest extends AbstractOuterStrategiesTestCase +{ + public static function generatesValidStrategies(): \Generator + { + yield 'no strategies' => [ + [], + StrategyResult::Abstain, + ]; + + yield 'if all abstain' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + ], + StrategyResult::Abstain, + ]; + + yield 'if one denies after only abstain results' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Deny), + ], + StrategyResult::Deny, + ]; + + yield 'if one grants after only abstain results' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Grant), + ], + StrategyResult::Grant, + ]; + + yield 'if one grants after at least one Deny' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Deny), + self::generateStrategy(StrategyResult::Grant), + ], + StrategyResult::Deny, + ]; + + yield 'if one denies after at least one grant' => [ + [ + self::generateStrategy(StrategyResult::Abstain), + self::generateStrategy(StrategyResult::Grant), + self::generateStrategy(StrategyResult::Deny), + ], + StrategyResult::Deny, + ]; + } + + /** + * @dataProvider generatesValidStrategies + * + * @param iterable $strategies + */ + public function testItComputesCorrectly(iterable $strategies, StrategyResult $expected): void + { + $affirmativeStrategy = new UnanimousStrategy($strategies); + + self::assertSame($expected, $affirmativeStrategy->compute()); + } +} diff --git a/src/Symfony/Component/FeatureFlags/composer.json b/src/Symfony/Component/FeatureFlags/composer.json new file mode 100644 index 0000000000000..1ef0aee7d9612 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/feature-flags", + "type": "library", + "description": "Provide feature flags mechanism.", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "psr/container": "^2.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\FeatureFlags\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/FeatureFlags/phpunit.xml.dist b/src/Symfony/Component/FeatureFlags/phpunit.xml.dist new file mode 100644 index 0000000000000..bb2fd5516f812 --- /dev/null +++ b/src/Symfony/Component/FeatureFlags/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + +