refactor(config): add EnumResolver for cache-backed enum resolution and extend DockerSecretsResolver with caching

- Introduce `EnumResolver` to centralize and cache enum value conversions.
- Enhance `DockerSecretsResolver` with result caching to avoid redundant file reads and improve performance.
- Update `Environment` to integrate `EnumResolver` for enriched enum resolution support and improved maintainability.
- Adjust unit tests to validate caching mechanisms and error handling improvements.
This commit is contained in:
2025-11-03 23:47:08 +01:00
parent 2a0c797051
commit a1242f776e
6 changed files with 332 additions and 25 deletions

View File

@@ -37,7 +37,6 @@ services:
# Redis # Redis
- REDIS_HOST=staging-redis - REDIS_HOST=staging-redis
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
# Cache # Cache
- CACHE_DRIVER=redis - CACHE_DRIVER=redis
- CACHE_PREFIX=${CACHE_PREFIX:-staging} - CACHE_PREFIX=${CACHE_PREFIX:-staging}
@@ -365,7 +364,6 @@ services:
# Redis # Redis
- REDIS_HOST=staging-redis - REDIS_HOST=staging-redis
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_PASSWORD_FILE=/run/secrets/redis_password - REDIS_PASSWORD_FILE=/run/secrets/redis_password
# Queue # Queue
- QUEUE_DRIVER=redis - QUEUE_DRIVER=redis
@@ -421,7 +419,6 @@ services:
# Redis # Redis
- REDIS_HOST=staging-redis - REDIS_HOST=staging-redis
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_PASSWORD_FILE=/run/secrets/redis_password - REDIS_PASSWORD_FILE=/run/secrets/redis_password
volumes: volumes:
- staging-code:/var/www/html - staging-code:/var/www/html

View File

@@ -18,8 +18,14 @@ use App\Framework\Filesystem\ValueObjects\FilePath;
* - DB_PASSWORD_FILE=/run/secrets/db_password * - DB_PASSWORD_FILE=/run/secrets/db_password
* - Resolves to content of that file * - Resolves to content of that file
*/ */
final readonly class DockerSecretsResolver final class DockerSecretsResolver
{ {
/**
* Cache for resolved secrets (key => resolved value or false if not found)
* @var array<string, string|false>
*/
private array $cache = [];
/** /**
* Resolve a Docker Secret for given key * Resolve a Docker Secret for given key
* *
@@ -29,10 +35,22 @@ final readonly class DockerSecretsResolver
*/ */
public function resolve(string $key, array $variables): ?string public function resolve(string $key, array $variables): ?string
{ {
// Check cache first
if (isset($this->cache[$key])) {
// false means "checked and not found", null means not cached yet
if ($this->cache[$key] === false) {
return null;
}
// Return cached value
return $this->cache[$key];
}
$fileKey = $key . '_FILE'; $fileKey = $key . '_FILE';
// Check if *_FILE variable exists // Check if *_FILE variable exists
if (!isset($variables[$fileKey])) { if (!isset($variables[$fileKey])) {
// Cache the "not found" result
$this->cache[$key] = false;
return null; return null;
} }
@@ -40,6 +58,7 @@ final readonly class DockerSecretsResolver
// Validate file path // Validate file path
if (!is_string($filePath)) { if (!is_string($filePath)) {
$this->cache[$key] = false;
return null; return null;
} }
@@ -56,6 +75,7 @@ final readonly class DockerSecretsResolver
$file = FilePath::create($filePath); $file = FilePath::create($filePath);
if (!$file->exists()) { if (!$file->exists()) {
$this->cache[$key] = false;
return null; return null;
} }
@@ -68,6 +88,7 @@ final readonly class DockerSecretsResolver
$isRoot = function_exists('posix_geteuid') && posix_geteuid() === 0; $isRoot = function_exists('posix_geteuid') && posix_geteuid() === 0;
if (!$file->isReadable() && !$isRoot) { if (!$file->isReadable() && !$isRoot) {
// Not readable and not root, can't read it // Not readable and not root, can't read it
$this->cache[$key] = false;
return null; return null;
} }
@@ -76,12 +97,18 @@ final readonly class DockerSecretsResolver
$content = @file_get_contents($filePathString); $content = @file_get_contents($filePathString);
if ($content === false) { if ($content === false) {
$this->cache[$key] = false;
return null; return null;
} }
return trim($content); $result = trim($content);
// Cache the successful result
$this->cache[$key] = $result;
return $result;
} catch (\Throwable) { } catch (\Throwable) {
// Invalid file path or read error // Invalid file path or read error
$this->cache[$key] = false;
return null; return null;
} }
} }

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Config;
use BackedEnum;
/**
* Resolves enum values from mixed input with caching
*
* Handles conversion of values to BackedEnum instances with caching
* to avoid repeated tryFrom() calls for the same value/enum combination.
*/
final class EnumResolver
{
/**
* Cache for resolved enum values (cacheKey => enum instance)
* @var array<string, BackedEnum>
*/
private array $cache = [];
/**
* Resolve a value to an enum instance
*
* @param mixed $value The value to convert (string, int, etc.)
* @param string $enumClass The enum class name (must be a BackedEnum)
* @param BackedEnum $default Default value to return if conversion fails
* @return BackedEnum The resolved enum instance or default
*/
public function resolve(mixed $value, string $enumClass, BackedEnum $default): BackedEnum
{
if (! $default instanceof $enumClass) {
throw new \InvalidArgumentException('Default value must be an instance of the enum class');
}
// If value is null, return default immediately (no need to cache)
if ($value === null) {
return $default;
}
// Create cache key: serialize value + enum class
$cacheKey = serialize($value) . '|' . $enumClass;
// Check cache first
if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
}
// Try to convert value to enum
$result = $enumClass::tryFrom($value) ?? $default;
// Cache the result
$this->cache[$cacheKey] = $result;
return $result;
}
}

