Skip to content

[FeatureFlag] Propose a simple version #53213

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 46 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
91d08d4
[FeatureFlag] Propose a simple version
Jean-Beru Dec 7, 2023
92596b3
extract FrameworkBundle integration
Jean-Beru Dec 26, 2023
a9431cb
fix typos, doc and default feature value
Jean-Beru Dec 29, 2023
978f100
review
Jean-Beru Jan 2, 2024
d7ee4d6
add some tests
Jean-Beru Jan 2, 2024
2e1e2e5
happy new year
Jean-Beru Jan 2, 2024
c6f5df9
cs
Jean-Beru Jan 2, 2024
5daeb43
add base exception
Jean-Beru Jan 3, 2024
2d41a57
refact DataCollector
Jean-Beru Jan 3, 2024
b2e690c
add isDisabled
Jean-Beru Jan 3, 2024
11b2a36
fix collector
Jean-Beru Jan 4, 2024
23d41e4
fix forgotten method in FeatureCheckerInterface
Jean-Beru Jan 4, 2024
d0b1914
fix interface
Jean-Beru Jan 4, 2024
13ea142
remove exception catching
Jean-Beru Jan 29, 2024
6fd6e9e
[FeatureFlag] Add FrameworkBundle integration
Jean-Beru Dec 26, 2023
fc6adc3
exception format
Jean-Beru Feb 29, 2024
dc4a8dd
cs
Jean-Beru Feb 29, 2024
911cdc9
add Neirda24 as co-author
Jean-Beru Mar 1, 2024
86f584b
add ContainerInterface dependency
Jean-Beru Mar 1, 2024
70bb733
fix exception namespace
Jean-Beru Mar 1, 2024
e1c2f48
fix namespace again
Jean-Beru Mar 1, 2024
e945698
remove merge file
Jean-Beru Mar 1, 2024
09fe655
Fix bundle and add details in profiler
Jean-Beru Apr 5, 2024
28763a0
Add Twig functions to UndefinedCallableHandler::FUNCTION_COMPONENTS
Jean-Beru Apr 5, 2024
1b1f3fd
cs
Jean-Beru Apr 5, 2024
66f83b6
rebase
Jean-Beru Apr 5, 2024
55d47fc
review
Jean-Beru Apr 5, 2024
e0dea4c
cs
Jean-Beru Apr 5, 2024
07a7d48
tests
Jean-Beru Apr 5, 2024
c7630e2
psalm
Jean-Beru Apr 5, 2024
5832442
readme
Jean-Beru Apr 12, 2024
75b95fb
add functional tests
Jean-Beru Apr 12, 2024
3d51ae6
fix framework bundle test
Jean-Beru Apr 16, 2024
11c1227
[FeatureFlag] Use providers
Jean-Beru Oct 8, 2024
1643936
cs
Jean-Beru Oct 9, 2024
6378c62
review
Jean-Beru Oct 11, 2024
fe338c1
fix bad rebase
Jean-Beru Oct 16, 2024
f8c3041
update duplicate feature message
Jean-Beru Oct 17, 2024
0706557
remove expected value
Jean-Beru Nov 5, 2024
1b20951
replace ProviderInterface::has by a nullable ProviderInterface::get
Jean-Beru Nov 6, 2024
d3033bf
update README.md
Jean-Beru Nov 6, 2024
20b994c
remove useless exceptions
Jean-Beru Nov 6, 2024
4fc0981
add .github folder
Jean-Beru Jan 7, 2025
a042334
review
Jean-Beru Jan 7, 2025
2f490f8
fix rebase
Jean-Beru May 2, 2025
802cd2c
add ResetInterface
Jean-Beru May 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"symfony/error-handler": "self.version",
"symfony/event-dispatcher": "self.version",
"symfony/expression-language": "self.version",
"symfony/feature-flag": "self.version",
"symfony/filesystem": "self.version",
"symfony/finder": "self.version",
"symfony/form": "self.version",
Expand Down
26 changes: 26 additions & 0 deletions src/Symfony/Bridge/Twig/Extension/FeatureFlagExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bridge\Twig\Extension;

use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

final class FeatureFlagExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('feature_is_enabled', [FeatureFlagRuntime::class, 'isEnabled']),
new TwigFunction('feature_get_value', [FeatureFlagRuntime::class, 'getValue']),
];
}
}
32 changes: 32 additions & 0 deletions src/Symfony/Bridge/Twig/Extension/FeatureFlagRuntime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bridge\Twig\Extension;

use Symfony\Component\FeatureFlag\FeatureCheckerInterface;

