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/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/Command/ListFeatureCommand.php b/src/Command/ListFeatureCommand.php new file mode 100644 index 0000000..9eec880 --- /dev/null +++ b/src/Command/ListFeatureCommand.php @@ -0,0 +1,126 @@ + + * 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\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_CSV = 'csv'; + private const FORMAT_JSON = 'json'; + private const FORMAT_TABLE = 'table'; + + /** @var StorageInterface */ + private $storage; + + public function __construct(StorageInterface $storage) + { + parent::__construct('novaway:feature-flag:list'); + + $this->storage = $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); + } + + 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; + + case self::FORMAT_TABLE: + $this->renderTable($output, $features); + break; + + default: + /* @phpstan-ignore-next-line */ + $output->writeln("Invalid format: {$input->getOption('format')}"); + + return 1; + } + + return 0; + } + + private function renderTable(OutputInterface $output, array $features): void + { + $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(); + } + + 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); + } + + 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/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/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/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'] ?? ''); 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); + } +} diff --git a/tests/Unit/Command/ListFeatureCommandTest.php b/tests/Unit/Command/ListFeatureCommandTest.php new file mode 100644 index 0000000..2588551 --- /dev/null +++ b/tests/Unit/Command/ListFeatureCommandTest.php @@ -0,0 +1,138 @@ + + * 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 +{ + private const TEST_DATA = [ + 'empty-features' => [ + 'features' => [], + 'output' => [ + 'table' => << << << [ + 'features' => [ + 'feature1' => [ + 'enabled' => true, + 'description' => 'Feature 1 description', + ], + 'feature2' => [ + 'enabled' => false, + 'description' => 'Feature 2 description', + ], + 'feature3' => [ + 'enabled' => true, + 'description' => 'Feature 3 description', + ], + ], + 'output' => [ + 'table' => << << <<createCommandTester(); + $commandTester->execute(['--format' => 'invalid']); + + static::assertNotSame(0, $commandTester->getStatusCode()); + static::assertSame(<<getDisplay()); + } + + /** + * @dataProvider featuresProvider + */ + public function testConfiguredFeaturesAreDisplayedInAskedFormat(array $features, string $outputFormat, string $expectedOutput): void + { + $commandTester = $this->createCommandTester($features); + $commandTester->execute(['--format' => $outputFormat]); + + static::assertSame(0, $commandTester->getStatusCode()); + static::assertSame($expectedOutput, $commandTester->getDisplay()); + } + + public 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 + { + $storage = new ArrayStorage($features); + $command = new ListFeatureCommand($storage); + + return new CommandTester($command); + } +}