From ee4ed6a8c975b449ef317e6b234746a7fbb3087b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20DECOOL?= Date: Mon, 1 May 2023 14:09:38 +0200 Subject: [PATCH 1/8] Add command to list available fflags --- src/Command/ListFeatureCommand.php | 55 +++++++++++++ tests/Unit/Command/ListFeatureCommandTest.php | 81 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/Command/ListFeatureCommand.php create mode 100644 tests/Unit/Command/ListFeatureCommandTest.php diff --git a/src/Command/ListFeatureCommand.php b/src/Command/ListFeatureCommand.php new file mode 100644 index 0000000..2121cca --- /dev/null +++ b/src/Command/ListFeatureCommand.php @@ -0,0 +1,55 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Novaway\Bundle\FeatureFlagBundle\Command; + +use Novaway\Bundle\FeatureFlagBundle\Storage\StorageInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +final class ListFeatureCommand extends Command +{ + /** @var StorageInterface */ + private $storage; + + public function __construct(StorageInterface $storage) + { + parent::__construct('novaway:feature-flag:list'); + + $this->storage = $storage; + } + + protected function configure(): void + { + parent::configure(); // TODO: Change the autogenerated stub + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $features = $this->storage->all(); + + $table = new Table($output); + $table->setHeaders(['Name', 'Enabled', 'Description']); + foreach ($features as $feature) { + $table->addRow([ + $feature->getKey(), + $feature->isEnabled() ? 'Yes' : 'No', + $feature->getDescription(), + ]); + } + + $table->render(); + + return Command::SUCCESS; + } +} diff --git a/tests/Unit/Command/ListFeatureCommandTest.php b/tests/Unit/Command/ListFeatureCommandTest.php new file mode 100644 index 0000000..46af197 --- /dev/null +++ b/tests/Unit/Command/ListFeatureCommandTest.php @@ -0,0 +1,81 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Novaway\Bundle\FeatureFlagBundle\Tests\Unit\Command; + +use Novaway\Bundle\FeatureFlagBundle\Command\ListFeatureCommand; +use Novaway\Bundle\FeatureFlagBundle\Storage\ArrayStorage; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +final class ListFeatureCommandTest extends TestCase +{ + /** + * @dataProvider tableListFeatureProvider + */ + public function testConfiguredFeaturesAreDisplayedInTable(array $features, string $expectedOutput): void + { + $commandTester = $this->createCommandTester($features); + $commandTester->execute([]); + + $commandTester->assertCommandIsSuccessful(); + + static::assertSame($expectedOutput, $commandTester->getDisplay()); + } + + public function tableListFeatureProvider(): iterable + { + yield 'no feature' => [ + [], + << [ + [ + 'feature1' => [ + 'enabled' => true, + 'description' => 'Feature 1 description', + ], + 'feature2' => [ + 'enabled' => false, + 'description' => 'Feature 2 description', + ], + 'feature3' => [ + 'enabled' => true, + 'description' => 'Feature 3 description', + ], + ], + << Date: Mon, 1 May 2023 14:24:05 +0200 Subject: [PATCH 2/8] Add JSON output --- composer.json | 1 + phpstan.neon.dist | 3 +- src/Command/ListFeatureCommand.php | 37 ++++++++++- src/Model/Feature.php | 9 +++ tests/Unit/Command/ListFeatureCommandTest.php | 61 +++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 69cb0f6..f62bbe3 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ ], "require": { "php": ">= 7.3", + "ext-json": "*", "doctrine/annotations": "^1.12|^2.0", "symfony/framework-bundle": "~4.4|~5.0|~6.0", "symfony/yaml": "~4.4|~5.0|~6.0" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2381268..ed3a0b6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,4 +5,5 @@ parameters: level: max paths: - src - checkGenericClassInNonGenericObjectType: false \ No newline at end of file + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false diff --git a/src/Command/ListFeatureCommand.php b/src/Command/ListFeatureCommand.php index 2121cca..5011a79 100644 --- a/src/Command/ListFeatureCommand.php +++ b/src/Command/ListFeatureCommand.php @@ -11,14 +11,19 @@ namespace Novaway\Bundle\FeatureFlagBundle\Command; +use Novaway\Bundle\FeatureFlagBundle\Model\Feature; use Novaway\Bundle\FeatureFlagBundle\Storage\StorageInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; final class ListFeatureCommand extends Command { + private const FORMAT_JSON = 'json'; + private const FORMAT_TABLE = 'table'; + /** @var StorageInterface */ private $storage; @@ -31,13 +36,31 @@ public function __construct(StorageInterface $storage) protected function configure(): void { - parent::configure(); // TODO: Change the autogenerated stub + $this->addOption('format', 'f', InputOption::VALUE_OPTIONAL, 'Output format', self::FORMAT_TABLE); } protected function execute(InputInterface $input, OutputInterface $output): int { $features = $this->storage->all(); + switch ($input->getOption('format')) { + case self::FORMAT_JSON: + $this->renderJson($output, $features); + break; + + case self::FORMAT_TABLE: + $this->renderTable($output, $features); + break; + + default: + throw new \InvalidArgumentException('Invalid output format'); + } + + return Command::SUCCESS; + } + + private function renderTable(OutputInterface $output, array $features): void + { $table = new Table($output); $table->setHeaders(['Name', 'Enabled', 'Description']); foreach ($features as $feature) { @@ -49,7 +72,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $table->render(); + } - return Command::SUCCESS; + private function renderJson(OutputInterface $output, array $features): void + { + $json = json_encode( + array_map(static function (Feature $feature): array { + return $feature->toArray(); + }, $features), + JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR + ); + + $output->writeln($json); } } diff --git a/src/Model/Feature.php b/src/Model/Feature.php index fdada24..43b0197 100644 --- a/src/Model/Feature.php +++ b/src/Model/Feature.php @@ -56,4 +56,13 @@ public function isEnabled(): bool { return $this->enabled; } + + public function toArray(): array + { + return [ + 'key' => $this->key, + 'enabled' => $this->enabled, + 'description' => $this->description, + ]; + } } diff --git a/tests/Unit/Command/ListFeatureCommandTest.php b/tests/Unit/Command/ListFeatureCommandTest.php index 46af197..5a12718 100644 --- a/tests/Unit/Command/ListFeatureCommandTest.php +++ b/tests/Unit/Command/ListFeatureCommandTest.php @@ -31,6 +31,19 @@ public function testConfiguredFeaturesAreDisplayedInTable(array $features, strin static::assertSame($expectedOutput, $commandTester->getDisplay()); } + /** + * @dataProvider jsonListFeatureProvider + */ + public function testConfiguredFeaturesAreDisplayedInJson(array $features, string $expectedOutput): void + { + $commandTester = $this->createCommandTester($features); + $commandTester->execute(['--format' => 'json']); + + $commandTester->assertCommandIsSuccessful(); + + static::assertSame($expectedOutput, $commandTester->getDisplay()); + } + public function tableListFeatureProvider(): iterable { yield 'no feature' => [ @@ -71,6 +84,54 @@ public function tableListFeatureProvider(): iterable ]; } + public function jsonListFeatureProvider(): iterable + { + yield 'no feature' => [ + [], + << [ + [ + 'feature1' => [ + 'enabled' => true, + 'description' => 'Feature 1 description', + ], + 'feature2' => [ + 'enabled' => false, + 'description' => 'Feature 2 description', + ], + 'feature3' => [ + 'enabled' => true, + 'description' => 'Feature 3 description', + ], + ], + << Date: Mon, 1 May 2023 14:38:20 +0200 Subject: [PATCH 3/8] Add CSV output --- src/Command/ListFeatureCommand.php | 34 +++++++++++++ tests/Unit/Command/ListFeatureCommandTest.php | 48 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/Command/ListFeatureCommand.php b/src/Command/ListFeatureCommand.php index 5011a79..974aefd 100644 --- a/src/Command/ListFeatureCommand.php +++ b/src/Command/ListFeatureCommand.php @@ -21,6 +21,7 @@ final class ListFeatureCommand extends Command { + private const FORMAT_CSV = 'csv'; private const FORMAT_JSON = 'json'; private const FORMAT_TABLE = 'table'; @@ -44,6 +45,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $features = $this->storage->all(); switch ($input->getOption('format')) { + case self::FORMAT_CSV: + $this->renderCsv($output, $features); + break; + case self::FORMAT_JSON: $this->renderJson($output, $features); break; @@ -85,4 +90,33 @@ private function renderJson(OutputInterface $output, array $features): void $output->writeln($json); } + + private function renderCsv(OutputInterface $output, array $features): void + { + $output->writeln($this->getCsvLine(['Name', 'Enabled', 'Description'])); + + foreach ($features as $feature) { + $output->writeln($this->getCsvLine($feature->toArray())); + } + } + + private function getCsvLine(array $columns): string + { + $fp = fopen('php://temp', 'w+'); + if (false === $fp) { + throw new \RuntimeException('Unable to open temporary file'); + } + + fputcsv($fp, $columns); + + rewind($fp); + $data = fread($fp, 1048576); // 1MB + if (false === $data) { + throw new \RuntimeException('Unable to read temporary file'); + } + + fclose($fp); + + return rtrim($data, PHP_EOL); + } } diff --git a/tests/Unit/Command/ListFeatureCommandTest.php b/tests/Unit/Command/ListFeatureCommandTest.php index 5a12718..92d6eb3 100644 --- a/tests/Unit/Command/ListFeatureCommandTest.php +++ b/tests/Unit/Command/ListFeatureCommandTest.php @@ -44,6 +44,19 @@ public function testConfiguredFeaturesAreDisplayedInJson(array $features, string static::assertSame($expectedOutput, $commandTester->getDisplay()); } + /** + * @dataProvider csvListFeatureProvider + */ + public function testConfiguredFeaturesAreDisplayedInCsv(array $features, string $expectedOutput): void + { + $commandTester = $this->createCommandTester($features); + $commandTester->execute(['--format' => 'csv']); + + $commandTester->assertCommandIsSuccessful(); + + static::assertSame($expectedOutput, $commandTester->getDisplay()); + } + public function tableListFeatureProvider(): iterable { yield 'no feature' => [ @@ -132,6 +145,41 @@ public function jsonListFeatureProvider(): iterable ]; } + public function csvListFeatureProvider(): iterable + { + yield 'no feature' => [ + [], + << [ + [ + 'feature1' => [ + 'enabled' => true, + 'description' => 'Feature 1 description', + ], + 'feature2' => [ + 'enabled' => false, + 'description' => 'Feature 2 description', + ], + 'feature3' => [ + 'enabled' => true, + 'description' => 'Feature 3 description', + ], + ], + << Date: Mon, 1 May 2023 14:56:59 +0200 Subject: [PATCH 4/8] Mutualiaze ListFeatureCommand test case --- tests/Unit/Command/ListFeatureCommandTest.php | 166 ++++++------------ 1 file changed, 56 insertions(+), 110 deletions(-) diff --git a/tests/Unit/Command/ListFeatureCommandTest.php b/tests/Unit/Command/ListFeatureCommandTest.php index 92d6eb3..64cef89 100644 --- a/tests/Unit/Command/ListFeatureCommandTest.php +++ b/tests/Unit/Command/ListFeatureCommandTest.php @@ -18,59 +18,28 @@ final class ListFeatureCommandTest extends TestCase { - /** - * @dataProvider tableListFeatureProvider - */ - public function testConfiguredFeaturesAreDisplayedInTable(array $features, string $expectedOutput): void - { - $commandTester = $this->createCommandTester($features); - $commandTester->execute([]); - - $commandTester->assertCommandIsSuccessful(); - - static::assertSame($expectedOutput, $commandTester->getDisplay()); - } - - /** - * @dataProvider jsonListFeatureProvider - */ - public function testConfiguredFeaturesAreDisplayedInJson(array $features, string $expectedOutput): void - { - $commandTester = $this->createCommandTester($features); - $commandTester->execute(['--format' => 'json']); - - $commandTester->assertCommandIsSuccessful(); - - static::assertSame($expectedOutput, $commandTester->getDisplay()); - } - - /** - * @dataProvider csvListFeatureProvider - */ - public function testConfiguredFeaturesAreDisplayedInCsv(array $features, string $expectedOutput): void - { - $commandTester = $this->createCommandTester($features); - $commandTester->execute(['--format' => 'csv']); - - $commandTester->assertCommandIsSuccessful(); - - static::assertSame($expectedOutput, $commandTester->getDisplay()); - } - - public function tableListFeatureProvider(): iterable - { - yield 'no feature' => [ - [], - << [ + 'features' => [], + 'output' => [ + 'table' => << << << [ - [ +CSV, + ], + ], + 'with-features' => [ + 'features' => [ 'feature1' => [ 'enabled' => true, 'description' => 'Feature 1 description', @@ -84,7 +53,8 @@ public function tableListFeatureProvider(): iterable 'description' => 'Feature 3 description', ], ], - << [ + 'table' => << [ - [], - << [ - [ - 'feature1' => [ - 'enabled' => true, - 'description' => 'Feature 1 description', - ], - 'feature2' => [ - 'enabled' => false, - 'description' => 'Feature 2 description', - ], - 'feature3' => [ - 'enabled' => true, - 'description' => 'Feature 3 description', - ], - ], - << << <<createCommandTester(); + + $this->expectException(\InvalidArgumentException::class); + + $commandTester->execute(['--format' => 'invalid']); } - public function csvListFeatureProvider(): iterable + /** + * @dataProvider featuresProvider + */ + public function testConfiguredFeaturesAreDisplayedInAskedFormat(array $features, string $outputFormat, string $expectedOutput): void { - yield 'no feature' => [ - [], - <<createCommandTester($features); + $commandTester->execute(['--format' => $outputFormat]); -CSV - ]; + $commandTester->assertCommandIsSuccessful(); - yield 'with features' => [ - [ - 'feature1' => [ - 'enabled' => true, - 'description' => 'Feature 1 description', - ], - 'feature2' => [ - 'enabled' => false, - 'description' => 'Feature 2 description', - ], - 'feature3' => [ - 'enabled' => true, - 'description' => 'Feature 3 description', - ], - ], - <<getDisplay()); + } -CSV - ]; + private function featuresProvider(): iterable + { + foreach (self::TEST_DATA as $caseDescription => $testData) { + foreach ($testData['output'] as $format => $expectedOutput) { + yield "$caseDescription in $format format" => [$testData['features'], $format, $expectedOutput]; + } + } } private function createCommandTester(array $features = []): CommandTester From 91cda7fbd9c0e87388d2f090732731aaa4ed52cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20DECOOL?= Date: Mon, 1 May 2023 15:03:18 +0200 Subject: [PATCH 5/8] Add command description --- src/Command/ListFeatureCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Command/ListFeatureCommand.php b/src/Command/ListFeatureCommand.php index 974aefd..8768fcf 100644 --- a/src/Command/ListFeatureCommand.php +++ b/src/Command/ListFeatureCommand.php @@ -37,6 +37,7 @@ public function __construct(StorageInterface $storage) protected function configure(): void { + $this->setDescription('List all features with their state'); $this->addOption('format', 'f', InputOption::VALUE_OPTIONAL, 'Output format', self::FORMAT_TABLE); } From 9029c21c38022a4fd192917ffc1b19982d3ec014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20DECOOL?= Date: Mon, 1 May 2023 15:25:11 +0200 Subject: [PATCH 6/8] Fix command for lower Symfony versions --- src/Command/ListFeatureCommand.php | 7 +++++-- tests/Unit/Command/ListFeatureCommandTest.php | 12 +++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Command/ListFeatureCommand.php b/src/Command/ListFeatureCommand.php index 8768fcf..9eec880 100644 --- a/src/Command/ListFeatureCommand.php +++ b/src/Command/ListFeatureCommand.php @@ -59,10 +59,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int break; default: - throw new \InvalidArgumentException('Invalid output format'); + /* @phpstan-ignore-next-line */ + $output->writeln("Invalid format: {$input->getOption('format')}"); + + return 1; } - return Command::SUCCESS; + return 0; } private function renderTable(OutputInterface $output, array $features): void diff --git a/tests/Unit/Command/ListFeatureCommandTest.php b/tests/Unit/Command/ListFeatureCommandTest.php index 64cef89..2588551 100644 --- a/tests/Unit/Command/ListFeatureCommandTest.php +++ b/tests/Unit/Command/ListFeatureCommandTest.php @@ -98,10 +98,13 @@ final class ListFeatureCommandTest extends TestCase public function testAnErrorOccuredIfInvalidFormatIsProvided(): void { $commandTester = $this->createCommandTester(); + $commandTester->execute(['--format' => 'invalid']); - $this->expectException(\InvalidArgumentException::class); + static::assertNotSame(0, $commandTester->getStatusCode()); + static::assertSame(<<execute(['--format' => 'invalid']); +OUTPUT, $commandTester->getDisplay()); } /** @@ -112,12 +115,11 @@ public function testConfiguredFeaturesAreDisplayedInAskedFormat(array $features, $commandTester = $this->createCommandTester($features); $commandTester->execute(['--format' => $outputFormat]); - $commandTester->assertCommandIsSuccessful(); - + static::assertSame(0, $commandTester->getStatusCode()); static::assertSame($expectedOutput, $commandTester->getDisplay()); } - private function featuresProvider(): iterable + public function featuresProvider(): iterable { foreach (self::TEST_DATA as $caseDescription => $testData) { foreach ($testData['output'] as $format => $expectedOutput) { From 76a94863bb95b5c596d71c1a2ec3764cbac30460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20DECOOL?= Date: Mon, 1 May 2023 15:44:27 +0200 Subject: [PATCH 7/8] Register ListFeatureCommand in dependency container --- phpunit.xml.dist | 2 +- src/Resources/config/services.yml | 4 +++ .../Command/ListFeatureCommandTest.php | 27 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/Command/ListFeatureCommandTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bee66d4..60da0cf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,4 +21,4 @@ src - \ No newline at end of file + diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml index 0ed84ce..50d129e 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yml @@ -1,4 +1,8 @@ services: + Novaway\Bundle\FeatureFlagBundle\Command\ListFeatureCommand: + arguments: ['@novaway_feature_flag.storage'] + tags: ['console.command'] + Novaway\Bundle\FeatureFlagBundle\Manager\DefaultFeatureManager: arguments: ['@Novaway\Bundle\FeatureFlagBundle\Storage\ArrayStorage'] Novaway\Bundle\FeatureFlagBundle\Manager\LegacyFeatureManager: diff --git a/tests/Functional/Command/ListFeatureCommandTest.php b/tests/Functional/Command/ListFeatureCommandTest.php new file mode 100644 index 0000000..296783e --- /dev/null +++ b/tests/Functional/Command/ListFeatureCommandTest.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 Novaway\Bundle\FeatureFlagBundle\Tests\Functional\Command; + +use Novaway\Bundle\FeatureFlagBundle\Tests\Functional\WebTestCase; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Command\Command; + +final class ListFeatureCommandTest extends WebTestCase +{ + public function testCommandIsAvailable(): void + { + $application = new Application(static::bootKernel()); + $command = $application->find('novaway:feature-flag:list'); + + static::assertInstanceOf(Command::class, $command); + } +} From b85c636aba55b63cdaa1fcefee508f72355e9fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20DECOOL?= Date: Tue, 2 May 2023 11:23:43 +0200 Subject: [PATCH 8/8] Sort ArrayStorage features by key order --- src/Storage/ArrayStorage.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Storage/ArrayStorage.php b/src/Storage/ArrayStorage.php index 2a31c31..acfc4bd 100644 --- a/src/Storage/ArrayStorage.php +++ b/src/Storage/ArrayStorage.php @@ -24,6 +24,8 @@ class ArrayStorage extends AbstractStorage */ public function __construct(array $features = []) { + ksort($features); + $this->features = []; foreach ($features as $key => $feature) { $this->features[$key] = new Feature($key, $feature['enabled'], $feature['description'] ?? '');