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

@@ -18,8 +18,14 @@ use App\Framework\Filesystem\ValueObjects\FilePath;
* - DB_PASSWORD_FILE=/run/secrets/db_password
* - 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
*
@@ -29,10 +35,22 @@ final readonly class DockerSecretsResolver
*/
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';
// Check if *_FILE variable exists
if (!isset($variables[$fileKey])) {
// Cache the "not found" result
$this->cache[$key] = false;
return null;
}
@@ -40,6 +58,7 @@ final readonly class DockerSecretsResolver
// Validate file path
if (!is_string($filePath)) {
$this->cache[$key] = false;
return null;
}
@@ -56,6 +75,7 @@ final readonly class DockerSecretsResolver
$file = FilePath::create($filePath);
if (!$file->exists()) {
$this->cache[$key] = false;
return null;
}
@@ -68,6 +88,7 @@ final readonly class DockerSecretsResolver
$isRoot = function_exists('posix_geteuid') && posix_geteuid() === 0;
if (!$file->isReadable() && !$isRoot) {
// Not readable and not root, can't read it
$this->cache[$key] = false;
return null;
}
@@ -76,12 +97,18 @@ final readonly class DockerSecretsResolver
$content = @file_get_contents($filePathString);
if ($content === false) {
$this->cache[$key] = false;
return null;
}
return trim($content);
$result = trim($content);
// Cache the successful result
$this->cache[$key] = $result;
return $result;
} catch (\Throwable) {
// Invalid file path or read error
$this->cache[$key] = false;
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

@@ -14,10 +14,10 @@ final readonly class Environment
* @param array<string, mixed> $variables
*/
public function __construct(
private array $variables = [],
private DockerSecretsResolver $secretsResolver = new DockerSecretsResolver()
) {
}
private array $variables = [],
private DockerSecretsResolver $secretsResolver = new DockerSecretsResolver(),
private EnumResolver $enumResolver = new EnumResolver()
) {}
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
// This allows Docker Secrets to override empty values
// Resolver handles caching internally
$secretValue = $this->secretsResolver->resolve($key, $this->variables);
if ($secretValue !== null) {
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")
// 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];
}
@@ -137,18 +140,10 @@ final readonly class Environment
public function getEnum(EnvKey|string $key, string $enumClass, BackedEnum $default): BackedEnum
{
$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);
if ($value === null) {
return $default;
}
return $enumClass::tryFrom($value) ?? $default;
// Use EnumResolver which handles caching internally
return $this->enumResolver->resolve($value, $enumClass, $default);
}
public function has(EnvKey|string $key): bool
@@ -177,14 +172,14 @@ final readonly class Environment
// Resolve Docker Secrets for variables that are empty or not set
// This ensures that variables like DB_PASSWORD are resolved from their *_FILE counterparts
$resolved = [];
// First pass: Process existing variables
foreach ($all as $key => $value) {
// Skip *_FILE variables (we'll resolve them below)
if (str_ends_with($key, '_FILE')) {
continue;
}
// If variable is empty or not set, check for Docker Secret
if (empty($value) || $value === '' || $value === null) {
$secretValue = $this->secretsResolver->resolve($key, $all);
@@ -199,14 +194,14 @@ final readonly class Environment
// Include non-empty values
$resolved[$key] = $value;
}
// Second pass: Check for *_FILE variables that don't have corresponding resolved values
// This handles cases where a *_FILE exists but the variable itself is not in the array
foreach ($all as $key => $value) {
if (str_ends_with($key, '_FILE')) {
// Extract the base key name (e.g., APP_KEY from APP_KEY_FILE)
$baseKey = substr($key, 0, -5); // Remove '_FILE' suffix
// If base key is not set or empty, try to resolve it
if (!isset($resolved[$baseKey]) || empty($resolved[$baseKey])) {
$secretValue = $this->secretsResolver->resolve($baseKey, $all);
@@ -214,7 +209,7 @@ final readonly class Environment
$resolved[$baseKey] = $secretValue;
}
}
// Always include *_FILE variables in output
$resolved[$key] = $value;
}