View File

@@ -15,9 +15,9 @@ final readonly class Environment
*/ */
public function __construct( public function __construct(
private array $variables = [], private array $variables = [],
private DockerSecretsResolver $secretsResolver = new DockerSecretsResolver() private DockerSecretsResolver $secretsResolver = new DockerSecretsResolver(),
) { private EnumResolver $enumResolver = new EnumResolver()
} ) {}
public function get(EnvKey|string $key, mixed $default = null): mixed public function get(EnvKey|string $key, mixed $default = null): mixed
{ {
@@ -36,6 +36,7 @@ final readonly class Environment
// 2. Docker Secrets support: Check for *_FILE pattern // 2. Docker Secrets support: Check for *_FILE pattern
// This allows Docker Secrets to override empty values // This allows Docker Secrets to override empty values
// Resolver handles caching internally
$secretValue = $this->secretsResolver->resolve($key, $this->variables); $secretValue = $this->secretsResolver->resolve($key, $this->variables);
if ($secretValue !== null) { if ($secretValue !== null) {
return $secretValue; return $secretValue;
@@ -51,7 +52,9 @@ final readonly class Environment
// 4. If internal variable was set (even if empty), return it (to distinguish between "not set" and "empty") // 4. If internal variable was set (even if empty), return it (to distinguish between "not set" and "empty")
// This preserves the original behavior: if variable is explicitly set to empty string, return it // This preserves the original behavior: if variable is explicitly set to empty string, return it
if (isset($this->variables[$key])) { // BUT: Only do this if no Docker Secret was found (otherwise empty string would override the secret)
// secretValue is null if no secret was found
if (isset($this->variables[$key]) && $secretValue === null) {
return $this->variables[$key]; return $this->variables[$key];
} }
@@ -137,18 +140,10 @@ final readonly class Environment
public function getEnum(EnvKey|string $key, string $enumClass, BackedEnum $default): BackedEnum public function getEnum(EnvKey|string $key, string $enumClass, BackedEnum $default): BackedEnum
{ {
$key = $this->keyToString($key); $key = $this->keyToString($key);
if (! $default instanceof $enumClass) {
throw new \InvalidArgumentException('Default value must be an instance of the enum class');
}
$value = $this->get($key); $value = $this->get($key);
if ($value === null) { // Use EnumResolver which handles caching internally
return $default; return $this->enumResolver->resolve($value, $enumClass, $default);
}
return $enumClass::tryFrom($value) ?? $default;
} }
public function has(EnvKey|string $key): bool public function has(EnvKey|string $key): bool

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
use App\Framework\Config\EnumResolver;
use BackedEnum;
describe('EnumResolver', function () {
beforeEach(function () {
$this->resolver = new EnumResolver();
});
describe('resolve()', function () {
it('converts string value to enum', function () {
// Create a test enum
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$default = TestEnum::BAR;
$result = $this->resolver->resolve('foo', TestEnum::class, $default);
expect($result)->toBe(TestEnum::FOO);
});
it('converts int value to enum', function () {
// Create a test enum with int backing
if (!enum_exists('IntTestEnum')) {
eval('enum IntTestEnum: int { case ONE = 1; case TWO = 2; }');
}
$default = IntTestEnum::TWO;
$result = $this->resolver->resolve(1, IntTestEnum::class, $default);
expect($result)->toBe(IntTestEnum::ONE);
});
it('returns default when value cannot be converted', function () {
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$default = TestEnum::BAR;
$result = $this->resolver->resolve('invalid', TestEnum::class, $default);
expect($result)->toBe($default);
});
it('returns default when value is null', function () {
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$default = TestEnum::BAR;
$result = $this->resolver->resolve(null, TestEnum::class, $default);
expect($result)->toBe($default);
});
it('throws exception when default is not instance of enum class', function () {
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
if (!enum_exists('AnotherEnum')) {
eval('enum AnotherEnum: string { case BAZ = "baz"; }');
}
$wrongDefault = AnotherEnum::BAZ;
expect(function () use ($wrongDefault) {
$this->resolver->resolve('foo', TestEnum::class, $wrongDefault);
})->toThrow(\InvalidArgumentException::class);
});
});
describe('caching', function () {
it('caches enum conversion results', function () {
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$default = TestEnum::BAR;
// First call - should convert
$enum1 = $this->resolver->resolve('foo', TestEnum::class, $default);
expect($enum1)->toBe(TestEnum::FOO);
// Second call to same resolver instance should use cache
// (We can't directly verify cache, but we can verify it returns the same instance)
$enum2 = $this->resolver->resolve('foo', TestEnum::class, $default);
expect($enum2)->toBe(TestEnum::FOO);
expect($enum1)->toBe($enum2);
});
it('caches different enum classes separately', function () {
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
if (!enum_exists('AnotherTestEnum')) {
eval('enum AnotherTestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$default1 = TestEnum::BAR;
$default2 = AnotherTestEnum::BAR;
$enum1 = $this->resolver->resolve('foo', TestEnum::class, $default1);
$enum2 = $this->resolver->resolve('foo', AnotherTestEnum::class, $default2);
expect($enum1)->toBe(TestEnum::FOO);
expect($enum2)->toBe(AnotherTestEnum::FOO);
expect($enum1)->not->toBe($enum2); // Different enum classes
});
it('caches different values separately', function () {
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$default = TestEnum::BAR;
$enum1 = $this->resolver->resolve('foo', TestEnum::class, $default);
$enum2 = $this->resolver->resolve('bar', TestEnum::class, $default);
expect($enum1)->toBe(TestEnum::FOO);
expect($enum2)->toBe(TestEnum::BAR);
});
it('caches default values', function () {
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$default = TestEnum::BAR;
// First call with null
$enum1 = $this->resolver->resolve(null, TestEnum::class, $default);
expect($enum1)->toBe($default);
// Second call with null should return cached default
$enum2 = $this->resolver->resolve(null, TestEnum::class, $default);
expect($enum2)->toBe($default);
expect($enum1)->toBe($enum2);
});
});
});

View File

@@ -583,6 +583,89 @@ describe('Environment', function () {
unlink($dbPasswordPath); unlink($dbPasswordPath);
unlink($apiKeyPath); unlink($apiKeyPath);
}); });
it('uses Docker secret when variable is set to empty string', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_from_file');
$env = new Environment([
'REDIS_PASSWORD' => '', // Empty string
'REDIS_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('REDIS_PASSWORD');
// Docker Secret should override empty string
expect($value)->toBe('secret_from_file');
// Cleanup
unlink($secretPath);
});
it('returns empty string when variable is empty and no Docker secret exists', function () {
$env = new Environment([
'REDIS_PASSWORD' => '', // Empty string, no FILE
], $this->dockerSecretsResolver);
$value = $env->get('REDIS_PASSWORD');
// Empty string should be returned when explicitly set
expect($value)->toBe('');
});
it('caches Docker secret resolution results', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test_caching';
file_put_contents($secretPath, 'cached_secret_value');
$env = new Environment([
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
// First call - should read from file
$value1 = $env->get('DB_PASSWORD');
expect($value1)->toBe('cached_secret_value');
// Modify file content (should not affect cached value)
file_put_contents($secretPath, 'modified_secret_value');
// Second call - should use cache, not re-read file
$value2 = $env->get('DB_PASSWORD');
expect($value2)->toBe('cached_secret_value');
// Cleanup
unlink($secretPath);
});
it('caches enum conversion results', function () {
// Create a test enum
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$env = new Environment([
'APP_ENV' => 'foo',
], $this->dockerSecretsResolver);
$default = TestEnum::BAR;
// First call - should convert
$enum1 = $env->getEnum('APP_ENV', TestEnum::class, $default);
expect($enum1)->toBe(TestEnum::FOO);
// Second call to same instance should use cache
$enum2 = $env->getEnum('APP_ENV', TestEnum::class, $default);
expect($enum2)->toBe(TestEnum::FOO);
expect($enum1)->toBe($enum2);
// Different enum class should have different cache entry
if (!enum_exists('AnotherTestEnum')) {
eval('enum AnotherTestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$enum3 = $env->getEnum('APP_ENV', AnotherTestEnum::class, AnotherTestEnum::BAR);
expect($enum3)->toBe(AnotherTestEnum::FOO);
});
}); });
describe('Edge Cases', function () { describe('Edge Cases', function () {