From 41e22723c4f005c831440ea12878951c8f2ba912 Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Thu, 2 Jun 2022 16:29:31 -0500 Subject: [PATCH] Add Enum Env Var Processor --- .../FrameworkExtension.php | 2 +- .../DependencyInjection/CHANGELOG.md | 1 + .../Compiler/RegisterEnvVarProcessorsPass.php | 2 +- .../DependencyInjection/Dumper/PhpDumper.php | 4 +- .../DependencyInjection/EnvVarProcessor.php | 30 +++++++- .../Loader/Configurator/EnvConfigurator.php | 12 +++ .../EnvPlaceholderParameterBag.php | 2 +- .../RegisterEnvVarProcessorsPassTest.php | 3 +- .../Tests/EnvVarProcessorTest.php | 74 +++++++++++++++++++ .../Tests/Fixtures/IntBackedEnum.php | 17 +++++ .../Tests/Fixtures/StringBackedEnum.php | 17 +++++ .../Configurator/EnvConfiguratorTest.php | 4 +- .../EnvPlaceholderParameterBagTest.php | 12 ++- 13 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/IntBackedEnum.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/StringBackedEnum.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index bd8a2f72907af..8834c48d422bd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1722,7 +1722,7 @@ private function registerSecretsConfiguration(array $config, ContainerBuilder $c } if ($config['decryption_env_var']) { - if (!preg_match('/^(?:[-.\w]*+:)*+\w++$/', $config['decryption_env_var'])) { + if (!preg_match('/^(?:[-.\w\\\\]*+:)*+\w++$/', $config['decryption_env_var'])) { throw new InvalidArgumentException(sprintf('Invalid value "%s" set as "decryption_env_var": only "word" characters are allowed.', $config['decryption_env_var'])); } diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index b0aaeaafaca32..ce6a726d7777a 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add argument `&$asGhostObject` to LazyProxy's `DumperInterface` to allow using ghost objects for lazy loading services + * Add `enum` env var processor 6.1 --- diff --git a/src/Symfony/Component/DependencyInjection/Compiler/RegisterEnvVarProcessorsPass.php b/src/Symfony/Component/DependencyInjection/Compiler/RegisterEnvVarProcessorsPass.php index 251889ebedb3a..0973164dbb2cf 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/RegisterEnvVarProcessorsPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/RegisterEnvVarProcessorsPass.php @@ -25,7 +25,7 @@ */ class RegisterEnvVarProcessorsPass implements CompilerPassInterface { - private const ALLOWED_TYPES = ['array', 'bool', 'float', 'int', 'string']; + private const ALLOWED_TYPES = ['array', 'bool', 'float', 'int', 'string', \BackedEnum::class]; public function process(ContainerBuilder $container) { diff --git a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php index 71ffae7adb838..3b77a99d4a6ad 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/PhpDumper.php @@ -1539,7 +1539,7 @@ private function addDefaultParametersMethod(): string $export = $this->exportParameters([$value], '', 12, $hasEnum); $export = explode('0 => ', substr(rtrim($export, " ]\n"), 2, -1), 2); - if ($hasEnum || preg_match("/\\\$this->(?:getEnv\('(?:[-.\w]*+:)*+\w++'\)|targetDir\.'')/", $export[1])) { + if ($hasEnum || preg_match("/\\\$this->(?:getEnv\('(?:[-.\w\\\\]*+:)*+\w++'\)|targetDir\.'')/", $export[1])) { $dynamicPhp[$key] = sprintf('%s%s => %s,', $export[0], $this->export($key), $export[1]); } else { $php[] = sprintf('%s%s => %s,', $export[0], $this->export($key), $export[1]); @@ -1952,7 +1952,7 @@ private function dumpParameter(string $name): string return $dumpedValue; } - if (!preg_match("/\\\$this->(?:getEnv\('(?:[-.\w]*+:)*+\w++'\)|targetDir\.'')/", $dumpedValue)) { + if (!preg_match("/\\\$this->(?:getEnv\('(?:[-.\w\\\\]*+:)*+\w++'\)|targetDir\.'')/", $dumpedValue)) { return sprintf('$this->parameters[%s]', $this->doExport($name)); } } diff --git a/src/Symfony/Component/DependencyInjection/EnvVarProcessor.php b/src/Symfony/Component/DependencyInjection/EnvVarProcessor.php index bfa316b78a4e2..efe22a87e603f 100644 --- a/src/Symfony/Component/DependencyInjection/EnvVarProcessor.php +++ b/src/Symfony/Component/DependencyInjection/EnvVarProcessor.php @@ -21,11 +21,12 @@ class EnvVarProcessor implements EnvVarProcessorInterface { private ContainerInterface $container; + /** @var \Traversable */ private \Traversable $loaders; private array $loadedVars = []; /** - * @param EnvVarLoaderInterface[] $loaders + * @param \Traversable|null $loaders */ public function __construct(ContainerInterface $container, \Traversable $loaders = null) { @@ -56,6 +57,7 @@ public static function getProvidedTypes(): array 'string' => 'string', 'trim' => 'string', 'require' => 'bool|int|float|string|array', + 'enum' => \BackedEnum::class, ]; } @@ -86,6 +88,26 @@ public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed return $array[$key]; } + if ('enum' === $prefix) { + if (false === $i) { + throw new RuntimeException(sprintf('Invalid env "enum:%s": a "%s" class-string should be provided.', $name, \BackedEnum::class)); + } + + $next = substr($name, $i + 1); + $backedEnumClassName = substr($name, 0, $i); + $backedEnumValue = $getEnv($next); + + if (!\is_string($backedEnumValue) && !\is_int($backedEnumValue)) { + throw new RuntimeException(sprintf('Resolved value of "%s" did not result in a string or int value.', $next)); + } + + if (!is_subclass_of($backedEnumClassName, \BackedEnum::class)) { + throw new RuntimeException(sprintf('"%s" is not a "%s".', $backedEnumClassName, \BackedEnum::class)); + } + + return $backedEnumClassName::tryFrom($backedEnumValue) ?? throw new RuntimeException(sprintf('Enum value "%s" is not backed by "%s".', $backedEnumValue, $backedEnumClassName)); + } + if ('default' === $prefix) { if (false === $i) { throw new RuntimeException(sprintf('Invalid env "default:%s": a fallback parameter should be provided.', $name)); @@ -112,7 +134,7 @@ public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed } if ('file' === $prefix || 'require' === $prefix) { - if (!is_scalar($file = $getEnv($name))) { + if (!\is_scalar($file = $getEnv($name))) { throw new RuntimeException(sprintf('Invalid file name: env var "%s" is non-scalar.', $name)); } if (!is_file($file)) { @@ -184,7 +206,7 @@ public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed return null; } - if (!is_scalar($env)) { + if (!\is_scalar($env)) { throw new RuntimeException(sprintf('Non-scalar env var "%s" cannot be cast to "%s".', $name, $prefix)); } @@ -283,7 +305,7 @@ public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed $value = $this->container->getParameter($match[1]); } - if (!is_scalar($value)) { + if (!\is_scalar($value)) { throw new RuntimeException(sprintf('Parameter "%s" found when resolving env var "%s" must be scalar, "%s" given.', $match[1], $name, get_debug_type($value))); } diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/EnvConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/EnvConfigurator.php index c7ee82328e0f9..fe6780326f6e5 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/EnvConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/EnvConfigurator.php @@ -221,4 +221,16 @@ public function require(): static return $this; } + + /** + * @param class-string<\BackedEnum> $backedEnumClassName + * + * @return $this + */ + public function enum(string $backedEnumClassName): static + { + array_unshift($this->stack, 'enum', $backedEnumClassName); + + return $this; + } } diff --git a/src/Symfony/Component/DependencyInjection/ParameterBag/EnvPlaceholderParameterBag.php b/src/Symfony/Component/DependencyInjection/ParameterBag/EnvPlaceholderParameterBag.php index 0b6f082aaeb94..bee5f8c49afd7 100644 --- a/src/Symfony/Component/DependencyInjection/ParameterBag/EnvPlaceholderParameterBag.php +++ b/src/Symfony/Component/DependencyInjection/ParameterBag/EnvPlaceholderParameterBag.php @@ -44,7 +44,7 @@ public function get(string $name): array|bool|string|int|float|null return $placeholder; // return first result } } - if (!preg_match('/^(?:[-.\w]*+:)*+\w++$/', $env)) { + if (!preg_match('/^(?:[-.\w\\\\]*+:)*+\w++$/', $env)) { throw new InvalidArgumentException(sprintf('Invalid %s name: only "word" characters are allowed.', $name)); } if ($this->has($name) && null !== ($defaultValue = parent::get($name)) && !\is_string($defaultValue)) { diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php index c92b48c73a485..9718554a59bd2 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterEnvVarProcessorsPassTest.php @@ -48,6 +48,7 @@ public function testSimpleProcessor() 'string' => ['string'], 'trim' => ['string'], 'require' => ['bool', 'int', 'float', 'string', 'array'], + 'enum' => [\BackedEnum::class], ]; $this->assertSame($expected, $container->getParameterBag()->getProvidedTypes()); @@ -65,7 +66,7 @@ public function testNoProcessor() public function testBadProcessor() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid type "foo" returned by "Symfony\Component\DependencyInjection\Tests\Compiler\BadProcessor::getProvidedTypes()", expected one of "array", "bool", "float", "int", "string".'); + $this->expectExceptionMessage('Invalid type "foo" returned by "Symfony\Component\DependencyInjection\Tests\Compiler\BadProcessor::getProvidedTypes()", expected one of "array", "bool", "float", "int", "string", "BackedEnum".'); $container = new ContainerBuilder(); $container->register('foo', BadProcessor::class)->addTag('container.env_var_processor'); diff --git a/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php b/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php index 11a60057c499c..35c49d554f075 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/EnvVarProcessorTest.php @@ -20,6 +20,8 @@ use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException; use Symfony\Component\DependencyInjection\Exception\ParameterCircularReferenceException; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\DependencyInjection\Tests\Fixtures\IntBackedEnum; +use Symfony\Component\DependencyInjection\Tests\Fixtures\StringBackedEnum; class EnvVarProcessorTest extends TestCase { @@ -464,6 +466,78 @@ public function testGetEnvKeyChained() })); } + /** + * @dataProvider provideGetEnvEnum + */ + public function testGetEnvEnum(\BackedEnum $backedEnum) + { + $processor = new EnvVarProcessor(new Container()); + + $result = $processor->getEnv('enum', $backedEnum::class.':foo', function (string $name) use ($backedEnum) { + $this->assertSame('foo', $name); + + return $backedEnum->value; + }); + + $this->assertSame($backedEnum, $result); + } + + public function provideGetEnvEnum(): iterable + { + return [ + [StringBackedEnum::Bar], + [IntBackedEnum::Nine], + ]; + } + + public function testGetEnvEnumInvalidEnum() + { + $processor = new EnvVarProcessor(new Container()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid env "enum:foo": a "BackedEnum" class-string should be provided.'); + + $processor->getEnv('enum', 'foo', function () { + $this->fail('Should not get here'); + }); + } + + public function testGetEnvEnumInvalidResolvedValue() + { + $processor = new EnvVarProcessor(new Container()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Resolved value of "foo" did not result in a string or int value.'); + + $processor->getEnv('enum', StringBackedEnum::class.':foo', function () { + return null; + }); + } + + public function testGetEnvEnumInvalidArg() + { + $processor = new EnvVarProcessor(new Container()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"bogus" is not a "BackedEnum".'); + + $processor->getEnv('enum', 'bogus:foo', function () { + return ''; + }); + } + + public function testGetEnvEnumInvalidBackedValue() + { + $processor = new EnvVarProcessor(new Container()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Enum value "bogus" is not backed by "'.StringBackedEnum::class.'".'); + + $processor->getEnv('enum', StringBackedEnum::class.':foo', function () { + return 'bogus'; + }); + } + /** * @dataProvider validNullables */ diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IntBackedEnum.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IntBackedEnum.php new file mode 100644 index 0000000000000..a97c7a341a376 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/IntBackedEnum.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +enum IntBackedEnum: int +{ + case Nine = 9; +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/StringBackedEnum.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/StringBackedEnum.php new file mode 100644 index 0000000000000..b118cc7550eb8 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/StringBackedEnum.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +enum StringBackedEnum: string +{ + case Bar = 'bar'; +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/Configurator/EnvConfiguratorTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/Configurator/EnvConfiguratorTest.php index 0b354e7615635..a7c36c105427f 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/Configurator/EnvConfiguratorTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/Configurator/EnvConfiguratorTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Loader\Configurator\EnvConfigurator; +use Symfony\Component\DependencyInjection\Tests\Fixtures\StringBackedEnum; final class EnvConfiguratorTest extends TestCase { @@ -24,7 +25,7 @@ public function test(string $expected, EnvConfigurator $envConfigurator) $this->assertSame($expected, (string) $envConfigurator); } - public function provide() + public function provide(): iterable { yield ['%env(FOO)%', new EnvConfigurator('FOO')]; yield ['%env(string:FOO)%', new EnvConfigurator('string:FOO')]; @@ -32,5 +33,6 @@ public function provide() yield ['%env(key:path:url:FOO)%', (new EnvConfigurator('FOO'))->url()->key('path')]; yield ['%env(default:fallback:bar:arg1:FOO)%', (new EnvConfigurator('FOO'))->custom('bar', 'arg1')->default('fallback')]; yield ['%env(my_processor:my_argument:FOO)%', (new EnvConfigurator('FOO'))->myProcessor('my_argument')]; + yield ['%env(enum:'.StringBackedEnum::class.':FOO)%', (new EnvConfigurator('FOO'))->enum(StringBackedEnum::class)]; } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/ParameterBag/EnvPlaceholderParameterBagTest.php b/src/Symfony/Component/DependencyInjection/Tests/ParameterBag/EnvPlaceholderParameterBagTest.php index 9134f1f6c0186..57c7962ef55c9 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/ParameterBag/EnvPlaceholderParameterBagTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/ParameterBag/EnvPlaceholderParameterBagTest.php @@ -14,14 +14,24 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\DependencyInjection\Loader\Configurator\EnvConfigurator; use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; +use Symfony\Component\DependencyInjection\Tests\Fixtures\StringBackedEnum; class EnvPlaceholderParameterBagTest extends TestCase { + public function testEnumEnvVarProcessorPassesRegex() + { + $bag = new EnvPlaceholderParameterBag(); + $name = \trim((new EnvConfigurator('FOO'))->enum(StringBackedEnum::class), '%'); + $this->assertIsString($bag->get($name)); + } + public function testGetThrowsInvalidArgumentExceptionIfEnvNameContainsNonWordCharacters() { - $this->expectException(InvalidArgumentException::class); $bag = new EnvPlaceholderParameterBag(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid env(%foo%) name: only "word" characters are allowed.'); $bag->get('env(%foo%)'); }