final class FeatureFlagRuntime
{
public function __construct(
private readonly FeatureCheckerInterface $featureChecker,
) {
}

public function isEnabled(string $featureName): bool
{
return $this->featureChecker->isEnabled($featureName);
}

public function getValue(string $featureName): mixed
{
return $this->featureChecker->getValue($featureName);
}
}
2 changes: 2 additions & 0 deletions src/Symfony/Bridge/Twig/UndefinedCallableHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class UndefinedCallableHandler
'field_choices' => 'form',
'logout_url' => 'security-http',
'logout_path' => 'security-http',
'feature_get_value' => 'feature-flag',
'feature_is_enabled' => 'feature-flag',
'is_granted' => 'security-core',
'is_granted_for_user' => 'security-core',
'impersonation_path' => 'security-http',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\FeatureFlag\Debug\TraceableFeatureChecker;

class FeatureFlagPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('feature_flag.feature_checker')) {
return;
}

$features = [];
foreach ($container->findTaggedServiceIds('feature_flag.feature') as $serviceId => $tags) {
$className = $this->getServiceClass($container, $serviceId);
$r = $container->getReflectionClass($className);

if (null === $r) {
throw new \RuntimeException(\sprintf('Invalid service "%s": class "%s" does not exist.', $serviceId, $className));
}

foreach ($tags as $tag) {
$featureName = ($tag['feature'] ?? '') ?: $className;
if (\array_key_exists($featureName, $features)) {
throw new \RuntimeException(\sprintf('Feature "%s" already defined in the "feature_flag.provider.in_memory" provider.', $featureName));
}

$method = $tag['method'] ?? '__invoke';
if (!$r->hasMethod($method)) {
throw new \RuntimeException(\sprintf('Invalid feature method "%s": method "%s::%s()" does not exist.', $serviceId, $r->getName(), $method));
}
if (!$r->getMethod($method)->isPublic()) {
throw new \RuntimeException(\sprintf('Invalid feature method "%s": method "%s::%s()" must be public.', $serviceId, $r->getName(), $method));
}

$features[$featureName] = $container->setDefinition(
'.feature_flag.feature',
(new Definition(\Closure::class))
->setLazy(true)
->setFactory([\Closure::class, 'fromCallable'])
->setArguments([[new Reference($serviceId), $method]]),
);
}
}

$container->getDefinition('feature_flag.provider.in_memory')
->setArgument('$features', $features)
;

if (!$container->has('feature_flag.data_collector')) {
return;
}

foreach ($container->findTaggedServiceIds('feature_flag.feature_checker') as $serviceId => $tags) {
$container->register('debug.'.$serviceId, TraceableFeatureChecker::class)
->setDecoratedService($serviceId)
->setArguments([
'$decorated' => new Reference('.inner'),
'$dataCollector' => new Reference('feature_flag.data_collector'),
])
;
}
}

private function getServiceClass(ContainerBuilder $container, string $serviceId): ?string
{
while (true) {
$definition = $container->findDefinition($serviceId);

if (!$definition->getClass() && $definition instanceof ChildDefinition) {
$serviceId = $definition->getParent();

continue;
}

return $definition->getClass();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class UnusedTagsPass implements CompilerPassInterface
'controller.targeted_value_resolver',
'data_collector',
'event_dispatcher.dispatcher',
'feature_flag.feature',
'feature_flag.feature_checker',
'feature_flag.provider',
'form.type',
'form.type_extension',
'form.type_guesser',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\FeatureFlag\FeatureCheckerInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
use Symfony\Component\HttpClient\HttpClient;
Expand Down Expand Up @@ -184,6 +185,7 @@ public function getConfigTreeBuilder(): TreeBuilder
$this->addWebhookSection($rootNode, $enableIfStandalone);
$this->addRemoteEventSection($rootNode, $enableIfStandalone);
$this->addJsonStreamerSection($rootNode, $enableIfStandalone);
$this->addFeatureFlagSection($rootNode, $enableIfStandalone);

return $treeBuilder;
}
Expand Down Expand Up @@ -2742,4 +2744,16 @@ private function addJsonStreamerSection(ArrayNodeDefinition $rootNode, callable
->end()
;
}

private function addFeatureFlagSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void
{
$rootNode
->children()
->arrayNode('feature_flag')
->info('FeatureFlag configuration')
->{$enableIfStandalone('symfony/feature-flag', FeatureCheckerInterface::class)}()
->fixXmlConfig('feature_flag')
->end()
->end();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\FeatureFlag\Attribute\AsFeature;
use Symfony\Component\FeatureFlag\FeatureChecker;
use Symfony\Component\FeatureFlag\Provider\ProviderInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\Glob;
Expand Down Expand Up @@ -169,6 +172,7 @@
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Router;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
Expand Down Expand Up @@ -310,6 +314,7 @@ public function load(array $configs, ContainerBuilder $container): void
$this->readConfigEnabled('property_access', $container, $config['property_access']);
$this->readConfigEnabled('profiler', $container, $config['profiler']);
$this->readConfigEnabled('workflows', $container, $config['workflows']);
$this->readConfigEnabled('feature_flag', $container, $config['feature_flag']);

// A translator must always be registered (as support is included by
// default in the Form and Validator component). If disabled, an identity
Expand Down Expand Up @@ -638,6 +643,13 @@ public function load(array $configs, ContainerBuilder $container): void
$this->registerHtmlSanitizerConfiguration($config['html_sanitizer'], $container, $loader);
}

if ($this->readConfigEnabled('feature_flag', $container, $config['feature_flag'])) {
if (!class_exists(FeatureChecker::class)) {
throw new LogicException('FeatureFlag support cannot be enabled as the FeatureFlag component is not installed. Try running "composer require symfony/feature-flag".');
}
$this->registerFeatureFlagConfiguration($config['feature_flag'], $container, $loader);
}

if (ContainerBuilder::willBeAvailable('symfony/mime', MimeTypes::class, ['symfony/framework-bundle'])) {
$loader->load('mime_type.php');
}
Expand Down Expand Up @@ -1011,6 +1023,10 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $
$loader->load('serializer_debug.php');
}

