From 08e647550df9e6af1579ea3250fd25de99ffe6c6 Mon Sep 17 00:00:00 2001 From: Dominik Chrastecky Date: Fri, 12 Jan 2024 21:26:58 +0100 Subject: [PATCH 1/5] Feat: Add test command --- src/Command/TestFlagCommand.php | 149 +++++++++++++++++++++++++++++ src/Resources/config/services.yaml | 9 ++ 2 files changed, 158 insertions(+) create mode 100644 src/Command/TestFlagCommand.php diff --git a/src/Command/TestFlagCommand.php b/src/Command/TestFlagCommand.php new file mode 100644 index 0000000..ed262e8 --- /dev/null +++ b/src/Command/TestFlagCommand.php @@ -0,0 +1,149 @@ +addArgument( + name: 'flag', + mode: InputArgument::REQUIRED, + description: 'The name of the feature flag to check the result for', + ) + ->addOption( + name: 'force', + shortcut: 'f', + mode: InputOption::VALUE_NONE, + description: 'When this flag is present, fresh results without cache will be forced', + ) + ->addOption( + name: 'user-id', + mode: InputOption::VALUE_REQUIRED, + description: "[Context] Provide the current user's ID", + default: null, + ) + ->addOption( + name: 'ip-address', + mode: InputOption::VALUE_REQUIRED, + description: '[Context] Provide the current IP address', + default: null, + ) + ->addOption( + name: 'session-id', + mode: InputOption::VALUE_REQUIRED, + description: '[Context] Provide the current session ID', + default: null, + ) + ->addOption( + name: 'hostname', + mode: InputOption::VALUE_REQUIRED, + description: '[Context] Provide the current hostname', + default: null, + ) + ->addOption( + name: 'environment', + mode: InputOption::VALUE_REQUIRED, + description: '[Context] Provide the current environment', + default: null, + ) + ->addOption( + name: 'current-time', + mode: InputOption::VALUE_REQUIRED, + description: '[Context] Provide the current date and time', + default: null, + ) + ->addOption( + 'custom-context', + mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + description: '[Context] Custom context values in the format [contextName]=[contextValue], for example: myCustomContextField=someValue', + default: null, + ) + ->addOption( // must use positional arguments, because $suggestedValues is not a real argument + 'expected', + null, + InputOption::VALUE_REQUIRED, + 'For use in testing, if this option is present, the exit code will be either 0 or 1 depending on whether the expectation matches the result', + null, + ['true', 'false'], // suggested values + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + if ($input->getOption('force')) { + $this->cache->clear(); + } + + $result = $this->unleash->isEnabled( + $input->getArgument('flag'), + $this->createContext($input), + ); + + $expected = $input->getOption('expected'); + if ($expected !== null) { + $expected = $expected === 'true'; + } + $success = ($expected === null && $result) || ($expected !== null && $result === $expected); + $message = "The feature flag '{$input->getArgument('flag')}' evaluated to: " . ($result ? 'true' : 'false'); + + $success + ? $io->success($message) + : $io->error($message) + ; + + return $expected === null + ? Command::SUCCESS + : ( + $result === $expected + ? Command::SUCCESS + : Command::FAILURE + ) + ; + } + + private function createContext(InputInterface $input): Context + { + $customContext = []; + foreach ($input->getOption('custom-context') as $item) { + if (!fnmatch('*=*', $item)) { + throw new LogicException('The value must be a key=value pair.'); + } + [$key, $value] = explode('=', $item); + $customContext[trim($key)] = trim($value); + } + + return new UnleashContext( + currentUserId: $input->getOption('user-id'), + ipAddress: $input->getOption('ip-address'), + sessionId: $input->getOption('session-id'), + customContext: $customContext, + hostname: $input->getOption('hostname'), + environment: $input->getOption('environment'), + currentTime: $input->getOption('current-time'), + ); + } +} diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 0fdad8a..e62f000 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -151,3 +151,12 @@ services: - '@event_dispatcher' tags: - kernel.event_subscriber + + unleash.client.command.test_flag: + class: Unleash\Client\Bundle\Command\TestFlagCommand + arguments: + $name: 'unleash:test-flag' + $unleash: '@unleash.client.unleash' + $cache: '@unleash.client.internal.cache' + tags: + - console.command From 3019fb12e6079f2d2d5e722cee2f26fde563e007 Mon Sep 17 00:00:00 2001 From: Dominik Chrastecky Date: Fri, 12 Jan 2024 21:39:54 +0100 Subject: [PATCH 2/5] Fix phpstan violations --- composer.json | 3 ++- src/Command/TestFlagCommand.php | 39 +++++++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 91f3066..1701793 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ }, "config": { "allow-plugins": { - "php-http/discovery": true + "php-http/discovery": true, + "phpstan/extension-installer": false } } } diff --git a/src/Command/TestFlagCommand.php b/src/Command/TestFlagCommand.php index ed262e8..bc080ac 100644 --- a/src/Command/TestFlagCommand.php +++ b/src/Command/TestFlagCommand.php @@ -94,12 +94,16 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + + $flagName = $input->getArgument('flag'); + assert(is_string($flagName)); + if ($input->getOption('force')) { $this->cache->clear(); } $result = $this->unleash->isEnabled( - $input->getArgument('flag'), + $flagName, $this->createContext($input), ); @@ -108,7 +112,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $expected = $expected === 'true'; } $success = ($expected === null && $result) || ($expected !== null && $result === $expected); - $message = "The feature flag '{$input->getArgument('flag')}' evaluated to: " . ($result ? 'true' : 'false'); + $message = "The feature flag '{$flagName}' evaluated to: " . ($result ? 'true' : 'false'); $success ? $io->success($message) @@ -127,8 +131,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function createContext(InputInterface $input): Context { + $customContextInput = $input->getOption('custom-context'); + assert(is_array($customContextInput)); + $customContext = []; - foreach ($input->getOption('custom-context') as $item) { + foreach ($customContextInput as $item) { if (!fnmatch('*=*', $item)) { throw new LogicException('The value must be a key=value pair.'); } @@ -136,14 +143,28 @@ private function createContext(InputInterface $input): Context $customContext[trim($key)] = trim($value); } + $userId = $input->getOption('user-id'); + $ipAddress = $input->getOption('ip-address'); + $sessionId = $input->getOption('session-id'); + $hostname = $input->getOption('hostname'); + $environment = $input->getOption('environment'); + $currentTime = $input->getOption('current-time'); + + assert($userId === null || is_string($userId)); + assert($ipAddress === null || is_string($ipAddress)); + assert($sessionId === null || is_string($sessionId)); + assert($hostname === null || is_string($hostname)); + assert($environment === null || is_string($environment)); + assert($currentTime === null || is_string($currentTime)); + return new UnleashContext( - currentUserId: $input->getOption('user-id'), - ipAddress: $input->getOption('ip-address'), - sessionId: $input->getOption('session-id'), + currentUserId: $userId, + ipAddress: $ipAddress, + sessionId: $sessionId, customContext: $customContext, - hostname: $input->getOption('hostname'), - environment: $input->getOption('environment'), - currentTime: $input->getOption('current-time'), + hostname: $hostname, + environment: $environment, + currentTime: $currentTime, ); } } From 1a03aa989e3bcb5fa0698ad73e5cb270324903d2 Mon Sep 17 00:00:00 2001 From: Dominik Chrastecky Date: Fri, 12 Jan 2024 21:41:51 +0100 Subject: [PATCH 3/5] Use correct cache type --- src/Command/TestFlagCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Command/TestFlagCommand.php b/src/Command/TestFlagCommand.php index bc080ac..46115da 100644 --- a/src/Command/TestFlagCommand.php +++ b/src/Command/TestFlagCommand.php @@ -3,7 +3,7 @@ namespace Unleash\Client\Bundle\Command; use LogicException; -use Psr\Cache\CacheItemPoolInterface; +use Psr\SimpleCache\CacheInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -19,7 +19,7 @@ final class TestFlagCommand extends Command public function __construct( string $name, private readonly Unleash $unleash, - private readonly CacheItemPoolInterface $cache, + private readonly CacheInterface $cache, ) { parent::__construct($name); } From 883760e712dc7188bb1d608a3e07456e93638b0c Mon Sep 17 00:00:00 2001 From: Dominik Chrastecky Date: Fri, 12 Jan 2024 21:43:34 +0100 Subject: [PATCH 4/5] Add description --- src/Command/TestFlagCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Command/TestFlagCommand.php b/src/Command/TestFlagCommand.php index 46115da..e34598f 100644 --- a/src/Command/TestFlagCommand.php +++ b/src/Command/TestFlagCommand.php @@ -27,6 +27,7 @@ public function __construct( protected function configure(): void { $this + ->setDescription('Check the status of an Unleash feature') ->addArgument( name: 'flag', mode: InputArgument::REQUIRED, From aa42195949586fabd37e53a304029a0714dd86cb Mon Sep 17 00:00:00 2001 From: Dominik Chrastecky Date: Fri, 12 Jan 2024 21:53:43 +0100 Subject: [PATCH 5/5] Add command to README --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index e41e24e..ab59944 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,43 @@ unleash_symfony_client: fetching_enabled: false ``` +## Test command + +If you need to quickly test what will your flags evaluate to, you can use the built-in command `unleash:test-flag`. + +The command is documented and here's the output of `./bin/console unleash:test-flag --help`: + +``` +Description: + Check the status of an Unleash feature + +Usage: + unleash:test-flag [options] [--] + +Arguments: + flag The name of the feature flag to check the result for + +Options: + -f, --force When this flag is present, fresh results without cache will be forced + --user-id=USER-ID [Context] Provide the current user's ID + --ip-address=IP-ADDRESS [Context] Provide the current IP address + --session-id=SESSION-ID [Context] Provide the current session ID + --hostname=HOSTNAME [Context] Provide the current hostname + --environment=ENVIRONMENT [Context] Provide the current environment + --current-time=CURRENT-TIME [Context] Provide the current date and time + --custom-context=CUSTOM-CONTEXT [Context] Custom context values in the format [contextName]=[contextValue], for example: myCustomContextField=someValue (multiple values allowed) + --expected=EXPECTED For use in testing, if this option is present, the exit code will be either 0 or 1 depending on whether the expectation matches the result + -h, --help Display help for the given command. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -e, --env=ENV The Environment name. [default: "dev"] + --no-debug Switch off debug mode. + --profile Enables profiling (requires debug). + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug +``` + ## Configuration reference This is the autogenerated config dump (by running `php bin/console config:dump unleash_symfony_client`):