diff --git a/src/Evaluator.php b/src/Evaluator.php index f810894..fe98ed8 100644 --- a/src/Evaluator.php +++ b/src/Evaluator.php @@ -4,41 +4,99 @@ declare(strict_types=1); namespace FlagPole; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + final class Evaluator { + private LoggerInterface $logger; + + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? new NullLogger(); + } + public function evaluate(Flag $flag, ?Context $context = null, bool $default = false): bool { $context ??= new Context(); // 1) Allow list wins if present if (!empty($flag->allowList)) { - $key = $this->resolveTargetingKey($context); + $key = $this->resolveTargetingKey($context, $flag->targetingKey); if ($key !== null && in_array((string)$key, $flag->allowList, true)) { + $this->logger->info(sprintf('Flag "%s" enabled for key "%s" via allowList.', $flag->name, $key)); return true; } } // 2) Explicit on/off if ($flag->enabled !== null) { + $this->logger->info(sprintf('Flag "%s" evaluated to %s via explicit "enabled" override.', $flag->name, $flag->enabled ? 'TRUE' : 'FALSE')); return $flag->enabled; } - // 3) Percentage rollout if available + // 3) Rules matching + if (!empty($flag->rules)) { + foreach ($flag->rules as $rule) { + if ($this->matchRule($rule, $context)) { + $this->logger->info(sprintf('Flag "%s" enabled via rule (attribute: %s, operator: %s).', $flag->name, $rule->attribute, $rule->operator)); + return true; + } + } + } + + // 4) Percentage rollout if available if ($flag->rolloutPercentage !== null) { - $key = $this->resolveTargetingKey($context); + $key = $this->resolveTargetingKey($context, $flag->targetingKey); if ($key === null) { + $this->logger->info(sprintf('Flag "%s" rollout evaluation failed: no targeting key found. Using default: %s.', $flag->name, $default ? 'TRUE' : 'FALSE')); return $default; } $bucket = $this->computeBucket($flag->name, (string)$key); - return $bucket < $flag->rolloutPercentage; + $result = $bucket < $flag->rolloutPercentage; + $this->logger->info(sprintf( + 'Flag "%s" evaluated to %s via rolloutPercentage (%d%%). Bucket: %d, Key: %s.', + $flag->name, + $result ? 'TRUE' : 'FALSE', + $flag->rolloutPercentage, + $bucket, + $key + )); + return $result; } - // 4) Fallback + // 5) Fallback + $this->logger->info(sprintf('Flag "%s" using fallback default: %s.', $flag->name, $default ? 'TRUE' : 'FALSE')); return $default; } - private function resolveTargetingKey(Context $context): ?string + private function matchRule(Rule $rule, Context $context): bool { + $attributeValue = $context->get($rule->attribute); + + return match ($rule->operator) { + 'eq' => $attributeValue === $rule->value, + 'neq' => $attributeValue !== $rule->value, + 'gt' => $attributeValue > $rule->value, + 'gte' => $attributeValue >= $rule->value, + 'lt' => $attributeValue < $rule->value, + 'lte' => $attributeValue <= $rule->value, + 'in' => is_array($rule->value) && in_array($attributeValue, $rule->value, true), + 'nin' => is_array($rule->value) && !in_array($attributeValue, $rule->value, true), + 'contains' => is_string($attributeValue) && is_string($rule->value) && str_contains($attributeValue, $rule->value), + default => throw new \LogicException(sprintf('Unsupported operator "%s" encountered during evaluation.', $rule->operator)), + }; + } + + private function resolveTargetingKey(Context $context, ?string $preferredKey = null): ?string + { + if ($preferredKey !== null) { + $v = $context->get($preferredKey); + if ($v !== null && $v !== '') { + return (string)$v; + } + } + $candidates = ['key', 'userId', 'id', 'email']; foreach ($candidates as $attr) { $v = $context->get($attr); @@ -51,10 +109,8 @@ final class Evaluator private function computeBucket(string $flagName, string $key): int { - $hash = crc32($flagName . ':' . $key); - // Normalize to unsigned 32-bit to avoid negative values on some platforms - $unsigned = (int) sprintf('%u', $hash); - // Map to 0..99 - return (int)($unsigned % 100); + $hash = hash('xxh3', $flagName . ':' . $key); + + return (int)(hexdec(substr($hash, 0, 8)) % 100); } } diff --git a/src/FeatureManager.php b/src/FeatureManager.php index 0780b46..860df9f 100644 --- a/src/FeatureManager.php +++ b/src/FeatureManager.php @@ -5,13 +5,18 @@ declare(strict_types=1); namespace FlagPole; use FlagPole\Repository\FlagRepositoryInterface; +use Psr\Log\LoggerInterface; final class FeatureManager { + private Evaluator $evaluator; + public function __construct( private FlagRepositoryInterface $repository, - private Evaluator $evaluator = new Evaluator() + ?Evaluator $evaluator = null, + ?LoggerInterface $logger = null ) { + $this->evaluator = $evaluator ?? new Evaluator($logger); } public function isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool diff --git a/src/Flag.php b/src/Flag.php index a983c76..f2605ff 100644 --- a/src/Flag.php +++ b/src/Flag.php @@ -15,6 +15,9 @@ final class Flag public readonly ?int $rolloutPercentage = null, /** @var list */ public readonly array $allowList = [], + /** @var list */ + public readonly array $rules = [], + public readonly ?string $targetingKey = null, ) { if ($this->rolloutPercentage !== null) { if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) { diff --git a/src/FlagHydrator.php b/src/FlagHydrator.php new file mode 100644 index 0000000..6a6fb26 --- /dev/null +++ b/src/FlagHydrator.php @@ -0,0 +1,51 @@ +, rules?:list, targetingKey?:string|null} $data + * @return Flag + */ + public function hydrate(string $name, array $data): Flag + { + $rules = []; + if (isset($data['rules']) && is_array($data['rules'])) { + foreach ($data['rules'] as $ruleDef) { + if (!isset($ruleDef['attribute'], $ruleDef['operator'], $ruleDef['value'])) { + throw new \InvalidArgumentException(sprintf('Invalid rule definition for flag "%s". Missing attribute, operator, or value.', $name)); + } + + if (!in_array($ruleDef['operator'], self::VALID_OPERATORS, true)) { + throw new \InvalidArgumentException(sprintf('Invalid operator "%s" for flag "%s".', $ruleDef['operator'], $name)); + } + + $rules[] = new Rule( + attribute: $ruleDef['attribute'], + operator: $ruleDef['operator'], + value: $ruleDef['value'] + ); + } + } + + return new Flag( + name: $name, + enabled: $data['enabled'] ?? null, + rolloutPercentage: $data['rolloutPercentage'] ?? null, + allowList: $data['allowList'] ?? [], + rules: $rules, + targetingKey: $data['targetingKey'] ?? null + ); + } +} diff --git a/src/Repository/InMemoryFlagRepository.php b/src/Repository/InMemoryFlagRepository.php index 5714d1a..b02daa0 100644 --- a/src/Repository/InMemoryFlagRepository.php +++ b/src/Repository/InMemoryFlagRepository.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace FlagPole\Repository; use FlagPole\Flag; +use FlagPole\FlagHydrator; +use FlagPole\Rule; /** * Simple in-memory repository. Useful for bootstrapping or tests. @@ -15,18 +17,14 @@ final class InMemoryFlagRepository implements FlagRepositoryInterface private array $flags = []; /** - * @param array}> $config + * @param array, rules?:list, targetingKey?:string|null}> $config */ public static function fromArray(array $config): self { + $hydrator = new FlagHydrator(); $items = []; foreach ($config as $name => $def) { - $items[$name] = new Flag( - name: (string)$name, - enabled: $def['enabled'] ?? null, - rolloutPercentage: $def['rolloutPercentage'] ?? null, - allowList: $def['allowList'] ?? [] - ); + $items[$name] = $hydrator->hydrate((string)$name, $def); } return new self($items); } diff --git a/src/Rule.php b/src/Rule.php new file mode 100644 index 0000000..ca99e27 --- /dev/null +++ b/src/Rule.php @@ -0,0 +1,18 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('rolloutPercentage must be between 0 and 100'); + new Flag('invalid', rolloutPercentage: 101); + } + + public function testFlagValidationNegative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('rolloutPercentage must be between 0 and 100'); + new Flag('invalid', rolloutPercentage: -1); + } + + public function testHydratorMissingFields(): void + { + $hydrator = new FlagHydrator(); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid rule definition for flag "test". Missing attribute, operator, or value.'); + $hydrator->hydrate('test', [ + 'rules' => [ + ['attribute' => 'plan'] // missing operator and value + ] + ]); + } + + public function testHydratorInvalidOperator(): void + { + $hydrator = new FlagHydrator(); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid operator "invalid_op" for flag "test".'); + $hydrator->hydrate('test', [ + 'rules' => [ + ['attribute' => 'plan', 'operator' => 'invalid_op', 'value' => 'pro'] + ] + ]); + } + + /** + * @dataProvider operatorProvider + */ + public function testAllOperators(string $operator, mixed $ruleValue, mixed $contextValue, bool $expected): void + { + $evaluator = new Evaluator(); + $flag = new Flag('test', rules: [ + new Rule('attr', $operator, $ruleValue) + ]); + $context = Context::fromArray(['attr' => $contextValue]); + + $this->assertSame($expected, $evaluator->evaluate($flag, $context)); + } + + public static function operatorProvider(): array + { + return [ + 'eq match' => ['eq', 'val', 'val', true], + 'eq mismatch' => ['eq', 'val', 'other', false], + 'neq match' => ['neq', 'val', 'other', true], + 'neq mismatch' => ['neq', 'val', 'val', false], + 'gt match' => ['gt', 10, 11, true], + 'gt mismatch' => ['gt', 10, 10, false], + 'gte match eq' => ['gte', 10, 10, true], + 'gte match gt' => ['gte', 10, 11, true], + 'gte mismatch' => ['gte', 10, 9, false], + 'lt match' => ['lt', 10, 9, true], + 'lt mismatch' => ['lt', 10, 10, false], + 'lte match eq' => ['lte', 10, 10, true], + 'lte match lt' => ['lte', 10, 9, true], + 'lte mismatch' => ['lte', 10, 11, false], + 'in match' => ['in', ['a', 'b'], 'a', true], + 'in mismatch' => ['in', ['a', 'b'], 'c', false], + 'nin match' => ['nin', ['a', 'b'], 'c', true], + 'nin mismatch' => ['nin', ['a', 'b'], 'a', false], + 'contains match' => ['contains', 'foo', 'foobar', true], + 'contains mismatch' => ['contains', 'baz', 'foobar', false], + ]; + } + + public function testRuntimeUnsupportedOperator(): void + { + $evaluator = new Evaluator(); + // We bypass the hydrator which has validation to test the evaluator safety + $flag = new Flag('test', rules: [ + new Rule('attr', 'unsupported', 'val') + ]); + $context = Context::fromArray(['attr' => 'val']); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Unsupported operator "unsupported" encountered during evaluation.'); + $evaluator->evaluate($flag, $context); + } +} diff --git a/tests/FeatureManagerTest.php b/tests/FeatureManagerTest.php index 9952898..b8f2a04 100644 --- a/tests/FeatureManagerTest.php +++ b/tests/FeatureManagerTest.php @@ -8,6 +8,7 @@ use FlagPole\Context; use FlagPole\FeatureManager; use FlagPole\Repository\InMemoryFlagRepository; use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; final class FeatureManagerTest extends TestCase { @@ -23,6 +24,27 @@ final class FeatureManagerTest extends TestCase $this->assertFalse($fm->isEnabled('hard-off', null, true)); } + public function testLogging(): void + { + $logger = new class extends AbstractLogger { + public array $logs = []; + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->logs[] = (string)$message; + } + }; + + $repo = InMemoryFlagRepository::fromArray([ + 'test-flag' => ['enabled' => true], + ]); + $fm = new FeatureManager($repo, null, $logger); + + $fm->isEnabled('test-flag'); + + $this->assertCount(1, $logger->logs); + $this->assertStringContainsString('Flag "test-flag" evaluated to TRUE via explicit "enabled" override', $logger->logs[0]); + } + public function testAllowListWins(): void { $repo = InMemoryFlagRepository::fromArray([ @@ -116,4 +138,70 @@ final class FeatureManagerTest extends TestCase $this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false)); $this->assertTrue($fm->isEnabled('gradual', $emptyCtx, true)); } + + public function testRulesMatching(): void + { + $repo = InMemoryFlagRepository::fromArray([ + 'beta-users' => [ + 'rules' => [ + ['attribute' => 'group', 'operator' => 'eq', 'value' => 'beta'], + ], + ], + 'version-check' => [ + 'rules' => [ + ['attribute' => 'version', 'operator' => 'gte', 'value' => '2.0'], + ], + ], + 'multi-rules' => [ + 'rules' => [ + ['attribute' => 'plan', 'operator' => 'in', 'value' => ['pro', 'enterprise']], + ['attribute' => 'region', 'operator' => 'eq', 'value' => 'us-east'], + ], + ], + ]); + $fm = new FeatureManager($repo); + + // Single rule 'eq' + $this->assertTrue($fm->isEnabled('beta-users', Context::fromArray(['group' => 'beta']))); + $this->assertFalse($fm->isEnabled('beta-users', Context::fromArray(['group' => 'standard']))); + + // Single rule 'gte' + $this->assertTrue($fm->isEnabled('version-check', Context::fromArray(['version' => '2.0']))); + $this->assertTrue($fm->isEnabled('version-check', Context::fromArray(['version' => '2.1']))); + $this->assertFalse($fm->isEnabled('version-check', Context::fromArray(['version' => '1.9']))); + + // Multi rules (OR behavior because it matches ANY rule in the list as implemented currently) + // Wait, the plan said: "Precedence: allowList > enabled > rules > rolloutPercentage" + // And the implementation I did: + /* + if (!empty($flag->rules)) { + foreach ($flag->rules as $rule) { + if ($this->matchRule($rule, $context)) { + return true; + } + } + } + */ + // This is indeed OR behavior. If ANY rule matches, it returns true. + $this->assertTrue($fm->isEnabled('multi-rules', Context::fromArray(['plan' => 'pro']))); + $this->assertTrue($fm->isEnabled('multi-rules', Context::fromArray(['region' => 'us-east']))); + $this->assertFalse($fm->isEnabled('multi-rules', Context::fromArray(['plan' => 'free', 'region' => 'eu-west']))); + } + + public function testExplicitTargetingKey(): void + { + $repo = InMemoryFlagRepository::fromArray([ + 'custom-key' => [ + 'targetingKey' => 'orgId', + 'allowList' => ['org_123'], + ], + ]); + $fm = new FeatureManager($repo); + + $ctxMatched = Context::fromArray(['orgId' => 'org_123']); + $ctxUnmatched = Context::fromArray(['orgId' => 'org_456', 'userId' => 'org_123']); // userId matches but orgId doesn't + + $this->assertTrue($fm->isEnabled('custom-key', $ctxMatched)); + $this->assertFalse($fm->isEnabled('custom-key', $ctxUnmatched)); + } }