if ($this->isInitializedConfigEnabled('feature_flag')) {
$loader->load('feature_flag_debug.php');
}

$container->setParameter('profiler_listener.only_exceptions', $config['only_exceptions']);
$container->setParameter('profiler_listener.only_main_requests', $config['only_main_requests']);

Expand Down Expand Up @@ -3428,6 +3444,45 @@ private function registerHtmlSanitizerConfiguration(array $config, ContainerBuil
}
}

private function registerFeatureFlagConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void
{
$loader->load('feature_flag.php');

$container->registerForAutoconfiguration(ProviderInterface::class)
->addTag('feature_flag.provider')
;

$container->registerAttributeForAutoconfiguration(AsFeature::class,
static function (ChildDefinition $definition, AsFeature $attribute, \ReflectionClass|\ReflectionMethod $reflector): void {
$featureName = $attribute->name;

if ($reflector instanceof \ReflectionClass) {
$className = $reflector->getName();
$method = $attribute->method;

$featureName ??= $className;
} else {
$className = $reflector->getDeclaringClass()->getName();
if (null !== $attribute->method && $reflector->getName() !== $attribute->method) {
throw new \LogicException(\sprintf('Using the #[%s(method: "%s")] attribute on a method is not valid. Either remove the method value or move this to the top of the class (%s).', AsFeature::class, $attribute->method, $className));
}

$method = $reflector->getName();
$featureName ??= "{$className}::{$method}";
}

$definition->addTag('feature_flag.feature', [
'feature' => $featureName,
'method' => $method,
]);
},
);

if (ContainerBuilder::willBeAvailable('symfony/routing', Router::class, ['symfony/framework-bundle', 'symfony/routing'])) {
$loader->load('feature_flag_routing.php');
}
}

public function getXsdValidationBasePath(): string|false
{
return \dirname(__DIR__).'/Resources/config/schema';
Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AssetsContextPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ContainerBuilderDebugDumpPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ErrorLoggerCompilerPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\FeatureFlagPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ProfilerPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\RemoveUnusedSessionMarshallingHandlerPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TestServiceContainerRealRefPass;
Expand Down Expand Up @@ -190,6 +191,7 @@ public function build(ContainerBuilder $container): void
$container->addCompilerPass(new VirtualRequestStackPass());
$container->addCompilerPass(new TranslationUpdateCommandPass(), PassConfig::TYPE_BEFORE_REMOVING);
$this->addCompilerPassIfExists($container, StreamablePass::class);
$container->addCompilerPass(new FeatureFlagPass());

if ($container->getParameter('kernel.debug')) {
$container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 2);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Symfony\Component\FeatureFlag\FeatureChecker;
use Symfony\Component\FeatureFlag\FeatureCheckerInterface;
use Symfony\Component\FeatureFlag\Provider\ChainProvider;
use Symfony\Component\FeatureFlag\Provider\InMemoryProvider;
use Symfony\Component\FeatureFlag\Provider\ProviderInterface;

return static function (ContainerConfigurator $container) {
$container->services()

->set('feature_flag.provider.in_memory', InMemoryProvider::class)
->args([
'$features' => abstract_arg('Defined in FeatureFlagPass.'),
])
->tag('feature_flag.provider')

->set('feature_flag.provider', ChainProvider::class)
->args([
'$providers' => tagged_iterator('feature_flag.provider'),
])
->alias(ProviderInterface::class, 'feature_flag.provider')

->set('feature_flag.feature_checker', FeatureChecker::class)
->args([
'$provider' => service('feature_flag.provider'),
])
->alias(FeatureCheckerInterface::class, 'feature_flag.feature_checker')
;
};
Loading
Loading