Skip to content

[FeatureFlags] Propose a new component #51649

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 25 commits into
base: 7.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
129043f
[FeatureToggle] Import component from private sources
Neirda24 Jul 28, 2023
48819ec
[UPDATE] Fix twig function must be snake_case + gitattributes for bundle
Neirda24 Jul 30, 2023
2f875ea
cleanup before RFC (#1)
Jean-Beru Sep 7, 2023
f2cc75b
[UPDATE] Removed the Random Strategy & Provider
Neirda24 Sep 7, 2023
13d6176
[UPDATE] According to comments
Neirda24 Sep 13, 2023
1d4fb6c
[FabBot] Apply some patches
Neirda24 Sep 13, 2023
a8a2536
[UPDATE] Remove Outer*Interface's
Neirda24 Sep 21, 2023
844ae65
[UPDATE] Use Traversable instead of ArrayIterator
Neirda24 Sep 21, 2023
0473edc
[UPDATE] Remove forgottent Outer*Interface's
Neirda24 Sep 21, 2023
ba54221
[UPDATE] Add StrategyInterface on previous Outer*Strategy's
Neirda24 Sep 21, 2023
4f03511
[FEATURE] Add header, query strategies through request stack and make…
Neirda24 Sep 21, 2023
416e91d
[UPDATE] Add UnanimousStrategy
Neirda24 Sep 21, 2023
17fae0a
[UPDATE] Prefixed some return phpdoc tag with phpstan-
Neirda24 Sep 21, 2023
ad36be4
[UPDATE] Rename to
Neirda24 Sep 21, 2023
858f66d
[UPDATE] Fix phpunit covers annotation
Neirda24 Sep 21, 2023
6a356aa
[UPDATE][fabbot] Coding standard
Neirda24 Sep 22, 2023
cec1e92
[UPDATE][fabbot] usage of void in tests
Neirda24 Sep 22, 2023
7618497
[UPDATE] Improve performance on FeatureCollection to stop at first found
Neirda24 Sep 22, 2023
0ff46e4
[Update] Move Debug files
Jean-Beru Sep 25, 2023
19b5c5f
Rework of providers
Neirda24 Sep 29, 2023
5be52b0
[UPDATE] Fix coding standards according to fabbot
Neirda24 Sep 29, 2023
63f256f
rename to FeatureFlags (#6)
Jean-Beru Sep 29, 2023
1673877
Feature/add debug command
Neirda24 Oct 5, 2023
00bed51
[UPDATE] Add coding standards
Neirda24 Oct 5, 2023
c4842ba
move to FrameworkBundle (#9)
Jean-Beru Oct 12, 2023
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 @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/Symfony/Bridge/Twig/Extension/FeatureFlagsExtension.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 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']),
];
}
}
30 changes: 30 additions & 0 deletions src/Symfony/Bridge/Twig/Extension/FeatureFlagsRuntime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?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\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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
<?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\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<string, ProviderInterface> */
private iterable $featureProviders;

/** @param iterable<string, ProviderInterface> $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 <info>%command.name%</info> command displays all configured feature flags:

<info>php %command.full_name%</info>

To get more insight for a flag, specify its name:

<info>php %command.full_name% my-feature</info>
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<string> $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);
}
}
Loading