From 6b5768605f28edbf76fb33bbb69b4d7e69f70aac Mon Sep 17 00:00:00 2001 From: aubes <3941035+aubes@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:12:18 +0200 Subject: [PATCH] feat: parse flag values strictly in built-in providers --- CHANGELOG.md | 6 + docs/features/twig.md | 2 + docs/providers/env-var.md | 10 +- docs/providers/in-memory.md | 10 +- docs/providers/redis.md | 12 +- src/Provider/EnvVarProvider.php | 15 +- src/Provider/InMemoryProvider.php | 11 +- src/Provider/RedisProvider.php | 89 +++------ src/Provider/ResolutionDetailsTrait.php | 39 +++- tests/Provider/EnvVarProviderTest.php | 235 ++++++++++++++++++++++++ tests/Provider/InMemoryProviderTest.php | 108 +++++++++++ tests/Provider/RedisProviderTest.php | 155 ++++++++++++++++ 12 files changed, 601 insertions(+), 91 deletions(-) create mode 100644 tests/Provider/EnvVarProviderTest.php create mode 100644 tests/Provider/InMemoryProviderTest.php create mode 100644 tests/Provider/RedisProviderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8399c1c..0af39da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,15 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `API` service is now an isolated `OpenFeatureAPI` instance created by the container (SDK 2.3.0 isolated instances) instead of the `OpenFeatureAPI::getInstance()` global singleton. Provider, hooks, and evaluation context are no longer shared with other kernels running in the same PHP process. - `open-feature/sdk` requirement raised from `^2.2` to `^2.3` (the bundle relies on the public `OpenFeatureAPI` constructor introduced in SDK 2.3.0). +- `EnvVarProvider` and `RedisProvider` now return a `PARSE_ERROR` (and the default value) for raw values that do not match the requested type, instead of silently casting them (`"abc"` as integer used to resolve to `0`, `"banana"` as boolean to `false`). +- `InMemoryProvider` returns a `TYPE_MISMATCH` instead of casting for integers other than `0`/`1` requested as boolean (e.g. `2` or `-1`, previously `true`), non-string values requested as string (e.g. `42`, previously `"42"`), and floats requested as integer (e.g. `1.5`, previously `1`). +- `ResolutionDetailsTrait::toBool()` is replaced by `parseBool()`, `parseInt()`, `parseFloat()`, and `parseObject()`. ### Fixed - `flags` and `providers`: keys are now kept as declared. Dashes were converted to underscores (a flag declared as `new-checkout` could only be evaluated as `new_checkout`), and an object flag holding a `name` key was renamed after that value. The undocumented list form `flags: [{name: ..., value: ...}]` is no longer supported. +- `RedisProvider` now logs Redis client failures at `error` level. Previously, an unavailable Redis silently resolved every flag to its default value, with nothing in the logs. ### Upgrade notes - Run `composer update open-feature/sdk` if your lock file pins a version below 2.3.0. - Code calling `OpenFeatureAPI::getInstance()` directly now gets an instance distinct from the bundle's `API` service (different provider, hooks, and evaluation context). Inject the `API` or `Client` service instead. +- **Check your raw flag values.** `EnvVarProvider` and `RedisProvider` no longer cast unparsable values: a boolean flag set to anything other than `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`/empty (e.g. `FEATURE_X=enabled`, previously `false`) or a numeric flag with a non-numeric value (for integers, decimal or exponent notation such as `10.0` or `1e3` too; leading zeros such as `08` are accepted) now resolves to the default value with a `PARSE_ERROR`. `InMemoryProvider` flags declared with a mismatching type (e.g. `max_items: 1.5` read as integer, `label: 42` read as string) now resolve to the default value with a `TYPE_MISMATCH`, like with typed providers such as flagd. In Twig, `{{ feature_value('max_items') }}` without a default reads the flag as a string and now renders `''`: pass a typed default (`feature_value('max_items', 10)`). These errors are not logged by the SDK: check the `open_feature` profiler panel in dev (error column), or register a hook that logs `ResolutionDetails::getError()` in `after()`. +- Custom providers using `ResolutionDetailsTrait::toBool()` must switch to `parseBool($flagKey, $raw, $defaultValue)`, which returns a `ResolutionDetails` instead of a `bool`. ## [0.3.0] - 2026-06-15 diff --git a/docs/features/twig.md b/docs/features/twig.md index e7f76a2..5af6f57 100644 --- a/docs/features/twig.md +++ b/docs/features/twig.md @@ -30,4 +30,6 @@ The SDK method is dispatched based on the type of the default value: | `float` | `getFloatValue()` | | `array` | `getObjectValue()` | +Always pass a default of the flag's type. Without a default, the flag is read as a string: a boolean or integer flag then resolves to `''` with a `TYPE_MISMATCH` error, as typed providers such as flagd do. + > **Note:** The Twig extension is only registered when `twig/twig` is available in the project. diff --git a/docs/providers/env-var.md b/docs/providers/env-var.md index 9ca7ff7..898349d 100644 --- a/docs/providers/env-var.md +++ b/docs/providers/env-var.md @@ -33,8 +33,14 @@ FEATURE_MAX_ITEMS=10 FEATURE_CONFIG='{"color":"blue"}' ``` -Boolean truthy values: `true`, `1`, `yes`, `on`. +| Requested type | Accepted raw values | +|---|---| +| boolean | `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off` (case-insensitive), empty string (`false`) | +| integer | whole numbers with an optional sign; leading zeros allowed (`08`), no decimal or exponent notation (`10.0`, `1e3`) | +| float | any numeric notation (`3.14`, `08`, `1e3`) | +| string | any value | +| object | a JSON object or array | -Object values must be JSON-encoded. Invalid JSON returns the default value with `ErrorCode::PARSE_ERROR`. +Any other raw value returns the default value with `ErrorCode::PARSE_ERROR` (e.g. `FEATURE_X=enabled` read as boolean). If the environment variable is not set, the provider returns the default value with `ErrorCode::FLAG_NOT_FOUND`. diff --git a/docs/providers/in-memory.md b/docs/providers/in-memory.md index a1893ed..e7df25a 100644 --- a/docs/providers/in-memory.md +++ b/docs/providers/in-memory.md @@ -24,9 +24,11 @@ A flag value must be resolved with the method matching its YAML type: | YAML value | Resolved via | |---|---| -| `true` / `false` | `getBooleanValue()` (also readable as `getStringValue()`) | -| `42` | `getIntegerValue()` or `getFloatValue()` (also readable as `getStringValue()`) | +| `true` / `false` | `getBooleanValue()` | +| `0` / `1` | `getBooleanValue()` (as `false` / `true`), `getIntegerValue()` or `getFloatValue()` | +| `42` | `getIntegerValue()` or `getFloatValue()` | +| `1.5` | `getFloatValue()` | | `"dark"` | `getStringValue()` | -| `{color: blue}` | `getObjectValue()` | +| `{color: blue}` or a list | `getObjectValue()` | -Any other combination returns the default value with `ErrorCode::TYPE_MISMATCH`. +Any other combination returns the default value with `ErrorCode::TYPE_MISMATCH`. No value is cast to string: read a boolean or numeric flag with its own method. diff --git a/docs/providers/redis.md b/docs/providers/redis.md index 034f222..557e38e 100644 --- a/docs/providers/redis.md +++ b/docs/providers/redis.md @@ -46,10 +46,18 @@ feature:config -> '{"color":"blue"}' ## Values -Boolean truthy values: `true`, `1`, `yes`, `on`. +| Requested type | Accepted raw values | +|---|---| +| boolean | `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off` (case-insensitive), empty string (`false`) | +| integer | whole numbers with an optional sign; leading zeros allowed (`08`), no decimal or exponent notation (`10.0`, `1e3`) | +| float | any numeric notation (`3.14`, `08`, `1e3`) | +| string | any value | +| object | a JSON object or array | -Object values must be JSON-encoded. Invalid JSON returns the default value with `ErrorCode::PARSE_ERROR`. +Any other raw value returns the default value with `ErrorCode::PARSE_ERROR` (e.g. `"enabled"` read as boolean). ## Error handling If Redis is unavailable, the provider returns the default value with an error reason (`ErrorCode::GENERAL`) instead of throwing an exception. This makes it safe to use in production without risking a full outage if Redis goes down. + +Each failure is logged at `error` level with the exception, so an outage shows up in your logs. During an outage, this means one log entry per flag evaluation: use Monolog's `fingers_crossed` or deduplication handlers if the volume is a concern. diff --git a/src/Provider/EnvVarProvider.php b/src/Provider/EnvVarProvider.php index 812f791..e446d76 100644 --- a/src/Provider/EnvVarProvider.php +++ b/src/Provider/EnvVarProvider.php @@ -6,7 +6,6 @@ use OpenFeature\implementation\provider\AbstractProvider; use OpenFeature\interfaces\flags\EvaluationContext; -use OpenFeature\interfaces\provider\ErrorCode; use OpenFeature\interfaces\provider\Provider; use OpenFeature\interfaces\provider\ResolutionDetails; @@ -32,7 +31,7 @@ public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?Evalua return $this->flagNotFound($flagKey, $defaultValue); } - return $this->found($this->toBool($raw)); + return $this->parseBool($flagKey, $raw, $defaultValue); } public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails @@ -54,7 +53,7 @@ public function resolveIntegerValue(string $flagKey, int $defaultValue, ?Evaluat return $this->flagNotFound($flagKey, $defaultValue); } - return $this->found((int) $raw); + return $this->parseInt($flagKey, $raw, $defaultValue); } public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails @@ -65,7 +64,7 @@ public function resolveFloatValue(string $flagKey, float $defaultValue, ?Evaluat return $this->flagNotFound($flagKey, $defaultValue); } - return $this->found((float) $raw); + return $this->parseFloat($flagKey, $raw, $defaultValue); } /** @@ -79,13 +78,7 @@ public function resolveObjectValue(string $flagKey, array $defaultValue, ?Evalua return $this->flagNotFound($flagKey, $defaultValue); } - $decoded = \json_decode($raw, true); - - if (!\is_array($decoded)) { - return $this->error(ErrorCode::PARSE_ERROR(), \sprintf('Flag "%s" contains invalid JSON', $flagKey), $defaultValue); - } - - return $this->found($decoded); + return $this->parseObject($flagKey, $raw, $defaultValue); } private function getEnvVar(string $flagKey): ?string diff --git a/src/Provider/InMemoryProvider.php b/src/Provider/InMemoryProvider.php index 5069d68..8343175 100644 --- a/src/Provider/InMemoryProvider.php +++ b/src/Provider/InMemoryProvider.php @@ -29,7 +29,8 @@ public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?Evalua $value = $this->flags[$flagKey]; - if (!\is_bool($value) && !\is_int($value)) { + // 0/1 accepted for YAML convenience; other integers are ambiguous + if (!\is_bool($value) && $value !== 0 && $value !== 1) { return $this->error(ErrorCode::TYPE_MISMATCH(), \sprintf('Flag "%s" is not of type boolean', $flagKey), $defaultValue); } @@ -44,11 +45,11 @@ public function resolveStringValue(string $flagKey, string $defaultValue, ?Evalu $value = $this->flags[$flagKey]; - if (!\is_scalar($value)) { + if (!\is_string($value)) { return $this->error(ErrorCode::TYPE_MISMATCH(), \sprintf('Flag "%s" is not of type string', $flagKey), $defaultValue); } - return $this->found((string) $value); + return $this->found($value); } public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails @@ -59,11 +60,11 @@ public function resolveIntegerValue(string $flagKey, int $defaultValue, ?Evaluat $value = $this->flags[$flagKey]; - if (!\is_int($value) && !\is_float($value)) { + if (!\is_int($value)) { return $this->error(ErrorCode::TYPE_MISMATCH(), \sprintf('Flag "%s" is not of type integer', $flagKey), $defaultValue); } - return $this->found((int) $value); + return $this->found($value); } public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails diff --git a/src/Provider/RedisProvider.php b/src/Provider/RedisProvider.php index 328e7b5..0dd5cb5 100644 --- a/src/Provider/RedisProvider.php +++ b/src/Provider/RedisProvider.php @@ -27,62 +27,22 @@ public function __construct( public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails { - try { - $raw = $this->getRaw($flagKey); - } catch (\RuntimeException $e) { - return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue); - } - - if ($raw === null) { - return $this->flagNotFound($flagKey, $defaultValue); - } - - return $this->found($this->toBool($raw)); + return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseBool($flagKey, $raw, $defaultValue)); } public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails { - try { - $raw = $this->getRaw($flagKey); - } catch (\RuntimeException $e) { - return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue); - } - - if ($raw === null) { - return $this->flagNotFound($flagKey, $defaultValue); - } - - return $this->found($raw); + return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->found($raw)); } public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails { - try { - $raw = $this->getRaw($flagKey); - } catch (\RuntimeException $e) { - return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue); - } - - if ($raw === null) { - return $this->flagNotFound($flagKey, $defaultValue); - } - - return $this->found((int) $raw); + return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseInt($flagKey, $raw, $defaultValue)); } public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails { - try { - $raw = $this->getRaw($flagKey); - } catch (\RuntimeException $e) { - return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue); - } - - if ($raw === null) { - return $this->flagNotFound($flagKey, $defaultValue); - } - - return $this->found((float) $raw); + return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseFloat($flagKey, $raw, $defaultValue)); } /** @@ -90,33 +50,32 @@ public function resolveFloatValue(string $flagKey, float $defaultValue, ?Evaluat */ public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetails { - try { - $raw = $this->getRaw($flagKey); - } catch (\RuntimeException $e) { - return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue); - } - - if ($raw === null) { - return $this->flagNotFound($flagKey, $defaultValue); - } - - $decoded = \json_decode($raw, true); - - if (!\is_array($decoded)) { - return $this->error(ErrorCode::PARSE_ERROR(), \sprintf('Flag "%s" contains invalid JSON', $flagKey), $defaultValue); - } - - return $this->found($decoded); + return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseObject($flagKey, $raw, $defaultValue)); } - private function getRaw(string $flagKey): ?string + /** + * @param bool|float|int|mixed[]|string $defaultValue + * @param \Closure(string): ResolutionDetails $parse + */ + private function resolveRaw(string $flagKey, bool|string|int|float|array $defaultValue, \Closure $parse): ResolutionDetails { try { - $value = $this->client->get($this->prefix . $flagKey); + $raw = $this->client->get($this->prefix . $flagKey); } catch (\Throwable $e) { - throw new \RuntimeException($e->getMessage(), previous: $e); + // The SDK does not log errors returned in ResolutionDetails + $this->logger?->error('OpenFeature Redis provider failed to read flag "{flag}": {message}', [ + 'flag' => $flagKey, + 'message' => $e->getMessage(), + 'exception' => $e, + ]); + + return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue); + } + + if ($raw === false || $raw === null) { + return $this->flagNotFound($flagKey, $defaultValue); } - return ($value === false || $value === null) ? null : $value; + return $parse($raw); } } diff --git a/src/Provider/ResolutionDetailsTrait.php b/src/Provider/ResolutionDetailsTrait.php index e7e6bbf..a8e1abc 100644 --- a/src/Provider/ResolutionDetailsTrait.php +++ b/src/Provider/ResolutionDetailsTrait.php @@ -41,8 +41,43 @@ private function error(ErrorCode $code, string $message, bool|string|int|float|a ->build(); } - private function toBool(string $value): bool + /** + * Accepts true/false, 1/0, yes/no, on/off (case-insensitive) and the empty string (false). + */ + private function parseBool(string $flagKey, string $raw, bool $defaultValue): ResolutionDetails { - return \in_array(\strtolower($value), ['true', '1', 'yes', 'on'], true); + $value = \filter_var($raw, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE); + + return $value === null ? $this->parseError($flagKey, 'boolean', $defaultValue) : $this->found($value); + } + + private function parseInt(string $flagKey, string $raw, int $defaultValue): ResolutionDetails + { + // Leading zeros are accepted ("08"), as FILTER_VALIDATE_FLOAT already does + $normalized = \preg_replace('/^([+-]?)0+(?=\d)/', '$1', \trim($raw)); + $value = \filter_var($normalized, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE); + + return $value === null ? $this->parseError($flagKey, 'integer', $defaultValue) : $this->found($value); + } + + private function parseFloat(string $flagKey, string $raw, float $defaultValue): ResolutionDetails + { + $value = \filter_var($raw, \FILTER_VALIDATE_FLOAT, \FILTER_NULL_ON_FAILURE); + + return $value === null ? $this->parseError($flagKey, 'float', $defaultValue) : $this->found($value); + } + + /** @param mixed[] $defaultValue */ + private function parseObject(string $flagKey, string $raw, array $defaultValue): ResolutionDetails + { + $value = \json_decode($raw, true); + + return \is_array($value) ? $this->found($value) : $this->parseError($flagKey, 'JSON object', $defaultValue); + } + + /** @param bool|float|int|mixed[]|string $defaultValue */ + private function parseError(string $flagKey, string $type, bool|string|int|float|array $defaultValue): ResolutionDetails + { + return $this->error(ErrorCode::PARSE_ERROR(), \sprintf('Flag "%s" is not a valid %s', $flagKey, $type), $defaultValue); } } diff --git a/tests/Provider/EnvVarProviderTest.php b/tests/Provider/EnvVarProviderTest.php new file mode 100644 index 0000000..5515fba --- /dev/null +++ b/tests/Provider/EnvVarProviderTest.php @@ -0,0 +1,235 @@ +resolveBooleanValue('my_flag', true); + + $this->assertTrue($result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertNotNull($result->getError()); + $this->assertEquals(ErrorCode::FLAG_NOT_FOUND(), $result->getError()->getResolutionErrorCode()); + } + + #[DataProvider('provideTruthyValues')] + public function testResolveBooleanValueReturnsTrueForTruthyEnvVar(string $value): void + { + \putenv("FEATURE_MY_FLAG={$value}"); + $provider = new EnvVarProvider(); + + $result = $provider->resolveBooleanValue('my_flag', false); + + $this->assertTrue($result->getValue()); + } + + /** @return array */ + public static function provideTruthyValues(): array + { + return [ + 'true string' => ['true'], + 'TRUE string' => ['TRUE'], + 'one string' => ['1'], + 'yes string' => ['yes'], + 'on string' => ['on'], + ]; + } + + #[DataProvider('provideFalsyValues')] + public function testResolveBooleanValueReturnsFalseForFalsyEnvVar(string $value): void + { + \putenv("FEATURE_MY_FLAG={$value}"); + $provider = new EnvVarProvider(); + + $result = $provider->resolveBooleanValue('my_flag', true); + + $this->assertFalse($result->getValue()); + } + + /** @return array */ + public static function provideFalsyValues(): array + { + return [ + 'false string' => ['false'], + 'zero string' => ['0'], + 'no string' => ['no'], + 'off string' => ['off'], + 'empty string' => [''], + ]; + } + + public function testResolveStringValueReturnsEnvVar(): void + { + \putenv('FEATURE_MY_STRING=hello'); + $provider = new EnvVarProvider(); + + $result = $provider->resolveStringValue('my_string', 'default'); + + $this->assertSame('hello', $result->getValue()); + $this->assertSame(Reason::DEFAULT, $result->getReason()); + $this->assertNull($result->getError()); + } + + #[DataProvider('provideIntegerValues')] + public function testResolveIntegerValueParsesValidNotations(string $raw, int $expected): void + { + \putenv("FEATURE_MY_INT={$raw}"); + $provider = new EnvVarProvider(); + + $result = $provider->resolveIntegerValue('my_int', 99); + + $this->assertSame($expected, $result->getValue()); + $this->assertNull($result->getError()); + } + + /** @return array */ + public static function provideIntegerValues(): array + { + return [ + 'plain' => ['42', 42], + 'leading zero' => ['08', 8], + 'several leading zeros' => ['007', 7], + 'negative with leading zero' => ['-08', -8], + 'explicit plus sign' => ['+8', 8], + 'zero' => ['0', 0], + 'double zero' => ['00', 0], + 'surrounding spaces' => [' 8 ', 8], + ]; + } + + #[DataProvider('provideFloatValues')] + public function testResolveFloatValueParsesNumericNotations(string $raw, float $expected): void + { + \putenv("FEATURE_MY_FLOAT={$raw}"); + $provider = new EnvVarProvider(); + + $result = $provider->resolveFloatValue('my_float', 0.0); + + $this->assertSame($expected, $result->getValue()); + $this->assertNull($result->getError()); + } + + /** @return array */ + public static function provideFloatValues(): array + { + return [ + 'decimal' => ['3.14', 3.14], + 'integer with leading zero' => ['08', 8.0], + 'exponent notation' => ['1e3', 1000.0], + ]; + } + + public function testResolveObjectValueReturnsDecodedJsonEnvVar(): void + { + \putenv('FEATURE_MY_OBJECT={"key":"value"}'); + $provider = new EnvVarProvider(); + + $result = $provider->resolveObjectValue('my_object', []); + + $this->assertSame(['key' => 'value'], $result->getValue()); + } + + public function testResolveObjectValueAcceptsJsonArray(): void + { + \putenv('FEATURE_MY_OBJECT=[1,2]'); + $provider = new EnvVarProvider(); + + $result = $provider->resolveObjectValue('my_object', []); + + $this->assertSame([1, 2], $result->getValue()); + } + + public function testCustomPrefixIsUsed(): void + { + \putenv('APP_MY_FLAG=true'); + $provider = new EnvVarProvider('APP_'); + + $result = $provider->resolveBooleanValue('my_flag', false); + + $this->assertTrue($result->getValue()); + } + + #[DataProvider('provideFlagKeys')] + public function testFlagKeyIsMappedToTheEnvVarName(string $flagKey): void + { + \putenv('FEATURE_MY_FLAG=true'); + $provider = new EnvVarProvider(); + + $result = $provider->resolveBooleanValue($flagKey, false); + + $this->assertTrue($result->getValue()); + } + + /** @return array */ + public static function provideFlagKeys(): array + { + return [ + 'hyphen' => ['my-flag'], + 'dot' => ['my.flag'], + 'uppercase' => ['MY_FLAG'], + ]; + } + + public function testGetMetadataReturnsProviderName(): void + { + $provider = new EnvVarProvider(); + + $this->assertSame('EnvVarProvider', $provider->getMetadata()->getName()); + } + + #[DataProvider('provideUnparsableValues')] + public function testUnparsableValueReturnsParseError(string $method, string $raw, mixed $default): void + { + \putenv("FEATURE_MY_FLAG={$raw}"); + $provider = new EnvVarProvider(); + + $result = $provider->{$method}('my_flag', $default); + $this->assertInstanceOf(ResolutionDetails::class, $result); + + $this->assertSame($default, $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals(ErrorCode::PARSE_ERROR(), $result->getError()?->getResolutionErrorCode()); + } + + /** @return array */ + public static function provideUnparsableValues(): array + { + return [ + 'boolean' => ['resolveBooleanValue', 'banana', true], + 'integer' => ['resolveIntegerValue', 'abc', 7], + 'integer with decimals' => ['resolveIntegerValue', '1.5', 7], + 'integer with decimal zero' => ['resolveIntegerValue', '10.0', 7], + 'integer in exponent notation' => ['resolveIntegerValue', '1e3', 7], + 'integer overflow' => ['resolveIntegerValue', '9223372036854775808', 7], + 'hexadecimal integer' => ['resolveIntegerValue', '0x1A', 7], + 'float' => ['resolveFloatValue', 'abc', 1.5], + 'object' => ['resolveObjectValue', 'not-json', ['fallback' => true]], + 'object from JSON scalar' => ['resolveObjectValue', '42', ['fallback' => true]], + ]; + } +} diff --git a/tests/Provider/InMemoryProviderTest.php b/tests/Provider/InMemoryProviderTest.php new file mode 100644 index 0000000..aeea35b --- /dev/null +++ b/tests/Provider/InMemoryProviderTest.php @@ -0,0 +1,108 @@ + $value]); + + $result = $provider->{$method}('flag', $default); + $this->assertInstanceOf(ResolutionDetails::class, $result); + + $this->assertSame($expected, $result->getValue()); + $this->assertSame(Reason::DEFAULT, $result->getReason()); + $this->assertNull($result->getError()); + } + + /** @return array */ + public static function provideFlagValues(): array + { + return [ + 'boolean' => ['resolveBooleanValue', false, true, false], + 'boolean from integer 1' => ['resolveBooleanValue', 1, false, true], + 'boolean from integer 0' => ['resolveBooleanValue', 0, true, false], + 'string' => ['resolveStringValue', 'dark', 'light', 'dark'], + 'integer' => ['resolveIntegerValue', 10, 5, 10], + 'float' => ['resolveFloatValue', 1.5, 0.0, 1.5], + 'float from integer' => ['resolveFloatValue', 2, 0.0, 2.0], + 'object' => ['resolveObjectValue', ['color' => 'blue'], [], ['color' => 'blue']], + 'object from list' => ['resolveObjectValue', [1, 2], [], [1, 2]], + ]; + } + + #[DataProvider('provideDefaultValues')] + public function testUnknownFlagReturnsFlagNotFound(string $method, mixed $default): void + { + $provider = new InMemoryProvider([]); + + $result = $provider->{$method}('unknown', $default); + $this->assertInstanceOf(ResolutionDetails::class, $result); + + $this->assertSame($default, $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals(ErrorCode::FLAG_NOT_FOUND(), $result->getError()?->getResolutionErrorCode()); + } + + /** @return array */ + public static function provideDefaultValues(): array + { + return [ + 'boolean' => ['resolveBooleanValue', true], + 'string' => ['resolveStringValue', 'default'], + 'integer' => ['resolveIntegerValue', 42], + 'float' => ['resolveFloatValue', 1.5], + 'object' => ['resolveObjectValue', ['key' => 'value']], + ]; + } + + #[DataProvider('provideTypeMismatches')] + public function testMismatchedValueReturnsTypeMismatch(string $method, mixed $value, mixed $default): void + { + $provider = new InMemoryProvider(['flag' => $value]); + + $result = $provider->{$method}('flag', $default); + $this->assertInstanceOf(ResolutionDetails::class, $result); + + $this->assertSame($default, $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals(ErrorCode::TYPE_MISMATCH(), $result->getError()?->getResolutionErrorCode()); + } + + /** @return array */ + public static function provideTypeMismatches(): array + { + return [ + 'boolean from integer 2' => ['resolveBooleanValue', 2, false], + 'boolean from string' => ['resolveBooleanValue', 'true', false], + 'boolean from null' => ['resolveBooleanValue', null, false], + 'string from boolean' => ['resolveStringValue', true, 'default'], + 'string from integer' => ['resolveStringValue', 42, 'default'], + 'integer from float' => ['resolveIntegerValue', 1.5, 0], + 'integer from string' => ['resolveIntegerValue', '10', 0], + 'integer from boolean' => ['resolveIntegerValue', true, 0], + 'float from string' => ['resolveFloatValue', '1.5', 0.0], + 'object from string' => ['resolveObjectValue', 'not-an-array', ['key' => 'value']], + ]; + } + + public function testGetMetadataReturnsProviderName(): void + { + $provider = new InMemoryProvider([]); + + $this->assertSame('InMemoryProvider', $provider->getMetadata()->getName()); + } +} diff --git a/tests/Provider/RedisProviderTest.php b/tests/Provider/RedisProviderTest.php new file mode 100644 index 0000000..b93b6e0 --- /dev/null +++ b/tests/Provider/RedisProviderTest.php @@ -0,0 +1,155 @@ +createStub(RedisClientInterface::class); + $client->method('get')->willReturn($raw); + + $result = (new RedisProvider($client))->{$method}('flag', $default); + $this->assertInstanceOf(ResolutionDetails::class, $result); + + $this->assertSame($expected, $result->getValue()); + $this->assertSame(Reason::DEFAULT, $result->getReason()); + $this->assertNull($result->getError()); + } + + /** @return array */ + public static function provideRawValues(): array + { + return [ + 'boolean true' => ['resolveBooleanValue', 'true', false, true], + 'boolean false' => ['resolveBooleanValue', 'off', true, false], + 'string' => ['resolveStringValue', 'dark', 'light', 'dark'], + 'integer' => ['resolveIntegerValue', '10', 5, 10], + 'float' => ['resolveFloatValue', '3.14', 1.0, 3.14], + 'object' => ['resolveObjectValue', '{"color":"blue","size":3}', [], ['color' => 'blue', 'size' => 3]], + ]; + } + + #[DataProvider('provideMissingKeys')] + public function testMissingKeyReturnsFlagNotFound(string $method, ?false $raw, mixed $default): void + { + $client = $this->createStub(RedisClientInterface::class); + $client->method('get')->willReturn($raw); + + $result = (new RedisProvider($client))->{$method}('unknown', $default); + $this->assertInstanceOf(ResolutionDetails::class, $result); + + $this->assertSame($default, $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals(ErrorCode::FLAG_NOT_FOUND(), $result->getError()?->getResolutionErrorCode()); + } + + /** @return array */ + public static function provideMissingKeys(): array + { + return [ + 'boolean, client returns null' => ['resolveBooleanValue', null, true], + 'boolean, client returns false' => ['resolveBooleanValue', false, true], + 'string' => ['resolveStringValue', null, 'default'], + 'integer' => ['resolveIntegerValue', null, 42], + 'float' => ['resolveFloatValue', null, 1.5], + 'object' => ['resolveObjectValue', null, ['key' => 'value']], + ]; + } + + #[DataProvider('provideUnparsableValues')] + public function testUnparsableValueReturnsParseError(string $method, string $raw, mixed $default): void + { + $client = $this->createStub(RedisClientInterface::class); + $client->method('get')->willReturn($raw); + + $result = (new RedisProvider($client))->{$method}('flag', $default); + $this->assertInstanceOf(ResolutionDetails::class, $result); + + $this->assertSame($default, $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals(ErrorCode::PARSE_ERROR(), $result->getError()?->getResolutionErrorCode()); + } + + /** @return array */ + public static function provideUnparsableValues(): array + { + return [ + 'boolean' => ['resolveBooleanValue', 'banana', true], + 'integer' => ['resolveIntegerValue', 'abc', 7], + 'integer with decimals' => ['resolveIntegerValue', '1.5', 7], + 'float' => ['resolveFloatValue', 'abc', 1.5], + 'object' => ['resolveObjectValue', 'not-json', ['key' => 'value']], + ]; + } + + public function testUsesConfiguredPrefix(): void + { + $client = $this->createMock(RedisClientInterface::class); + $client->expects($this->once()) + ->method('get') + ->with('flags:my_flag') + ->willReturn('true'); + + $provider = new RedisProvider($client, 'flags:'); + $provider->resolveBooleanValue('my_flag', false); + } + + public function testDefaultPrefixIsFeatureColon(): void + { + $client = $this->createMock(RedisClientInterface::class); + $client->expects($this->once()) + ->method('get') + ->with('feature:my_flag') + ->willReturn('true'); + + $provider = new RedisProvider($client); + $provider->resolveBooleanValue('my_flag', false); + } + + public function testGetMetadataReturnsProviderName(): void + { + $client = $this->createStub(RedisClientInterface::class); + $provider = new RedisProvider($client); + + $this->assertSame('RedisProvider', $provider->getMetadata()->getName()); + } + + public function testClientFailureReturnsGeneralError(): void + { + $exception = new \RuntimeException('Connection refused'); + $client = $this->createStub(RedisClientInterface::class); + $client->method('get')->willThrowException($exception); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with( + 'OpenFeature Redis provider failed to read flag "{flag}": {message}', + ['flag' => 'flag', 'message' => 'Connection refused', 'exception' => $exception], + ); + + $provider = new RedisProvider($client); + $provider->setLogger($logger); + $result = $provider->resolveBooleanValue('flag', true); + + $this->assertTrue($result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals(ErrorCode::GENERAL(), $result->getError()?->getResolutionErrorCode()); + $this->assertStringContainsString('Connection refused', (string) $result->getError()?->getResolutionErrorMessage()); + } +}