Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit d8b1159

Browse files
committedJun 6, 2024
Fix: Resolve env preprocessors for LateBoundDsnParameter
1 parent d3ed999 commit d8b1159

13 files changed

+271
-2
lines changed
 

‎.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/.idea
22
/vendor
33
/composer.lock
4+
/var
45

56
/.php-cs-fixer.php
67
/.php-cs-fixer.cache

‎.php-cs-fixer.dist.php

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
$finder = (new PhpCsFixer\Finder())
44
->in(__DIR__ . '/src')
5+
->in(__DIR__ . '/tests')
56
;
67

78
return (new PhpCsFixer\Config())

‎composer.json

+8-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
"Unleash\\Client\\Bundle\\": "src/"
1717
}
1818
},
19+
"autoload-dev": {
20+
"psr-4": {
21+
"Unleash\\Client\\Bundle\\Test\\": "tests/"
22+
}
23+
},
1924
"require-dev": {
2025
"rector/rector": "^0.15.23",
2126
"phpstan/phpstan": "^1.10",
@@ -24,7 +29,9 @@
2429
"symfony/security-core": "^5.0 | ^6.0 | ^7.0",
2530
"symfony/expression-language": "^5.0 | ^6.0 | ^7.0",
2631
"twig/twig": "^3.3",
27-
"symfony/yaml": "^6.3 | ^7.0"
32+
"symfony/yaml": "^6.3 | ^7.0",
33+
"phpunit/phpunit": "^11.1",
34+
"symfony/phpunit-bridge": "^7.0"
2835
},
2936
"suggest": {
3037
"symfony/security-bundle": "For integration of Symfony users into Unleash context",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Unleash\Client\Bundle\DependencyInjection\Compiler;
6+
7+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
8+
use Symfony\Component\DependencyInjection\ContainerBuilder;
9+
use Symfony\Component\DependencyInjection\Reference;
10+
use Unleash\Client\Bundle\DependencyInjection\Dsn\LateBoundDsnParameter;
11+
12+
final class ProcessLateBoundParameters implements CompilerPassInterface
13+
{
14+
public function process(ContainerBuilder $container): void
15+
{
16+
$processors = array_map(
17+
fn (string $serviceName) => new Reference($serviceName),
18+
array_keys($container->findTaggedServiceIds('container.env_var_processor')),
19+
);
20+
21+
foreach ($container->getDefinitions() as $definition) {
22+
if ($definition->getClass() !== LateBoundDsnParameter::class) {
23+
continue;
24+
}
25+
$definition->setArgument('$preprocessors', $processors);
26+
}
27+
}
28+
}

‎src/DependencyInjection/Dsn/LateBoundDsnParameter.php

+65-1
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,32 @@
33
namespace Unleash\Client\Bundle\DependencyInjection\Dsn;
44

55
use Stringable;
6+
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
7+
use Unleash\Client\Bundle\Exception\UnknownEnvPreprocessorException;
8+
use Unleash\Client\Exception\InvalidValueException;
69

710
final readonly class LateBoundDsnParameter implements Stringable
811
{
12+
/**
13+
* @param iterable<EnvVarProcessorInterface> $preprocessors
14+
*/
915
public function __construct(
1016
private string $envName,
1117
private string $parameter,
18+
private iterable $preprocessors,
1219
) {
1320
}
1421

1522
public function __toString(): string
1623
{
17-
$dsn = getenv($this->envName) ?: $_ENV[$this->envName] ?? null;
24+
$dsn = $this->getEnvValue($this->envName);
1825
if ($dsn === null) {
1926
return '';
2027
}
28+
if (!is_string($dsn)) {
29+
$type = is_object($dsn) ? get_class($dsn) : gettype($dsn);
30+
throw new InvalidValueException("The environment variables {$this->envName} must resolve to a string, {$type} given instead");
31+
}
2132

2233
$query = parse_url($dsn, PHP_URL_QUERY);
2334
if ($query === null) {
@@ -38,4 +49,57 @@ public function __toString(): string
3849

3950
return $result;
4051
}
52+
53+
private function getEnvValue(string $env): mixed
54+
{
55+
$parts = array_reverse(explode(':', $env));
56+
57+
$envName = array_shift($parts);
58+
$result = getenv($envName) ?: $_ENV[$envName] ?? null;
59+
60+
$buffer = [];
61+
while (count($parts) > 0) {
62+
$current = array_shift($parts);
63+
$preprocessor = $this->findPreprocessor($current);
64+
if ($preprocessor === null) {
65+
$buffer[] = $current;
66+
continue;
67+
}
68+
$envToPass = $envName;
69+
if (count($buffer)) {
70+
$envToPass = implode(':', array_reverse($buffer));
71+
$envToPass .= ":{$envName}";
72+
$buffer = [];
73+
}
74+
75+
$result = $preprocessor->getEnv($current, $envToPass, function (string $name) use ($envName, $result) {
76+
if ($name === $envName) {
77+
return $result;
78+
}
79+
80+
return getenv($name) ?: $_ENV[$envName] ?? null;
81+
});
82+
}
83+
84+
if (count($buffer)) {
85+
throw new UnknownEnvPreprocessorException('Unknown env var processor: ' . implode(':', array_reverse($buffer)));
86+
}
87+
88+
return $result;
89+
}
90+
91+
private function findPreprocessor(string $prefix): ?EnvVarProcessorInterface
92+
{
93+
foreach ($this->preprocessors as $preprocessor) {
94+
$types = array_keys($preprocessor::getProvidedTypes());
95+
$currentType = explode(':', $prefix)[0];
96+
if (!in_array($currentType, $types, true)) {
97+
continue;
98+
}
99+
100+
return $preprocessor;
101+
}
102+
103+
return null;
104+
}
41105
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Unleash\Client\Bundle\Exception;
4+
5+
use Symfony\Component\Config\Definition\Exception\Exception;
6+
7+
final class UnknownEnvPreprocessorException extends Exception
8+
{
9+
}

‎src/UnleashClientBundle.php

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Unleash\Client\Bundle\DependencyInjection\Compiler\BootstrapResolver;
1010
use Unleash\Client\Bundle\DependencyInjection\Compiler\CacheServiceResolverCompilerPass;
1111
use Unleash\Client\Bundle\DependencyInjection\Compiler\HttpServicesResolverCompilerPass;
12+
use Unleash\Client\Bundle\DependencyInjection\Compiler\ProcessLateBoundParameters;
1213
use Unleash\Client\Strategy\StrategyHandler;
1314

1415
final class UnleashClientBundle extends Bundle
@@ -30,5 +31,6 @@ public function build(ContainerBuilder $container): void
3031
PassConfig::TYPE_OPTIMIZE
3132
);
3233
$container->addCompilerPass(new BootstrapResolver());
34+
$container->addCompilerPass(new ProcessLateBoundParameters(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100_000);
3335
}
3436
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
namespace Unleash\Client\Bundle\Test\DependencyInjection\Dsn;
4+
5+
use Closure;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
use ReflectionException;
8+
use ReflectionObject;
9+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
10+
use Symfony\Component\DependencyInjection\EnvVarProcessor;
11+
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
12+
use Unleash\Client\Bundle\DependencyInjection\Dsn\LateBoundDsnParameter;
13+
use Unleash\Client\Bundle\Test\TestKernel;
14+
15+
final class LateBoundDsnParameterTest extends KernelTestCase
16+
{
17+
protected function tearDown(): void
18+
{
19+
while (true) {
20+
$previousHandler = set_exception_handler(static fn () => null);
21+
restore_exception_handler();
22+
23+
if ($previousHandler === null) {
24+
break;
25+
}
26+
restore_exception_handler();
27+
}
28+
}
29+
30+
/**
31+
* @param string $envValue The raw value of the string that will be assigned to the raw env name
32+
* @param string $envName The name of the environment variable including all preprocessors
33+
* @param mixed $expected If it is a callable, it's called with the result as a parameter to do complex assertions
34+
* otherwise a strict comparison with the result is done.
35+
*
36+
* @throws ReflectionException
37+
*/
38+
#[DataProvider('preprocessorsData')]
39+
public function testPreprocessors(string $envValue, string $envName, mixed $expected)
40+
{
41+
$preprocessors = [new EnvVarProcessor(self::getContainer(), null), $this->customEnvProcessor()];
42+
43+
$rawEnv = array_reverse(explode(':', $envName))[0];
44+
$_ENV[$rawEnv] = $envValue;
45+
46+
$instance = new LateBoundDsnParameter($envName, '', $preprocessors);
47+
$getEnvValue = (new ReflectionObject($instance))->getMethod('getEnvValue');
48+
$result = $getEnvValue->invoke($instance, $envName);
49+
50+
if (is_callable($expected)) {
51+
$expected($result);
52+
} else {
53+
self::assertSame($expected, $result);
54+
}
55+
}
56+
57+
public static function preprocessorsData(): iterable
58+
{
59+
yield ['test', 'TEST_ENV', 'test'];
60+
yield ['1', 'string:int:TEST_ENV', '1'];
61+
yield ['1', 'bool:TEST_ENV', true];
62+
yield ['1', 'not:bool:TEST_ENV', false];
63+
yield ['55', 'int:TEST_ENV', 55];
64+
yield ['55.5', 'float:TEST_ENV', 55.5];
65+
yield ['JSON_THROW_ON_ERROR', 'const:TEST_ENV', JSON_THROW_ON_ERROR];
66+
yield [base64_encode('test'), 'base64:TEST_ENV', 'test'];
67+
yield [json_encode(['a' => 1, 'b' => 'c']), 'json:TEST_ENV', ['a' => 1, 'b' => 'c']];
68+
yield ['test_%some_param%', 'resolve:TEST_ENV', 'test_test'];
69+
yield ['a,b,c,d', 'csv:TEST_ENV', ['a', 'b', 'c', 'd']];
70+
yield ['a,b,c,d', 'shuffle:csv:TEST_ENV', function (array $result) {
71+
self::assertTrue(in_array('a', $result));
72+
self::assertTrue(in_array('b', $result));
73+
self::assertTrue(in_array('c', $result));
74+
self::assertTrue(in_array('d', $result));
75+
}];
76+
yield [__DIR__ . '/../../data/file.txt', 'file:TEST_ENV', "hello\n"];
77+
yield [__DIR__ . '/../../data/file.php', 'require:TEST_ENV', 'test'];
78+
yield [__DIR__ . '/../../data/file.txt', 'trim:file:TEST_ENV', 'hello'];
79+
yield [json_encode(['a' => 'test']), 'key:a:json:TEST_ENV', 'test'];
80+
yield ['https://testUser:testPwd@test-domain.org:8000/test-path?testQuery=testValue#testFragment', 'url:TEST_ENV', function (array $result) {
81+
self::assertSame('https', $result['scheme']);
82+
self::assertSame('test-domain.org', $result['host']);
83+
self::assertSame('testUser', $result['user']);
84+
self::assertSame('testPwd', $result['pass']);
85+
self::assertSame('test-path', $result['path']);
86+
self::assertSame('testQuery=testValue', $result['query']);
87+
self::assertSame('testFragment', $result['fragment']);
88+
self::assertSame(8000, $result['port']);
89+
}];
90+
yield ['https://testUser:testPwd@test-domain.org:8000/test-path?testQuery=testValue#testFragment', 'key:testQuery:query_string:key:query:url:TEST_ENV', 'testValue'];
91+
yield ['whatever', 'defined:TEST_ENV', true];
92+
yield ['whatever', 'test:TEST_ENV', 'test'];
93+
}
94+
95+
public function testDefault()
96+
{
97+
$envName = 'default:some_param:NONEXISTENT_ENV'; // some_param is from kernel container
98+
$preprocessors = [new EnvVarProcessor(self::getContainer(), null)];
99+
$instance = new LateBoundDsnParameter($envName, '', $preprocessors);
100+
$getEnvValue = (new ReflectionObject($instance))->getMethod('getEnvValue');
101+
$result = $getEnvValue->invoke($instance, $envName);
102+
self::assertSame('test', $result);
103+
}
104+
105+
protected static function getKernelClass(): string
106+
{
107+
return TestKernel::class;
108+
}
109+
110+
private function customEnvProcessor(): EnvVarProcessorInterface
111+
{
112+
return new class implements EnvVarProcessorInterface {
113+
public function getEnv(string $prefix, string $name, Closure $getEnv): mixed
114+
{
115+
return 'test';
116+
}
117+
118+
public static function getProvidedTypes(): array
119+
{
120+
return ['test' => 'string'];
121+
}
122+
};
123+
}
124+
}

‎tests/TestKernel.php

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace Unleash\Client\Bundle\Test;
4+
5+
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
6+
use Symfony\Component\Config\Loader\LoaderInterface;
7+
use Symfony\Component\HttpKernel\Kernel;
8+
9+
final class TestKernel extends Kernel
10+
{
11+
public function registerBundles(): iterable
12+
{
13+
yield new FrameworkBundle();
14+
}
15+
16+
public function registerContainerConfiguration(LoaderInterface $loader): void
17+
{
18+
$loader->load(__DIR__ . '/config/framework.yaml');
19+
$loader->load(__DIR__ . '/config/parameters.yaml');
20+
}
21+
}

‎tests/config/framework.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# see https://symfony.com/doc/current/reference/configuration/framework.html
2+
framework:
3+
secret: 'abcd'
4+
test: true
5+
session:
6+
storage_factory_id: session.storage.factory.mock_file

‎tests/config/parameters.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
parameters:
2+
some_param: test

‎tests/data/file.php

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?php
2+
3+
return 'test';

‎tests/data/file.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hello

0 commit comments

Comments
 (0)