diff --git a/.editorconfig b/.editorconfig index 1ef065a..8f27871 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,3 +12,7 @@ trim_trailing_whitespace = true [*.php] insert_final_newline = true indent_size = 4 + +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore index 7a68dca..3e81678 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ cache/ .phpunit* example-1 + +monorepo +specs diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a99a62 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: install test setup-monorepo update-monorepo test-example-project + +install: + composer install + +test: + composer test + +setup-monorepo: + mkdir -p monorepo + if [ ! -d "monorepo/.git" ]; then \ + git clone git@github.com:featurevisor/featurevisor.git monorepo; \ + else \ + (cd monorepo && git fetch origin main && git checkout main && git pull origin main); \ + fi + (cd monorepo && make install && make build) + +update-monorepo: + (cd monorepo && git pull origin main) + +test-example-project: + ./featurevisor test --projectDirectoryPath="./monorepo/examples/example-1" diff --git a/README.md b/README.md index 7b3c35a..ced31d0 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) - [Updating datafile](#updating-datafile) - - [Interval-based update](#interval-based-update) - [Logging](#logging) + - [Levels](#levels) - [Customizing levels](#customizing-levels) - [Handler](#handler) - [Events](#events) @@ -347,18 +347,21 @@ The triggers for setting the datafile again can be: - a specific event in your application (like a user action), or - an event served via websocket or server-sent events (SSE) -### Interval-based update - -Here's an example of using interval-based update: - -@TODO - ## Logging By default, Featurevisor SDKs will print out logs to the console for `info` level and above. Featurevisor PHP-SDK by default uses [PSR-3 standard](https://www.php-fig.org/psr/psr-3/) simple implementation. You can also choose from many mature implementations like e.g. [Monolog](https://github.com/Seldaek/monolog) +### Levels + +These are all the available log levels: + +- `error` +- `warning` +- `info` +- `debug` + ### Customizing levels If you choose `debug` level to make the logs more verbose, you can set it at the time of SDK initialization. @@ -416,8 +419,6 @@ Featurevisor SDK implements a simple event emitter that allows you to listen to You can listen to these events that can occur at various stages in your application: -@TODO: verify these events - ### `datafile_set` ```php @@ -515,8 +516,8 @@ $myCustomHook = [ // rest of the properties below are all optional per hook // before evaluation - 'before' => function (options) { - $type = $options['type']; // `feature` | `variation` | `variable` + 'before' => function ($options) { + $type = $options['type']; // `flag` | `variation` | `variable` $featureKey = $options['featureKey']; $variableKey = $options['variableKey']; // if type is `variable` $context = $options['context']; @@ -655,9 +656,13 @@ $ vendor/bin/featurevisor test \ --quiet|verbose \ --onlyFailures \ --keyPattern="myFeatureKey" \ - --assertionPattern="#1" + --assertionPattern="#1" \ + --with-tags \ + --with-scopes ``` +If your assertions include `scope`, run tests with `--with-scopes` to evaluate against scoped datafiles generated on the fly via `npx featurevisor build --scope= --environment= --json`. + ### Benchmark Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). diff --git a/featurevisor b/featurevisor index 98542d0..58f763f 100755 --- a/featurevisor +++ b/featurevisor @@ -44,6 +44,8 @@ $cliOptions = [ 'variation' => parseCliOption($argv, 'variation'), 'verbose' => parseCliOption($argv, 'verbose'), 'inflate' => parseCliOption($argv, 'inflate'), + 'withScopes' => parseCliOption($argv, 'withScopes'), + 'withTags' => parseCliOption($argv, 'withTags'), 'rootDirectoryPath' => $cwd, 'populateUuid' => array_reduce($argv, function($acc, $arg) { if (strpos($arg, '--populateUuid=') === 0) { @@ -70,13 +72,13 @@ function executeCommand(string $command): string { function getConfig(string $featurevisorProjectPath): array { echo "Getting config..." . PHP_EOL; - $configOutput = executeCommand("(cd $featurevisorProjectPath && npx featurevisor config --json)"); + $configOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor config --json)"); return json_decode($configOutput, true); } function getSegments(string $featurevisorProjectPath): array { echo "Getting segments..." . PHP_EOL; - $segmentsOutput = executeCommand("(cd $featurevisorProjectPath && npx featurevisor list --segments --json)"); + $segmentsOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor list --segments --json)"); $segments = json_decode($segmentsOutput, true); $segmentsByKey = []; foreach ($segments as $segment) { @@ -85,14 +87,74 @@ function getSegments(string $featurevisorProjectPath): array { return $segmentsByKey; } -function buildDatafiles(string $featurevisorProjectPath, array $environments): array { - $datafilesByEnvironment = []; +function buildSingleDatafile( + string $featurevisorProjectPath, + string $environment, + ?string $tag = null, + ?string $scope = null +): array { + $command = "(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor build --json"; + $command .= " --environment=" . escapeshellarg($environment); + + if ($tag) { + $command .= " --tag=" . escapeshellarg($tag); + } + + if ($scope) { + $command .= " --scope=" . escapeshellarg($scope); + } + + $command .= ")"; + $output = executeCommand($command); + + $decoded = json_decode($output, true); + + return is_array($decoded) ? $decoded : []; +} + +function buildDatafiles(string $featurevisorProjectPath, array $config, array $cliOptions): array { + $datafilesByKey = []; + + $environments = $config['environments'] ?? []; + $scopes = $config['scopes'] ?? []; + $tags = $config['tags'] ?? []; + foreach ($environments as $environment) { echo "Building datafile for environment: $environment..." . PHP_EOL; - $datafileOutput = executeCommand("(cd $featurevisorProjectPath && npx featurevisor build --environment=$environment --json)"); - $datafilesByEnvironment[$environment] = json_decode($datafileOutput, true); + $datafilesByKey[$environment] = buildSingleDatafile($featurevisorProjectPath, $environment); + + if ($cliOptions['withScopes'] === true) { + foreach ($scopes as $scope) { + if (!isset($scope['name']) || !is_string($scope['name'])) { + continue; + } + $scopeName = $scope['name']; + echo "Building scoped datafile for environment: $environment, scope: $scopeName..." . PHP_EOL; + $datafilesByKey[$environment . '-scope-' . $scopeName] = buildSingleDatafile( + $featurevisorProjectPath, + $environment, + null, + $scopeName + ); + } + } + + if ($cliOptions['withTags'] === true) { + foreach ($tags as $tag) { + if (!is_string($tag)) { + continue; + } + echo "Building tagged datafile for environment: $environment, tag: $tag..." . PHP_EOL; + $datafilesByKey[$environment . '-tag-' . $tag] = buildSingleDatafile( + $featurevisorProjectPath, + $environment, + $tag + ); + } + } } - return $datafilesByEnvironment; + + return $datafilesByKey; } function getLoggerLevel(array $cliOptions): string { @@ -121,7 +183,7 @@ function getTests(string $featurevisorProjectPath, array $cliOptions): array { $testsSuffix .= " --assertionPattern=" . $cliOptions['assertionPattern']; } - $testsOutput = executeCommand("(cd $featurevisorProjectPath && npx featurevisor list --tests --applyMatrix --json" . $testsSuffix . ")"); + $testsOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor list --tests --applyMatrix --json" . $testsSuffix . ")"); return json_decode($testsOutput, true); } @@ -130,8 +192,8 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a $sticky = isset($assertion["sticky"]) ? $assertion["sticky"] : []; // Update the SDK instance context and sticky values for this assertion - $f->setContext($context); - $f->setSticky($sticky); + $f->setContext($context, true); + $f->setSticky($sticky, true); $hasError = false; $errors = ""; @@ -187,7 +249,7 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a foreach ($expectedEvaluations["flag"] as $key => $expectedValue) { if ($actualEvaluation[$key] !== $expectedValue) { $hasError = true; - $errors .= " ✘ expectedEvaluations.flag.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($evaluation[$key]) . PHP_EOL; + $errors .= " ✘ expectedEvaluations.flag.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($actualEvaluation[$key] ?? null) . PHP_EOL; } } } @@ -199,7 +261,7 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a foreach ($expectedEvaluations["variation"] as $key => $expectedValue) { if ($actualEvaluation[$key] !== $expectedValue) { $hasError = true; - $errors .= " ✘ expectedEvaluations.variation.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($evaluation[$key]) . PHP_EOL; + $errors .= " ✘ expectedEvaluations.variation.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($actualEvaluation[$key] ?? null) . PHP_EOL; } } } @@ -214,7 +276,7 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a foreach ($expectedEvaluation as $key => $expectedValue) { if ($actualEvaluation[$key] !== $expectedValue) { $hasError = true; - $errors .= " ✘ expectedEvaluations.variables.$variableKey.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($actualValue[$key]) . PHP_EOL; + $errors .= " ✘ expectedEvaluations.variables.$variableKey.$key: expected " . json_encode($expectedValue) . " but received " . json_encode($actualEvaluation[$key] ?? null) . PHP_EOL; } } } @@ -288,7 +350,7 @@ function test(array $cliOptions) { $config = getConfig($featurevisorProjectPath); $environments = $config['environments']; $segmentsByKey = getSegments($featurevisorProjectPath); - $datafilesByEnvironment = buildDatafiles($featurevisorProjectPath, $environments); + $datafilesByKey = buildDatafiles($featurevisorProjectPath, $config, $cliOptions); echo PHP_EOL; @@ -300,27 +362,6 @@ function test(array $cliOptions) { return; } - // Create SDK instances for each environment - $sdkInstancesByEnvironment = []; - foreach ($environments as $environment) { - $datafile = $datafilesByEnvironment[$environment]; - $sdkInstancesByEnvironment[$environment] = Featurevisor::createInstance([ - 'datafile' => $datafile, - 'logger' => Logger::create([ - 'level' => $level, - ]), - 'hooks' => [ - [ - 'name' => 'tester-hook', - 'bucketValue' => function ($options) { - // This will be overridden per assertion if needed - return $options["bucketValue"]; - } - ] - ] - ]); - } - $passedTestsCount = 0; $failedTestsCount = 0; $passedAssertionsCount = 0; @@ -337,29 +378,63 @@ function test(array $cliOptions) { $testResult = []; if (isset($test["feature"])) { - $environment = $assertion["environment"]; - $f = $sdkInstancesByEnvironment[$environment]; + $environment = $assertion["environment"] ?? null; + if (!$environment || !isset($datafilesByKey[$environment])) { + $testResult = [ + 'hasError' => true, + 'errors' => " ✘ missing datafile for environment: " . json_encode($environment) . PHP_EOL, + 'duration' => 0 + ]; + } else { + $datafile = $datafilesByKey[$environment]; + + if (isset($assertion["scope"])) { + $scopeDatafileKey = $environment . '-scope-' . $assertion["scope"]; + if ($cliOptions['withScopes'] === true && isset($datafilesByKey[$scopeDatafileKey])) { + $datafile = $datafilesByKey[$scopeDatafileKey]; + } elseif ($cliOptions['withScopes'] !== true) { + $scope = null; + foreach ($config['scopes'] ?? [] as $scopeCandidate) { + if (($scopeCandidate['name'] ?? null) === $assertion["scope"]) { + $scope = $scopeCandidate; + break; + } + } + + if ($scope && isset($scope['context']) && is_array($scope['context'])) { + $assertion['context'] = array_merge($scope['context'], $assertion['context'] ?? []); + } + } + } + + if (isset($assertion["tag"])) { + $tagDatafileKey = $environment . '-tag-' . $assertion["tag"]; + if ($cliOptions['withTags'] === true && isset($datafilesByKey[$tagDatafileKey])) { + $datafile = $datafilesByKey[$tagDatafileKey]; + } + } - // If "at" parameter is provided, create a new SDK instance with the specific hook - if (isset($assertion["at"])) { - $datafile = $datafilesByEnvironment[$environment]; $f = Featurevisor::createInstance([ 'datafile' => $datafile, 'logger' => Logger::create([ 'level' => $level, ]), + 'sticky' => $assertion['sticky'] ?? [], 'hooks' => [ [ 'name' => 'tester-hook', 'bucketValue' => function ($options) use ($assertion) { - return $assertion["at"] * 1000; + if (isset($assertion["at"])) { + return $assertion["at"] * 1000; + } + return $options["bucketValue"]; } ] ] ]); - } - $testResult = testFeature($assertion, $test["feature"], $f, $level); + $testResult = testFeature($assertion, $test["feature"], $f, $level); + } } else if (isset($test["segment"])) { $testResult = testSegment($assertion, $segmentsByKey[$test["segment"]], $level); } diff --git a/src/Conditions.php b/src/Conditions.php index 5b1ef14..4bb303e 100644 --- a/src/Conditions.php +++ b/src/Conditions.php @@ -87,12 +87,8 @@ public static function conditionIsMatched($condition, array $context, callable $ if (count($notConditions) === 0) { return true; } - foreach ($notConditions as $subCondition) { - if (self::conditionIsMatched($subCondition, $context, $getRegex)) { - return false; - } - } - return true; + // JS SDK semantics: "not" negates the entire AND group. + return !self::conditionIsMatched(['and' => $notConditions], $context, $getRegex); } $attribute = $condition['attribute'] ?? ''; @@ -121,14 +117,14 @@ public static function conditionIsMatched($condition, array $context, callable $ (is_string($contextValueFromPath) || is_numeric($contextValueFromPath) || $contextValueFromPath === null) ) { // in / notIn (where condition value is an array) + if (!self::pathExists($context, $attribute)) { + return false; + } $valueInContext = $contextValueFromPath; if ($operator === 'in') { return in_array($valueInContext, $value); - } elseif ( - $operator === 'notIn' && - self::pathExists($context, $attribute) - ) { + } elseif ($operator === 'notIn') { return !in_array($valueInContext, $value); } diff --git a/src/DatafileReader.php b/src/DatafileReader.php index 9aacdf7..c40042c 100644 --- a/src/DatafileReader.php +++ b/src/DatafileReader.php @@ -190,12 +190,9 @@ public function allConditionsAreMatched($conditions, array $context): bool return false; } if (isset($conditions['not']) && is_array($conditions['not'])) { - foreach ($conditions['not'] as $subCondition) { - if ($this->allConditionsAreMatched($subCondition, $context)) { - return false; - } - } - return true; + return $this->allConditionsAreMatched([ + 'and' => $conditions['not'], + ], $context) === false; } // If it's a plain array, treat as AND (all must match) if (array_keys($conditions) === range(0, count($conditions) - 1)) { diff --git a/src/EvaluateByBucketing.php b/src/EvaluateByBucketing.php index eae156b..228a209 100644 --- a/src/EvaluateByBucketing.php +++ b/src/EvaluateByBucketing.php @@ -233,23 +233,76 @@ public static function evaluate(array $options, array $feature, ?array $variable // variable if ($type === 'variable' && $variableKey) { // override from rule - if ($matchedTraffic && isset($matchedTraffic['variables'][$variableKey])) { - $result['evaluation'] = [ - 'type' => $type, - 'featureKey' => $featureKey, - 'reason' => Evaluation::RULE, - 'bucketKey' => $bucketKey, - 'bucketValue' => $bucketValue, - 'ruleKey' => $matchedTraffic['key'], - 'traffic' => $matchedTraffic, - 'variableKey' => $variableKey, - 'variableSchema' => $variableSchema, - 'variableValue' => $matchedTraffic['variables'][$variableKey] - ]; + if ($matchedTraffic) { + // "variableOverrides" + if (isset($matchedTraffic['variableOverrides'][$variableKey])) { + $overrides = $matchedTraffic['variableOverrides'][$variableKey]; + $override = null; + $overrideIndex = -1; - $logger->debug('override from rule', $result['evaluation']); + foreach ($overrides as $index => $o) { + if (isset($o['conditions'])) { + $conditions = is_string($o['conditions']) && $o['conditions'] !== '*' + ? json_decode($o['conditions'], true) + : $o['conditions']; - return $result; + if ($datafileReader->allConditionsAreMatched($conditions, $context)) { + $override = $o; + $overrideIndex = $index; + break; + } + } + + if (isset($o['segments'])) { + $segments = $datafileReader->parseSegmentsIfStringified($o['segments']); + if ($datafileReader->allSegmentsAreMatched($segments, $context)) { + $override = $o; + $overrideIndex = $index; + break; + } + } + } + + if ($override) { + $result['evaluation'] = [ + 'type' => $type, + 'featureKey' => $featureKey, + 'reason' => Evaluation::VARIABLE_OVERRIDE_RULE, + 'bucketKey' => $bucketKey, + 'bucketValue' => $bucketValue, + 'ruleKey' => $matchedTraffic['key'] ?? null, + 'traffic' => $matchedTraffic, + 'variableKey' => $variableKey, + 'variableSchema' => $variableSchema, + 'variableValue' => $override['value'], + 'variableOverrideIndex' => $overrideIndex, + ]; + + $logger->debug('variable override from rule', $result['evaluation']); + + return $result; + } + } + + // from "variables" + if (isset($matchedTraffic['variables']) && array_key_exists($variableKey, $matchedTraffic['variables'])) { + $result['evaluation'] = [ + 'type' => $type, + 'featureKey' => $featureKey, + 'reason' => Evaluation::RULE, + 'bucketKey' => $bucketKey, + 'bucketValue' => $bucketValue, + 'ruleKey' => $matchedTraffic['key'], + 'traffic' => $matchedTraffic, + 'variableKey' => $variableKey, + 'variableSchema' => $variableSchema, + 'variableValue' => $matchedTraffic['variables'][$variableKey] + ]; + + $logger->debug('override from rule', $result['evaluation']); + + return $result; + } } // check variations @@ -301,7 +354,7 @@ public static function evaluate(array $options, array $feature, ?array $variable $result['evaluation'] = [ 'type' => $type, 'featureKey' => $featureKey, - 'reason' => Evaluation::VARIABLE_OVERRIDE, + 'reason' => Evaluation::VARIABLE_OVERRIDE_VARIATION, 'bucketKey' => $bucketKey, 'bucketValue' => $bucketValue, 'ruleKey' => $matchedTraffic['key'] ?? null, diff --git a/src/Evaluation.php b/src/Evaluation.php index ce38e39..d5472a9 100644 --- a/src/Evaluation.php +++ b/src/Evaluation.php @@ -15,6 +15,8 @@ class Evaluation public const VARIABLE_DEFAULT = 'variable_default'; public const VARIABLE_DISABLED = 'variable_disabled'; public const VARIABLE_OVERRIDE = 'variable_override'; + public const VARIABLE_OVERRIDE_VARIATION = 'variable_override_variation'; + public const VARIABLE_OVERRIDE_RULE = 'variable_override_rule'; public const NO_MATCH = 'no_match'; public const FORCED = 'forced'; public const STICKY = 'sticky'; diff --git a/src/Featurevisor.php b/src/Featurevisor.php index d58ae9e..aacad1d 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -4,7 +4,7 @@ use Closure; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use Psr\Log\LogLevel; class Featurevisor { @@ -18,6 +18,7 @@ class Featurevisor /** * @param array{ * datafile?: string|array, + * logLevel?: LogLevel::*|string, * logger?: LoggerInterface, * context?: array, * sticky?: array, @@ -33,7 +34,9 @@ class Featurevisor */ public static function createInstance(array $options): self { - $logger = $options['logger'] ?? new NullLogger(); + $logger = $options['logger'] ?? Logger::create([ + 'level' => $options['logLevel'] ?? Logger::DEFAULT_LEVEL, + ]); return new self( isset($options['datafile']) @@ -114,6 +117,13 @@ public function getFeature(string $featureKey): ?array return $this->datafileReader->getFeature($featureKey); } + public function setLogLevel(string $level): void + { + if (method_exists($this->logger, 'setLevel')) { + $this->logger->setLevel($level); + } + } + public function addHook(array $hook): ?callable { return $this->hooksManager->add($hook); @@ -398,12 +408,7 @@ public function getVariable(string $featureKey, string $variableKey, array $cont public function getVariableBoolean(string $featureKey, string $variableKey, array $context = [], array $options = []): ?bool { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - - if ($value === null) { - return null; - } - - return (bool) $value; + return Helpers::getValueByType($value, 'boolean'); } /** @@ -418,12 +423,7 @@ public function getVariableBoolean(string $featureKey, string $variableKey, arra public function getVariableString(string $featureKey, string $variableKey, array $context = [], array $options = []): ?string { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - - if ($value === null) { - return null; - } - - return (string) $value; + return Helpers::getValueByType($value, 'string'); } /** @@ -438,12 +438,7 @@ public function getVariableString(string $featureKey, string $variableKey, array public function getVariableInteger(string $featureKey, string $variableKey, array $context = [], array $options = []): ?int { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - - if ($value === null) { - return null; - } - - return (int) $value; + return Helpers::getValueByType($value, 'integer'); } /** @@ -458,12 +453,7 @@ public function getVariableInteger(string $featureKey, string $variableKey, arra public function getVariableDouble(string $featureKey, string $variableKey, array $context = [], array $options = []): ?float { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - - if ($value === null) { - return null; - } - - return (float) $value; + return Helpers::getValueByType($value, 'double'); } /** @@ -478,12 +468,7 @@ public function getVariableDouble(string $featureKey, string $variableKey, array public function getVariableArray(string $featureKey, string $variableKey, array $context = [], array $options = []): ?array { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - - if ($value === null) { - return null; - } - - return is_array($value) ? $value : [$value]; + return Helpers::getValueByType($value, 'array'); } /** @@ -498,12 +483,7 @@ public function getVariableArray(string $featureKey, string $variableKey, array public function getVariableObject(string $featureKey, string $variableKey, array $context = [], array $options = []) { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - - if ($value === null) { - return null; - } - - return is_array($value) ? $value : null; + return Helpers::getValueByType($value, 'object'); } /** diff --git a/src/Helpers.php b/src/Helpers.php index 5d490ae..274ca74 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -23,7 +23,7 @@ public static function getValueByType($value, string $fieldType) case 'array': return is_array($value) ? $value : null; case 'object': - return is_object($value) ? $value : null; + return is_array($value) || is_object($value) ? $value : null; // @NOTE: `json` is not handled here intentionally default: return $value; diff --git a/src/Logger.php b/src/Logger.php index 92e12b4..973f192 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -44,10 +44,10 @@ public static function create(array $options = []): self ); } - public function __construct(string $level = self::DEFAULT_LEVEL, Closure $handler = null) + public function __construct(string $level = self::DEFAULT_LEVEL, ?Closure $handler = null) { $this->handler = $handler ?? static fn ($level, $message, array $context) => self::defaultLogHandler($level, $message, $context); - $this->level = $level; + $this->setLevel($level); } public function setLevel(string $level): void @@ -61,7 +61,13 @@ public function setLevel(string $level): void public function log($level, $message, array $context = []): void { - $shouldHandle = array_search($this->level, self::ALL_LEVELS) >= array_search($level, self::ALL_LEVELS); + $level = (string) $level; + + if (!in_array($level, self::ALL_LEVELS, true)) { + throw new InvalidArgumentException('Invalid log level'); + } + + $shouldHandle = array_search($this->level, self::ALL_LEVELS, true) >= array_search($level, self::ALL_LEVELS, true); if (!$shouldHandle) { return; @@ -85,4 +91,5 @@ private static function defaultLogHandler($level, $message, ?array $details = nu ) . PHP_EOL ); } + } diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index d16ab9f..52d36c3 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -281,8 +281,8 @@ public function testNotCondition() { [ 'attribute' => 'browser_version', 'operator' => 'equals', 'value' => '1.0' ], ]]]; self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); + self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); } diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index 6167da6..f0a8f63 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -23,6 +23,58 @@ public function testShouldCreateInstanceWithDatafileContent() self::assertTrue(method_exists($sdk, 'getVariation')); } + public function testShouldCreateInstanceWithLogLevel() + { + $logs = []; + $sdk = Featurevisor::createInstance([ + 'logLevel' => LogLevel::DEBUG, + 'logger' => Logger::create([ + 'level' => LogLevel::ERROR, + 'handler' => function ($level, $message, $context) use (&$logs) { + $logs[] = compact('level', 'message', 'context'); + }, + ]), + 'datafile' => [ + 'schemaVersion' => '2', + 'revision' => '1.0', + 'features' => [], + 'segments' => [], + ], + ]); + + $sdk->setContext(['userId' => '123']); + + // logger option should take precedence over logLevel option + self::assertCount(0, $logs); + } + + public function testShouldSetLogLevelAfterInitialization() + { + $logs = []; + $sdk = Featurevisor::createInstance([ + 'logger' => Logger::create([ + 'level' => LogLevel::ERROR, + 'handler' => function ($level, $message, $context) use (&$logs) { + $logs[] = compact('level', 'message', 'context'); + }, + ]), + 'datafile' => [ + 'schemaVersion' => '2', + 'revision' => '1.0', + 'features' => [], + 'segments' => [], + ], + ]); + + $sdk->setContext(['userId' => '123']); + self::assertCount(0, $logs); + + $sdk->setLogLevel(LogLevel::DEBUG); + $sdk->setContext(['country' => 'nl']); + self::assertCount(1, $logs); + self::assertSame('debug', $logs[0]['level']); + } + public function testShouldConfigurePlainBucketBy() { $capturedBucketKey = ''; @@ -1096,6 +1148,141 @@ public function testShouldGetVariablesWithoutAnyVariations() self::assertEquals('orange', $sdk->getVariable('test', 'color', array_merge($defaultContext, ['country' => 'nl']))); } + public function testShouldApplyRuleVariableOverridesOnTopOfRuleVariables() + { + $sdk = Featurevisor::createInstance([ + 'datafile' => [ + 'schemaVersion' => '2', + 'revision' => '1.0', + 'segments' => [ + 'germany' => [ + 'key' => 'germany', + 'conditions' => json_encode([ + [ + 'attribute' => 'country', + 'operator' => 'equals', + 'value' => 'de', + ], + ]), + ], + 'mobile' => [ + 'key' => 'mobile', + 'conditions' => json_encode([ + [ + 'attribute' => 'device', + 'operator' => 'equals', + 'value' => 'mobile', + ], + ]), + ], + ], + 'features' => [ + 'test' => [ + 'key' => 'test', + 'bucketBy' => 'userId', + 'variablesSchema' => [ + 'config' => [ + 'key' => 'config', + 'type' => 'object', + 'defaultValue' => [ + 'source' => 'default', + 'nested' => ['value' => 0], + ], + ], + ], + 'traffic' => [ + [ + 'key' => 'germany', + 'segments' => 'germany', + 'percentage' => 100000, + 'variables' => [ + 'config' => [ + 'source' => 'rule', + 'nested' => ['value' => 10], + 'flag' => true, + ], + ], + 'variableOverrides' => [ + 'config' => [ + [ + 'segments' => 'mobile', + 'value' => [ + 'source' => 'rule', + 'nested' => ['value' => 20], + 'flag' => true, + ], + ], + [ + 'conditions' => [ + [ + 'attribute' => 'country', + 'operator' => 'equals', + 'value' => 'de', + ], + ], + 'value' => [ + 'source' => 'rule', + 'nested' => ['value' => 30], + 'flag' => true, + ], + ], + ], + ], + ], + [ + 'key' => 'everyone', + 'segments' => '*', + 'percentage' => 100000, + 'variables' => [ + 'config' => [ + 'source' => 'everyone', + 'nested' => ['value' => 1], + ], + ], + ], + ], + ], + ], + ], + ]); + + self::assertEquals( + [ + 'source' => 'rule', + 'nested' => ['value' => 30], + 'flag' => true, + ], + $sdk->getVariableObject('test', 'config', [ + 'userId' => 'user-1', + 'country' => 'de', + ]) + ); + + self::assertEquals( + [ + 'source' => 'rule', + 'nested' => ['value' => 20], + 'flag' => true, + ], + $sdk->getVariableObject('test', 'config', [ + 'userId' => 'user-1', + 'country' => 'de', + 'device' => 'mobile', + ]) + ); + + self::assertEquals( + [ + 'source' => 'everyone', + 'nested' => ['value' => 1], + ], + $sdk->getVariableObject('test', 'config', [ + 'userId' => 'user-1', + 'country' => 'nl', + ]) + ); + } + public function testShouldCheckIfEnabledForIndividuallyNamedSegments() { $sdk = Featurevisor::createInstance([ @@ -1160,4 +1347,138 @@ public function testShouldCheckIfEnabledForIndividuallyNamedSegments() self::assertTrue($sdk->isEnabled('test', ['userId' => '123', 'country' => 'nl'])); self::assertTrue($sdk->isEnabled('test', ['userId' => '123', 'country' => 'us', 'device' => 'iphone'])); } + + public function testShouldGetArrayAndObjectVariables() + { + $sdk = Featurevisor::createInstance([ + 'datafile' => [ + 'schemaVersion' => '2', + 'revision' => '1.0', + 'features' => [ + 'withArray' => [ + 'key' => 'withArray', + 'bucketBy' => 'userId', + 'variablesSchema' => [ + 'simpleArray' => [ + 'key' => 'simpleArray', + 'type' => 'array', + 'defaultValue' => ['red', 'blue', 'green'], + ], + 'simpleStringArray' => [ + 'key' => 'simpleStringArray', + 'type' => 'array', + 'defaultValue' => ['red', 'blue', 'green'], + ], + 'objectArray' => [ + 'key' => 'objectArray', + 'type' => 'array', + 'defaultValue' => [ + ['color' => 'red', 'opacity' => 100], + ['color' => 'blue', 'opacity' => 90], + ['color' => 'green', 'opacity' => 95], + ], + ], + ], + 'traffic' => [ + [ + 'key' => '1', + 'segments' => '*', + 'percentage' => 100000, + 'allocation' => [], + ], + ], + ], + 'withObject' => [ + 'key' => 'withObject', + 'bucketBy' => 'userId', + 'variablesSchema' => [ + 'themeConfig' => [ + 'key' => 'themeConfig', + 'type' => 'object', + 'defaultValue' => [ + 'theme' => 'light', + 'darkMode' => false, + ], + ], + 'headerConfig' => [ + 'key' => 'headerConfig', + 'type' => 'object', + 'defaultValue' => [ + 'style' => ['fontSize' => 18, 'bold' => true], + 'title' => 'Welcome', + ], + ], + 'mixedConfig' => [ + 'key' => 'mixedConfig', + 'type' => 'object', + 'defaultValue' => [ + 'name' => 'mixed', + 'enabled' => true, + 'meta' => ['score' => 0.95, 'items' => ['a', 'b']], + ], + ], + ], + 'traffic' => [ + [ + 'key' => '1', + 'segments' => '*', + 'percentage' => 100000, + 'allocation' => [], + ], + ], + ], + ], + 'segments' => [], + ], + ]); + + $context = ['userId' => 'user-1']; + + self::assertEquals(['red', 'blue', 'green'], $sdk->getVariable('withArray', 'simpleArray', $context)); + self::assertEquals(['red', 'blue', 'green'], $sdk->getVariableArray('withArray', 'simpleArray', $context)); + self::assertEquals( + [ + ['color' => 'red', 'opacity' => 100], + ['color' => 'blue', 'opacity' => 90], + ['color' => 'green', 'opacity' => 95], + ], + $sdk->getVariableArray('withArray', 'objectArray', $context) + ); + + self::assertEquals( + ['theme' => 'light', 'darkMode' => false], + $sdk->getVariableObject('withObject', 'themeConfig', $context) + ); + self::assertEquals( + [ + 'style' => ['fontSize' => 18, 'bold' => true], + 'title' => 'Welcome', + ], + $sdk->getVariableObject('withObject', 'headerConfig', $context) + ); + self::assertEquals( + [ + 'name' => 'mixed', + 'enabled' => true, + 'meta' => ['score' => 0.95, 'items' => ['a', 'b']], + ], + $sdk->getVariableObject('withObject', 'mixedConfig', $context) + ); + + self::assertNull($sdk->getVariableArray('withArray', 'nonExisting', $context)); + self::assertNull($sdk->getVariableObject('withObject', 'nonExisting', $context)); + self::assertNull($sdk->getVariableArray('nonExistingFeature', 'simpleArray', $context)); + self::assertNull($sdk->getVariableObject('nonExistingFeature', 'themeConfig', $context)); + + $all = $sdk->getAllEvaluations($context); + self::assertTrue($all['withArray']['enabled']); + self::assertEquals(['red', 'blue', 'green'], $all['withArray']['variables']['simpleArray']); + self::assertEquals( + [ + 'theme' => 'light', + 'darkMode' => false, + ], + $all['withObject']['variables']['themeConfig'] + ); + } } diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php new file mode 100644 index 0000000..91437e4 --- /dev/null +++ b/tests/HelpersTest.php @@ -0,0 +1,35 @@ + 1], Helpers::getValueByType(['a' => 1], 'object')); + self::assertSame(['1', '2'], Helpers::getValueByType(['1', '2'], 'array')); + self::assertSame(1, Helpers::getValueByType('1', 'integer')); + self::assertSame(1.1, Helpers::getValueByType('1.1', 'double')); + self::assertSame(['x' => 1], Helpers::getValueByType(['x' => 1], 'json')); + } + + public function testShouldReturnNullForNullValue() + { + self::assertNull(Helpers::getValueByType(null, 'string')); + } + + public function testShouldReturnNullWhenCallablePassedForString() + { + self::assertNull(Helpers::getValueByType(static fn () => true, 'string')); + } +}