diff --git a/.circleci/config.yml b/.circleci/config.yml index c2509694..2f431d18 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ workflows: - test-on-linux: matrix: parameters: - php-version: ["7.3", "7.4", "8.0", "8.1"] + php-version: ["8.0", "8.1"] composer-dependencies: ["lowest", "highest"] - test-on-windows @@ -77,22 +77,12 @@ jobs: name: downgrade to lowest versions command: composer update --prefer-lowest --prefer-stable - - run: - name: psalm linting - command: ./vendor/bin/psalm --no-cache - - when: - condition: - not: - equal: [ "8.1", <> ] - steps: - - run: - name: php-cs-fixer check - command: composer cs-check - run: name: run tests - command: php -d xdebug.mode=coverage vendor/bin/phpunit - environment: - XDEBUG_MODE: coverage + command: make test + - run: + name: lint + command: make lint - run: name: build contract test service diff --git a/.gitignore b/.gitignore index a1cabfc2..2c6a5ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.iml composer.phar .php_cs.cache +.php-cs-fixer.cache .vagrant integration-tests/vendor composer.lock diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 2168f178..3fbf486a 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -9,7 +9,9 @@ publications: description: Packagist jobs: - - template: + - docker: + image: ldcircleci/php-sdk-release:4 # Releaser's default for PHP is still php-sdk-release:3, which is PHP 7.x + template: name: php documentation: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 177e7954..578541d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,8 +28,11 @@ composer install To run all unit tests: -``` +```shell ./vendor/bin/phpunit + +# Or, as a shortcut in Linux: +make test ``` It is preferable to run tests against all supported minor versions of PHP (as described in `README.md` under Requirements), or at least the lowest and highest versions, prior to submitting a pull request. However, LaunchDarkly's CI tests will run automatically against all supported versions. @@ -45,3 +48,13 @@ To run the SDK contract test suite in Linux (see [`test-service/README.md`](./te ```bash make contract-tests ``` + +To run the Psalm linter and cs-check: + +```shell +./vendor/bin/psalm --no-cache +composer cs-check + +# Or, as a shortcut in Linux: +make lint +``` diff --git a/Makefile b/Makefile index b5ac5e88..bfe4bf98 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,21 @@ +test: + php -d xdebug.mode=coverage vendor/bin/phpunit + +lint: + ./vendor/bin/psalm --no-cache + composer cs-check + + TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log # TEST_HARNESS_PARAMS can be set to add -skip parameters for any contract tests that cannot yet pass # Explanation of current skips: -# - "secondary": In the PHP SDK this is not an addressable attribute for clauses; in other -# SDKs, it is. This was underspecified in the past; in future major versions, the other -# SDKs and the contract tests will be in line with the PHP behavior. -# - "date - bad syntax", "semver - bad type": The PHP SDK has insufficiently strict -# validation for these types. We will definitely fix this in 5.0 but may or may not -# address it in 4.x, since it does not prevent any valid values from working. +# - "evaluation/parameterized/attribute references/array index is not supported": Due to how PHP +# arrays work, there's no way to disallow an array index lookup without breaking object property +# lookups for properties that are numeric strings. TEST_HARNESS_PARAMS := $(TEST_HARNESS_PARAMS) \ - -skip 'evaluation/parameterized/secondary' \ - -skip 'evaluation/parameterized/operators - date - bad syntax' \ - -skip 'evaluation/parameterized/operators - semver - bad type' + -skip 'evaluation/parameterized/attribute references/array index is not supported' build-contract-tests: @cd test-service && composer install --no-progress @@ -26,7 +29,7 @@ start-contract-test-service-bg: run-contract-tests: @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/main/downloader/run.sh \ - | VERSION=v1 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + | VERSION=v2 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests diff --git a/README.md b/README.md index e2f739c4..3ac9acfe 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Supported PHP versions -This version of the LaunchDarkly SDK is compatible with PHP 7.3 and higher. +This version of the LaunchDarkly SDK is compatible with PHP 8.0 and higher. ## Getting started diff --git a/composer.json b/composer.json index d957502d..6f50d356 100644 --- a/composer.json +++ b/composer.json @@ -14,13 +14,13 @@ } ], "require": { - "php": ">=7.3", - "monolog/monolog": "^1.6|^2.0|^3.0", + "php": ">=8.0", + "monolog/monolog": "^2.0|^3.0", "psr/log": "^1.0|^2.0|^3.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": ">=2.18.0 <3.0", - "guzzlehttp/guzzle": "^6.3 | ^7", + "friendsofphp/php-cs-fixer": "^3.12.0", + "guzzlehttp/guzzle": "^7", "kevinrob/guzzle-cache-middleware": "^4.0", "phpunit/php-code-coverage": "^9", "phpunit/phpunit": "^9", diff --git a/src/LaunchDarkly/EvaluationDetail.php b/src/LaunchDarkly/EvaluationDetail.php index 80a7599d..c8223ad8 100644 --- a/src/LaunchDarkly/EvaluationDetail.php +++ b/src/LaunchDarkly/EvaluationDetail.php @@ -1,5 +1,7 @@ _value = $value; $this->_variationIndex = $variationIndex; @@ -32,20 +29,21 @@ public function __construct($value, ?int $variationIndex, EvaluationReason $reas } /** - * Returns the value of the flag variation for the user. + * Returns the result of the flag evaluation. This will be either one of the flag's variations or the default + * value that was passed to the {@see \LaunchDarkly\LDClient::variationDetail()} method. * - * @return mixed + * @return mixed the flag value */ - public function getValue() + public function getValue(): mixed { return $this->_value; } /** - * Returns the index of the flag variation for the user, e.g. 0 for the first variation - - * or null if it was the default value. + * The index of the returned value within the flag's list of variations, e.g. 0 for the first variation-- + * or null if it was the default value (evaluation failed). * - * @return int | null + * @return ?int the variation index if applicable */ public function getVariationIndex(): ?int { diff --git a/src/LaunchDarkly/EvaluationReason.php b/src/LaunchDarkly/EvaluationReason.php index 140d74d2..a20ab080 100644 --- a/src/LaunchDarkly/EvaluationReason.php +++ b/src/LaunchDarkly/EvaluationReason.php @@ -1,5 +1,7 @@ _kind) { case self::RULE_MATCH: - return $this->_kind . '(' . ($this->_ruleIndex ?? 0) . ',' . ($this->_ruleId ?? '') . ')'; + return $this->_kind . '(' . ($this->_ruleIndex ?: 0) . ',' . ($this->_ruleId ?: '') . ')'; case self::PREREQUISITE_FAILED: - return $this->_kind . '(' . ($this->_prerequisiteKey ?? '') . ')'; + return $this->_kind . '(' . ($this->_prerequisiteKey ?: '') . ')'; case self::ERROR: - return $this->_kind . '(' . ($this->_errorKind ?? '') . ')'; + return $this->_kind . '(' . ($this->_errorKind ?: '') . ')'; default: return $this->_kind; } diff --git a/src/LaunchDarkly/FeatureFlagsState.php b/src/LaunchDarkly/FeatureFlagsState.php index de5cd70d..a1ecd3c0 100644 --- a/src/LaunchDarkly/FeatureFlagsState.php +++ b/src/LaunchDarkly/FeatureFlagsState.php @@ -1,5 +1,7 @@ **/ - protected $_flagMetadata; + protected bool $_valid = false; + protected array $_flagValues; + protected array $_flagMetadata; /** * @ignore @@ -44,16 +41,15 @@ public function __construct(bool $valid) public function addFlag( FeatureFlag $flag, EvaluationDetail $detail, + bool $forceReasonTracking = false, bool $withReason = false, bool $detailsOnlyIfTracked = false ): void { - $requireExperimentData = $flag->isExperiment($detail->getReason()); - $this->_flagValues[$flag->getKey()] = $detail->getValue(); $meta = []; - $trackEvents = $flag->isTrackEvents() || $requireExperimentData; - $trackReason = $requireExperimentData; + $trackEvents = $flag->isTrackEvents() || $forceReasonTracking; + $trackReason = $forceReasonTracking; $omitDetails = false; if ($detailsOnlyIfTracked) { @@ -100,9 +96,9 @@ public function isValid(): bool * @param string $key the feature flag key * @return mixed the flag's value; null if the flag returned the default value, or if there was no such flag */ - public function getFlagValue(string $key) + public function getFlagValue(string $key): mixed { - return isset($this->_flagValues[$key]) ? $this->_flagValues[$key] : null; + return $this->_flagValues[$key] ?? null; } /** @@ -115,11 +111,7 @@ public function getFlagValue(string $key) */ public function getFlagReason(string $key): ?EvaluationReason { - if (isset($this->_flagMetadata[$key])) { - $meta = $this->_flagMetadata[$key]; - return isset($meta['reason']) ? $meta['reason'] : null; - } - return null; + return ($this->_flagMetadata[$key] ?? [])['reason'] ?? null; } /** @@ -155,7 +147,7 @@ public function jsonSerialize(): array $metaMap = []; foreach ($this->_flagMetadata as $key => $meta) { $meta = array_replace([], $meta); - if (isset($meta['reason'])) { + if ($meta['reason'] ?? null) { $meta['reason'] = $meta['reason']->jsonSerialize(); } $metaMap[$key] = $meta; diff --git a/src/LaunchDarkly/Impl/EvalResult.php b/src/LaunchDarkly/Impl/EvalResult.php deleted file mode 100644 index 8a679f2f..00000000 --- a/src/LaunchDarkly/Impl/EvalResult.php +++ /dev/null @@ -1,35 +0,0 @@ -_detail = $detail; - $this->_prerequisiteEvents = $prerequisiteEvents; - } - - public function getDetail(): EvaluationDetail - { - return $this->_detail; - } - - public function getPrerequisiteEvents(): array - { - return $this->_prerequisiteEvents; - } -} diff --git a/src/LaunchDarkly/Impl/Evaluation/EvalResult.php b/src/LaunchDarkly/Impl/Evaluation/EvalResult.php new file mode 100644 index 00000000..86aa0b2d --- /dev/null +++ b/src/LaunchDarkly/Impl/Evaluation/EvalResult.php @@ -0,0 +1,39 @@ +_detail = $detail; + $this->_forceReasonTracking = $forceReasonTracking; + } + + public function getDetail(): EvaluationDetail + { + return $this->_detail; + } + + public function isForceReasonTracking(): bool + { + return $this->_forceReasonTracking; + } +} diff --git a/src/LaunchDarkly/Impl/Evaluation/EvaluationException.php b/src/LaunchDarkly/Impl/Evaluation/EvaluationException.php new file mode 100644 index 00000000..ceb58893 --- /dev/null +++ b/src/LaunchDarkly/Impl/Evaluation/EvaluationException.php @@ -0,0 +1,28 @@ +_errorKind = $errorKind; + } + + public function getErrorKind(): string + { + return $this->_errorKind; + } +} diff --git a/src/LaunchDarkly/Impl/Evaluation/Evaluator.php b/src/LaunchDarkly/Impl/Evaluation/Evaluator.php new file mode 100644 index 00000000..2a62a464 --- /dev/null +++ b/src/LaunchDarkly/Impl/Evaluation/Evaluator.php @@ -0,0 +1,325 @@ +_featureRequester = $featureRequester; + $this->_logger = $logger ?: Util::makeNullLogger(); + } + + /** + * The client's entry point for evaluating a flag. No other Evaluator methods should be exposed. + * + * @param FeatureFlag $flag an existing feature flag; any other referenced flags or segments will be + * queried via the FeatureRequester + * @param LDContext $context the evaluation context + * @param ?callable $prereqEvalSink a function that may be called with a + * PrerequisiteEvaluationRecord parameter for any prerequisite flags that are evaluated as a side + * effect of evaluating this flag + * @return EvalResult the outputs of evaluation + */ + public function evaluate(FeatureFlag $flag, LDContext $context, ?callable $prereqEvalSink): EvalResult + { + $stateStack = null; + $state = new EvaluatorState($flag); + try { + return $this->evaluateInternal($flag, $context, $prereqEvalSink, $state); + } catch (EvaluationException $e) { + return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error($e->getErrorKind()))); + } catch (\Throwable $e) { + Util::logExceptionAtErrorLevel($this->_logger, $e, 'Unexpected error when evaluating flag ' . $flag->getKey()); + return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::EXCEPTION_ERROR))); + } + } + + private function evaluateInternal( + FeatureFlag $flag, + LDContext $context, + ?callable $prereqEvalSink, + EvaluatorState $state + ): EvalResult { + if (!$flag->isOn()) { + return EvaluatorHelpers::getOffResult($flag, EvaluationReason::off()); + } + + $prereqFailureReason = $this->checkPrerequisites($flag, $context, $prereqEvalSink, $state); + if ($prereqFailureReason !== null) { + return EvaluatorHelpers::getOffResult($flag, $prereqFailureReason); + } + + // Check to see if targets match + $targetResult = $this->checkTargets($flag, $context); + if ($targetResult) { + return $targetResult; + } + + // Now walk through the rules and see if any match + foreach ($flag->getRules() as $i => $rule) { + if ($this->ruleMatchesContext($rule, $context, $state)) { + return EvaluatorHelpers::getResultForVariationOrRollout( + $flag, + $rule, + $rule->isTrackEvents(), + $context, + EvaluationReason::ruleMatch($i, $rule->getId()) + ); + } + } + return EvaluatorHelpers::getResultForVariationOrRollout( + $flag, + $flag->getFallthrough(), + $flag->isTrackEventsFallthrough(), + $context, + EvaluationReason::fallthrough() + ); + } + + private function checkPrerequisites( + FeatureFlag $flag, + LDContext $context, + ?callable $prereqEvalSink, + EvaluatorState $state + ): ?EvaluationReason { + // We use the state object to guard against circular references in prerequisites. To avoid + // the overhead of creating the $state->prerequisiteStack array in the most common case where + // there's only a single level of prerequisites, we treat $state->originalFlag as the first + // element in the stack. + $flagKey = $flag->getKey(); + if ($flag !== $state->originalFlag) { + if ($state->prerequisiteStack === null) { + $state->prerequisiteStack = []; + } + $state->prerequisiteStack[] = $flagKey; + } + try { + foreach ($flag->getPrerequisites() as $prereq) { + $prereqKey = $prereq->getKey(); + + if ($prereqKey === $state->originalFlag->getKey() || + ($state->prerequisiteStack !== null && in_array($prereqKey, $state->prerequisiteStack))) { + throw new EvaluationException( + "prerequisite relationship to \"$prereqKey\" caused a circular reference; this is probably a temporary condition due to an incomplete update", + EvaluationReason::MALFORMED_FLAG_ERROR + ); + } + $prereqOk = true; + $prereqFeatureFlag = $this->_featureRequester->getFeature($prereqKey); + if ($prereqFeatureFlag === null) { + $prereqOk = false; + } else { + // Note that if the prerequisite flag is off, we don't consider it a match no matter what its + // off variation was. But we still need to evaluate it in order to generate an event. + $prereqEvalResult = $this->evaluateInternal($prereqFeatureFlag, $context, $prereqEvalSink, $state); + $variation = $prereq->getVariation(); + if (!$prereqFeatureFlag->isOn() || $prereqEvalResult->getDetail()->getVariationIndex() !== $variation) { + $prereqOk = false; + } + if ($prereqEvalSink !== null) { + $prereqEvalSink(new PrerequisiteEvaluationRecord($prereqFeatureFlag, $flag, $prereqEvalResult)); + } + } + if (!$prereqOk) { + return EvaluationReason::prerequisiteFailed($prereqKey); + } + } + } finally { + if ($state->prerequisiteStack !== null && count($state->prerequisiteStack) !== 0) { + array_pop($state->prerequisiteStack); + } + } + return null; + } + + private function checkTargets(FeatureFlag $flag, LDContext $context): ?EvalResult + { + $userTargets = $flag->getTargets(); + $contextTargets = $flag->getContextTargets(); + if (count($contextTargets) === 0) { + // old-style data has only targets for users + if (count($userTargets) !== 0) { + $userContext = $context->getIndividualContext(LDContext::DEFAULT_KIND); + if ($userContext === null) { + return null; + } + foreach ($userTargets as $t) { + if (in_array($userContext->getKey(), $t->getValues())) { + return EvaluatorHelpers::targetMatchResult($flag, $t); + } + } + } + return null; + } + + foreach ($contextTargets as $t) { + if (($t->getContextKind() ?: LDContext::DEFAULT_KIND) === LDContext::DEFAULT_KIND) { + $userContext = $context->getIndividualContext(LDContext::DEFAULT_KIND); + if ($userContext === null) { + continue; + } + $userKey = $userContext->getKey(); + foreach ($userTargets as $ut) { + if ($ut->getVariation() === $t->getVariation()) { + if (in_array($userKey, $ut->getValues())) { + return EvaluatorHelpers::targetMatchResult($flag, $ut); + } + break; + } + } + } else { + if (EvaluatorHelpers::contextKeyIsInTargetList($context, $t->getContextKind(), $t->getValues())) { + return EvaluatorHelpers::targetMatchResult($flag, $t); + } + } + } + + return null; + } + + private function ruleMatchesContext(Rule $rule, LDContext $context, EvaluatorState $state): bool + { + foreach ($rule->getClauses() as $clause) { + if (!$this->clauseMatchesContext($clause, $context, $state)) { + return false; + } + } + return true; + } + + private function clauseMatchesContext(Clause $clause, LDContext $context, EvaluatorState $state): bool + { + if ($clause->getOp() === 'segmentMatch') { + foreach ($clause->getValues() as $segmentKey) { + if ($state->segmentStack !== null && in_array($segmentKey, $state->segmentStack)) { + throw new EvaluationException( + "segment rule referencing segment \"$segmentKey\" caused a circular reference; this is probably a temporary condition due to an incomplete update", + EvaluationReason::MALFORMED_FLAG_ERROR + ); + } + $segment = $this->_featureRequester->getSegment($segmentKey); + if ($segment) { + if ($this->segmentMatchesContext($segment, $context, $state)) { + return EvaluatorHelpers::maybeNegate($clause, true); + } + } + } + return EvaluatorHelpers::maybeNegate($clause, false); + } + return EvaluatorHelpers::matchClauseWithoutSegments($clause, $context); + } + + private function segmentMatchesContext(Segment $segment, LDContext $context, EvaluatorState $state): bool + { + if (EvaluatorHelpers::contextKeyIsInTargetList($context, null, $segment->getIncluded())) { + return true; + } + foreach ($segment->getIncludedContexts() as $t) { + if (EvaluatorHelpers::contextKeyIsInTargetList($context, $t->getContextKind(), $t->getValues())) { + return true; + } + } + if (EvaluatorHelpers::contextKeyIsInTargetList($context, null, $segment->getExcluded())) { + return false; + } + foreach ($segment->getExcludedContexts() as $t) { + if (EvaluatorHelpers::contextKeyIsInTargetList($context, $t->getContextKind(), $t->getValues())) { + return false; + } + } + $rules = $segment->getRules(); + if (count($rules) !== 0) { + // Evaluating rules means we might be doing recursive segment matches, so we'll push the current + // segment key onto the stack for cycle detection. + if ($state->segmentStack === null) { + $state->segmentStack = []; + } + $state->segmentStack[] = $segment->getKey(); + try { + foreach ($rules as $rule) { + if ($this->segmentRuleMatchesContext($rule, $context, $segment->getKey(), $segment->getSalt(), $state)) { + return true; + } + } + } finally { + array_pop($state->segmentStack); + } + } + return false; + } + + private function segmentRuleMatchesContext( + SegmentRule $rule, + LDContext $context, + string $segmentKey, + string $segmentSalt, + EvaluatorState $state + ): bool { + $rulej = print_r($rule, true); + foreach ($rule->getClauses() as $clause) { + if (!$this->clauseMatchesContext($clause, $context, $state)) { + return false; + } + } + // If the weight is absent, this rule matches + if ($rule->getWeight() === null) { + return true; + } + // All of the clauses are met. See if the user buckets in + $bucketBy = $rule->getBucketBy() ?: 'key'; + try { + $bucket = EvaluatorBucketing::getBucketValueForContext( + $context, + $rule->getRolloutContextKind(), + $segmentKey, + $bucketBy, + $segmentSalt, + null + ); + } catch (InvalidAttributeReferenceException $e) { + return false; + } + $weight = $rule->getWeight() / 100000.0; + return $bucket < $weight; + } +} diff --git a/src/LaunchDarkly/Impl/Evaluation/EvaluatorBucketing.php b/src/LaunchDarkly/Impl/Evaluation/EvaluatorBucketing.php new file mode 100644 index 00000000..b0781b24 --- /dev/null +++ b/src/LaunchDarkly/Impl/Evaluation/EvaluatorBucketing.php @@ -0,0 +1,95 @@ +getVariation(); + if ($variation !== null) { + return [$variation, false]; + } + $rollout = $vr->getRollout(); + if ($rollout === null) { + return [null, false]; + } + $variations = $rollout->getVariations(); + if (count($variations) === 0) { + return [null, false]; + } + + $bucketBy = ($rollout->isExperiment() ? null : $rollout->getBucketBy()) ?: 'key'; + $bucket = self::getBucketValueForContext( + $context, + $rollout->getContextKind(), + $_key, + $bucketBy, + $_salt, + $rollout->getSeed() + ); + $experiment = $rollout->isExperiment() && $bucket >= 0; + // getBucketValueForContext returns a negative value if the context didn't exist, in which case we + // still end up returning the first bucket, but we will force the "in experiment" state to be false. + + $sum = 0.0; + foreach ($variations as $wv) { + $sum += $wv->getWeight() / 100000.0; + if ($bucket < $sum) { + return [$wv->getVariation(), $experiment && !$wv->isUntracked()]; + } + } + $lastVariation = $variations[count($variations) - 1]; + return [$lastVariation->getVariation(), $experiment && !$lastVariation->isUntracked()]; + } + + public static function getBucketValueForContext( + LDContext $context, + ?string $contextKind, + string $key, + string $attr, + ?string $salt, + ?int $seed + ): float { + $matchContext = $context->getIndividualContext($contextKind ?? LDContext::DEFAULT_KIND); + if ($matchContext === null) { + return -1; + } + $contextValue = EvaluatorHelpers::getContextValueForAttributeReference($matchContext, $attr, $contextKind); + if ($contextValue === null) { + return 0.0; + } + if (is_int($contextValue)) { + $contextValue = (string) $contextValue; + } elseif (!is_string($contextValue)) { + return 0.0; + } + $idHash = $contextValue; + if (isset($seed)) { + $prefix = (string) $seed; + } else { + $prefix = $key . "." . ($salt ?: ''); + } + $hash = substr(sha1($prefix . "." . $idHash), 0, 15); + $longVal = (int)base_convert($hash, 16, 10); + $result = $longVal / self::LONG_SCALE; + + return $result; + } +} diff --git a/src/LaunchDarkly/Impl/Evaluation/EvaluatorHelpers.php b/src/LaunchDarkly/Impl/Evaluation/EvaluatorHelpers.php new file mode 100644 index 00000000..c640237c --- /dev/null +++ b/src/LaunchDarkly/Impl/Evaluation/EvaluatorHelpers.php @@ -0,0 +1,189 @@ +getIndividualContext($contextKind ?: LDContext::DEFAULT_KIND); + return $matchContext !== null && in_array($matchContext->getKey(), $keys); + } + + public static function evaluationDetailForVariation( + FeatureFlag $flag, + int $index, + EvaluationReason $reason + ): EvaluationDetail { + $vars = $flag->getVariations(); + if ($index < 0 || $index >= count($vars)) { + return new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); + } + return new EvaluationDetail($vars[$index], $index, $reason); + } + + public static function getContextValueForAttributeReference( + LDContext $context, + string $attributeRef, + ?string $forContextKind + ): mixed { + $parsed = ($forContextKind === null || $forContextKind === '') ? + // If no context kind was specified, treat the attribute as just an attribute name, not a reference path + AttributeReference::fromLiteral($attributeRef) : + // If a context kind was specified, parse it as a path + AttributeReference::fromPath($attributeRef); + if (($err = $parsed->getError()) !== null) { + throw new InvalidAttributeReferenceException($err); + } + $depth = $parsed->getDepth(); + $value = $context->get($parsed->getComponent(0)); + if ($depth <= 1) { + return $value; + } + for ($i = 1; $i < $depth; $i++) { + $propName = $parsed->getComponent($i); + if (is_object($value)) { + $value = get_object_vars($value)[$propName] ?? null; + } elseif (is_array($value)) { + // Note that either a JSON array or a JSON object could be represented as a PHP array. + // There is no good way to distinguish between ["a", "b"] and {"0": "a", "1": "b"}. + // Therefore, our lookup logic here is slightly more permissive than other SDKs, where + // an attempt to get /attr/0 would only work in the second case and not in the first. + $value = $value[$propName] ?? null; + } else { + return null; + } + } + return $value; + } + + public static function getOffResult(FeatureFlag $flag, EvaluationReason $reason): EvalResult + { + $offVar = $flag->getOffVariation(); + if ($offVar === null) { + return new EvalResult(new EvaluationDetail(null, null, $reason), false); + } + return new EvalResult(self::evaluationDetailForVariation($flag, $offVar, $reason), false); + } + + public static function getResultForVariationOrRollout( + FeatureFlag $flag, + VariationOrRollout $r, + bool $forceTracking, + LDContext $context, + EvaluationReason $reason + ): EvalResult { + try { + list($index, $inExperiment) = EvaluatorBucketing::variationIndexForContext($r, $context, $flag->getKey(), $flag->getSalt()); + } catch (InvalidAttributeReferenceException $e) { + return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR))); + } + if ($index === null) { + return new EvalResult( + new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)), + false + ); + } + if ($inExperiment) { + if ($reason->getKind() === EvaluationReason::FALLTHROUGH) { + $reason = EvaluationReason::fallthrough(true); + } elseif ($reason->getKind() === EvaluationReason::RULE_MATCH) { + $reason = EvaluationReason::ruleMatch($reason->getRuleIndex(), $reason->getRuleId(), true); + } + } + return new EvalResult( + EvaluatorHelpers::evaluationDetailForVariation($flag, $index, $reason), + $inExperiment || $forceTracking + ); + } + + public static function matchClauseWithoutSegments(Clause $clause, LDContext $context): bool + { + $attr = $clause->getAttribute(); + if ($attr === null) { + return false; + } + if ($attr === 'kind') { + return self::maybeNegate($clause, self::matchClauseByKind($clause, $context)); + } + $actualContext = $context->getIndividualContext($clause->getContextKind() ?? LDContext::DEFAULT_KIND); + if ($actualContext === null) { + return false; + } + $contextValue = self::getContextValueForAttributeReference($actualContext, $attr, $clause->getContextKind()); + if ($contextValue === null) { + return false; + } + if (is_array($contextValue)) { + foreach ($contextValue as $element) { + if (self::matchAnyClauseValue($clause, $element)) { + return EvaluatorHelpers::maybeNegate($clause, true); + } + } + return self::maybeNegate($clause, false); + } else { + return self::maybeNegate($clause, self::matchAnyClauseValue($clause, $contextValue)); + } + } + + private static function matchClauseByKind(Clause $clause, LDContext $context): bool + { + // If attribute is "kind", then we treat operator and values as a match expression against a list + // of all individual kinds in the context. That is, for a multi-kind context with kinds of "org" + // and "user", it is a match if either of those strings is a match with Operator and Values. + for ($i = 0; $i < $context->getIndividualContextCount(); $i++) { + $c = $context->getIndividualContext($i); + if ($c !== null && self::matchAnyClauseValue($clause, $c->getKind())) { + return true; + } + } + return false; + } + + private static function matchAnyClauseValue(Clause $clause, mixed $contextValue): bool + { + $op = $clause->getOp(); + foreach ($clause->getValues() as $v) { + $result = Operators::apply($op, $contextValue, $v); + if ($result === true) { + return true; + } + } + return false; + } + + public static function maybeNegate(Clause $clause, bool $b): bool + { + return $clause->isNegate() ? !$b : $b; + } + + public static function targetMatchResult(FeatureFlag $flag, Target $t): EvalResult + { + return new EvalResult( + self::evaluationDetailForVariation($flag, $t->getVariation(), EvaluationReason::targetMatch()), + false + ); + } +} diff --git a/src/LaunchDarkly/Impl/Evaluation/InvalidAttributeReferenceException.php b/src/LaunchDarkly/Impl/Evaluation/InvalidAttributeReferenceException.php new file mode 100644 index 00000000..3b2d19f3 --- /dev/null +++ b/src/LaunchDarkly/Impl/Evaluation/InvalidAttributeReferenceException.php @@ -0,0 +1,19 @@ +comparePrecedence($cVer) == 0; + return self::semver_operator($u, $c, 0); case "semVerLessThan": - $uVer = self::parseSemVer($u); - $cVer = self::parseSemVer($c); - return ($uVer != null) && ($cVer != null) && $uVer->comparePrecedence($cVer) < 0; + return self::semver_operator($u, $c, -1); case "semVerGreaterThan": - $uVer = self::parseSemVer($u); - $cVer = self::parseSemVer($c); - return ($uVer != null) && ($cVer != null) && $uVer->comparePrecedence($cVer) > 0; + return self::semver_operator($u, $c, 1); } } catch (Exception $ignored) { } return false; } + private static function semver_operator(mixed $u, mixed $c, int $expectedComparisonResult): bool + { + if (!is_string($u) || !is_string($c)) { + return false; + } + $uVer = self::parseSemVer($u); + $cVer = self::parseSemVer($c); + return ($uVer != null) && ($cVer != null) && $uVer->comparePrecedence($cVer) == $expectedComparisonResult; + } + /** * A stricter version of the built-in is_numeric checker. * @@ -128,33 +130,37 @@ public static function apply(?string $op, $u, $c): bool * @param mixed $value * @return bool */ - public static function is_numeric($value): bool + public static function is_numeric(mixed $value): bool { return is_numeric($value) && !is_string($value); } /** - * @param mixed|null $in - * @return ?numeric + * @param mixed $in + * @return ?int */ - public static function parseTime($in) + public static function parseTime(mixed $in): ?int { - if (is_numeric($in)) { - return $in; + if (is_string($in)) { + $dateTime = DateTime::createFromFormat(DateTimeInterface::RFC3339_EXTENDED, $in); + if ($dateTime == null) { + // try the same format but without fractional seconds + $dateTime = DateTime::createFromFormat(DateTimeInterface::RFC3339, $in); + } + if ($dateTime == null) { + return null; + } + return Util::dateTimeToUnixMillis($dateTime); + } + + if (is_numeric($in)) { // check this after is_string, because a numeric string would return true + return (int)$in; } if ($in instanceof DateTime) { return Util::dateTimeToUnixMillis($in); } - if (is_string($in)) { - try { - $dateTime = new DateTime($in, new DateTimeZone('UTC')); - return Util::dateTimeToUnixMillis($dateTime); - } catch (Exception $e) { - return null; - } - } return null; } diff --git a/src/LaunchDarkly/Impl/Evaluation/PrerequisiteEvaluationRecord.php b/src/LaunchDarkly/Impl/Evaluation/PrerequisiteEvaluationRecord.php new file mode 100644 index 00000000..e58766df --- /dev/null +++ b/src/LaunchDarkly/Impl/Evaluation/PrerequisiteEvaluationRecord.php @@ -0,0 +1,43 @@ +_flag = $flag; + $this->_prereqOfFlag = $prereqOfFlag; + $this->_result = $result; + } + + public function getFlag(): FeatureFlag + { + return $this->_flag; + } + + public function getPrereqOfFlag(): FeatureFlag + { + return $this->_prereqOfFlag; + } + + public function getResult(): EvalResult + { + return $this->_result; + } +} diff --git a/src/LaunchDarkly/Impl/Events/EventFactory.php b/src/LaunchDarkly/Impl/Events/EventFactory.php index 53cac6e2..ab9a421d 100644 --- a/src/LaunchDarkly/Impl/Events/EventFactory.php +++ b/src/LaunchDarkly/Impl/Events/EventFactory.php @@ -1,11 +1,14 @@ isExperiment($detail->getReason()); + $detail = $result->getDetail(); + $forceReasonTracking = $result->isForceReasonTracking(); $e = [ 'kind' => 'feature', 'creationDate' => Util::currentTimeUnixMillis(), 'key' => $flag->getKey(), - 'user' => $user, + 'context' => $context, 'variation' => $detail->getVariationIndex(), 'value' => $detail->getValue(), 'default' => $default, 'version' => $flag->getVersion() ]; // the following properties are handled separately so we don't waste bandwidth on unused keys - if ($addExperimentData || $flag->isTrackEvents()) { + if ($forceReasonTracking || $flag->isTrackEvents()) { $e['trackEvents'] = true; } if ($flag->getDebugEventsUntilDate()) { @@ -57,25 +60,22 @@ public function newEvalEvent( if ($prereqOfFlag) { $e['prereqOf'] = $prereqOfFlag->getKey(); } - if (($addExperimentData || $this->_withReasons)) { + if (($forceReasonTracking || $this->_withReasons)) { $e['reason'] = $detail->getReason()->jsonSerialize(); } - if ($user->getAnonymous()) { - $e['contextKind'] = 'anonymousUser'; - } return $e; } /** - * @return (mixed|null)[] + * @return mixed[] */ - public function newDefaultEvent(FeatureFlag $flag, LDUser $user, EvaluationDetail $detail): array + public function newDefaultEvent(FeatureFlag $flag, LDContext $context, EvaluationDetail $detail): array { $e = [ 'kind' => 'feature', 'creationDate' => Util::currentTimeUnixMillis(), 'key' => $flag->getKey(), - 'user' => $user, + 'context' => $context, 'value' => $detail->getValue(), 'default' => $detail->getValue(), 'version' => $flag->getVersion() @@ -90,22 +90,19 @@ public function newDefaultEvent(FeatureFlag $flag, LDUser $user, EvaluationDetai if ($this->_withReasons) { $e['reason'] = $detail->getReason()->jsonSerialize(); } - if ($user->getAnonymous()) { - $e['contextKind'] = 'anonymousUser'; - } return $e; } /** - * @return (mixed|null)[] + * @return mixed[] */ - public function newUnknownFlagEvent(string $key, LDUser $user, EvaluationDetail $detail): array + public function newUnknownFlagEvent(string $key, LDContext $context, EvaluationDetail $detail): array { $e = [ 'kind' => 'feature', 'creationDate' => Util::currentTimeUnixMillis(), 'key' => $key, - 'user' => $user, + 'context' => $context, 'value' => $detail->getValue(), 'default' => $detail->getValue() ]; @@ -113,76 +110,38 @@ public function newUnknownFlagEvent(string $key, LDUser $user, EvaluationDetail if ($this->_withReasons) { $e['reason'] = $detail->getReason()->jsonSerialize(); } - if ($user->getAnonymous()) { - $e['contextKind'] = 'anonymousUser'; - } return $e; } /** - * @return (mixed|null)[] + * @return mixed[] */ - public function newIdentifyEvent(LDUser $user): array + public function newIdentifyEvent(LDContext $context): array { return [ 'kind' => 'identify', 'creationDate' => Util::currentTimeUnixMillis(), - 'key' => strval($user->getKey()), - 'user' => $user + 'context' => $context ]; } /** - * @param string $eventName - * @param LDUser $user - * @param mixed|null $data - * @param null|numeric $metricValue - * - * @return (mixed|null)[] + * @return mixed[] */ - public function newCustomEvent(string $eventName, LDUser $user, $data, $metricValue): array + public function newCustomEvent(string $eventName, LDContext $context, mixed $data, int|float|null $metricValue): array { $e = [ 'kind' => 'custom', 'creationDate' => Util::currentTimeUnixMillis(), 'key' => $eventName, - 'user' => $user + 'context' => $context ]; - if (isset($data)) { + if ($data !== null) { $e['data'] = $data; } - if (isset($metricValue)) { + if ($metricValue !== null) { $e['metricValue'] = $metricValue; } - if ($user->getAnonymous()) { - $e['contextKind'] = 'anonymousUser'; - } - return $e; - } - - /** - * @return (mixed|null)[] - */ - public function newAliasEvent(LDUser $user, LDUser $previousUser): array - { - $e = [ - 'kind' => 'alias', - 'key' => strval($user->getKey()), - 'contextKind' => static::contextKind($user), - 'previousKey' => strval($previousUser->getKey()), - 'previousContextKind' => static::contextKind($previousUser), - 'creationDate' => Util::currentTimeUnixMillis() - ]; - return $e; } - - private static function contextKind(LDUser $user): string - { - if ($user->getAnonymous()) { - return 'anonymousUser'; - } else { - return 'user'; - } - } } diff --git a/src/LaunchDarkly/Impl/Events/EventProcessor.php b/src/LaunchDarkly/Impl/Events/EventProcessor.php index 421fb4a8..ceae128a 100644 --- a/src/LaunchDarkly/Impl/Events/EventProcessor.php +++ b/src/LaunchDarkly/Impl/Events/EventProcessor.php @@ -1,9 +1,11 @@ _allAttrsPrivate = isset($options['all_attributes_private']) && $options['all_attributes_private']; - $this->_privateAttrNames = isset($options['private_attribute_names']) ? $options['private_attribute_names'] : []; + $this->_allAttributesPrivate = !!($options['all_attributes_private'] ?? false); + + $allParsedPrivate = []; + foreach ($options['private_attribute_names'] ?? [] as $attr) { + $parsed = AttributeReference::fromPath($attr); + if ($parsed->getError() === null) { + $allParsedPrivate[] = $parsed; + } + } + $this->_privateAttributes = $allParsedPrivate; } public function serializeEvents(array $events): string @@ -41,8 +50,8 @@ private function filterEvent(array $e): array { $ret = []; foreach ($e as $key => $value) { - if ($key == 'user') { - $ret[$key] = $this->serializeUser($value); + if ($key == 'context') { + $ret[$key] = $this->serializeContext($value); } else { $ret[$key] = $value; } @@ -50,53 +59,108 @@ private function filterEvent(array $e): array return $ret; } - private function filterAttrs(array $attrs, array &$json, ?array $userPrivateAttrs, array &$allPrivateAttrs, bool $stringify): void + private function serializeContext(LDContext $context): array { - foreach ($attrs as $key => $value) { - if ($value !== null) { - if ($this->_allAttrsPrivate || - (!is_null($userPrivateAttrs) && in_array($key, $userPrivateAttrs)) || - in_array($key, $this->_privateAttrNames)) { - $allPrivateAttrs[] = $key; - } else { - $json[$key] = $stringify ? strval($value) : $value; + if ($context->isMultiple()) { + $ret = ['kind' => 'multi']; + for ($i = 0; $i < $context->getIndividualContextCount(); $i++) { + $c = $context->getIndividualContext($i); + if ($c !== null) { + $ret[$c->getKind()] = $this->serializeContextSingleKind($c, false); } } + return $ret; + } else { + return $this->serializeContextSingleKind($context, true); + } + } + + private function serializeContextSingleKind(LDContext $c, bool $includeKind): array + { + $ret = ['key' => $c->getKey()]; + if ($includeKind) { + $ret['kind'] = $c->getKind(); + } + if ($c->isAnonymous()) { + $ret['anonymous'] = true; + } + $redacted = []; + $allPrivate = array_merge($this->_privateAttributes, $c->getPrivateAttributes() ?? []); + if ($c->getName() !== null && !$this->checkWholeAttributePrivate('name', $allPrivate, $redacted)) { + $ret['name'] = $c->getName(); + } + foreach ($c->getCustomAttributeNames() as $attr) { + if (!$this->checkWholeAttributePrivate($attr, $allPrivate, $redacted)) { + $value = $c->get($attr); + $ret[$attr] = self::redactJsonValue(null, $attr, $value, $allPrivate, $redacted); + } + } + if (count($redacted) !== 0) { + $ret['_meta'] = ['redactedAttributes' => $redacted]; } + return $ret; } - private function serializeUser(LDUser $user): array + private function checkWholeAttributePrivate(string $attr, array $allPrivate, array &$redactedOut): bool { - $json = ["key" => strval($user->getKey())]; - $userPrivateAttrs = $user->getPrivateAttributeNames(); - $allPrivateAttrs = []; + if ($this->_allAttributesPrivate) { + $redactedOut[] = $attr; + return true; + } + foreach ($allPrivate as $p) { + if ($p->getComponent(0) === $attr && $p->getDepth() === 1) { + $redactedOut[] = $attr; + return true; + } + } + return false; + } - $attrs = [ - 'secondary' => $user->getSecondary(), - 'ip' => $user->getIP(), - 'country' => $user->getCountry(), - 'email' => $user->getEmail(), - 'name' => $user->getName(), - 'avatar' => $user->getAvatar(), - 'firstName' => $user->getFirstName(), - 'lastName' => $user->getLastName() - ]; - $this->filterAttrs($attrs, $json, $userPrivateAttrs, $allPrivateAttrs, true); - if ($user->getAnonymous()) { - $json['anonymous'] = true; + private static function redactJsonValue(?array $parentPath, string $name, mixed $value, array $allPrivate, array &$redactedOut): mixed + { + if (!is_array($value) || count($value) === 0) { + return $value; } - $custom = $user->getCustom(); - if (!is_null($custom) && !empty($user->getCustom())) { - $customOut = []; - $this->filterAttrs($custom, $customOut, $userPrivateAttrs, $allPrivateAttrs, false); - if ($customOut) { // if this is empty, we will return a json array for 'custom' instead of an object - $json['custom'] = $customOut; + $ret = []; + $currentPath = $parentPath ?? []; + $currentPath[] = $name; + foreach ($value as $k => $v) { + if (is_int($k)) { + // This is a regular array, not an object with string properties-- redactions don't apply. Technically, + // that's not a 100% solid assumption because in PHP, an array could have a mix of int and string keys. + // But that's not true in JSON or in pretty much any other SDK, so there wouldn't really be any clear + // way to apply our redaction logic in that case anyway. + return $value; + } + $wasRedacted = false; + foreach ($allPrivate as $p) { + if ($p->getDepth() !== count($currentPath) + 1) { + continue; + } + if ($p->getComponent(count($currentPath)) !== $k) { + continue; + } + $match = true; + for ($i = 0; $i < count($currentPath); $i++) { + if ($p->getComponent($i) !== $currentPath[$i]) { + $match = false; + break; + } + } + if ($match) { + $redactedOut[] = $p->getPath(); + $wasRedacted = true; + break; + } + } + if (!$wasRedacted) { + $ret[$k] = self::redactJsonValue($currentPath, $k, $v, $allPrivate, $redactedOut); } } - if (count($allPrivateAttrs)) { - sort($allPrivateAttrs); - $json['privateAttrs'] = $allPrivateAttrs; + if (count($ret) === 0) { + // Substitute an empty object here, because an empty array would serialize as [] rather than {} + return new \stdClass(); } - return $json; + return $ret; } } diff --git a/src/LaunchDarkly/Impl/Events/NullEventProcessor.php b/src/LaunchDarkly/Impl/Events/NullEventProcessor.php index a4267836..529034df 100644 --- a/src/LaunchDarkly/Impl/Events/NullEventProcessor.php +++ b/src/LaunchDarkly/Impl/Events/NullEventProcessor.php @@ -1,5 +1,7 @@ _expiration = $expiration; - } - - public function getCachedString(string $cacheKey): ?string - { - $value = \apc_fetch($cacheKey); - return $value === false ? null : $value; - } - - public function putCachedString(string $cacheKey, ?string $data): void - { - \apc_store($cacheKey, $data, $this->_expiration); - } -} diff --git a/src/LaunchDarkly/Impl/Integrations/ApcuFeatureRequesterCache.php b/src/LaunchDarkly/Impl/Integrations/ApcuFeatureRequesterCache.php index 5a15795d..9b2f411a 100644 --- a/src/LaunchDarkly/Impl/Integrations/ApcuFeatureRequesterCache.php +++ b/src/LaunchDarkly/Impl/Integrations/ApcuFeatureRequesterCache.php @@ -1,5 +1,7 @@ _options = $options; $this->_cache = $this->createCache($options); - if (isset($options['logger']) && $options['logger']) { + if ($options['logger'] ?? null) { $this->_logger = $options['logger']; } else { $this->_logger = new NullLogger(); @@ -143,12 +140,10 @@ public function getAllFeatures(): ?array protected function getJsonItem(string $namespace, string $key): ?array { $cacheKey = $this->makeCacheKey($namespace, $key); - $raw = $this->_cache ? $this->_cache->getCachedString($cacheKey) : null; + $raw = $this->_cache?->getCachedString($cacheKey); if ($raw === null) { $raw = $this->readItemString($namespace, $key); - if ($this->_cache) { - $this->_cache->putCachedString($cacheKey, $raw); - } + $this->_cache?->putCachedString($cacheKey, $raw); } return ($raw === null) ? null : json_decode($raw, true); } @@ -156,7 +151,7 @@ protected function getJsonItem(string $namespace, string $key): ?array protected function getJsonItemList(string $namespace): array { $cacheKey = $this->makeCacheKey($namespace, self::ALL_ITEMS_KEY); - $raw = $this->_cache ? $this->_cache->getCachedString($cacheKey) : null; + $raw = $this->_cache?->getCachedString($cacheKey); if ($raw) { $values = json_decode($raw, true); } else { @@ -164,9 +159,7 @@ protected function getJsonItemList(string $namespace): array if (!$values) { $values = []; } - if ($this->_cache) { - $this->_cache->putCachedString($cacheKey, json_encode($values)); - } + $this->_cache?->putCachedString($cacheKey, json_encode($values)); } foreach ($values as $i => $s) { $values[$i] = json_decode($s, true); diff --git a/src/LaunchDarkly/Impl/Integrations/FeatureRequesterCache.php b/src/LaunchDarkly/Impl/Integrations/FeatureRequesterCache.php index 9dc67687..9b577128 100644 --- a/src/LaunchDarkly/Impl/Integrations/FeatureRequesterCache.php +++ b/src/LaunchDarkly/Impl/Integrations/FeatureRequesterCache.php @@ -1,5 +1,7 @@ _filePaths = is_array($filePaths) ? $filePaths : [$filePaths]; $this->_flags = []; @@ -80,47 +79,35 @@ private function loadFile(string $filePath, array &$flags, array &$segments): vo if ($data == null) { throw new \InvalidArgumentException("File is not valid JSON: " . $filePath); } - if (isset($data['flags'])) { - foreach ($data['flags'] as $key => $value) { - $flag = FeatureFlag::decode($value); - $this->tryToAdd($flags, $key, $flag, "feature flag"); - } + foreach ($data['flags'] ?? [] as $key => $value) { + $flag = FeatureFlag::decode($value); + $this->tryToAdd($flags, $key, $flag, "feature flag"); } - if (isset($data['flagValues'])) { - foreach ($data['flagValues'] as $key => $value) { - $flag = FeatureFlag::decode([ - "key" => $key, - "version" => 1, - "on" => false, - "prerequisites" => [], - "salt" => "", - "targets" => [], - "rules" => [], - "fallthrough" => [], - "offVariation" => 0, - "variations" => [$value], - "deleted" => false, - "trackEvents" => false, - "clientSide" => false - ]); - $this->tryToAdd($flags, $key, $flag, "feature flag"); - } + foreach ($data['flagValues'] ?? [] as $key => $value) { + $flag = FeatureFlag::decode([ + "key" => $key, + "version" => 1, + "on" => false, + "prerequisites" => [], + "salt" => "", + "targets" => [], + "rules" => [], + "fallthrough" => [], + "offVariation" => 0, + "variations" => [$value], + "deleted" => false, + "trackEvents" => false, + "clientSide" => false + ]); + $this->tryToAdd($flags, $key, $flag, "feature flag"); } - if (isset($data['segments'])) { - foreach ($data['segments'] as $key => $value) { - $segment = Segment::decode($value); - $this->tryToAdd($segments, $key, $segment, "user segment"); - } + foreach ($data['segments'] ?? [] as $key => $value) { + $segment = Segment::decode($value); + $this->tryToAdd($segments, $key, $segment, "user segment"); } } - /** - * @param array $array - * @param string $key - * @param mixed $item - * @param string $kind - */ - private function tryToAdd(array &$array, string $key, $item, string $kind): void + private function tryToAdd(array &$array, string $key, mixed $item, string $kind): void { if (isset($array[$key])) { throw new \InvalidArgumentException("File data contains more than one " . $kind . " with key: " . $key); diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php index cca758fa..e1f6f312 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php @@ -1,12 +1,14 @@ _contextKind = $contextKind; $this->_attribute = $attribute; $this->_op = $op; $this->_values = $values; @@ -37,49 +34,17 @@ private function __construct(?string $attribute, ?string $op, array $values, boo */ public static function getDecoder(): \Closure { - return function ($v) { - return new Clause($v['attribute'], $v['op'], $v['values'], $v['negate']); - }; - } - - public function matchesUser(LDUser $user, ?FeatureRequester $featureRequester): bool - { - if ($this->_op === 'segmentMatch') { - foreach ($this->_values as $value) { - $segment = $featureRequester ? $featureRequester->getSegment($value) : null; - if ($segment) { - if ($segment->matchesUser($user)) { - return $this->_maybeNegate(true); - } - } - } - return $this->_maybeNegate(false); - } else { - return $this->matchesUserNoSegments($user); - } + return fn ($v) => new Clause($v['contextKind'] ?? null, $v['attribute'], $v['op'], $v['values'], $v['negate']); } - public function matchesUserNoSegments(LDUser $user): bool + public function getAttribute(): ?string { - $userValue = $user->getValueForEvaluation($this->_attribute); - if ($userValue === null) { - return false; - } - if (is_array($userValue)) { - foreach ($userValue as $element) { - if ($this->matchAny($element)) { - return $this->_maybeNegate(true); - } - } - return $this->_maybeNegate(false); - } else { - return $this->_maybeNegate($this->matchAny($userValue)); - } + return $this->_attribute; } - public function getAttribute(): ?string + public function getContextKind(): ?string { - return $this->_attribute; + return $this->_contextKind; } public function getOp(): ?string @@ -96,29 +61,4 @@ public function isNegate(): bool { return $this->_negate; } - - /** - * @param mixed|null $userValue - * - * @return bool - */ - private function matchAny($userValue): bool - { - foreach ($this->_values as $v) { - $result = Operators::apply($this->_op, $userValue, $v); - if ($result === true) { - return true; - } - } - return false; - } - - private function _maybeNegate(bool $b): bool - { - if ($this->_negate) { - return !$b; - } else { - return $b; - } - } } diff --git a/src/LaunchDarkly/Impl/Model/FeatureFlag.php b/src/LaunchDarkly/Impl/Model/FeatureFlag.php index ea465b40..05c16168 100644 --- a/src/LaunchDarkly/Impl/Model/FeatureFlag.php +++ b/src/LaunchDarkly/Impl/Model/FeatureFlag.php @@ -1,13 +1,8 @@ _prerequisites = $prerequisites; $this->_salt = $salt; $this->_targets = $targets; + $this->_contextTargets = $contextTargets; $this->_rules = $rules; $this->_fallthrough = $fallthrough; $this->_offVariation = $offVariation; @@ -98,25 +82,25 @@ protected function __construct( */ public static function getDecoder(): \Closure { - return function ($v) { - return new FeatureFlag( + return fn ($v) => + new FeatureFlag( $v['key'], $v['version'], $v['on'], array_map(Prerequisite::getDecoder(), $v['prerequisites'] ?: []), $v['salt'], array_map(Target::getDecoder(), $v['targets'] ?: []), + array_map(Target::getDecoder(), $v['contextTargets'] ?? []), array_map(Rule::getDecoder(), $v['rules'] ?: []), call_user_func(VariationOrRollout::getDecoder(), $v['fallthrough']), $v['offVariation'], $v['variations'] ?: [], $v['deleted'], - isset($v['trackEvents']) && $v['trackEvents'], - isset($v['trackEventsFallthrough']) && $v['trackEventsFallthrough'], - isset($v['debugEventsUntilDate']) ? $v['debugEventsUntilDate'] : null, - isset($v['clientSide']) && $v['clientSide'] + !!($v['trackEvents'] ?? false), + !!($v['trackEventsFallthrough'] ?? false), + $v['debugEventsUntilDate'] ?? null, + !!($v['clientSide'] ?? false) ); - }; } public static function decode(array $v): self @@ -125,156 +109,70 @@ public static function decode(array $v): self return $decoder($v); } - public function isOn(): bool + public function isClientSide(): bool { - return $this->_on; + return $this->_clientSide; } - public function evaluate(LDUser $user, FeatureRequester $featureRequester, EventFactory $eventFactory): EvalResult + /** @return Target[] */ + public function getContextTargets(): array { - $prereqEvents = []; - $detail = $this->evaluateInternal($user, $featureRequester, $prereqEvents, $eventFactory); - return new EvalResult($detail, $prereqEvents); + return $this->_contextTargets; } - public function isExperiment(EvaluationReason $reason): bool + public function getDebugEventsUntilDate(): ?int { - if ($reason->isInExperiment()) { - return true; - } - - switch ($reason->getKind()) { - case 'RULE_MATCH': - $i = $reason->getRuleIndex(); - $rules = $this->getRules(); - return isset($i) && $i >= 0 && $i < count($rules) && $rules[$i]->isTrackEvents(); - case 'FALLTHROUGH': - return $this->isTrackEventsFallthrough(); - default: - return false; - } + return $this->_debugEventsUntilDate; } - private function evaluateInternal( - LDUser $user, - FeatureRequester $featureRequester, - array &$events, - EventFactory $eventFactory - ): EvaluationDetail { - if (!$this->isOn()) { - return $this->getOffValue(EvaluationReason::off()); - } - - $prereqFailureReason = $this->checkPrerequisites($user, $featureRequester, $events, $eventFactory); - if ($prereqFailureReason !== null) { - return $this->getOffValue($prereqFailureReason); - } - - // Check to see if targets match - if ($this->_targets != null) { - foreach ($this->_targets as $target) { - foreach ($target->getValues() as $value) { - if ($value === $user->getKey()) { - return $this->getVariation($target->getVariation(), EvaluationReason::targetMatch()); - } - } - } - } - // Now walk through the rules and see if any match - if ($this->_rules != null) { - foreach ($this->_rules as $i => $rule) { - if ($rule->matchesUser($user, $featureRequester)) { - return $this->getValueForVariationOrRollout( - $rule, - $user, - EvaluationReason::ruleMatch($i, $rule->getId()) - ); - } - } - } - return $this->getValueForVariationOrRollout($this->_fallthrough, $user, EvaluationReason::fallthrough()); + public function isDeleted(): bool + { + return $this->_deleted; } - private function checkPrerequisites(LDUser $user, FeatureRequester $featureRequester, array &$events, EventFactory $eventFactory): ?EvaluationReason + public function getFallthrough(): VariationOrRollout { - if ($this->_prerequisites != null) { - foreach ($this->_prerequisites as $prereq) { - $prereqOk = true; - try { - $prereqFeatureFlag = $featureRequester->getFeature($prereq->getKey()); - if ($prereqFeatureFlag == null) { - $prereqOk = false; - } else { - $prereqEvalResult = $prereqFeatureFlag->evaluateInternal($user, $featureRequester, $events, $eventFactory); - $variation = $prereq->getVariation(); - if (!$prereqFeatureFlag->isOn() || $prereqEvalResult->getVariationIndex() !== $variation) { - $prereqOk = false; - } - $events[] = $eventFactory->newEvalEvent($prereqFeatureFlag, $user, $prereqEvalResult, null, $this); - } - } catch (\Exception $e) { - $prereqOk = false; - } - if (!$prereqOk) { - return EvaluationReason::prerequisiteFailed($prereq->getKey()); - } - } - } - return null; + return $this->_fallthrough; } - private function getVariation(int $index, EvaluationReason $reason): EvaluationDetail + public function getKey(): string { - if ($index < 0 || $index >= count($this->_variations)) { - return new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - } - return new EvaluationDetail($this->_variations[$index], $index, $reason); + return $this->_key; } - private function getOffValue(EvaluationReason $reason): EvaluationDetail + public function getOffVariation(): ?int { - if ($this->_offVariation === null) { - return new EvaluationDetail(null, null, $reason); - } - return $this->getVariation($this->_offVariation, $reason); + return $this->_offVariation; } - private function getValueForVariationOrRollout(VariationOrRollout $r, LDUser $user, EvaluationReason $reason): EvaluationDetail + public function isOn(): bool { - list($index, $inExperiment) = $r->variationIndexForUser($user, $this->_key, $this->_salt); - if ($index === null) { - return new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - } - if ($inExperiment) { - if ($reason->getKind() === EvaluationReason::FALLTHROUGH) { - $reason = EvaluationReason::fallthrough(true); - } elseif ($reason->getKind() === EvaluationReason::RULE_MATCH) { - $reason = EvaluationReason::ruleMatch($reason->getRuleIndex(), $reason->getRuleId(), true); - } - } - return $this->getVariation($index, $reason); + return $this->_on; } - public function getVersion(): int + /** @return Prerequisite[] */ + public function getPrerequisites(): array { - return $this->_version; + return $this->_prerequisites; } - public function getKey(): string + /** @return Rule[] */ + public function getRules(): array { - return $this->_key; + return $this->_rules; } - public function isDeleted(): bool + public function getSalt(): string { - return $this->_deleted; + return $this->_salt; } - public function getRules(): array + /** @return Target[] */ + public function getTargets(): array { - return $this->_rules; + return $this->_targets; } - + public function isTrackEvents(): bool { return $this->_trackEvents; @@ -285,13 +183,13 @@ public function isTrackEventsFallthrough(): bool return $this->_trackEventsFallthrough; } - public function getDebugEventsUntilDate(): ?int + public function getVariations(): array { - return $this->_debugEventsUntilDate; + return $this->_variations; } - public function isClientSide(): bool + public function getVersion(): int { - return $this->_clientSide; + return $this->_version; } } diff --git a/src/LaunchDarkly/Impl/Model/Prerequisite.php b/src/LaunchDarkly/Impl/Model/Prerequisite.php index 725a205b..04705259 100644 --- a/src/LaunchDarkly/Impl/Model/Prerequisite.php +++ b/src/LaunchDarkly/Impl/Model/Prerequisite.php @@ -1,5 +1,7 @@ _key = $key; $this->_variation = $variation; @@ -25,9 +25,7 @@ protected function __construct(string $key, int $variation) public static function getDecoder(): \Closure { - return function (array $v) { - return new Prerequisite($v['key'], $v['variation']); - }; + return fn (array $v) => new Prerequisite($v['key'], $v['variation']); } public function getKey(): string diff --git a/src/LaunchDarkly/Impl/Model/Rollout.php b/src/LaunchDarkly/Impl/Model/Rollout.php index 43b42eb7..1268372c 100644 --- a/src/LaunchDarkly/Impl/Model/Rollout.php +++ b/src/LaunchDarkly/Impl/Model/Rollout.php @@ -1,5 +1,7 @@ _variations = $variations; $this->_bucketBy = $bucketBy; - $this->_kind = $kind ?? 'rollout'; + $this->_kind = $kind ?: 'rollout'; $this->_seed = $seed; + $this->_contextKind = $contextKind; } /** @@ -45,7 +47,7 @@ public static function getDecoder(): \Closure $vars = array_map($decoder, $v['variations']); $bucket = $v['bucketBy'] ?? null; - return new Rollout($vars, $bucket, $v['kind'] ?? null, $v['seed'] ?? null); + return new Rollout($vars, $bucket, $v['kind'] ?? null, $v['seed'] ?? null, $v['contextKind'] ?? null); }; } @@ -71,4 +73,9 @@ public function isExperiment(): bool { return $this->_kind === self::KIND_EXPERIMENT; } + + public function getContextKind(): ?string + { + return $this->_contextKind; + } } diff --git a/src/LaunchDarkly/Impl/Model/Rule.php b/src/LaunchDarkly/Impl/Model/Rule.php index 0b842da1..23377c26 100644 --- a/src/LaunchDarkly/Impl/Model/Rule.php +++ b/src/LaunchDarkly/Impl/Model/Rule.php @@ -1,9 +1,8 @@ + new Rule( $v['variation'] ?? null, isset($v['rollout']) ? call_user_func(Rollout::getDecoder(), $v['rollout']) : null, $v['id'] ?? null, array_map(Clause::getDecoder(), $v['clauses']), - $v['trackEvents']?? false + !!($v['trackEvents'] ?? false) ); - }; - } - - public function matchesUser(LDUser $user, ?FeatureRequester $featureRequester): bool - { - foreach ($this->_clauses as $clause) { - if (!$clause->matchesUser($user, $featureRequester)) { - return false; - } - } - return true; } public function getId(): ?string diff --git a/src/LaunchDarkly/Impl/Model/Segment.php b/src/LaunchDarkly/Impl/Model/Segment.php index d24e60d3..b22f9896 100644 --- a/src/LaunchDarkly/Impl/Model/Segment.php +++ b/src/LaunchDarkly/Impl/Model/Segment.php @@ -1,8 +1,8 @@ _version = $version; $this->_included = $included; $this->_excluded = $excluded; + $this->_includedContexts = $includedContexts; + $this->_excludedContexts = $excludedContexts; $this->_salt = $salt; $this->_rules = $rules; $this->_deleted = $deleted; @@ -49,17 +53,18 @@ protected function __construct( public static function getDecoder(): \Closure { - return function (array $v) { - return new Segment( + return fn (array $v) => + new Segment( $v['key'], $v['version'], $v['included'] ?: [], $v['excluded'] ?: [], + array_map(SegmentTarget::getDecoder(), $v['includedContexts'] ?? []), + array_map(SegmentTarget::getDecoder(), $v['excludedContexts'] ?? []), $v['salt'], array_map(SegmentRule::getDecoder(), $v['rules'] ?: []), $v['deleted'] ); - }; } public static function decode(array $v): Segment @@ -67,29 +72,33 @@ public static function decode(array $v): Segment return static::getDecoder()($v); } - public function matchesUser(LDUser $user): bool + public function isDeleted(): bool { - $key = $user->getKey(); - if (!$key) { - return false; - } - if (in_array($key, $this->_included, true)) { - return true; - } - if (in_array($key, $this->_excluded, true)) { - return false; - } - foreach ($this->_rules as $rule) { - if ($rule->matchesUser($user, $this->_key, $this->_salt)) { - return true; - } - } - return false; + return $this->_deleted; } - public function getVersion(): ?int + /** @return string[] */ + public function getExcluded(): array { - return $this->_version; + return $this->_excluded; + } + + /** @return SegmentTarget[] */ + public function getExcludedContexts(): array + { + return $this->_excludedContexts; + } + + /** @return string[] */ + public function getIncluded(): array + { + return $this->_included; + } + + /** @return SegmentTarget[] */ + public function getIncludedContexts(): array + { + return $this->_includedContexts; } public function getKey(): string @@ -97,8 +106,19 @@ public function getKey(): string return $this->_key; } - public function isDeleted(): bool + /** @return SegmentRule[] */ + public function getRules(): array { - return $this->_deleted; + return $this->_rules; + } + + public function getSalt(): string + { + return $this->_salt; + } + + public function getVersion(): ?int + { + return $this->_version; } } diff --git a/src/LaunchDarkly/Impl/Model/SegmentRule.php b/src/LaunchDarkly/Impl/Model/SegmentRule.php index fd3d8281..0238f45b 100644 --- a/src/LaunchDarkly/Impl/Model/SegmentRule.php +++ b/src/LaunchDarkly/Impl/Model/SegmentRule.php @@ -1,8 +1,8 @@ _clauses = $clauses; $this->_weight = $weight; $this->_bucketBy = $bucketBy; + $this->_rolloutContextKind = $rolloutContextKind; } public static function getDecoder(): \Closure { - return function (array $v) { - return new SegmentRule( - array_map(Clause::getDecoder(), $v['clauses'] ?: []), - $v['weight'] ?? null, - $v['bucketBy'] ?? null - ); - }; - } - - public function matchesUser(LDUser $user, string $segmentKey, string $segmentSalt): bool - { - foreach ($this->_clauses as $clause) { - if (!$clause->matchesUserNoSegments($user)) { - return false; - } - } - // If the weight is absent, this rule matches - if ($this->_weight === null) { - return true; - } - // All of the clauses are met. See if the user buckets in - $bucketBy = ($this->_bucketBy === null) ? "key" : $this->_bucketBy; - $bucket = VariationOrRollout::bucketUser($user, $segmentKey, $bucketBy, $segmentSalt, null); - $weight = $this->_weight / 100000.0; - return $bucket < $weight; + return fn (array $v) => new SegmentRule( + array_map(Clause::getDecoder(), $v['clauses'] ?: []), + $v['weight'] ?? null, + $v['bucketBy'] ?? null, + $v['rolloutContextKind'] ?? null + ); } /** @@ -69,4 +50,14 @@ public function getBucketBy(): ?string { return $this->_bucketBy; } + + public function getRolloutContextKind(): ?string + { + return $this->_rolloutContextKind; + } + + public function getWeight(): ?int + { + return $this->_weight; + } } diff --git a/src/LaunchDarkly/Impl/Model/SegmentTarget.php b/src/LaunchDarkly/Impl/Model/SegmentTarget.php new file mode 100644 index 00000000..7ff4aac8 --- /dev/null +++ b/src/LaunchDarkly/Impl/Model/SegmentTarget.php @@ -0,0 +1,44 @@ +_contextKind = $contextKind; + $this->_values = $values; + } + + public static function getDecoder(): \Closure + { + return fn (array $v) => new SegmentTarget($v['contextKind'] ?? null, $v['values']); + } + + public function getContextKind(): ?string + { + return $this->_contextKind; + } + + /** + * @return \string[] + */ + public function getValues(): array + { + return $this->_values; + } +} diff --git a/src/LaunchDarkly/Impl/Model/Target.php b/src/LaunchDarkly/Impl/Model/Target.php index b4e8116e..22893adf 100644 --- a/src/LaunchDarkly/Impl/Model/Target.php +++ b/src/LaunchDarkly/Impl/Model/Target.php @@ -1,5 +1,7 @@ _contextKind = $contextKind; $this->_values = $values; $this->_variation = $variation; } public static function getDecoder(): \Closure { - return function (array $v) { - $values = $v['values']; - $variation = $v['variation']; - return new Target($values, $variation); - }; + return fn (array $v) => new Target($v['contextKind'] ?? null, $v['values'], $v['variation']); + } + + public function getContextKind(): ?string + { + return $this->_contextKind; } /** diff --git a/src/LaunchDarkly/Impl/Model/VariationOrRollout.php b/src/LaunchDarkly/Impl/Model/VariationOrRollout.php index ccf32459..7d0a2fac 100644 --- a/src/LaunchDarkly/Impl/Model/VariationOrRollout.php +++ b/src/LaunchDarkly/Impl/Model/VariationOrRollout.php @@ -1,8 +1,8 @@ _variation = $variation; $this->_rollout = $rollout; @@ -51,62 +46,4 @@ public function getRollout(): ?Rollout { return $this->_rollout; } - - public function variationIndexForUser(LDUser $user, string $_key, ?string $_salt): array - { - if ($this->_variation !== null) { - return [$this->_variation, false]; - } - $rollout = $this->_rollout; - if ($rollout === null) { - return [null, false]; - } - $variations = $rollout->getVariations(); - if ($variations) { - $bucketBy = $rollout->getBucketBy() ?? "key"; - $bucket = self::bucketUser($user, $_key, $bucketBy, $_salt, $rollout->getSeed()); - $sum = 0.0; - foreach ($variations as $wv) { - $sum += $wv->getWeight() / 100000.0; - if ($bucket < $sum) { - return [$wv->getVariation(), $rollout->isExperiment() && !$wv->isUntracked()]; - } - } - $lastVariation = $variations[count($variations) - 1]; - return [$lastVariation->getVariation(), $rollout->isExperiment() && !$lastVariation->isUntracked()]; - } - return [null, false]; - } - - public static function bucketUser( - LDUser $user, - string $_key, - string $attr, - ?string $_salt, - ?int $seed - ): float { - $userValue = $user->getValueForEvaluation($attr); - if ($userValue != null) { - if (is_int($userValue)) { - $userValue = (string) $userValue; - } - if (is_string($userValue)) { - $idHash = $userValue; - if (isset($seed)) { - $prefix = (string) $seed; - } else { - $prefix = $_key . "." . ($_salt ?? ''); - } - if ($user->getSecondary() !== null) { - $idHash = $idHash . "." . strval($user->getSecondary()); - } - $hash = substr(sha1($prefix . "." . $idHash), 0, 15); - $longVal = (int)base_convert($hash, 16, 10); - $result = $longVal / self::$LONG_SCALE; - - return $result; - } - } - return 0.0; - } } diff --git a/src/LaunchDarkly/Impl/Model/WeightedVariation.php b/src/LaunchDarkly/Impl/Model/WeightedVariation.php index d7c90737..fdf8e65d 100644 --- a/src/LaunchDarkly/Impl/Model/WeightedVariation.php +++ b/src/LaunchDarkly/Impl/Model/WeightedVariation.php @@ -1,5 +1,7 @@ _variation = $variation; $this->_weight = $weight; @@ -31,13 +30,11 @@ private function __construct(int $variation, int $weight, bool $untracked) */ public static function getDecoder(): \Closure { - return function (array $v) { - return new WeightedVariation( - $v['variation'], - $v['weight'], - $v['untracked'] ?? false - ); - }; + return fn (array $v) => new WeightedVariation( + (int)$v['variation'], + (int)$v['weight'], + $v['untracked'] ?? false + ); } public function getVariation(): int diff --git a/src/LaunchDarkly/Impl/PreloadedFeatureRequester.php b/src/LaunchDarkly/Impl/PreloadedFeatureRequester.php index 81999054..7cda2d09 100644 --- a/src/LaunchDarkly/Impl/PreloadedFeatureRequester.php +++ b/src/LaunchDarkly/Impl/PreloadedFeatureRequester.php @@ -1,10 +1,12 @@ _knownFeatures[$key])) { - return $this->_knownFeatures[$key]; - } - return null; + return $this->_knownFeatures[$key] ?? null; } /** diff --git a/src/LaunchDarkly/Impl/SemanticVersion.php b/src/LaunchDarkly/Impl/SemanticVersion.php index f6a77566..365a1d38 100644 --- a/src/LaunchDarkly/Impl/SemanticVersion.php +++ b/src/LaunchDarkly/Impl/SemanticVersion.php @@ -1,5 +1,7 @@ 0|[1-9]\d*)(\.(?0|[1-9]\d*))?(\.(?0|[1-9]\d*))?(\-(?[0-9A-Za-z\-\.]+))?(\+(?[0-9A-Za-z\-\.]+))?$/'; + private static string $REGEX = '/^(?0|[1-9]\d*)(\.(?0|[1-9]\d*))?(\.(?0|[1-9]\d*))?(\-(?[0-9A-Za-z\-\.]+))?(\+(?[0-9A-Za-z\-\.]+))?$/'; - /** @var int */ - public $major; - /** @var int */ - public $minor; - /** @var int */ - public $patch; - /** @var string */ - public $prerelease; - /** @var string */ - public $build; + public int $major; + public int $minor; + public int $patch; + public string $prerelease; + public string $build; public function __construct( int $major, diff --git a/src/LaunchDarkly/Impl/UnrecoverableHTTPStatusException.php b/src/LaunchDarkly/Impl/UnrecoverableHTTPStatusException.php index dc6cad1e..9f345dec 100644 --- a/src/LaunchDarkly/Impl/UnrecoverableHTTPStatusException.php +++ b/src/LaunchDarkly/Impl/UnrecoverableHTTPStatusException.php @@ -1,5 +1,7 @@ error($message . ': ' . $e->getMessage()); + $logger->debug("$e"); + } + + public static function makeNullLogger(): LoggerInterface + { + return new Logger('', [new NullHandler()]); + } } diff --git a/src/LaunchDarkly/Integrations/Curl.php b/src/LaunchDarkly/Integrations/Curl.php index 32c73b16..a751005f 100644 --- a/src/LaunchDarkly/Integrations/Curl.php +++ b/src/LaunchDarkly/Integrations/Curl.php @@ -1,5 +1,7 @@ + new \LaunchDarkly\Impl\Integrations\CurlEventPublisher( $sdkKey, array_merge($baseOptions, $options) ); - }; } } diff --git a/src/LaunchDarkly/Integrations/Files.php b/src/LaunchDarkly/Integrations/Files.php index 7da571ed..7a6ac4cb 100644 --- a/src/LaunchDarkly/Integrations/Files.php +++ b/src/LaunchDarkly/Integrations/Files.php @@ -1,5 +1,7 @@ + new \LaunchDarkly\Impl\Integrations\GuzzleFeatureRequester( $baseUri, $sdkKey, array_merge($baseOptions, $options) ); - }; } /** @@ -55,13 +56,12 @@ public static function featureRequester($options = []) * - `timeout`: read timeout in seconds; defaults to 3 * @return mixed an object to be stored in the `event_publisher` configuration property */ - public static function eventPublisher($options = []) + public static function eventPublisher(array $options = []): mixed { - return function (string $sdkKey, array $baseOptions) use ($options) { - return new \LaunchDarkly\Impl\Integrations\GuzzleEventPublisher( + return fn (string $sdkKey, array $baseOptions) => + new \LaunchDarkly\Impl\Integrations\GuzzleEventPublisher( $sdkKey, array_merge($baseOptions, $options) ); - }; } } diff --git a/src/LaunchDarkly/Integrations/TestData.php b/src/LaunchDarkly/Integrations/TestData.php index 239e4ef5..b8229754 100644 --- a/src/LaunchDarkly/Integrations/TestData.php +++ b/src/LaunchDarkly/Integrations/TestData.php @@ -1,18 +1,18 @@ _key; } @@ -53,11 +54,11 @@ public function getKey() /** * Creates a deep copy of the flag builder. Subsequent updates to * the original FlagBuilder object will not update the copy and - * vise versa. + * vice versa. * * @return FlagBuilder A copy of the flag builder object */ - public function copy() + public function copy(): FlagBuilder { $to = new FlagBuilder($this->_key); @@ -65,29 +66,20 @@ public function copy() $to->_variations = $this->_variations; $to->_offVariation = $this->_offVariation; $to->_fallthroughVariation = $this->_fallthroughVariation; - $to->_targets = $this->_targets; + $to->_targets = []; + foreach ($this->_targets as $k => $v) { // this array contains arrays so must be explicitly deep-copied + $to->_targets[$k] = $v; + } $to->_rules = $this->_rules; return $to; } - /** - * Determines if the current flag is a boolean flag. - * - * @return boolean true if flag is a boolean flag, false otherwise - */ - private function _isBooleanFlag() - { - return (count($this->_variations) === 2 - && $this->_variations[TRUE_VARIATION_INDEX] === true - && $this->_variations[FALSE_VARIATION_INDEX] === false); - } - /** * A shortcut for setting the flag to use the standard boolean configuration. * - * This is the default for all new flags created with - * `$ldclient->integrations->test_data->TestData->flag()`. + * This is the default for all new flags created with {@see + * \LaunchDarkly\Integrations\TestData::flag()}. * * The flag will have two variations, `true` and `false` (in that order); * it will return `false` whenever targeting is off, and `true` when targeting is on @@ -95,30 +87,30 @@ private function _isBooleanFlag() * * @return FlagBuilder the flag builder */ - public function booleanFlag() + public function booleanFlag(): FlagBuilder { - if ($this->_isBooleanFlag()) { + if (count($this->_variations) === 2 + && $this->_variations[TRUE_VARIATION_INDEX] === true + && $this->_variations[FALSE_VARIATION_INDEX] === false) { return $this; - } else { - return ($this->variations(true, false) - ->fallthroughVariation(TRUE_VARIATION_INDEX) - ->offVariation(FALSE_VARIATION_INDEX)); } + return ($this->variations(true, false) + ->fallthroughVariation(TRUE_VARIATION_INDEX) + ->offVariation(FALSE_VARIATION_INDEX)); } /** * Sets targeting to be on or off for this flag. * - * The effect of this depends on the rest of the flag configuration, - * just as it does on the real LaunchDarkly dashboard. In the default - * configuration that you get from calling TestData->flag() with a - * new flag key, the flag will return false whenever targeting is - * off, and true when targeting is on. + * The effect of this depends on the rest of the flag configuration, just as it does on the + * real LaunchDarkly dashboard. In the default configuration that you get from calling {@see + * \LaunchDarkly\Integrations\TestData::flag()} with a new flag key, the flag will return false + * whenever targeting is off, and true when targeting is on. * * @param bool $on true if targeting should be on * @return FlagBuilder the flag builder object */ - public function on($on) + public function on(bool $on): FlagBuilder { $this->_on = $on; return $this; @@ -136,15 +128,14 @@ public function on($on) * variation index `0` for the first, `1` for the second, etc. * @return FlagBuilder the flag builder */ - public function fallthroughVariation($variation) + public function fallthroughVariation(bool|int $variation): FlagBuilder { if (is_bool($variation)) { $this->booleanFlag()->_fallthroughVariation = $this->variationForBoolean($variation); return $this; - } else { - $this->_fallthroughVariation = $variation; - return $this; } + $this->_fallthroughVariation = $variation; + return $this; } /** @@ -154,21 +145,33 @@ public function fallthroughVariation($variation) * @param bool|int $variation either boolean variation or integer index of variation * @return FlagBuilder the flag builder */ - public function offVariation($variation) + public function offVariation(bool|int $variation): FlagBuilder { if (is_bool($variation)) { $this->booleanFlag()->_offVariation = $this->variationForBoolean($variation); return $this; } - $this->_offVariation = $variation; return $this; } + /** + * Deprecated name for variationForAll. + * + * @param bool|int $variation `true` or `false` or the desired variation index to return: + * `0` for the first, `1` for the second, etc. + * @return FlagBuilder the flag builder + * @deprecated Use {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::variationForAll()}. + */ + public function variationForAllUsers(bool|int $variation): FlagBuilder + { + return $this->variationForAll($variation); + } + /** * Sets the flag to always return the specified variation for all users. * - * The variation is specified, Targeting is switched on, and any existing targets or rules are removed. + * The variation is specified, targeting is switched on, and any existing targets or rules are removed. * The fallthrough variation is set to the specified value. The off variation is left unchanged. * * If the flag was previously configured with other variations and the variation specified is a boolean, @@ -177,92 +180,110 @@ public function offVariation($variation) * @param bool|int $variation `true` or `false` or the desired variation index to return: * `0` for the first, `1` for the second, etc. * @return FlagBuilder the flag builder + * @see \LaunchDarkly\Integrations\TestData\FlagBuilder::valueForAll() */ - public function variationForAllUsers($variation) + public function variationForAll(bool|int $variation): FlagBuilder { if (is_bool($variation)) { - return $this->booleanFlag()->variationForAllUsers($this->variationForBoolean($variation)); - } else { - return $this->on(true)->clearRules()->clearUserTargets()->fallthroughVariation($variation); + return $this->booleanFlag()->variationForAll($this->variationForBoolean($variation)); } + return $this->on(true)->clearRules()->clearTargets()->fallthroughVariation($variation); + } + + /** + * Deprecated name for valueForAll. + * + * @param mixed $value the desired value to be returned for all users + * @return FlagBuilder the flag builder + * @deprecated Use {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::valueForAll()}. + */ + public function valueForAllUsers(mixed $value): FlagBuilder + { + return $this->valueForAll($value); } /** * Sets the flag to always return the specified variation value for all users. * - * The value may be of any JSON type. This method changes the flag to have - * only a single variation, which is this value, and to return the same - * variation regardless of whether targeting is on or off. Any existing - * targets or rules are removed. + * The value may be of any JSON-serializable type. This method changes the flag to have + * only a single variation, which is this value, and to return the same variation regardless + * of whether targeting is on or off. Any existing targets or rules are removed. * * @param mixed $value the desired value to be returned for all users * @return FlagBuilder the flag builder + * @see \LaunchDarkly\Integrations\TestData\FlagBuilder::variationForAll() */ - public function valueForAllUsers($value) + public function valueForAll(mixed $value): FlagBuilder { - $json = json_decode(json_encode($value), true); - if (json_last_error() === JSON_ERROR_NONE) { - $this->variations($json); - return $this->variationForAllUsers(0); - } else { - return $this; - } + return $this->variations($value)->variationForAll(0); } /** * Sets the flag to return the specified variation for a specific user key when targeting * is on. * - * This has no effect when targeting is turned off for the flag. + * This is a shortcut for calling {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::variationForKey()} + * with `LDContext::DEFAULT_KIND` as the context kind. * - * The variation is specified by number, out of whatever variation values have already been - * defined. + * This has no effect when targeting is turned off for the flag. * * @param string $userKey string a user key - * @param int|bool $variation the desired variation to be returned for this - * user when targeting is on: 0 for the first, 1 for the second, etc. + * @param bool|int $variation `true` or `false` or the desired variation index to return: + * `0` for the first, `1` for the second, etc. + * @return FlagBuilder the flag builder + * @see \LaunchDarkly\Integrations\TestData\FlagBuilder::variationForKey() + */ + public function variationForUser(string $userKey, bool|int $variation): FlagBuilder + { + return $this->variationForKey(LDContext::DEFAULT_KIND, $userKey, $variation); + } + + /** + * Sets the flag to return the specified boolean variation for a specific context, identified + * by context kind and key, when targeting is on. + * + * This has no effect when targeting is turned off for the flag. + * + * @param string contextKind the context kind + * @param string $key string the context key + * @param bool|int $variation `true` or `false` or the desired variation index to return: + * `0` for the first, `1` for the second, etc. * @return FlagBuilder the flag builder */ - public function variationForUser(string $userKey, $variation) + public function variationForKey(string $contextKind, string $key, bool|int $variation): FlagBuilder { if (is_bool($variation)) { return $this->booleanFlag() - ->variationForUser($userKey, $this->variationForBoolean($variation)); - } else { - $variationIndex = $variation; - $targets = $this->_targets; - - $variationKeys = array_keys($this->_variations); - foreach ($variationKeys as $idx) { - if ($idx == $variationIndex) { - $targetForVariation = $targets[$idx] ?? []; - - if (!in_array($userKey, $targetForVariation)) { - $targetForVariation[] = $userKey; - } - $this->_targets[$idx] = $targetForVariation; - } elseif (array_key_exists($idx, $targets)) { - $targetForVariation = $targets[$idx]; - $userKeyIdx = array_search($userKey, $targetForVariation); - // $userKeyIdx can be 0,1,2,3 etc or false if not found. - // Needs a strict check to ensure it doesn't eval to true - // when index === 0 - if ($userKeyIdx !== false) { - array_splice($targetForVariation, $userKeyIdx, 1); - $this->_targets[$idx] = $targetForVariation; - } + ->variationForKey($contextKind, $key, $this->variationForBoolean($variation)); + } + $variationIndex = $variation; + + $targets = $this->_targets[$contextKind] ?? []; + foreach ($this->_variations as $idx => $value) { + $targetsForVariation = $targets[$idx] ?? []; + if ($idx === $variationIndex) { + if (!in_array($key, $targetsForVariation)) { + $targetsForVariation[] = $key; + $targets[$idx] = $targetsForVariation; + } + } elseif (array_key_exists($idx, $targets)) { + $foundIndex = array_search($key, $targetsForVariation); + if ($foundIndex !== false) { + array_splice($targetsForVariation, $foundIndex, 1); + $targets[$idx] = $targetsForVariation; } } - return $this; } + ksort($targets); // ensures deterministic order of output + $this->_targets[$contextKind] = $targets; + return $this; } /** * Changes the allowable variation values for the flag. * - * The value may be of any valid JSON type. For instance, a boolean flag - * normally has true, false; a string-valued flag might have - * 'red', 'green'; etc. + * The values may be of any JSON-serializable types. For instance, a boolean flag + * normally has true, false; a string-valued flag might have 'red', 'green'; etc. * * Example: A single variation * @@ -272,18 +293,26 @@ public function variationForUser(string $userKey, $variation) * * $td->flag('new-flag')->variations('red', 'green', 'blue') * - * @param array $variations the the desired variations + * @param mixed[] $variations the the desired variations * @return FlagBuilder the flag builder object */ - public function variations(...$variations): FlagBuilder + public function variations(mixed ...$variations): FlagBuilder { - $this->_variations = $variations; + $validatedVariations = []; + foreach ($variations as $value) { + $json = json_decode(json_encode($value), true); + $validatedVariations[] = $json; + } + $this->_variations = $validatedVariations; return $this; } /** * Starts defining a flag rule, using the "is one of" operator. * + * This is a shortcut for calling {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::ifMatchContext()} + * with `LDContext::DEFAULT_KIND` as the context kind. + * * For example, this creates a rule that returns `true` if the name is "Patsy" or "Edina": * * $testData->flag("flag") @@ -291,44 +320,94 @@ public function variations(...$variations): FlagBuilder * ->thenReturn(true); * * @param string $attribute the user attribute to match against - * @param mixed $values values to compare to + * @param mixed[] $values values to compare to + * @return FlagRuleBuilder call `thenReturn(boolean)` or + * `thenReturn(int)` to finish the rule, or add more tests with another + * method like `andMatch()` + */ + public function ifMatch(string $attribute, mixed ...$values): FlagRuleBuilder + { + return $this->ifMatchContext(LDContext::DEFAULT_KIND, $attribute, ...$values); + } + + /** + * Starts defining a flag rule, using the "is one of" operator. This matching expression only + * applies to contexts of a specific kind. + * + * For example, this creates a rule that returns `true` if the name attribute for the + * "company" context is "Ella" or "Monsoon": + * + * $testData->flag("flag") + * ->ifMatchContext("company", "name", "Ella", "Monsoon") + * ->thenReturn(true); + * + * @param string $contextKind the context kind + * @param string $attribute the context attribute to match against + * @param mixed[] $values values to compare to * @return FlagRuleBuilder call `thenReturn(boolean)` or * `thenReturn(int)` to finish the rule, or add more tests with another * method like `andMatch()` */ - public function ifMatch($attribute, ...$values) + public function ifMatchContext(string $contextKind, string $attribute, mixed ...$values): FlagRuleBuilder { $flagRuleBuilder = new FlagRuleBuilder($this); - return $flagRuleBuilder->andMatch($attribute, $values); + return $flagRuleBuilder->andMatchContext($contextKind, $attribute, ...$values); } /** * Starts defining a flag rule, using the "is not one of" operator. * - * For example, this creates a rule that returns `true` if the name - * is neither "Saffron" nor "Bubble": + * This is a shortcut for calling {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::ifNotMatchContext()} + * with `LDContext::DEFAULT_KIND` as the context kind. + * + * For example, this creates a rule that returns `true` if the name is neither "Saffron" nor "Bubble": * * $testData->flag("flag") * ->ifNotMatch("name", "Saffron", "Bubble") * ->thenReturn(true); * - * @param string $attribute the user attribute to match against - * @param mixed $values values to compare to + * @param string $attribute the context attribute to match against + * @param mixed[] $values values to compare to * @return FlagRuleBuilder call `thenReturn(boolean)` or * `thenReturn(int)` to finish the rule, or add more tests with another * method like `andMatch()` */ - public function ifNotMatch(string $attribute, mixed $values): FlagRuleBuilder + public function ifNotMatch(string $attribute, mixed ...$values): FlagRuleBuilder + { + return $this->ifNotMatchContext(LDContext::DEFAULT_KIND, $attribute, ...$values); + } + + /** + * Starts defining a flag rule, using the "is not one of" operator. + * + * This is a shortcut for calling {@see \LaunchDarkly\Integrations\TestData\FlagRuleBuilder::ifMatchContext()} + * with `ContextKind::Default` as the context kind. + * + * For example, this creates a rule that returns `true` if the name attribute for the + * "company" context is neither "Pendant" nor "Sterling Cooper": + * + * $testData->flag("flag") + * ->ifNotMatchContext("company", "name", "Pendant", "Sterling Cooper") + * ->thenReturn(true); + * + * @param string $contextKind the context kind + * @param string $attribute the context attribute to match against + * @param mixed[] $values values to compare to + * @return FlagRuleBuilder call `thenReturn(boolean)` or + * `thenReturn(int)` to finish the rule, or add more tests with another + * method like `andMatch()` + */ + public function ifNotMatchContext(string $contextKind, string $attribute, mixed ...$values): FlagRuleBuilder { $flagRuleBuilder = new FlagRuleBuilder($this); - return $flagRuleBuilder->andNotMatch($attribute, $values); + return $flagRuleBuilder->andNotMatchContext($contextKind, $attribute, ...$values); } /** * @param FlagRuleBuilder $flagRuleBuilder * @return FlagBuilder the flag builder */ - public function addRule($flagRuleBuilder): FlagBuilder + public function addRule(FlagRuleBuilder $flagRuleBuilder): FlagBuilder { array_push($this->_rules, $flagRuleBuilder); return $this; @@ -347,12 +426,23 @@ public function clearRules(): FlagBuilder } /** - * Removes any existing user targets from the flag. This undoes the effect of methods like - * `variationForUser(string, boolean)`. + * Deprecated name for clearTargets. * * @return FlagBuilder the same builder + * @deprecated Use {@see \LaunchDarkly\Integrations\TestData\FlagBuilder::clearTargets()}. */ public function clearUserTargets(): FlagBuilder + { + return $this->clearTargets(); + } + + /** + * Removes any existing targets for individual user/context keys from the flag. This undoes the effect of + * the `variationForUser` and `variationForKey` methods. + * + * @return FlagBuilder the same builder + */ + public function clearTargets(): FlagBuilder { $this->_targets = []; return $this; @@ -390,7 +480,7 @@ public function build(int $version): array // Fields necessary to be able to pass the result // of build() into FeatureFlag::decode 'prerequisites' => [], - 'salt' => null, + 'salt' => '', ]; $baseFlagObject['offVariation'] = $this->_offVariation; @@ -399,18 +489,30 @@ public function build(int $version): array ]; $targets = []; - foreach ($this->_targets as $varIndex => $userKeys) { - $targets[$varIndex] = [ - 'variation' => $varIndex, - 'values' => $userKeys - ]; + $contextTargets = []; + foreach ($this->_targets as $kind => $targetsForKind) { + foreach ($targetsForKind as $varIndex => $keys) { + if ($kind === LDContext::DEFAULT_KIND) { + $targets[] = [ + 'variation' => $varIndex, + 'values' => $keys + ]; + $contextTargets[] = [ + 'contextKind' => LDContext::DEFAULT_KIND, + 'variation' => $varIndex, + 'values' => [] + ]; + } else { + $contextTargets[] = [ + 'contextKind' => $kind, + 'variation' => $varIndex, + 'values' => $keys + ]; + } + } } - - // used to reset index - ksort($targets); - $targets = array_values($targets); - $baseFlagObject['targets'] = $targets; + $baseFlagObject['contextTargets'] = $contextTargets; $baseFlagObject['rules'] = []; diff --git a/src/LaunchDarkly/Integrations/TestData/FlagRuleBuilder.php b/src/LaunchDarkly/Integrations/TestData/FlagRuleBuilder.php index 96214d23..32a48624 100644 --- a/src/LaunchDarkly/Integrations/TestData/FlagRuleBuilder.php +++ b/src/LaunchDarkly/Integrations/TestData/FlagRuleBuilder.php @@ -1,9 +1,13 @@ flag("flag") + * ->ifMatch("name", "Patsy") + * ->andMatch("country", "gb") + * ->thenReturn(true); + * + * @param string $attribute the user attribute to match against + * @param mixed[] $values values to compare to + * @return FlagRuleBuilder the rule builder + */ + public function andMatch(string $attribute, mixed ...$values) + { + return $this->andMatchContext(LDContext::DEFAULT_KIND, $attribute, ...$values); + } + + /** + * Adds another clause, using the "is one of" operator. This matching expression only + * applies to contexts of a specific kind. + * + * For example, this creates a rule that returns `true` if the name attribute for the + * "company" context is "Ella", and the country attribute for the "company" context is "gb": * * $testData->flag("flag") - * ->ifMatch("NAME", "Patsy") - * ->andMatch("COUNTRY", "gb") + * ->ifMatchContext("company", "name", "Ella") + * ->andMatchContext("company", "country", "gb") * ->thenReturn(true); * * @param string $attribute the user attribute to match against - * @param mixed $values values to compare to + * @param mixed[] $values values to compare to * @return FlagRuleBuilder the rule builder */ - public function andMatch(string $attribute, $values) + public function andMatchContext(string $contextKind, string $attribute, mixed ...$values) { $newClause = [ + "contextKind" => $contextKind, "attribute" => $attribute, "op" => 'in', "values" => $values, @@ -62,6 +90,9 @@ public function andMatch(string $attribute, $values) /** * Adds another clause, using the "is not one of" operator. * + * This is a shortcut for calling {@see \LaunchDarkly\Integrations\TestData\FlagRuleBuilder::andNotMatchContext()} + * with`LDContext::DEFAULT_KIND` as the context kind. + * * For example, this creates a rule that returns `true` if * the name is "Patsy" and the country is not "gb": * @@ -71,12 +102,34 @@ public function andMatch(string $attribute, $values) * ->thenReturn(true); * * @param string $attribute the user attribute to match against - * @param mixed $values values to compare to + * @param mixed[] $values values to compare to + * @return FlagRuleBuilder the rule builder + */ + public function andNotMatch(string $attribute, mixed ...$values) + { + return $this->andNotMatchContext(LDContext::DEFAULT_KIND, $attribute, ...$values); + } + + /** + * Adds another clause, using the "is not one of" operator. This matching expression only + * applies to contexts of a specific kind. + * + * For example, this creates a rule that returns `true` if the name attribute for the + * "company" context is "Ella", and the country attribute for the "company" context is not "gb": + * + * $testData->flag("flag") + * ->ifMatchContext("company", "name", "Ella") + * ->andNotMatchContext("company", "country", "gb") + * ->thenReturn(true); + * + * @param string $attribute the user attribute to match against + * @param mixed[] $values values to compare to * @return FlagRuleBuilder the rule builder */ - public function andNotMatch(string $attribute, ...$values) + public function andNotMatchContext(string $contextKind, string $attribute, mixed ...$values) { $newClause = [ + "contextKind" => $contextKind, "attribute" => $attribute, "op" => 'in', "values" => $values, @@ -93,7 +146,7 @@ public function andNotMatch(string $attribute, ...$values) * @param bool|int $variation the value to return if the rule matches the user * @return FlagBuilder the flag builder */ - public function thenReturn($variation) + public function thenReturn(bool|int $variation): FlagBuilder { if (is_bool($variation)) { $this->_flagBuilder->booleanFlag(); @@ -111,7 +164,7 @@ public function thenReturn($variation) * @param int $id the rule id * @return array the array representation of the flag */ - public function build(int $id) + public function build(int $id): array { return [ "id" => "rule{$id}", diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index 8a28e725..7c9f3800 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -1,14 +1,21 @@ _featureRequester = $this->getFeatureRequester($sdkKey, $options); + + $this->_evaluator = new Evaluator($this->_featureRequester, $this->_logger); } /** @@ -156,8 +153,6 @@ private function getFeatureRequester(string $sdkKey, array $options): FeatureReq { if (isset($options['feature_requester']) && $options['feature_requester']) { $fr = $options['feature_requester']; - } elseif (isset($options['feature_requester_class']) && $options['feature_requester_class']) { - $fr = $options['feature_requester_class']; } else { $fr = Guzzle::featureRequester(); } @@ -177,107 +172,116 @@ private function getFeatureRequester(string $sdkKey, array $options): FeatureReq } /** - * Calculates the value of a feature flag for a given user. + * Calculates the value of a feature flag for a given context. * - * @param string $key The unique key for the feature flag - * @param LDUser $user The end user requesting the flag - * @param mixed $default The default value of the flag + * If an error makes it impossible to evaluate the flag (for instance, the feature flag key + * does not match any existing flag), `$defaultValue` is returned. * - * @return mixed The result of the Feature Flag evaluation, or $default if any errors occurred. + * @param string $key the unique key for the feature flag + * @param LDContext|LDUser $context the evaluation context or user + * @param mixed $defaultValue the default value of the flag + * @return mixed The variation for the given context, or `$defaultValue` if the flag cannot be evaluated + * @see \LaunchDarkly\LDClient::variationDetail() */ - public function variation(string $key, LDUser $user, $default = false) + public function variation(string $key, LDContext|LDUser $context, mixed $defaultValue = false): mixed { - $detail = $this->variationDetailInternal($key, $user, $default, $this->_eventFactoryDefault); + $detail = $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryDefault); return $detail->getValue(); } /** - * Calculates the value of a feature flag, and returns an object that describes the way the - * value was determined. + * Calculates the value of a feature flag for a given context, and returns an object that + * describes the way the value was determined. * * The "reason" property in the result will also be included in analytics events, if you are capturing * detailed event data for this flag. * - * @param string $key The unique key for the feature flag - * @param LDUser $user The end user requesting the flag - * @param mixed $default The default value of the flag + * @param string $key the unique key for the feature flag + * @param LDContext|LDUser $context the evaluation context or user + * @param mixed $defaultValue the default value of the flag * * @return EvaluationDetail An EvaluationDetail object that includes the feature flag value - * and evaluation reason. + * and evaluation reason */ - public function variationDetail(string $key, LDUser $user, $default = false): EvaluationDetail + public function variationDetail(string $key, LDContext|LDUser $context, mixed $defaultValue = false): EvaluationDetail { - return $this->variationDetailInternal($key, $user, $default, $this->_eventFactoryWithReasons); + return $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryWithReasons); } /** * @param string $key - * @param LDUser $user + * @param LDContext|LDUser $contextOrUser * @param mixed $default * @param EventFactory $eventFactory * * @return EvaluationDetail */ - private function variationDetailInternal(string $key, LDUser $user, $default, EventFactory $eventFactory): EvaluationDetail + private function variationDetailInternal(string $key, LDContext|LDUser $contextOrUser, mixed $default, EventFactory $eventFactory): EvaluationDetail { + $context = $contextOrUser instanceof LDUser ? LDContext::fromUser($contextOrUser) : $contextOrUser; $default = $this->_get_default($key, $default); - $errorResult = function (string $errorKind) use ($default): EvaluationDetail { - return new EvaluationDetail($default, null, EvaluationReason::error($errorKind)); - }; - $sendEvent = function (EvaluationDetail $detail, ?FeatureFlag $flag) use ($key, $user, $default, $eventFactory): void { + $errorDetail = fn (string $errorKind): EvaluationDetail => + new EvaluationDetail($default, null, EvaluationReason::error($errorKind)); + $sendEvent = function (EvalResult $result, ?FeatureFlag $flag) use ($key, $context, $default, $eventFactory): void { if ($flag) { - $event = $eventFactory->newEvalEvent($flag, $user, $detail, $default); + $event = $eventFactory->newEvalEvent($flag, $context, $result, $default); } else { - $event = $eventFactory->newUnknownFlagEvent($key, $user, $detail); + $event = $eventFactory->newUnknownFlagEvent($key, $context, $result->getDetail()); } $this->_eventProcessor->enqueue($event); }; + if (!$context->isValid()) { + $result = $errorDetail(EvaluationReason::USER_NOT_SPECIFIED_ERROR); + $sendEvent(new EvalResult($result, false), null); + $error = $context->getError(); + $this->_logger->warning("Context was invalid for flag evaluation ($error); returning default value"); + return $result; + } + if ($this->_offline) { - return $errorResult(EvaluationReason::CLIENT_NOT_READY_ERROR); + return $errorDetail(EvaluationReason::CLIENT_NOT_READY_ERROR); } try { - if ($user->isKeyBlank()) { - $this->_logger->warning("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly."); - } try { $flag = $this->_featureRequester->getFeature($key); } catch (UnrecoverableHTTPStatusException $e) { $this->handleUnrecoverableError(); - return $errorResult(EvaluationReason::EXCEPTION_ERROR); + return $errorDetail(EvaluationReason::EXCEPTION_ERROR); } if (is_null($flag)) { - $result = $errorResult(EvaluationReason::FLAG_NOT_FOUND_ERROR); - $sendEvent($result, null); - return $result; - } - if (is_null($user->getKey())) { - $result = $errorResult(EvaluationReason::USER_NOT_SPECIFIED_ERROR); - $sendEvent($result, $flag); - $this->_logger->warning("Variation called with null user key! Returning default value"); + $result = $errorDetail(EvaluationReason::FLAG_NOT_FOUND_ERROR); + $sendEvent(new EvalResult($result, false), null); return $result; } - $evalResult = $flag->evaluate($user, $this->_featureRequester, $eventFactory); - foreach ($evalResult->getPrerequisiteEvents() as $e) { - $this->_eventProcessor->enqueue($e); - } + $evalResult = $this->_evaluator->evaluate( + $flag, + $context, + function (PrerequisiteEvaluationRecord $pe) use ($context, $eventFactory) { + $event = $eventFactory->newEvalEvent( + $pe->getFlag(), + $context, + $pe->getResult(), + null, + $pe->getPrereqOfFlag() + ); + $this->_eventProcessor->enqueue($event); + } + ); $detail = $evalResult->getDetail(); if ($detail->isDefaultValue()) { $detail = new EvaluationDetail($default, null, $detail->getReason()); + $evalResult = new EvalResult($detail, $evalResult->isForceReasonTracking()); } - $sendEvent($detail, $flag); + $sendEvent($evalResult, $flag); return $detail; } catch (\Exception $e) { - $this->_logger->error("Caught $e"); - $result = $errorResult(EvaluationReason::EXCEPTION_ERROR); - try { - $sendEvent($result, null); - } catch (\Exception $e) { - $this->_logger->error("Caught $e"); - } + Util::logExceptionAtErrorLevel($this->_logger, $e, "Unexpected error evaluating flag $key"); + $result = $errorDetail(EvaluationReason::EXCEPTION_ERROR); + $sendEvent(new EvalResult($result, false), null); return $result; } } @@ -291,44 +295,57 @@ public function isOffline(): bool } /** - * Tracks that a user performed an event. + * Tracks that an application-defined event occurred. + * + * This method creates a "custom" analytics event containing the specified event name (key) + * and context properties. You may attach arbitrary data or a metric value to the event with the + * optional `data` and `metricValue` parameters. + * + * Note that event delivery is asynchronous, so the event may not actually be sent until later; + * see {@see \LaunchDarkly\LDClient::flush()}. * * @param string $eventName The name of the event - * @param LDUser $user The user that performed the event + * @param LDContext|LDUser $context The evaluation context or user associated with the event * @param mixed $data Optional additional information to associate with the event - * @param numeric $metricValue A numeric value used by the LaunchDarkly experimentation feature in - * numeric custom metrics. Can be omitted if this event is used by only non-numeric metrics. This - * field will also be returned as part of the custom event for Data Export. + * @param int|float|null $metricValue A numeric value used by the LaunchDarkly experimentation feature in + * numeric custom metrics; can be omitted if this event is used by only non-numeric metrics */ - public function track(string $eventName, LDUser $user, $data = null, $metricValue = null): void + public function track(string $eventName, LDContext|LDUser $context, mixed $data = null, int|float|null $metricValue = null): void { - if ($user->isKeyBlank()) { + $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; + if (!$context->isValid()) { $this->_logger->warning("Track called with null/empty user key!"); return; } - $this->_eventProcessor->enqueue($this->_eventFactoryDefault->newCustomEvent($eventName, $user, $data, $metricValue)); + $this->_eventProcessor->enqueue($this->_eventFactoryDefault->newCustomEvent($eventName, $context, $data, $metricValue)); } /** - * Reports details about a user. + * Reports details about an evaluation context or user. + * + * This method simply creates an analytics event containing the context properties, to + * that LaunchDarkly will know about that context if it does not already. * - * This simply registers the given user properties with LaunchDarkly without evaluating a feature flag. - * This also happens automatically when you evaluate a flag. + * Evaluating a flag, by calling {@see \LaunchDarkly\LDClient::variation()} or + * {@see \LaunchDarkly\LDClient::variationDetail()} :func:`variation_detail()`, also sends + * the context information to LaunchDarkly (if events are enabled), so you only need to use + * identify() if you want to identify the context without evaluating a flag. * - * @param LDUser $user The user properties + * @param LDContext|LDUser $context The context or user to register * @return void */ - public function identify(LDUser $user): void + public function identify(LDContext|LDUser $context): void { - if ($user->isKeyBlank()) { + $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; + if (!$context->isValid()) { $this->_logger->warning("Identify called with null/empty user key!"); return; } - $this->_eventProcessor->enqueue($this->_eventFactoryDefault->newIdentifyEvent($user)); + $this->_eventProcessor->enqueue($this->_eventFactoryDefault->newIdentifyEvent($context)); } /** - * Returns an object that encapsulates the state of all feature flags for a given user. + * Returns an object that encapsulates the state of all feature flags for a given context. * * This includes the flag values as well as other flag metadata that may be needed by front-end code, * since the most common use case for this method is [bootstrapping](https://docs.launchdarkly.com/sdk/features/bootstrapping) @@ -336,7 +353,7 @@ public function identify(LDUser $user): void * * This method does not send analytics events back to LaunchDarkly. * - * @param LDUser $user The end user requesting the feature flags + * @param LDContext|LDUser $context the evalation context or user * @param array $options Optional properties affecting how the state is computed: * - `clientSideOnly`: Set this to true to specify that only flags marked for client-side use * should be included; by default, all flags are included @@ -348,10 +365,12 @@ public function identify(LDUser $user): void * * @return FeatureFlagsState a FeatureFlagsState object (will never be null) */ - public function allFlagsState(LDUser $user, array $options = []): FeatureFlagsState + public function allFlagsState(LDContext|LDUser $context, array $options = []): FeatureFlagsState { - if (is_null($user->getKey())) { - $this->_logger->warning("allFlagsState called with null/empty user key! Returning empty state"); + $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; + if (!$context->isValid()) { + $error = $context->getError(); + $this->_logger->warning("Invalid context for allFlagsState ($error); returning empty state"); return new FeatureFlagsState(false); } @@ -370,59 +389,37 @@ public function allFlagsState(LDUser $user, array $options = []): FeatureFlagsSt $preloadedRequester = new PreloadedFeatureRequester($this->_featureRequester, $flags); // This saves us from doing repeated queries for prerequisite flags during evaluation + $tempEvaluator = new Evaluator($preloadedRequester); $state = new FeatureFlagsState(true); - $clientOnly = isset($options['clientSideOnly']) && $options['clientSideOnly']; - $withReasons = isset($options['withReasons']) && $options['withReasons']; - $detailsOnlyIfTracked = isset($options['detailsOnlyForTrackedFlags']) && $options['detailsOnlyForTrackedFlags']; + $clientOnly = !!($options['clientSideOnly'] ?? false); + $withReasons = !!($options['withReasons'] ?? false); + $detailsOnlyIfTracked = !!($options['detailsOnlyForTrackedFlags'] ?? false); foreach ($flags as $flag) { if ($clientOnly && !$flag->isClientSide()) { continue; } - $result = $flag->evaluate($user, $preloadedRequester, $this->_eventFactoryDefault); - $state->addFlag($flag, $result->getDetail(), $withReasons, $detailsOnlyIfTracked); + $result = $tempEvaluator->evaluate($flag, $context, null); + $state->addFlag($flag, $result->getDetail(), $result->isForceReasonTracking(), $withReasons, $detailsOnlyIfTracked); } return $state; } - /** - * Associates two users for analytics purposes. - * - * This can be helpful in the situation where a person is represented by multiple - * LaunchDarkly users. This may happen, for example, when a person initially logs into - * an application-- the person might be represented by an anonymous user prior to logging - * in and a different user after logging in, as denoted by a different user key. - * - * @param LDUser $user the newly identified user. - * @param LDUser $previousUser the previously identified user. - */ - public function alias(LDUser $user, LDUser $previousUser): void - { - if (is_null($user->getKey())) { - $this->_logger->warning("Alias called with null/empty user!"); - return; - } - - if (is_null($previousUser->getKey())) { - $this->_logger->warning("Alias called with null/empty previousUser!"); - return; - } - - $event = $this->_eventFactoryDefault->newAliasEvent($user, $previousUser); - $this->_eventProcessor->enqueue($event); - } - /** * Generates an HMAC sha256 hash for use in Secure mode. * * See: [Secure mode](https://docs.launchdarkly.com/sdk/features/secure-mode) + * + * @param LDContext|LDUser $context The evaluation context or user + * @return string The hash value */ - public function secureModeHash(LDUser $user): string + public function secureModeHash(LDContext|LDUser $context): string { - if (strlen($user->getKey()) === 0) { + $context = $context instanceof LDUser ? LDContext::fromUser($context) : $context; + if (!$context->isValid()) { return ""; } - return hash_hmac("sha256", $user->getKey(), $this->_sdkKey, false); + return hash_hmac("sha256", $context->getFullyQualifiedKey(), $this->_sdkKey, false); } /** @@ -441,12 +438,7 @@ public function flush(): bool } } - /** - * @param string $key - * @param mixed|null $default - * @return mixed|null - */ - protected function _get_default(string $key, $default) + protected function _get_default(string $key, mixed $default): mixed { if (array_key_exists($key, $this->_defaults)) { return $this->_defaults[$key]; diff --git a/src/LaunchDarkly/LDContext.php b/src/LaunchDarkly/LDContext.php new file mode 100644 index 00000000..4477f6f4 --- /dev/null +++ b/src/LaunchDarkly/LDContext.php @@ -0,0 +1,928 @@ +makeInvalid($error); + return; + } + if ($multiContexts !== null) { + if (count($multiContexts) === 0) { + $this->makeInvalid(self::ERR_KIND_MULTI_WITH_NO_KINDS); + return; + } + $errors = null; + for ($i = 0; $i < count($multiContexts); $i++) { + $c = $multiContexts[$i]; + if (!($c instanceof LDContext)) { + $this->makeInvalid('something other than an LDContext was used in a multi-context'); + return; + } + if (!$c->isValid()) { + if ($errors === null) { + $errors = ''; + } else { + $errors .= ', '; + } + $errors .= $c->getError(); + continue; + } + for ($j = 0; $j < $i; $j++) { + if ($multiContexts[$j]->getKind() === $c->getKind()) { + $this->makeInvalid(self::ERR_KIND_MULTI_DUPLICATES); + return; + } + } + } + if ($errors) { + $this->makeInvalid($errors); + return; + } + // Sort them by kind; they need to be sorted for computing a fully-qualified key, but even + // if getFullyQualifiedKey() is never called, this is helpful for equals() and determinacy. + $sorted = $multiContexts; + usort($sorted, fn (LDContext $c1, LDContext $c2) => $c1->_kind <=> $c2->_kind); + $this->_multiContexts = $sorted; + $this->_kind = self::MULTI_KIND; + // No other properties can be set for a multi-context, but we'll still ensure that all + // properties that are normally non-null have non-null values. + $this->_key = ''; + return; + } + if ($kind === null || $kind === '') { + $kind = self::DEFAULT_KIND; + } + $kindError = self::validateKind($kind); + if ($kindError) { + self::makeInvalid($kindError); + return; + } + if ($key === '') { + self::makeInvalid(self::ERR_NO_KEY); + return; + } + $this->_key = $key; + $this->_kind = $kind; + $this->_name = $name; + $this->_anonymous = $anonymous; + $this->_attributes = $attributes; + $this->_privateAttributes = $privateAttributes; + } + + /** + * Creates a single-kind LDContext with only the key and the kind specified. + * + * If you omit the kind, it defaults to "user" ({@see \LaunchDarkly\LDContext::DEFAULT_KIND}). + * + * To specify additional properties, use {@see \LaunchDarkly\LDContext::builder()}. To create a + * multi-context instead of a single one, use {@see \LaunchDarkly\LDContext::createMulti()} or + * {@see \LaunchDarkly\LDContext::multiBuilder()}. + * + * @param string $key the context key + * @param string|null $kind the context kind; if null, {@see \LaunchDarkly\LDContext::DEFAULT_KIND} is used + * @return LDContext an LDContext + * @see \LaunchDarkly\LDContext::createMulti() + * @see \LaunchDarkly\LDContext::builder() + */ + public static function create(string $key, ?string $kind = null): LDContext + { + return new LDContext($kind, $key, null, false, null, null, null, null); + } + + /** + * Creates a multi-context out of the specified single-kind LDContexts. + * + * To create an LDContext for a single context kind, use {@see \LaunchDarkly\LDContext::create()} + * or {@see \LaunchDarkly\LDContext::builder()}. + * + * For the returned LDContext to be valid, the contexts list must not be empty, and all of its + * elements must be valid LDContexts. Otherwise, the returned LDContext will be invalid as + * reported by {@see \LaunchDarkly\LDContext::getError()}. + * + * If only one context parameter is given, the method returns that same context. + * + * If the nested context is a multi-context, this is exactly equivalent to adding each of the + * individual kinds from it separately. See {@see \LaunchDarkly\LDContextMultiBuilder::add()}. + * + * @param LDContext $contexts,... a list of contexts + * @return LDContext an LDContext + * @see \LaunchDarkly\LDContext::create() + * @see \LaunchDarkly\LDContext::multiBuilder() + */ + public static function createMulti(LDContext ...$contexts): LDContext + { + if (count($contexts) === 0) { + return self::createWithError(self::ERR_KIND_MULTI_WITH_NO_KINDS); + } + $b = self::multiBuilder(); + foreach ($contexts as $c) { + $b->add($c); + } + return $b->build(); + } + + /** + * @param LDUser $user + * @return LDContext + */ + public static function fromUser(LDUser $user): LDContext + { + $attrs = null; + self::maybeAddAttr($attrs, "avatar", $user->getAvatar()); + self::maybeAddAttr($attrs, "country", $user->getCountry()); + self::maybeAddAttr($attrs, "email", $user->getEmail()); + self::maybeAddAttr($attrs, "firstName", $user->getFirstName()); + self::maybeAddAttr($attrs, "ip", $user->getIP()); + self::maybeAddAttr($attrs, "lastName", $user->getLastName()); + $userCustom = $user->getCustom(); + if ($userCustom !== null && count($userCustom) !== 0) { + if ($attrs === null) { + $attrs = []; + } + foreach ($userCustom as $k => $v) { + if (isAllowableUserCustomAttr($k)) { + $attrs[$k] = $v; + } + } + } + $privateAttrs = null; + $userPrivate = $user->getPrivateAttributeNames(); + if ($userPrivate !== null && count($userPrivate) !== 0) { + $privateAttrs = []; + foreach ($userPrivate as $pa) { + $privateAttrs[] = AttributeReference::fromLiteral($pa); + } + } + return new LDContext( + self::DEFAULT_KIND, + $user->getKey(), + $user->getName(), + $user->getAnonymous() ?? false, + $attrs, + $privateAttrs, + null, + null + ); + } + + private static function maybeAddAttr(?array &$attrsOut, string $name, ?string $value): void + { + if ($value !== null) { + if ($attrsOut === null) { + $attrsOut = []; + } + $attrsOut[$name] = $value; + } + } + + /** + * Creates a builder for building an LDContext. + * + * You may use {@see \LaunchDarkly\LDContextBuilder} methods to set additional attributes and/or + * change the `kind` before calling {@see \LaunchDarkly\LDContextBuilder::build()}. If you do not + * change any values, the defaults for the LDContext are that its `kind` is + * {@see \LaunchDarkly\LDContext::DEFAULT_KIND} ("user"), its `key` is set to the key parameter + * specified here, `anonymous` is `false`, and it has no values for any other attributes. + * + * This method is for building an LDContext that has only a single kind. To define a multi-context, + * use {@see \LaunchDarkly\LDContext::createMulti()} or {@see \LaunchDarkly\LDContext::multiBuilder()}. + * + * If `key` is an empty string, there is no default. An LDContext must have a non-empty key, so if + * you call {@see \LaunchDarkly\LDContextBuilder::build()} in this state without using + * {@see \LaunchDarkly\LDContextBuilder::key()} to set the key, you will get an invalid LDContext. + * + * @param string $key the context key + * @return LDContextBuilder a builder + * @see \LaunchDarkly\LDContext::multiBuilder() + * @see \LaunchDarkly\LDContext::create() + */ + public static function builder(string $key): LDContextBuilder + { + return new LDContextBuilder($key); + } + + /** + * Creates a builder for building a multi-context. + * + * This method is for building an LDContext that contains multiple contexts, each for a different + * context kind. To define a single context, use {@see \LaunchDarkly\LDContext::builder()} + * or {@see \LaunchDarkly\LDContext::create()} instead. + * + * The difference between this method and {@see \LaunchDarkly\LDContext::createMulti()} is + * simply that the builder allows you to add contexts one at a time, if that is more + * convenient for your logic. + * + * @return LDContextMultiBuilder a builder + * @see \LaunchDarkly\LDContext::createMulti() + * @see \LaunchDarkly\LDContext::builder() + */ + public static function multiBuilder(): LDContextMultiBuilder + { + return new LDContextMultiBuilder(); + } + + /** + * Creates an LDContext from a parsed JSON representation. + * + * The JSON must be in one of the standard formats used by LaunchDarkly. This can either be a + * context representation similar to what would be produced by {@see \LaunchDarkly\LDContext::jsonSerialize()}, + * or a user representation in the format used by older LaunchDarkly SDKs. A user representation + * does not have a `kind` property and will be converted to a context with the kind "user". + * + * ```php + * $json = '{"kind": "user", "key": "aaa"}'; + * $context = LDContext::fromJson($json); + * + * // or: + * $props = ['kind' => 'user', 'key' => 'true']; + * $context = LDContext::fromJson($props); + * ``` + * @param string|array|object $jsonObject a JSON representation as a string; or, an object or + * associative array corresponding to the parsed JSON + * @return LDContext a context + * @throws \InvalidArgumentException if any properties were invalid, or if you passed a string that + * was not well-formed JSON + */ + public static function fromJson($jsonObject): LDContext + { + if (is_string($jsonObject)) { + try { + $o = json_decode($jsonObject, false, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + throw new \InvalidArgumentException('invalid JSON', 0, $e); + } + } else { + $o = $jsonObject; + } + if (!is_array($o) && !is_object($o)) { + throw new \InvalidArgumentException('invalid JSON object'); + } + + $a = (array)$o; + $kind = $a['kind'] ?? null; + if ($kind === null) { + return self::decodeJsonOldUser($a, is_array($o)); + } + if ($kind === self::MULTI_KIND) { + $b = self::multiBuilder(); + foreach ($a as $k => $v) { + if ($k != 'kind') { + $b->add(self::decodeJsonSingleKind((array)$v, $k)); + } + } + return $b->build(); + } + return self::decodeJsonSingleKind($a, null); + } + + /** + * Returns `true` for a valid LDContext, `false` for an invalid one. + * + * A valid context is one that can be used in SDK operations. An invalid context is one that + * is missing necessary attributes or has invalid attributes, indicating an incorrect usage + * of the SDK API. The only ways for a context to be invalid are: + * + * - The `kind` property had a disallowed value. See {@see \LaunchDarkly\LDContextBuilder::kind()}. + * - For a single context, the `key` property was null or empty. + * - You tried to create a multi-context without specifying any contexts. + * - You tried to create a multi-context using the same context kind more than once. + * - You tried to create a multi-context where at least one of the individual LDContexts was invalid. + * + * In any of these cases, isValid() will return false, and {@see \LaunchDarkly\LDContext::getError()} + * will return a description of the error. + * + * Since in normal usage it is easy for applications to be sure they are using context kinds + * correctly, and because throwing an exception is undesirable in application code that uses + * LaunchDarkly, the SDK stores the error state in the LDContext itself and checks for such + * errors at the time the Context is used, such as in a flag evaluation. At that point, if + * the context is invalid, the operation will fail in some well-defined way as described in + * the documentation for that method, and the SDK will generally log a warning as well. But + * in any situation where you are not sure if you have a valid LDContext, you can check + * isValid() or {@see \LaunchDarkly\LDContext::getError()}. + * + * @return bool true if the context is valid + * @see \LaunchDarkly\LDContext::getError() + */ + public function isValid(): bool + { + return !$this->_error; + } + + /** + * Returns `null` for a valid LDContext, or an error message for an invalid one. + * + * If this is null, then {@see \LaunchDarkly\LDContext::isValid()} is true. If it is non-null, + * then {@see \LaunchDarkly\LDContext::isValid()} is false. + * + * @return string|null an error description or null + * @see \LaunchDarkly\LDContext::isValid() + */ + public function getError(): ?string + { + return $this->_error; + } + + /** + * Returns true if this is a multi-context. + * + * If this value is true, then {@see \LaunchDarkly\LDContext::getKind()} is guaranteed to be + * {@see \LaunchDarkly\LDContext::MULTI_KIND}, and you can inspect the individual context for + * each kind with {@see \LaunchDarkly\LDContext::getIndividualContext()}. + * + * If this value is false, then {@see \LaunchDarkly\LDContext::getKind()} is guaranteed to + * return a value that is not {@see \LaunchDarkly\LDContext::MULTI_KIND}. + * + * @return bool true for a multi-kind context, false for a single-kind context + */ + public function isMultiple(): bool + { + return $this->_multiContexts !== null; + } + + /** + * Returns the context's `kind` attribute. + * + * Every valid context has a non-empty kind. For multi-contexts, this value is + * {@see \LaunchDarkly\LDContext::MULTI_KIND} and the kinds within the context can be + * inspected with {@see \LaunchDarkly\LDContext::getIndividualContext()}. + * + * @return string the context kind + * @see \LaunchDarkly\LDContextBuilder::kind() + */ + public function getKind(): string + { + return $this->_kind; + } + + /** + * Returns the context's `key` attribute. + * + * For a single context, this value is set by {@see \LaunchDarkly\LDContext::create()}, + * {@see \LaunchDarkly\LDContext::builder()}, or {@see \LaunchDarkly\LDContextBuilder::key()}. + * + * For a multi-context, there is no single value and getKey() returns an empty string. + * empty string. Use {@see \LaunchDarkly\LDContext::getIndividualContext()} to get the + * context for a particular kind, then call getKey() on it. + * + * This value is never null. + * + * @return string the context key + * @see \LaunchDarkly\LDContextBuilder::key() + */ + public function getKey(): string + { + return $this->_key; + } + + /** + * Returns the context's `name` attribute. + * + * For a single context, this value is set by {@see \LaunchDarkly\LDContextBuilder::name()}. + * It is null if no value was set. + * + * For a multi-context, there is no single value and getName() returns null. Use + * {@see \LaunchDarkly\LDContext::getIndividualContext()} to get the context for a particular + * kind, then call getName() on it. + * + * @return string|null the context name or null + * @see \LaunchDarkly\LDContextBuilder::name() + */ + public function getName(): ?string + { + return $this->_name; + } + + /** + * Returns true if this context is only intended for flag evaluations and will not be + * indexed by LaunchDarkly. + * + * The default value is false. False means that this LDContext represents an entity + * such as a user that you want to be able to see on the LaunchDarkly dashboard. + * + * Setting `anonymous` to true excludes this context from the database that is + * used by the dashboard. It does not exclude it from analytics event data, so it is + * not the same as making attributes private; all non-private attributes will still be + * included in events and data export. There is no limitation on what other attributes + * may be included (so, for instance, `anonymous` does not mean there is no `name`), + * and the context will still have whatever `key` you have given it. + * + * This value is also addressable in evaluations as the attribute name "anonymous". It + * is always treated as a boolean true or false in evaluations. + * + * @return bool true if the context should be excluded from the LaunchDarkly database + * @see \LaunchDarkly\LDContextBuilder::anonymous() + */ + public function isAnonymous(): bool + { + return $this->_anonymous; + } + + /** + * Looks up the value of any attribute of the context by name. + * + * For a single-kind context, the attribute name can be any custom attribute that was set + * by {@see \LaunchDarkly\LDContextBuilder::set()}. It can also be one of the built-in ones like + * "kind", "key", or "name"; in such cases, it is equivalent to + * {@see \LaunchDarkly\LDContext::getKind()}, {@see \LaunchDarkly\LDContext::getKey()}, or + * {@see \LaunchDarkly\LDContext::getName()}. + * + * For a multi-context, the only supported attribute name is "kind". Use + * {@see \LaunchDarkly\LDContext::getIndividualContext()} to get the context for a + * particular kind and then get its attributes. + * + * If the value is found, the return value is the attribute value. If there is no such + * attribute, the return value is `null`. An attribute that actually exists cannot have a + * null value. + * + * @param string $attributeName the desired attribute name + * @return mixed the attribute value, or null if there is no such attribute + * @see \LaunchDarkly\LDContextBuilder::set() + */ + public function get(string $attributeName): mixed + { + switch ($attributeName) { + case 'key': + return $this->_key; + case 'kind': + return $this->_kind; + case 'name': + return $this->_name; + case 'anonymous': + return $this->_anonymous; + default: + if ($this->_attributes === null) { + return null; + } + return $this->_attributes[$attributeName] ?? null; + } + } + + /** + * Returns the names of all non-built-in attributes that have been set in this context. + * + * For a single-kind context, this includes all the names that were passed to + * {@see \LaunchDarkly\LDContextBuilder::set()} as long as the values were not null (since + * a null value in LaunchDarkly is equivalent to the attribute not being set). + * + * For a multi-context, there are no such names. + * + * @return array an array of strings (may be empty, but will never be null) + */ + public function getCustomAttributeNames(): array + { + return $this->_attributes ? array_keys($this->_attributes) : []; + // Note that array_keys uses the defined traversal order of associative arrays in PHP, + // which is that you get them in the same order they were added in. + } + + /** + * Returns the number of context kinds in this context. + * + * For a valid individual context, this returns 1. For a multi-context, it returns the number + * of context kinds. For an invalid context, it returns zero. + * + * @return int the number of context kinds + */ + public function getIndividualContextCount(): int + { + if ($this->_error) { + return 0; + } + return $this->_multiContexts ? count($this->_multiContexts) : 1; + } + + /** + * Returns the single-kind LDContext corresponding to one of the kinds in this context. + * + * The `kind` parameter can be either a number representing a zero-based index, or a string + * representing a context kind. + * + * If this method is called on a single-kind LDContext, then the only allowable value + * for `kind` is either zero or the same value as {@see \LaunchDarkly\LDContext::getKind()} + * , and the return value on success is the same LDContext. + * + * If the method is called on a multi-context, and `kind` is a number, it must be a + * non-negative index that is less than the number of kinds (that is, less than the return + * value of {@see \LaunchDarkly\LDContext::getIndividualContextCount()}, and the return + * value on success is one of the individual LDContexts within. Or, if `kind` is a string, + * it must match the context kind of one of the individual contexts. + * + * If there is no context corresponding to `kind`, the method returns null. + * + * @param int|string $kind the index or string value of a context kind + * @return LDContext|null the context corresponding to that index or kind, or null if none + */ + public function getIndividualContext($kind): ?LDContext + { + if (is_string($kind)) { + if ($this->_multiContexts === null) { + return $this->getKind() === $kind ? $this : null; + } + foreach ($this->_multiContexts as $c) { + if ($c->getKind() === $kind) { + return $c; + } + } + return null; + } + + if ($this->_multiContexts === null) { + return $kind === 0 ? $this : null; + } + return ($kind >= 0 && $kind < count($this->_multiContexts)) ? $this->_multiContexts[$kind] : null; + } + + /** + * Gets the list of all attribute references marked as private for this specific LDContext. + * + * This includes all attribute names/paths that were specified with + * {@see \LaunchDarkly\LDContextBuilder::private()}. If there are none, it is null. + * + * @return AttributeReference[]|null the list of private attributes, if any + */ + public function getPrivateAttributes(): ?array + { + return $this->_privateAttributes; + } + + /** + * Returns a string that describes the LDContext uniquely based on `kind` and `key` values. + * + * This value is used whenever LaunchDarkly needs a string identifier based on all of the + * `kind` and `key` values in the context. Applications typically do not need to use it. + * + * @return string the fully-qualified key + */ + public function getFullyQualifiedKey(): string + { + if (!$this->isValid()) { + return ''; + } + if ($this->_multiContexts === null) { + return $this->_kind === self::DEFAULT_KIND ? $this->_key : + ($this->_kind . ':' . self::escapeKeyForFullyQualifiedKey($this->_key)); + } + $ret = ''; + foreach ($this->_multiContexts as $c) { + if ($ret != '') { + $ret .= ':'; + } + $ret .= $c->_kind; + $ret .= ':'; + $ret .= self::escapeKeyForFullyQualifiedKey($c->_key); + } + return $ret; + } + + /** + * Tests whether two contexts are logically equal. + * + * Equality for single contexts means that all of their attributes are equal. Equality for + * multi-contexts means that the same context kinds are present in both, and the individual + * contexts for each kind are equal. + * + * @param LDContext $other another context + * @return bool true if it is equal to this context + */ + public function equals(LDContext $other): bool + { + if ($this->_kind != $other->_kind || $this->_key != $other->_key || $this->_name != $other->_name + || $this->_anonymous != $other->_anonymous || $this->_attributes != $other->_attributes + || $this->_privateAttributes != $other->_privateAttributes + || $this->_error != $other->_error) { + return false; + // Note that it's OK to compare _attributes because PHP does a deep-equality check for arrays, + // and it's OK to compare _privateAttributes because we have canonicalized them by sorting. + } + if ($this->_multiContexts === null) { + return true; + } + if ($other->_multiContexts === null || count($this->_multiContexts) != count($other->_multiContexts)) { + return false; + } + for ($i = 0; $i < count($this->_multiContexts); $i++) { + if (!$this->_multiContexts[$i]->equals($other->_multiContexts[$i])) { + return false; + } + } + return true; + } + + /** + * Returns a JSON representation of the context (as an associative array), in the format used by + * LaunchDarkly SDKs. + * + * Use this method if you are passing context data to the front end for use with the LaunchDarkly + * JavaScript SDK. + * + * Note that calling json_encode() on an LDContext object will automatically use the jsonSerialize() + * method. + * + * @return array an associative array suitable for passing as a JSON object + */ + public function jsonSerialize(): array + { + if ($this->_multiContexts === null) { + return $this->jsonSerializeSingleKind(true); + } + $ret = ['kind' => self::MULTI_KIND]; + foreach ($this->_multiContexts as $mc) { + $ret[$mc->_kind] = $mc->jsonSerializeSingleKind(false); + } + return $ret; + } + + /** + * Returns a string representation of the LDContext. + * + * For a valid LDContext, this is currently defined as being the same as the JSON representation, + * since that is the simplest way to represent all of the Context properties. However, application + * code should not rely on __toString() always being the same as the JSON representation. If + * you specifically want the latter, use `json_encode()` or {@see \LaunchDarkly\LDContext::jsonSerialize()}. + * For an invalid LDContext, __toString() returns a description of why it is invalid. + * + * @return string a string representation + */ + public function __toString(): string + { + if ($this->isValid()) { + return json_encode($this); + } + return '[invalid context: ' . $this->getError() . ']'; + } + + private function jsonSerializeSingleKind(bool $withKind): array + { + $ret = ['key' => $this->_key]; + if ($withKind) { + $ret['kind'] = $this->_kind; + } + if ($this->_name !== null) { + $ret['name'] = $this->_name; + } + if ($this->_anonymous) { + $ret['anonymous'] = true; + } + if ($this->_attributes !== null) { + $ret = array_merge($ret, $this->_attributes); + } + if ($this->_privateAttributes !== null) { + $ret['_meta'] = [ + 'privateAttributes' => array_map(fn (AttributeReference $a) => $a->getPath(), $this->_privateAttributes) + ]; + } + return $ret; + } + + private function makeInvalid(string $error): void + { + $this->_error = $error; + // No other properties can be set if there's an error, but we'll still ensure that all + // properties that are normally non-null have non-null values, to make null reference + // errors less likely. + $this->_key = ''; + $this->_kind = ''; + $this->_anonymous = false; + } + + private static function createWithError(string $error): LDContext + { + return new LDContext(null, '', null, false, null, null, null, $error); + } + + private static function decodeJsonSingleKind(array $o, ?string $kind): LDContext + { + $b = self::builder(''); + if ($kind !== null) { + $b->kind($kind); + } + foreach ($o as $k => $v) { + if ($k === '_meta') { + if ($v === null) { + continue; + } + if (!is_array($v) && !is_object($v)) { + throw self::parsingBadTypeError($k); + } + $a = (array)$v; // it might have been parsed as an object + $private = $a['privateAttributes'] ?? null; + if ($private !== null) { + if (!is_array($private)) { + throw self::parsingBadTypeError('privateAttributes'); + } + foreach ($private as $p) { + $b->private($p); + } + } + } else { + if (!$b->trySet($k, $v)) { + throw self::parsingBadTypeError($k); + } + if ($k === 'kind') { + $kind = $v; + } + } + } + if ($kind === '' || $kind === null) { + // the builder's validation wouldn't catch this because the builder has a default kind of "user" + return self::createWithError(self::ERR_KIND_CANNOT_BE_EMPTY); + } + return $b->build(); + } + + private static function decodeJsonOldUser(array $o, bool $wasParsedAsArray): LDContext + { + $b = self::builder(''); + $key = null; + foreach ($o as $k => $v) { + switch ($k) { + case 'custom': + if ($v !== null) { + if (!is_object($v) && !($wasParsedAsArray && is_array($v))) { + // The reason for the awkward test expression above is that if the JSON was parsed + // as an associative array, there is no way for us to distinguish {} from [] so we + // can't safely say that [] is invalid. + throw self::parsingBadTypeError($k); + } + foreach ((array)$v as $k1 => $v1) { + if (isAllowableUserCustomAttr($k1)) { + $b->set($k1, $v1); + } + } + } + break; + case 'privateAttributeNames': + if ($v !== null) { + if (!is_array($v)) { + throw self::parsingBadTypeError($k); + } + foreach ($v as $p) { + $b->private($p); + } + } + break; + case 'avatar': + case 'country': + case 'email': + case 'firstName': + case 'ip': + case 'lastName': + // These used to be built-in attributes with a string type constraint, so even though + // the new context model has no such constraint, we enforce it when parsing user JSON + if ($v !== null && !is_string($v)) { + throw self::parsingBadTypeError($k); + } + $b->set($k, $v); + break; + case 'anonymous': + // Special case where the old user model allowed anonymous to be null; we don't now, + // so treat null the same as false + if ($v !== null && !is_bool($v)) { + throw self::parsingBadTypeError($k); + } + $b->set($k, !!$v); + break; + default: + if (!$b->trySet($k, $v)) { + throw self::parsingBadTypeError($k); + } + if ($k === 'key') { + $key = $v; + } + } + } + if ($key === '') { + // The context builder won't allow an empty key, but it is allowed in the old user model. + $c = $b->key('x')->build(); + $c->_key = ''; + return $c; + } + return $b->build(); + } + + private static function validateKind(string $kind): ?string + { + switch ($kind) { + case 'kind': + return self::ERR_KIND_CANNOT_BE_KIND; + case 'multi': + return self::ERR_KIND_MULTI_FOR_SINGLE; + default: + if (preg_match('/[^-a-zA-Z0-9._]/', $kind)) { + return self::ERR_KIND_INVALID_CHARS; + } + return null; + } + } + + private static function escapeKeyForFullyQualifiedKey(string $s): string + { + // When building a fully-qualified key, ':' and '%' are percent-escaped; we do not use a full + // URL-encoding function because implementations of this are inconsistent across platforms. + return str_replace(':', '%3A', str_replace('%', '%25', $s)); + } + + private static function parsingBadTypeError(string $property): \InvalidArgumentException + { + return new \InvalidArgumentException("invalid context JSON: $property had an invalid type"); + } +} diff --git a/src/LaunchDarkly/LDContextBuilder.php b/src/LaunchDarkly/LDContextBuilder.php new file mode 100644 index 00000000..2def85a1 --- /dev/null +++ b/src/LaunchDarkly/LDContextBuilder.php @@ -0,0 +1,296 @@ +name('my-name') + * ->set('country', 'us') + * ->build(); + * ``` + * + * @see \LaunchDarkly\LDContext + */ +class LDContextBuilder +{ + private string $_key; + private ?string $_kind = null; + private ?string $_name = null; + private bool $_anonymous = false; + private ?array $_attributes = null; + /** @var AttributeReference[]|null */ + private ?array $_privateAttributes = null; + + /** + * Constructs a new builder. + * + * @param string key the context key + * @return LDContextBuilder + */ + public function __construct(string $key) + { + $this->_key = $key; + } + + /** + * Creates an LDContext from the current builder properties. + * + * The LDContext is immutable and will not be affected by any subsequent actions on the builder. + * + * It is possible to specify invalid attributes for an LDContextBuilder, such as an empty key. + * Instead of throwing an exception, the LDContextBuilder always returns an LDContext and + * you can check {@see \LaunchDarkly\LDContext::isValid()} or {@see \LaunchDarkly\LDContext::getError()} + * to see if it has an error. See {@see \LaunchDarkly\LDContext::isValid()} for more information + * about invalid conditions. If you pass an invalid LDContext to an SDK method, the SDK will + * detect this and will log a description of the error. + * + * @return LDContext a new {@see \LaunchDarkly\LDContext} + */ + public function build(): LDContext + { + return new LDContext( + $this->_kind ?: LDContext::DEFAULT_KIND, + $this->_key, + $this->_name, + $this->_anonymous, + $this->_attributes, + $this->_privateAttributes, + null, + null + ); + } + + /** + * Sets the context's key attribute. + * + * Every context has a key, which is always a string. It cannot be an empty string, but + * there are no other restrictions on its value. + * + * The key attribute can be referenced by flag rules, flag target lists, and segments. + * + * @param string $key the context key + * @return LDContextBuilder the builder + * @see \LaunchDarkly\LDContext::getKey() + */ + public function key(string $key): LDContextBuilder + { + $this->_key = $key; + return $this; + } + + /** + * Sets the context's kind attribute. + * + * Every context has a kind. Setting it to an empty string or null is equivalent to + * {@see \LaunchDarkly\LDContext::DEFAULT_KIND} ("user"). This value is case-sensitive. + * + * The meaning of the context kind is completely up to the application. Validation rules are + * as follows: + * + * - It may only contain letters, numbers, and the characters `.`, `_`, and `-`. + * - It cannot equal the literal string "kind". + * - For a single context, it cannot equal "multi". + * + * @param string $kind the context kind + * @return LDContextBuilder the builder + * @see \LaunchDarkly\LDContext::getKind() + */ + public function kind(string $kind): LDContextBuilder + { + $this->_kind = $kind; + return $this; + } + + /** + * Sets the context's name attribute. + * + * This attribute is optional. It has the following special rules: + * + * - Unlike most other attributes, it is always a string if it is specified. + * - The LaunchDarkly dashboard treats this attribute as the preferred display name for contexts. + * + * @param ?string $name the name attribute (null to unset the attribute) + * @return LDContextBuilder the builder + * @see \LaunchDarkly\LDContext::getName() + */ + public function name(?string $name): LDContextBuilder + { + $this->_name = $name; + return $this; + } + + /** + * Sets whether the context is only intended for flag evaluations and should not be + * indexed by LaunchDarkly. + * + * The default value is false. False means that this LDContext represents an entity + * such as a user that you want to be able to see on the LaunchDarkly dashboard. + * + * Setting `anonymous` to true excludes this context from the database that is + * used by the dashboard. It does not exclude it from analytics event data, so it is + * not the same as making attributes private; all non-private attributes will still be + * included in events and data export. There is no limitation on what other attributes + * may be included (so, for instance, `anonymous` does not mean there is no `name`), + * and the context will still have whatever `key` you have given it. + * + * This value is also addressable in evaluations as the attribute name "anonymous". It + * is always treated as a boolean true or false in evaluations. + * + * @param bool $anonymous true if the context should be excluded from the LaunchDarkly database + * @return LDContextBuilder the builder + * @see \LaunchDarkly\LDContext::isAnonymous() + */ + public function anonymous(bool $anonymous): LDContextBuilder + { + $this->_anonymous = $anonymous; + return $this; + } + + /** + * Sets the value of any attribute for the context. + * + * This includes only attributes that are addressable in evaluations-- not metadata + * such as {@see \LaunchDarkly\LDContextBuilder::private()}. If `attributeName` + * is `'private'`, you will be setting an attribute with that name which you can + * use in evaluations or to record data for your own purposes, but it will be unrelated + * to {@see \LaunchDarkly\LDContextBuilder::private()}. + * + * The allowable types for context attributes are equivalent to JSON types: boolean, + * number, string, array, or object. For all attribute names that do not have special + * meaning to LaunchDarkly, you may use any of those types. Values of different JSON + * types are always treated as different values: for instance, the number 1 is not the + * same as the string "1". + * + * The following attribute names have special restrictions on their value types, and + * any value of an unsupported type will be ignored (leaving the attribute unchanged): + * + * - `kind`, `key`: Must be a string. See {@see \LaunchDarkly\LDContextBuilder::kind()} + * and {@see \LaunchDarkly\LDContextBuilder::key()}. + * - `name`: Must be a string or null. See {@see \LaunchDarkly\LDContextBuilder::name()}. + * - `anonymous`: Must be a boolean. See {@see \LaunchDarkly\LDContextBuilder::anonymous()}. + * + * The attribute name "_meta" is not allowed, because it has special meaning in the + * JSON schema for contexts; any attempt to set an attribute with this name has no + * effect. + * + * Values that are JSON arrays or objects have special behavior when referenced in + * flag/segment rules. + * + * A value of `null` is equivalent to removing any current non-default value of the + * attribute. Null is not a valid attribute value in the LaunchDarkly model; any + * expressions in feature flags that reference an attribute with a null value will + * behave as if the attribute did not exist. + * + * @param string $attributeName the attribute name to set + * @param mixed $value the value to set + * @return LDContextBuilder the builder + * @see \LaunchDarkly\LDContext::get() + * @see \LaunchDarkly\LDContextBuilder::trySet() + */ + public function set(string $attributeName, mixed $value): LDContextBuilder + { + $this->trySet($attributeName, $value); + return $this; + } + + /** + * Same as set(), but returns a boolean indicating whether the attribute was successfully set. + * + * @param string $attributeName the attribute name to set + * @param mixed $value the value to set + * @return bool true if successful; false if the name was invalid or the value was not an + * allowed type for that attribute + * @see \LaunchDarkly\LDContextBuilder::set() + */ + public function trySet(string $attributeName, mixed $value): bool + { + switch ($attributeName) { + case 'key': + if (!is_string($value)) { + return false; + } + $this->_key = $value; + break; + case 'kind': + if (!is_string($value)) { + return false; + } + $this->_kind = $value; + break; + case 'name': + if ($value != null && !is_string($value)) { + return false; + } + $this->_name = $value; + break; + case 'anonymous': + if (!is_bool($value)) { + return false; + } + $this->_anonymous = $value; + break; + default: + if ($this->_attributes === null) { + $this->_attributes = []; + } + if ($value === null) { + unset($this->_attributes[$attributeName]); + } else { + $this->_attributes[$attributeName] = $value; + } + break; + } + return true; + } + + /** + * Designates any number of LDContext attributes, or properties within them, as private: that is, + * their values will not be sent to LaunchDarkly. + * + * Each parameter can be either a simple attribute name, or a slash-delimited path (in the format + * defined by {@see \LaunchDarkly\Types\AttributeReference}) referring to a JSON object property + * within an attribute. + * + * @param array $attributeRefs attribute names or references to mark as private + * @return LDContextBuilder the builder + * @see \LaunchDarkly\LDContext::getPrivateAttributes() + */ + public function private(string|AttributeReference ...$attributeRefs): LDContextBuilder + { + if (count($attributeRefs) === 0) { + return $this; + } + if ($this->_privateAttributes === null) { + $this->_privateAttributes = []; + } + foreach ($attributeRefs as $p) { + if (is_string($p)) { + $parsed = AttributeReference::fromPath($p); + } elseif ($p instanceof AttributeReference) { + $parsed = $p; + } else { + continue; + } + if ($parsed->getError() === null) { + $this->_privateAttributes[] = $parsed; + } + } + return $this; + } +} diff --git a/src/LaunchDarkly/LDContextMultiBuilder.php b/src/LaunchDarkly/LDContextMultiBuilder.php new file mode 100644 index 00000000..c31b0e82 --- /dev/null +++ b/src/LaunchDarkly/LDContextMultiBuilder.php @@ -0,0 +1,98 @@ +add(LDContext::create('my-user-key')) + * ->add(LDContext::create('my-org-key', 'organization')) + * ->build(); + * ``` + * + * @see \LaunchDarkly\LDContext + */ +class LDContextMultiBuilder +{ + private array $_contexts = []; + + /** + * Creates an LDContext from the current builder properties. + * + * The LDContext is immutable and will not be affected by any subsequent actions on the + * builder. + * + * It is possible for an LDContextMultiBuilder to represent an invalid state. Instead of + * throwing an exception, the LDContextMultiBuilder always returns an LDContext, and you + * can check {@see \LaunchDarkly\LDContext::isValid()} or + * {@see \LaunchDarkly\LDContext::getError()} to see if it has an error. See + * {@see \LaunchDarkly\LDContext::isValid()} for more information about invalid context + * conditions. If you pass an invalid context to an SDK method, the SDK will + * detect this and will log a description of the error. + * + * If only one context was added to the builder, this method returns that context rather + * than a multi-context. + * + * @return LDContext a new LDContext + */ + public function build(): LDContext + { + if (count($this->_contexts) === 1) { + return $this->_contexts[0]; // multi-context with only one context is the same as just that context + } + // LDContext constructor will handle validation + return new LDContext(LDContext::MULTI_KIND, '', null, false, null, null, $this->_contexts, null); + } + + /** + * Adds an individual LDContext for a specific kind to the builer. + * + * It is invalid to add more than one LDContext for the same kind, or to add an LDContext + * that is itself invalid. This error is detected when you call + * {@see \LaunchDarkly\LDContextMultiBuilder::build()}. + * + * If the nested context is a multi-context, this is exactly equivalent to adding each of the + * individual contexts from it separately. For instance, in the following example, `$multi1` and + * `$multi2` end up being exactly the same: + * ```php + * $c1 = LDContext::create('key1', 'kind1'); + * $c2 = LDContext::create('key2', 'kind2'); + * $c3 = LDContext::create('key3', 'kind3');' + * + * $multi1 = LDContext::multiBuilder()->add($c1)->add($c2)->add($c3).build(); + * + * $c1plus2 = LDContext::multiBuilder()->add($c1)->add($c2).build(); + * $multi2 = LDContext::multiBuilder()->add($c1plus2)->add($c3)->build(); + * ``` + * + * @param LDContext $context the context to add + * @return LDContextMultiBuilder the builder + */ + public function add(LDContext $context): LDContextMultiBuilder + { + if ($context->isMultiple()) { + for ($i = 0; $i < $context->getIndividualContextCount(); $i++) { + $c = $context->getIndividualContext($i); + if ($c) { + $this->add($c); + } + } + return $this; + } + $this->_contexts[] = $context; + return $this; + } +} diff --git a/src/LaunchDarkly/LDUser.php b/src/LaunchDarkly/LDUser.php index 70b4de42..50586bfe 100644 --- a/src/LaunchDarkly/LDUser.php +++ b/src/LaunchDarkly/LDUser.php @@ -1,10 +1,19 @@ _key = $key; - $this->_secondary = $secondary; $this->_ip = $ip; $this->_country = $country; $this->_email = $email; @@ -97,43 +81,37 @@ public function __construct( /** * Used internally in flag evaluation. * @ignore - * @return mixed|null + * @return mixed */ - public function getValueForEvaluation(?string $attr) + public function getValueForEvaluation(?string $attr): mixed { if (is_null($attr)) { return null; } switch ($attr) { case "key": - return $this->getKey(); - case "secondary": //not available for evaluation. - return null; + return $this->_key; case "ip": - return $this->getIP(); + return $this->_ip; case "country": - return $this->getCountry(); + return $this->_country; case "email": - return $this->getEmail(); + return $this->_email; case "name": - return $this->getName(); + return $this->_name; case "avatar": - return $this->getAvatar(); + return $this->_avatar; case "firstName": - return $this->getFirstName(); + return $this->_firstName; case "lastName": - return $this->getLastName(); + return $this->_lastName; case "anonymous": - return $this->getAnonymous(); + return $this->_anonymous; default: - $custom = $this->getCustom(); - if (is_null($custom)) { + if ($this->_custom === null) { return null; } - if (!array_key_exists($attr, $custom)) { - return null; - } - return $custom[$attr]; + return $this->_custom[$attr] ?? null; } } @@ -157,11 +135,6 @@ public function getKey(): string return $this->_key; } - public function getSecondary(): ?string - { - return $this->_secondary; - } - public function getEmail(): ?string { return $this->_email; diff --git a/src/LaunchDarkly/LDUserBuilder.php b/src/LaunchDarkly/LDUserBuilder.php index 60c3b117..20fa00e8 100644 --- a/src/LaunchDarkly/LDUserBuilder.php +++ b/src/LaunchDarkly/LDUserBuilder.php @@ -1,5 +1,7 @@ _key = $key; } - /** - * Sets the user's secondary key attribute. - * @param string|null $secondary The secondary key - * @return LDUserBuilder the same builder - */ - public function secondary(?string $secondary): LDUserBuilder - { - $this->_secondary = $secondary; - return $this; - } - - /** - * Sets the user's secondary key attribute, and marks it as private. - * @param string|null $secondary The secondary key - * @return LDUserBuilder the same builder - */ - public function privateSecondary(?string $secondary): LDUserBuilder - { - $this->_privateAttributeNames[] = 'secondary'; - return $this->secondary($secondary); - } - /** * Sets the user's IP address attribute. * @param string|null $ip The IP address @@ -270,7 +225,7 @@ public function custom(array $custom): LDUserBuilder * @param mixed $customValue The attribute value * @return LDUserBuilder the same builder */ - public function customAttribute(string $customKey, $customValue): LDUserBuilder + public function customAttribute(string $customKey, mixed $customValue): LDUserBuilder { $this->_custom[$customKey] = $customValue; return $this; @@ -283,7 +238,7 @@ public function customAttribute(string $customKey, $customValue): LDUserBuilder * @param mixed $customValue The attribute value * @return LDUserBuilder the same builder */ - public function privateCustomAttribute(string $customKey, $customValue): LDUserBuilder + public function privateCustomAttribute(string $customKey, mixed $customValue): LDUserBuilder { $this->_privateAttributeNames[] = $customKey; return $this->customAttribute($customKey, $customValue); @@ -297,7 +252,7 @@ public function build(): LDUser { return new LDUser( $this->_key, - $this->_secondary, + null, $this->_ip, $this->_country, $this->_email, diff --git a/src/LaunchDarkly/EventPublisher.php b/src/LaunchDarkly/Subsystems/EventPublisher.php similarity index 67% rename from src/LaunchDarkly/EventPublisher.php rename to src/LaunchDarkly/Subsystems/EventPublisher.php index f7165687..c2ff9fc7 100644 --- a/src/LaunchDarkly/EventPublisher.php +++ b/src/LaunchDarkly/Subsystems/EventPublisher.php @@ -1,12 +1,16 @@ _path = $path; + $this->_singleComponent = $singleComponent; + $this->_components = $components; + $this->_error = $error; + } + + /** + * Creates an AttributeReference from a string. For the supported syntax and examples, see + * comments on the {@see \LaunchDarkly\AttributeReference} type. + * + * This method always returns an AttributeRef that preserves the original string, even if + * validation fails. If validation fails, {@see \LaunchDarkly\AttributeReference::getError()} will + * return a non-null error and any SDK method that takes this AttributeReference as a parameter + * will consider it invalid. + * + * @param string $refPath an attribute name or path + * @return AttributeReference the parsed reference + */ + public static function fromPath(string $refPath): AttributeReference + { + if ($refPath === '' || $refPath === '/') { + return self::failed($refPath, self::ERR_ATTR_EMPTY); + } + if (!str_starts_with($refPath, '/')) { + return new AttributeReference($refPath, $refPath, null, null); + } + $components = explode('/', substr($refPath, 1)); + if (count($components) === 1) { + $attr = self::unescape($components[0]); + if ($attr === null) { + return self::failed($refPath, self::ERR_ATTR_INVALID_ESCAPE); + } + return new AttributeReference($refPath, $attr, null, null); + } + for ($i = 0; $i < count($components); $i++) { + $prop = $components[$i]; + if ($prop === '') { + return self::failed($refPath, self::ERR_ATTR_EXTRA_SLASH); + } + $prop = self::unescape($prop); + if ($prop === null) { + return self::failed($refPath, self::ERR_ATTR_INVALID_ESCAPE); + } + $components[$i] = $prop; + } + return new AttributeReference($refPath, null, $components, null); + } + + /** + * Similar to {@see \LaunchDarkly\AttributeReference::fromPath()}, except that it always + * interprets the string as a literal attribute name, never as a slash-delimited path expression. + * + * There is no escaping or unescaping, even if the name contains literal '/' or '~' characters. + * Since an attribute name can contain any characters, this method always returns a valid + * AttributeReference unless the name is empty. + * + * @param string $attributeName an attribute name + * @param AttributeReference the reference + */ + public static function fromLiteral(string $attributeName): AttributeReference + { + if ($attributeName === '') { + return self::failed($attributeName, self::ERR_ATTR_EMPTY); + } + // If the attribute name starts with a slash, we need to compute the escaped version so + // getPath() will always return a valid attribute reference path. This matters because + // lists of redacted attributes in events always use the path format. + $refPath = str_starts_with($attributeName, '/') ? + ('/' . self::escape($attributeName)) : + $attributeName; + return new AttributeReference($refPath, $attributeName, null, null); + } + + private static function failed(string $refPath, string $error): AttributeReference + { + return new AttributeReference($refPath, null, null, $error); + } + + /** + * Returns the original attribute reference path string. + */ + public function getPath(): string + { + return $this->_path; + } + + /** + * Returns null for a valid reference, or an error string for an invalid one. + * + * @return ?string an error string or null + */ + public function getError(): ?string + { + return $this->_error; + } + + /** + * The number of path components in the AttributeReference. + * + * For a simple attribute reference such as "name" with no leading slash, this returns 1. + * + * For an attribute reference with a leading slash, it is the number of slash-delimited path + * components after the initial slash. For instance, for "/a/b" it returns 2. + * + * For an invalid attribute reference, it returns zero. + * + * @return int the number of path components + */ + public function getDepth(): int + { + return $this->_components === null ? 1 : count($this->_components); + } + + /** + * Retrieves a single path component from the attribute reference. + * + * For a simple attribute reference such as "name" with no leading slash, it returns the + * attribute name if index is zero, and an empty string otherwise. + * + * For an attribute reference with a leading slash, if index is non-negative and less than + * getDepth(), it returns the path component string at that position. + * + * @param int index the zero-based index of the desired path component + * @return string the path component, or an empty string if not available + */ + public function getComponent(int $index): string + { + if ($this->_components === null) { + return $index === 0 ? ($this->_singleComponent ?: '') : ''; + } + return $index < 0 || $index >= count($this->_components) ? '' : $this->_components[$index]; + } + + private static function unescape(string $s): ?string + { + if (preg_match('/(~[^01]|~$)/', $s)) { + return null; + } + return str_replace('~0', '~', str_replace('~1', '/', $s)); + } + + private static function escape(string $s): ?string + { + return str_replace('/', '~1', str_replace('~', '~0', $s)); + } +} diff --git a/test-service/SdkClientEntity.php b/test-service/SdkClientEntity.php index 8b4af7fd..e29dee67 100644 --- a/test-service/SdkClientEntity.php +++ b/test-service/SdkClientEntity.php @@ -1,17 +1,28 @@ setFormatter(new Monolog\Formatter\LineFormatter( + $logger = new Logger('sdkclient'); + $stream = new StreamHandler('php://stderr', Logger::DEBUG); + $stream->setFormatter(new LineFormatter( "[%datetime%] %channel%.%level_name%: [$tag] %message%\n" )); $logger->pushHandler($stream); @@ -20,13 +31,13 @@ public function __construct($params) $this->_client = self::createSdkClient($params, $logger); } - public static function createSdkClient($params, $logger) + public static function createSdkClient($params, $logger): LDClient { $config = $params['configuration']; $sdkKey = $config['credential']; $options = [ - 'event_publisher' => LaunchDarkly\Integrations\Guzzle::eventPublisher(), + 'event_publisher' => Guzzle::eventPublisher(), 'logger' => $logger ]; @@ -39,7 +50,7 @@ public static function createSdkClient($params, $logger) $options['all_attributes_private'] = $eventsConfig['allAttributesPrivate'] ?? false; $options['private_attribute_names'] = $eventsConfig['globalPrivateAttributes'] ?? null; - return new LaunchDarkly\LDClient($sdkKey, $options); + return new LDClient($sdkKey, $options); } public function close() @@ -48,15 +59,11 @@ public function close() $this->_logger->info('Test ended'); } - public function doCommand($reqParams) + public function doCommand(mixed $reqParams): mixed { $command = $reqParams['command']; $commandParams = $reqParams[$command] ?? null; switch ($command) { - case 'aliasEvent': - $this->doAliasEvent($commandParams); - return null; - case 'customEvent': $this->doCustomEvent($commandParams); return null; @@ -78,52 +85,50 @@ public function doCommand($reqParams) case 'secureModeHash': return $this->doSecureModeHash($commandParams); + case 'contextBuild': + return $this->doContextBuild($commandParams); + + case 'contextConvert': + return $this->doContextConvert($commandParams); + default: return false; // means invalid command } } - private function doAliasEvent($params) - { - $this->_client->alias( - $this->makeUser($params['user']), - $this->makeUser($params['previousUser']) - ); - } - - private function doCustomEvent($params) + private function doCustomEvent(array $params): void { $this->_client->track( $params['eventKey'], - $this->makeUser($params['user']), + $this->makeContext($params['context']), $params['data'] ?? null, $params['metricValue'] ?? null ); } - private function doEvaluate($params) + private function doEvaluate(array $params): array { $flagKey = $params['flagKey']; - $user = $this->makeUser($params['user']); + $context = LDContext::fromJson($params['context']); $defaultValue = $params['defaultValue'] ?? null; $detail = $params['detail'] ?? false; if ($detail) { - $result = $this->_client->variationDetail($flagKey, $user, $defaultValue); + $result = $this->_client->variationDetail($flagKey, $context, $defaultValue); return [ "value" => $result->getValue(), "variationIndex" => $result->getVariationIndex(), "reason" => $result->getReason() ]; } else { - $value = $this->_client->variation($flagKey, $user, $defaultValue); + $value = $this->_client->variation($flagKey, $context, $defaultValue); return [ "value" => $value ]; } } - private function doEvaluateAll($params) + private function doEvaluateAll(array $params): array { $options = []; foreach (['clientSideOnly', 'detailsOnlyForTrackedFlags', 'withReasons'] as $option) { @@ -131,102 +136,80 @@ private function doEvaluateAll($params) $options[$option] = true; } } - $state = $this->_client->allFlagsState($this->makeUser($params['user']), $options); + $context = LDContext::fromJson($params['context']); + $state = $this->_client->allFlagsState($context, $options); return [ 'state' => $state->jsonSerialize() ]; } - private function doIdentifyEvent($params) + private function doIdentifyEvent(array $params): void { - $this->_client->identify($this->makeUser($params['user'])); + $this->_client->identify($this->makeContext($params['context'])); } - private function doSecureModeHash($params) + private function doSecureModeHash(array $params): array { - $user = $this->makeUser($params['user']); - $result = $this->_client->secureModeHash($user); + $context = $this->makeContext($params['context']); + $result = $this->_client->secureModeHash($context); return [ 'result' => $result ]; } - private function makeUser($data) + private function doContextBuild(array $params): array { - $privateAttributeNames = $data['privateAttributeNames'] ?? []; - - $builder = new LaunchDarkly\LDUserBuilder(isset($data['key']) ? $data['key'] : null); - - $secondary = $data['secondary'] ?? null; - if (in_array('secondary', $privateAttributeNames)) { - $builder->privateSecondary($secondary); - } else { - $builder->secondary($secondary); - } - - $ip = $data['ip'] ?? null; - if (in_array('ip', $privateAttributeNames)) { - $builder->privateIp($ip); - } else { - $builder->ip($ip); - } - - $country = $data['country'] ?? null; - if (in_array('country', $privateAttributeNames)) { - $builder->privateCountry($country); - } else { - $builder->country($country); - } - - $email = $data['email'] ?? null; - if (in_array('email', $privateAttributeNames)) { - $builder->privateEmail($email); - } else { - $builder->email($email); - } - - $name = $data['name'] ?? null; - if (in_array('name', $privateAttributeNames)) { - $builder->privateName($name); - } else { - $builder->name($name); + try { + if ($params['multi'] ?? null) { + $b = LDContext::multiBuilder(); + foreach ($params['multi'] as $mp) { + $b->add($this->buildSingleKind($mp)); + } + $c = $b->build(); + } else { + $c = $this->buildSingleKind($params['single']); + } + return $this->makeContextResponse($c); + } catch (\Throwable $e) { + return ['error' => "$e"]; } + } - $avatar = $data['avatar'] ?? null; - if (in_array('avatar', $privateAttributeNames)) { - $builder->privateAvatar($avatar); - } else { - $builder->avatar($avatar); + private function buildSingleKind(array $params): LDContext + { + $b = LDContext::builder($params['key'] ?? null); + if (($params['kind'] ?? null) != null) { + $b->kind($params['kind']); } - - $firstName = $data['firstName'] ?? null; - if (in_array('firstName', $privateAttributeNames)) { - $builder->privateFirstName($firstName); - } else { - $builder->firstName($firstName); + $b->name($params['name'] ?? null) + ->anonymous($params['anonymous'] ?? false); + if ($params['custom'] ?? null) { + foreach ($params['custom'] as $k => $v) { + $b->set($k, $v); + } } - - $lastName = $data['lastName'] ?? null; - if (in_array('lastName', $privateAttributeNames)) { - $builder->privateLastName($lastName); - } else { - $builder->lastName($lastName); + foreach ($params['private'] ?? [] as $p) { + $b->private($p); } + return $b->build(); + } - if (isset($data['anonymous'])) { - $builder->anonymous($data['anonymous']); - } + private function makeContextResponse(LDContext $c): array + { + return $c->isValid() ? ['output' => json_encode($c)] : ['error' => $c->getError()]; + } - if (isset($data['custom'])) { - foreach ($data['custom'] as $key => $value) { - if (in_array($key, $privateAttributeNames)) { - $builder->privateCustomAttribute($key, $value); - } else { - $builder->customAttribute($key, $value); - } - } + private function doContextConvert(array $params): array + { + try { + return $this->makeContextResponse(LDContext::fromJson($params['input'])); + } catch (\Throwable $e) { + return ['error' => "$e"]; } + } - return $builder->build(); + private function makeContext(array $data): LDContext + { + return LDContext::fromJson($data); } } diff --git a/test-service/TestDataStore.php b/test-service/TestDataStore.php index 29201490..d271a81f 100644 --- a/test-service/TestDataStore.php +++ b/test-service/TestDataStore.php @@ -1,17 +1,21 @@ _basePath = $basePath; } - public function addClientParams($params) + public function addClientParams(mixed $params): string { $data = json_encode($params); @@ -24,7 +28,7 @@ public function addClientParams($params) return $id; } - public function getClientParams($id) + public function getClientParams(string $id): array { $data = file_get_contents($this->getClientParamsFilePath($id)); if ($data === false) { @@ -33,12 +37,12 @@ public function getClientParams($id) return json_decode($data, true); } - public function deleteClientParams($id) + public function deleteClientParams(string $id): void { unlink($this->getClientParamsFilePath($id)); } - private function getClientParamsFilePath($id) + private function getClientParamsFilePath(string $id): string { return $this->_basePath . '/' . self::PREFIX . $id; } diff --git a/test-service/TestService.php b/test-service/TestService.php index 390ca9a6..a4232187 100644 --- a/test-service/TestService.php +++ b/test-service/TestService.php @@ -1,19 +1,24 @@ _store = $store; $this->_logger = $logger; - $this->_app = new flight\Engine(); + $this->_app = new Engine(); $this->_app->set('flight.log_errors', true); $this->_app->route('GET /', function () { @@ -21,18 +26,22 @@ public function __construct($store, $logger) }); $this->_app->route('POST /', function () { - $params = $this->_app->request()->data; + $params = self::parseRequestJson($this->_app->request()->getBody()); $id = $this->createClient($params); header("Location:/clients/$id"); }); + $this->_app->route('DELETE /', function () { + $this->_logger->info('Test harness has told us to quit'); + }); + $this->_app->route('POST /clients/@id', function ($id) { $c = $this->getClient($id); if (!$c) { http_response_code(404); return; } - $params = $this->_app->request()->data; + $params = self::parseRequestJson($this->_app->request()->getBody()); $resp = $c->doCommand($params); if ($resp === false) { http_response_code(400); @@ -48,12 +57,12 @@ public function __construct($store, $logger) }); } - public function start() + public function start(): void { $this->_app->start(); } - public function getStatus() + public function getStatus(): array { return [ 'name' => 'php-server-sdk', @@ -63,13 +72,14 @@ public function getStatus() 'all-flags-client-side-only', 'all-flags-details-only-for-tracked-flags', 'all-flags-with-reasons', + 'context-type', 'secure-mode-hash' ], 'clientVersion' => \LaunchDarkly\LDClient::VERSION ]; } - public function createClient($params) + public function createClient(mixed $params): string { $this->_logger->info("Creating client with parameters: " . json_encode($params)); @@ -78,7 +88,7 @@ public function createClient($params) return $this->_store->addClientParams($params); } - public function deleteClient($id) + public function deleteClient(string $id): bool { $c = $this->getClient($id); if ($c) { @@ -89,7 +99,7 @@ public function deleteClient($id) return false; } - private function getClient($id) + private function getClient(string $id): ?SdkClientEntity { $params = $this->_store->getClientParams($id); if ($params === null) { @@ -97,4 +107,39 @@ private function getClient($id) } return new SdkClientEntity($params); } + + // The following methods for normalizing parsed JSON are a workaround for PHP's inability to distinguish + // between an empty JSON array [] and an empty JSON object {} if you parse JSON into associative arrays. + // In order for some contract tests to work which involve empty object values, we need to be able to + // make such a distinction. But, we don't want to parse all of the JSON parameters as objects, because + // associative arrays are much more convenient for most of our logic. The solution is to parse everything + // as an object first, then convert every object to an array UNLESS it is an empty object. + + private static function parseRequestJson(string $json): array + { + return self::normalizeParsedData(json_decode($json)); + } + + private static function normalizeParsedData(mixed $value): mixed + { + if (is_array($value)) { + $ret = []; + foreach ($value as $element) { + $ret[] = self::normalizeParsedData($element); + } + return $ret; + } + if (!is_object($value)) { + return $value; + } + $props = get_object_vars($value); + if (count($props) === 0) { + return $value; + } + $ret = []; + foreach ($props as $k => $v) { + $ret[$k] = self::normalizeParsedData($v); + } + return $ret; + } } diff --git a/test-service/composer.json b/test-service/composer.json index eb37b506..d7e3778f 100644 --- a/test-service/composer.json +++ b/test-service/composer.json @@ -7,14 +7,19 @@ ], "require": { "doctrine/cache": "^1.0", - "guzzlehttp/guzzle": "^6.3 | ^7", + "guzzlehttp/guzzle": "^7", "kevinrob/guzzle-cache-middleware": "^4.0", "launchdarkly/server-sdk": "*", - "mikecao/flight": "1.* | 2.*", - "monolog/monolog": "1.*", - "php": ">=7.3", + "mikecao/flight": "^2", + "monolog/monolog": "^2", + "php": ">=8.0", "psr/log": "1.*" }, + "autoload": { + "psr-4": { + "Tests\\": "." + } + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/test-service/index.php b/test-service/index.php index 3c84a1e1..d7764d15 100644 --- a/test-service/index.php +++ b/test-service/index.php @@ -1,19 +1,21 @@ pushHandler(new Monolog\Handler\StreamHandler('php://stderr', Monolog\Logger::DEBUG)); +$logger = new Logger('testservice'); +$logger->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG)); $dataStorePath = getenv("LD_TEST_SERVICE_DATA_DIR"); if (!$dataStorePath) { diff --git a/tests/FlagBuilder.php b/tests/FlagBuilder.php new file mode 100644 index 00000000..d1cf70cd --- /dev/null +++ b/tests/FlagBuilder.php @@ -0,0 +1,158 @@ +_key = $key; + $this->_fallthrough = new VariationOrRollout(null, null); + } + + public function build(): FeatureFlag + { + return new FeatureFlag( + $this->_key, + $this->_version, + $this->_on, + $this->_prerequisites, + $this->_salt, + $this->_targets, + $this->_contextTargets, + $this->_rules, + $this->_fallthrough, + $this->_offVariation, + $this->_variations, + $this->_deleted, + $this->_trackEvents, + $this->_trackEventsFallthrough, + $this->_debugEventsUntilDate, + $this->_clientSide + ); + } + + public function contextTarget(string $contextKind, int $variation, string ...$values): FlagBuilder + { + $this->_contextTargets[] = new Target($contextKind, $values, $variation); + return $this; + } + + public function debugEventsUntilDate(?int $debugEventsUntilDate): FlagBuilder + { + $this->_debugEventsUntilDate = $debugEventsUntilDate; + return $this; + } + + public function fallthroughRollout(Rollout $rollout): FlagBuilder + { + $this->_fallthrough = new VariationOrRollout(null, $rollout); + return $this; + } + + public function fallthroughVariation(int $variation): FlagBuilder + { + $this->_fallthrough = new VariationOrRollout($variation, null); + return $this; + } + + public function offVariation(?int $offVariation): FlagBuilder + { + $this->_offVariation = $offVariation; + return $this; + } + + public function on(bool $on): FlagBuilder + { + $this->_on = $on; + return $this; + } + + public function prerequisite(string $key, int $variation): FlagBuilder + { + $this->_prerequisites[] = new Prerequisite($key, $variation); + return $this; + } + + public function prerequisites(array $prerequisites): FlagBuilder + { + $this->_prerequisites = $prerequisites; + return $this; + } + + public function rule(Rule $rule): FlagBuilder + { + $this->_rules[] = $rule; + return $this; + } + + public function rules(array $rules): FlagBuilder + { + $this->_rules = $rules; + return $this; + } + + public function salt(string $salt): FlagBuilder + { + $this->_salt = $salt; + return $this; + } + + public function target(int $variation, string ...$values): FlagBuilder + { + $this->_targets[] = new Target(null, $values, $variation); + return $this; + } + + public function trackEvents(bool $trackEvents): FlagBuilder + { + $this->_trackEvents = $trackEvents; + return $this; + } + + public function trackEventsFallthrough(bool $trackEventsFallthrough): FlagBuilder + { + $this->_trackEventsFallthrough = $trackEventsFallthrough; + return $this; + } + + public function variations(...$variations): FlagBuilder + { + $this->_variations = $variations; + return $this; + } + + public function version(int $version): FlagBuilder + { + $this->_version = $version; + return $this; + } +} diff --git a/tests/FlagRuleBuilder.php b/tests/FlagRuleBuilder.php new file mode 100644 index 00000000..6707198d --- /dev/null +++ b/tests/FlagRuleBuilder.php @@ -0,0 +1,60 @@ +_variation, $this->_rollout, $this->_id, $this->_clauses, $this->_trackEvents); + } + + public function clause(Clause $clause): FlagRuleBuilder + { + $this->_clauses[] = $clause; + return $this; + } + + public function clauses(array $clauses): FlagRuleBuilder + { + $this->_clauses = $clauses; + return $this; + } + + public function id(string $id): FlagRuleBuilder + { + $this->_id = $id; + return $this; + } + + public function rollout(Rollout $rollout): FlagRuleBuilder + { + $this->_rollout = $rollout; + $this->_variation = null; + return $this; + } + + public function trackEvents(bool $trackEvents): FlagRuleBuilder + { + $this->_trackEvents = $trackEvents; + return $this; + } + + public function variation(int $variation): FlagRuleBuilder + { + $this->_variation = $variation; + $this->_rollout = null; + return $this; + } +} diff --git a/tests/Impl/Evaluation/EvaluatorBucketingTest.php b/tests/Impl/Evaluation/EvaluatorBucketingTest.php new file mode 100644 index 00000000..e867ffad --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorBucketingTest.php @@ -0,0 +1,79 @@ +assertNotEquals($contextPoint1, $contextPoint2); + } + + public function testDifferentSaltsProduceDifferentAssignment() + { + $seed1 = 357; + $seed2 = 13; + $context = LDContext::create('userkey'); + $key = 'flag-key'; + $attr = 'key'; + $salt = 'testing123'; + $contextPoint1 = EvaluatorBucketing::getBucketValueForContext($context, null, $key, $attr, $salt, $seed1); + $contextPoint2 = EvaluatorBucketing::getBucketValueForContext($context, null, $key, $attr, $salt, $seed2); + + $this->assertNotEquals($contextPoint1, $contextPoint2); + } + + public function testSameSeedIsDeterministic() + { + $seed = 357; + $context = LDContext::create('userkey'); + $key = 'flag-key'; + $attr = 'key'; + $salt = 'testing123'; + $contextPoint1 = EvaluatorBucketing::getBucketValueForContext($context, null, $key, $attr, $salt, $seed); + $contextPoint2 = EvaluatorBucketing::getBucketValueForContext($context, null, $key, $attr, $salt, $seed); + + $this->assertEquals($contextPoint1, $contextPoint2); + } + + public function testContextKindSelectsContext() + { + $seed = 357; + $context1 = LDContext::create('key1'); + $context2 = LDContext::create('key2', 'kind2'); + $multi = LDContext::createMulti($context1, $context2); + + $key = 'flag-key'; + $attr = 'key'; + $salt = 'testing123'; + + $this->assertEquals( + EvaluatorBucketing::getBucketValueForContext($context1, null, $key, $attr, $salt, $seed), + EvaluatorBucketing::getBucketValueForContext($context1, 'user', $key, $attr, $salt, $seed) + ); + $this->assertEquals( + EvaluatorBucketing::getBucketValueForContext($context1, null, $key, $attr, $salt, $seed), + EvaluatorBucketing::getBucketValueForContext($multi, 'user', $key, $attr, $salt, $seed) + ); + $this->assertEquals( + EvaluatorBucketing::getBucketValueForContext($context2, 'kind2', $key, $attr, $salt, $seed), + EvaluatorBucketing::getBucketValueForContext($multi, 'kind2', $key, $attr, $salt, $seed) + ); + $this->assertNotEquals( + EvaluatorBucketing::getBucketValueForContext($multi, 'user', $key, $attr, $salt, $seed), + EvaluatorBucketing::getBucketValueForContext($multi, 'kind2', $key, $attr, $salt, $seed) + ); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorClauseTest.php b/tests/Impl/Evaluation/EvaluatorClauseTest.php new file mode 100644 index 00000000..3e7b8b75 --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorClauseTest.php @@ -0,0 +1,156 @@ +evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + self::assertEquals($expectMatch, $result->getDetail()->getValue()); + } + + private function assertMatchClause(Evaluator $eval, Clause $clause, LDContext $context, bool $expectMatch) + { + self::assertMatch($eval, ModelBuilders::booleanFlagWithClauses($clause), $context, $expectMatch); + } + + public function testClauseCanMatchBuiltInAttribute() + { + $clause = ModelBuilders::clause(null, 'name', 'in', 'Bob'); + $context = LDContext::builder('key')->name('Bob')->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, true); + } + + public function testClauseCanMatchCustomAttribute() + { + $clause = ModelBuilders::clause(null, 'legs', 'in', 4); + $context = LDContext::builder('key')->set('legs', 4)->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, true); + } + + public function testClauseReturnsFalseForMissingAttribute() + { + $clause = ModelBuilders::clause(null, 'legs', 'in', 4); + $context = LDContext::create('key'); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, false); + } + + public function testClauseMatchesContextValueToAnyOfMultipleValues() + { + $clause = ModelBuilders::clause(null, 'name', 'in', 'Bob', 'Carol'); + $context = LDContext::builder('key')->name('Carol')->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, true); + } + + public function testClauseMatchesArrayOfContextValuesToClauseValue() + { + $clause = ModelBuilders::clause(null, 'alias', 'in', 'Maurice'); + $context = LDContext::builder('key')->set('alias', ['Space Cowboy', 'Maurice'])->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, true); + } + + public function testClauseFindsNoMatchInArrayOfContextValues() + { + $clause = ModelBuilders::clause(null, 'alias', 'in', 'Ma'); + $context = LDContext::builder('key')->set('alias', ['Mary', 'May'])->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, false); + } + + public function testClauseCanBeNegatedToReturnFalse() + { + $clause = ModelBuilders::negate(ModelBuilders::clause(null, 'name', 'in', 'Bob')); + $context = LDContext::builder('key')->name('Bob')->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, false); + } + + public function testClauseCanBeNegatedToReturnTrue() + { + $clause = ModelBuilders::negate(ModelBuilders::clause(null, 'name', 'in', 'Rob')); + $context = LDContext::builder('key')->name('Bob')->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, true); + } + + public function testClauseWithUnknownOperatorDoesNotMatch() + { + $clause = ModelBuilders::clause(null, 'name', 'doesSomethingUnsupported', 'Bob'); + $context = LDContext::builder('key')->name('Bob')->build(); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context, false); + } + + public function testClauseMatchUsesContextKind() + { + $clause = ModelBuilders::clause('company', 'name', 'in', 'Catco'); + $context1 = LDContext::builder('cc')->kind('company')->name('Catco')->build(); + $context2 = LDContext::builder('l')->name('Lucy')->build(); + $context3 = LDContext::createMulti($context1, $context2); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context1, true); + self::assertMatchClause(static::$basicEvaluator, $clause, $context2, false); + self::assertMatchClause(static::$basicEvaluator, $clause, $context3, true); + } + + public function testClauseMatchByKindAttribute() + { + $clause = ModelBuilders::clause(null, 'kind', 'startsWith', 'a'); + $context1 = LDContext::create('key'); + $context2 = LDContext::create('key', 'ab'); + $context3 = LDContext::createMulti( + LDContext::create('key', 'cd'), + LDContext::create('key', 'ab') + ); + + self::assertMatchClause(static::$basicEvaluator, $clause, $context1, false); + self::assertMatchClause(static::$basicEvaluator, $clause, $context2, true); + self::assertMatchClause(static::$basicEvaluator, $clause, $context3, true); + } + + public function testSegmentMatchClauseRetrievesSegmentFromStore() + { + $context = LDContext::create('key'); + $segment = ModelBuilders::segmentBuilder('segkey')->included($context->getKey())->build(); + $requester = new MockFeatureRequester(); + $requester->addSegment($segment); + $evaluator = new Evaluator($requester); + + $clause = ModelBuilders::clauseMatchingSegment($segment); + + self::assertMatchClause($evaluator, $clause, $context, true); + } + + public function testSegmentMatchClauseFallsThroughWithNoErrorsIfSegmentNotFound() + { + $context = LDContext::create('key'); + $requester = new MockFeatureRequester(); + $requester->expectQueryForUnknownSegment('segkey'); + $evaluator = new Evaluator($requester); + + $clause = ModelBuilders::clause(null, '', 'segmentMatch', 'segkey'); + + self::assertMatchClause($evaluator, $clause, $context, false); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorFlagTest.php b/tests/Impl/Evaluation/EvaluatorFlagTest.php new file mode 100644 index 00000000..9b759316 --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorFlagTest.php @@ -0,0 +1,233 @@ +variations('fall', 'off', 'on') + ->on(false)->offVariation(1)->fallthroughVariation(0) + ->build(); + + $result = static::$basicEvaluator->evaluate($flag, LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail('off', 1, EvaluationReason::off()); + self::assertEquals($detail, $result->getDetail()); + self::assertFalse($result->isForceReasonTracking()); + } + + public function testFlagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() + { + $flag = ModelBuilders::flagBuilder('feature')->variations('fall', 'off', 'on') + ->on(false)->fallthroughVariation(0) + ->build(); + + $result = static::$basicEvaluator->evaluate($flag, LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(null, null, EvaluationReason::off()); + self::assertEquals($detail, $result->getDetail()); + self::assertFalse($result->isForceReasonTracking()); + } + + public function testFlagReturnsErrorIfOffVariationIsTooHigh() + { + $flag = ModelBuilders::flagBuilder('feature')->variations('fall', 'off', 'on') + ->on(false)->offVariation(999)->fallthroughVariation(0) + ->build(); + + $result = static::$basicEvaluator->evaluate($flag, LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testFlagReturnsErrorIfOffVariationIsNegative() + { + $flag = ModelBuilders::flagBuilder('feature')->variations('fall', 'off', 'on') + ->on(false)->offVariation(-1)->fallthroughVariation(0) + ->build(); + + $result = static::$basicEvaluator->evaluate($flag, LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testFlagMatchesContextFromRules() + { + global $defaultContext; + $flag = ModelBuilders::booleanFlagWithRules( + ModelBuilders::flagRuleBuilder() + ->id(RULE_ID) + ->variation(1) + ->clause(ModelBuilders::clauseMatchingContext($defaultContext)) + ->build() + ); + + $result = static::$basicEvaluator->evaluate($flag, $defaultContext, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testFlagReturnsErrorIfRuleVariationIsTooHigh() + { + global $defaultContext; + $flag = ModelBuilders::booleanFlagWithRules(ModelBuilders::flagRuleMatchingContext(999, $defaultContext)); + + $result = static::$basicEvaluator->evaluate($flag, $defaultContext, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testFlagReturnsErrorIfRuleVariationIsNegative() + { + global $defaultContext; + $flag = ModelBuilders::booleanFlagWithRules(ModelBuilders::flagRuleMatchingContext(-1, $defaultContext)); + + $result = static::$basicEvaluator->evaluate($flag, $defaultContext, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testFlagReturnsErrorIfRuleHasNoVariationOrRollout() + { + global $defaultContext; + $flag = ModelBuilders::booleanFlagWithRules( + ModelBuilders::flagRuleBuilder()->clause(ModelBuilders::clauseMatchingContext($defaultContext))->build() + ); + + $result = static::$basicEvaluator->evaluate($flag, $defaultContext, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testFlagReturnsErrorIfRuleHasRolloutWithNoVariations() + { + global $defaultContext; + $rollout = new Rollout([], null); + $flag = ModelBuilders::booleanFlagWithRules( + ModelBuilders::flagRuleBuilder()->clause(ModelBuilders::clauseMatchingContext($defaultContext)) + ->rollout($rollout)->build() + ); + + $result = static::$basicEvaluator->evaluate($flag, $defaultContext, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testRolloutSelectsBucket() + { + $context = LDContext::create('userkey'); + $flagKey = 'flagkey'; + $salt = 'salt'; + + // First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, + // so we can construct a rollout whose second bucket just barely contains that value + $bucketValue = floor(EvaluatorBucketing::getBucketValueForContext($context, null, $flagKey, "key", $salt, null) * 100000); + self::assertGreaterThan(0, $bucketValue); + self::assertLessThan(100000, $bucketValue); + + $badVariationA = 0; + $matchedVariation = 1; + $badVariationB = 2; + $rollout = new Rollout( + [ + ModelBuilders::weightedVariation($badVariationA, $bucketValue), // end of bucket range is not inclusive, so it will *not* match the target value + ModelBuilders::weightedVariation($matchedVariation, 1), // size of this bucket is 1, so it only matches that specific value + ModelBuilders::weightedVariation($badVariationB, 100000 - ($bucketValue + 1)) + ], + null + ); + $flag = ModelBuilders::flagBuilder($flagKey)->on(true)->variations('', '', '') + ->fallthroughRollout($rollout) + ->salt($salt) + ->build(); + + $result = static::$basicEvaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + self::assertSame($matchedVariation, $result->getDetail()->getVariationIndex()); + } + + public function testRolloutSelectsLastBucketIfBucketValueEqualsTotalWeight() + { + $context = LDContext::create('userkey'); + $flagKey = 'flagkey'; + $salt = 'salt'; + + $bucketValue = floor(EvaluatorBucketing::getBucketValueForContext($context, null, $flagKey, "key", $salt, null) * 100000); + + // We'll construct a list of variations that stops right at the target bucket value + $rollout = ModelBuilders::rolloutWithVariations( + ModelBuilders::weightedVariation(0, $bucketValue) + ); + $flag = ModelBuilders::flagBuilder($flagKey)->on(true)->variations('', '', '') + ->fallthroughRollout($rollout) + ->salt($salt) + ->build(); + + $result = static::$basicEvaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + self::assertSame(0, $result->getDetail()->getVariationIndex()); + } + + public function testRolloutCalculationBucketsByContextKeyByDefault() + { + $context = LDContext::create('userkey'); + $expectedBucketValue = 22464; + $rollout = new Rollout(makeRolloutVariations($expectedBucketValue, 1, 0), null); + $flag = ModelBuilders::booleanFlagWithRules( + ModelBuilders::flagRuleBuilder() + ->id(RULE_ID) + ->clause(ModelBuilders::clauseMatchingContext($context)) + ->rollout($rollout) + ->build() + ); + + $result = static::$basicEvaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); + self::assertEquals($detail, $result->getDetail()); + } + + public function testRolloutCalculationCanBucketBySpecificAttribute() + { + $context = LDContext::builder('userkey')->name('Bob')->build(); + $expectedBucketValue = 95913; + $rollout = new Rollout(makeRolloutVariations($expectedBucketValue, 1, 0), 'name'); + $flag = ModelBuilders::booleanFlagWithRules( + ModelBuilders::flagRuleBuilder() + ->id(RULE_ID) + ->clause(ModelBuilders::clauseMatchingContext($context)) + ->rollout($rollout) + ->build() + ); + + $result = static::$basicEvaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); + self::assertEquals($detail, $result->getDetail()); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorPrerequisiteTest.php b/tests/Impl/Evaluation/EvaluatorPrerequisiteTest.php new file mode 100644 index 00000000..0052cf73 --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorPrerequisiteTest.php @@ -0,0 +1,152 @@ +variations('fall', 'off', 'on') + ->on(true)->offVariation(1)->fallthroughVariation(0) + ->prerequisite('feature1', 1) + ->build(); + + $requester = new MockFeatureRequester(); + $requester->expectQueryForUnknownFlag('feature1'); + $evaluator = new Evaluator($requester); + + $result = $evaluator->evaluate($flag, LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); + $detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed('feature1')); + self::assertEquals($detail, $result->getDetail()); + } + + public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsOff() + { + $flag1 = ModelBuilders::flagBuilder('feature1')->variations('nogo', 'go') + ->on(false)->offVariation(1)->fallthroughVariation(0) + // note that even though it returns the desired variation, it is still off and therefore not a match + ->build(); + $flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on') + ->on(true)->offVariation(1)->fallthroughVariation(0) + ->prerequisite($flag1->getKey(), 1) + ->build(); + + $requester = new MockFeatureRequester(); + $requester->addFlag($flag1); + $evaluator = new Evaluator($requester); + $recorder = EvaluatorTestUtil::prerequisiteRecorder(); + + $result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record()); + $detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed($flag1->getKey())); + self::assertEquals($detail, $result->getDetail()); + + self::assertEquals(1, count($recorder->evals)); + $eval = $recorder->evals[0]; + self::assertEquals($flag1, $eval->getFlag()); + self::assertEquals('go', $eval->getResult()->getDetail()->getValue()); + self::assertEquals($flag0, $eval->getPrereqOfFlag()); + } + + public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() + { + $flag1 = ModelBuilders::flagBuilder('feature1')->variations('nogo', 'go') + ->on(true)->offVariation(1)->fallthroughVariation(0) + ->build(); + $flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on') + ->on(true)->offVariation(1)->fallthroughVariation(0) + ->prerequisite($flag1->getKey(), 1) + ->build(); + + $requester = new MockFeatureRequester(); + $requester->addFlag($flag1); + $evaluator = new Evaluator($requester); + $recorder = EvaluatorTestUtil::prerequisiteRecorder(); + + $result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record()); + $detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed($flag1->getKey())); + self::assertEquals($detail, $result->getDetail()); + + self::assertEquals(1, count($recorder->evals)); + $eval = $recorder->evals[0]; + self::assertEquals($flag1, $eval->getFlag()); + self::assertEquals('nogo', $eval->getResult()->getDetail()->getValue()); + self::assertEquals($flag0, $eval->getPrereqOfFlag()); + } + + public function testFlagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() + { + $flag1 = ModelBuilders::flagBuilder('feature1')->variations('nogo', 'go') + ->on(true)->offVariation(1)->fallthroughVariation(1) + ->build(); + $flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on') + ->on(true)->fallthroughVariation(0) + ->prerequisite($flag1->getKey(), 1) + ->build(); + + $requester = new MockFeatureRequester(); + $requester->addFlag($flag1); + $evaluator = new Evaluator($requester); + $recorder = EvaluatorTestUtil::prerequisiteRecorder(); + + $result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record()); + $detail = new EvaluationDetail('fall', 0, EvaluationReason::fallthrough()); + self::assertEquals($detail, $result->getDetail()); + + self::assertEquals(1, count($recorder->evals)); + $eval = $recorder->evals[0]; + self::assertEquals($flag1, $eval->getFlag()); + self::assertEquals('go', $eval->getResult()->getDetail()->getValue()); + self::assertEquals($flag0, $eval->getPrereqOfFlag()); + } + + public function recursionDepth() + { + return [[1], [2], [3], [4]]; + } + + /** @dataProvider recursionDepth */ + public function testPrerequisiteCycleDetection($depth) + { + $flagKeys = []; + for ($i = 0; $i < $depth; $i++) { + $flagKeys[] = "flagkey$i"; + } + $flags = []; + $requester = new MockFeatureRequester(); + for ($i = 0; $i < $depth; $i++) { + $flag = ModelBuilders::flagBuilder($flagKeys[$i]) + ->on(true) + ->variations(false, true) + ->offVariation(0) + ->prerequisite($flagKeys[($i + 1) % $depth], 0) + ->build(); + $flags[] = $flag; + $requester->addFlag($flag); + } + $evaluator = new Evaluator($requester); + + $result = $evaluator->evaluate($flags[0], LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); + // Note, we specified expectNoPrerequisiteEvals() above because we do not expect the evaluator + // to *finish* evaluating any of these prerequisites (it can't, because of the cycle), and so + // it won't get as far as emitting any prereq evaluation results. + + self::assertEquals(EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR), $result->getDetail()->getReason()); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorSegmentTest.php b/tests/Impl/Evaluation/EvaluatorSegmentTest.php new file mode 100644 index 00000000..d9a219af --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorSegmentTest.php @@ -0,0 +1,268 @@ +included($defaultContext->getKey())->build(); + $this->assertTrue(self::segmentMatchesContext($segment, $defaultContext)); + } + + public function testExplicitExcludeContext() + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->rule(ModelBuilders::segmentRuleMatchingContext($defaultContext)) + ->excluded($defaultContext->getKey()) + ->build(); + $this->assertFalse(self::segmentMatchesContext($segment, $defaultContext)); + } + + public function testExplicitIncludeHasPrecedence() + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->included($defaultContext->getKey())->excluded($defaultContext->getKey()) + ->build(); + $this->assertTrue(self::segmentMatchesContext($segment, $defaultContext)); + } + + public function testIncludedKeyForContextKind() + { + $c1 = LDContext::create('key1', 'kind1'); + $c2 = LDContext::create('key2', 'kind2'); + $multi = LDContext::createMulti($c1, $c2); + $segment = ModelBuilders::segmentBuilder('test') + ->includedContexts('kind1', 'key1') + ->build(); + $this->assertTrue(self::segmentMatchesContext($segment, $c1)); + $this->assertFalse(self::segmentMatchesContext($segment, $c2)); + $this->assertTrue(self::segmentMatchesContext($segment, $multi)); + } + + public function testExcludedKeyForContextKind() + { + $c1 = LDContext::create('key1', 'kind1'); + $c2 = LDContext::create('key2', 'kind2'); + $multi = LDContext::createMulti($c1, $c2); + $segment = ModelBuilders::segmentBuilder('test') + ->excludedContexts('kind1', 'key1') + ->rule(ModelBuilders::segmentRuleMatchingContext($c1)) + ->rule(ModelBuilders::segmentRuleMatchingContext($c2)) + ->build(); + $this->assertFalse(self::segmentMatchesContext($segment, $c1)); + $this->assertTrue(self::segmentMatchesContext($segment, $c2)); + $this->assertFalse(self::segmentMatchesContext($segment, $multi)); + } + + public function testMatchingRuleWithFullRollout() + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clauseMatchingContext($defaultContext)) + ->weight(100000) + ->build() + ) + ->build(); + $this->assertTrue(self::segmentMatchesContext($segment, $defaultContext)); + } + + public function testMatchingRuleWithZeroRollout() + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clauseMatchingContext($defaultContext)) + ->weight(0) + ->build() + ) + ->build(); + $this->assertFalse(self::segmentMatchesContext($segment, $defaultContext)); + } + + public function testRolloutCalculationCanBucketByKey() + { + $context = LDContext::builder('userkey')->name('Bob')->build(); + $this->verifyRollout($context, $context, 12551, 'test', 'salt', null, null); + } + + public function testRolloutCalculationCanBucketBySpecificAttribute() + { + $context = LDContext::builder('userkey')->name('Bob')->build(); + $this->verifyRollout($context, $context, 61691, 'test', 'salt', 'name', null); + } + + private function verifyRollout( + LDContext $evalContext, + LDContext $matchContext, + int $expectedBucketValue, + string $segmentKey, + string $salt, + ?string $bucketBy, + ?string $rolloutContextKind + ) { + $segmentShouldMatch = ModelBuilders::segmentBuilder($segmentKey) + ->salt($salt) + ->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clauseMatchingContext($matchContext)) + ->weight($expectedBucketValue + 1) + ->bucketBy($bucketBy) + ->rolloutContextKind($rolloutContextKind) + ->build() + ) + ->build(); + $segmentShouldNotMatch = ModelBuilders::segmentBuilder($segmentKey) + ->salt($salt) + ->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clauseMatchingContext($matchContext)) + ->weight($expectedBucketValue) + ->bucketBy($bucketBy) + ->rolloutContextKind($rolloutContextKind) + ->build() + ) + ->build(); + $this->assertTrue($this->segmentMatchesContext($segmentShouldMatch, $evalContext)); + $this->assertFalse($this->segmentMatchesContext($segmentShouldNotMatch, $evalContext)); + } + + public function testMatchingRuleWithMultipleClauses() + { + $segment = ModelBuilders::segmentBuilder('test') + ->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clause(null, 'email', 'in', 'test@example.com')) + ->clause(ModelBuilders::clause(null, 'name', 'in', 'bob')) + ->build() + ) + ->build(); + $context = LDContext::builder('foo')->name('bob')->set('email', 'test@example.com')->build(); + $this->assertTrue(self::segmentMatchesContext($segment, $context)); + } + + public function testRolloutUsesContextKind() + { + $context1 = LDContext::create('key1', 'kind1'); + $context2 = LDContext::create('key2', 'kind2'); + $multi = LDContext::createMulti($context1, $context2); + $expectedBucketValue = (int)(100000 * + EvaluatorBucketing::getBucketValueForContext($context2, 'kind2', 'test', 'key', 'salt', null)); + $this->verifyRollout($multi, $context2, $expectedBucketValue, 'test', 'salt', null, 'kind2'); + } + + public function testNonMatchingRuleWithMultipleClauses() + { + $segment = ModelBuilders::segmentBuilder('test') + ->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clause(null, 'email', 'in', 'test@example.com')) + ->clause(ModelBuilders::clause(null, 'name', 'in', 'bill')) + ->build() + ) + ->build(); + $context = LDContext::builder('foo')->name('bob')->set('email', 'test@example.com')->build(); + $this->assertFalse(self::segmentMatchesContext($segment, $context)); + } + + public function recursionDepth() + { + return [[1], [2], [3], [4]]; + } + + /** @dataProvider recursionDepth */ + public function testSegmentReferencingSegment($depth) + { + $context = LDContext::create('foo'); + + $segmentKeys = []; + for ($i = 0; $i < $depth; $i++) { + $segmentKeys[] = "segmentkey$i"; + } + $flags = []; + $requester = new MockFeatureRequester(); + for ($i = 0; $i < $depth; $i++) { + $builder = ModelBuilders::segmentBuilder($segmentKeys[$i]); + if ($i == $depth - 1) { + $builder->included($context->getKey()); + } else { + $builder->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clause(null, '', 'segmentMatch', $segmentKeys[$i + 1])) + ->build() + ); + } + $segment = $builder->build(); + $segments[] = $segment; + $requester->addSegment($segment); + } + $evaluator = new Evaluator($requester); + + $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clauseMatchingSegment($segments[0])); + + $result = $evaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + self::assertTrue($result->getDetail()->getValue()); + } + + /** @dataProvider recursionDepth */ + public function testSegmentCycleDetection($depth) + { + $context = LDContext::create('foo'); + + $segmentKeys = []; + for ($i = 0; $i < $depth; $i++) { + $segmentKeys[] = "segmentkey$i"; + } + $flags = []; + $requester = new MockFeatureRequester(); + for ($i = 0; $i < $depth; $i++) { + $builder = ModelBuilders::segmentBuilder($segmentKeys[$i]); + $builder->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clause(null, '', 'segmentMatch', $segmentKeys[($i + 1) % $depth])) + ->build() + ); + $segment = $builder->build(); + $segments[] = $segment; + $requester->addSegment($segment); + } + $evaluator = new Evaluator($requester); + + $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clauseMatchingSegment($segments[0])); + + $result = $evaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + self::assertEquals(EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR), $result->getDetail()->getReason()); + } + + private static function segmentMatchesContext(Segment $segment, LDContext $context): bool + { + $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clauseMatchingSegment($segment)); + + $requester = new MockFeatureRequester(); + $requester->addSegment($segment); + $evaluator = new Evaluator($requester, EvaluatorTestUtil::testLogger()); + + $detail = $evaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals())->getDetail(); + if ($detail->getValue() === null) { + self::assertTrue(false, "Evaluation failed with reason: " . json_encode($detail->getReason())); + } + return $detail->getValue(); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorTargetTest.php b/tests/Impl/Evaluation/EvaluatorTargetTest.php new file mode 100644 index 00000000..cf09d4e0 --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorTargetTest.php @@ -0,0 +1,116 @@ +target(self::MATCH_VAR_1, 'c') + ->target(self::MATCH_VAR_2, 'b', 'a') + ->build(); + + self::expectMatch($f, LDContext::create('a'), self::MATCH_VAR_2); + self::expectMatch($f, LDContext::create('b'), self::MATCH_VAR_2); + self::expectMatch($f, LDContext::create('c'), self::MATCH_VAR_1); + self::expectFallthrough($f, LDContext::create('z')); + + // in a multi-kind context, these targets match only the key for the user kind + self::expectMatch( + $f, + LDContext::createMulti(LDContext::create('b', 'dog'), LDContext::create('a')), + self::MATCH_VAR_2 + ); + self::expectMatch( + $f, + LDContext::createMulti(LDContext::create('a', 'dog'), LDContext::create('c')), + self::MATCH_VAR_1 + ); + self::expectFallthrough( + $f, + LDContext::createMulti(LDContext::create('b', 'dog'), LDContext::create('z')) + ); + self::expectFallthrough( + $f, + LDContext::createMulti(LDContext::create('a', 'dog'), LDContext::create('b', 'cat')) + ); + } + + public function userTargetsAndContextTargets() + { + $f = self::baseFlagBuilder() + ->target(self::MATCH_VAR_1, 'c') + ->target(self::MATCH_VAR_2, 'b', 'a') + ->contextTarget('dog', self::MATCH_VAR_1, 'a', 'b') + ->contextTarget('dog', self::MATCH_VAR_2, 'c') + ->contextTarget(LDContext::DEFAULT_KIND, self::MATCH_VAR_1) + ->contextTarget(LDContext::DEFAULT_KIND, self::MATCH_VAR_2) + ->build(); + + self::expectMatch($f, LDContext::create('a'), self::MATCH_VAR_2); + self::expectMatch($f, LDContext::create('b'), self::MATCH_VAR_2); + self::expectMatch($f, LDContext::create('c'), self::MATCH_VAR_1); + self::expectFallthrough($f, LDContext::create('z')); + + self::expectMatch( + $f, + LDContext::createMulti(LDContext::create('b', 'dog'), LDContext::create('a')), + self::MATCH_VAR_1 // the "dog" target takes precedence due to ordering + ); + self::expectMatch( + $f, + LDContext::createMulti(LDContext::create('z', 'dog'), LDContext::create('a')), + self::MATCH_VAR_2 // "dog" targets don't match, continue to "user" targets + ); + self::expectFallthrough( + $f, + LDContext::createMulti(LDContext::create('x', 'dog'), LDContext::create('z')) // nothing matches + ); + self::expectMatch( + $f, + LDContext::createMulti(LDContext::create('a', 'dog'), LDContext::create('b', 'cat')), + self::MATCH_VAR_1 + ); + } + + private static function baseFlagBuilder(): FlagBuilder + { + return ModelBuilders::flagBuilder('feature')->on(true)->variations(...self::VARIATIONS) + ->fallthroughVariation(self::FALLTHROUGH_VAR)->offVariation(self::FALLTHROUGH_VAR); + } + + private function expectMatch(FeatureFlag $f, LDContext $c, int $v) + { + $result = EvaluatorTestUtil::basicEvaluator()->evaluate($f, $c, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + self::assertEquals($v, $result->getDetail()->getVariationIndex()); + self::assertEquals(self::VARIATIONS[$v], $result->getDetail()->getValue()); + self::assertEquals(EvaluationReason::targetMatch(), $result->getDetail()->getReason()); + } + + private function expectFallthrough(FeatureFlag $f, LDContext $c) + { + $result = EvaluatorTestUtil::basicEvaluator()->evaluate($f, $c, EvaluatorTestUtil::expectNoPrerequisiteEvals()); + self::assertEquals(self::FALLTHROUGH_VAR, $result->getDetail()->getVariationIndex()); + self::assertEquals(self::VARIATIONS[self::FALLTHROUGH_VAR], $result->getDetail()->getValue()); + self::assertEquals(EvaluationReason::fallthrough(), $result->getDetail()->getReason()); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorTestUtil.php b/tests/Impl/Evaluation/EvaluatorTestUtil.php new file mode 100644 index 00000000..6103902f --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorTestUtil.php @@ -0,0 +1,48 @@ +evals[] = $pe; + }; + } +} diff --git a/tests/Impl/Model/OperatorsTest.php b/tests/Impl/Evaluation/OperatorsTest.php similarity index 86% rename from tests/Impl/Model/OperatorsTest.php rename to tests/Impl/Evaluation/OperatorsTest.php index 381490e9..bf4dccc7 100644 --- a/tests/Impl/Model/OperatorsTest.php +++ b/tests/Impl/Evaluation/OperatorsTest.php @@ -1,8 +1,8 @@ assertEquals(1001, Operators::parseTime("1970-01-01T00:00:01.001Z")); + $this->assertEquals(null, Operators::parseTime(null)); + $this->assertEquals(null, Operators::parseTime(true)); + $this->assertEquals(null, Operators::parseTime("")); + $this->assertEquals(null, Operators::parseTime("100")); $this->assertEquals(null, Operators::parseTime("NOT A REAL TIMESTAMP")); + $this->assertEquals(null, Operators::parseTime("1970-01-01")); // RFC3339 requires both date and time + $this->assertEquals(null, Operators::parseTime("00:00:01.001Z")); // ditto $this->assertEquals(null, Operators::parseTime([])); } @@ -73,6 +79,11 @@ public function testSemVer() $this->assertTrue(Operators::apply("semVerGreaterThan", "2.0.0-rc.1", "2.0.0-rc.0")); $this->assertFalse(Operators::apply("semVerLessThan", "2.0.0", "xbad%ver")); $this->assertFalse(Operators::apply("semVerGreaterThan", "2.0.0", "xbad%ver")); + + // numeric values are always invalid - must be a string + $this->assertFalse(Operators::apply("semVerEqual", 2, "2.0.0")); + $this->assertFalse(Operators::apply("semVerLessThan", 2, "2.0.1")); + $this->assertFalse(Operators::apply("semVerGreaterThan", 3, "2.0.1")); } public function comparisonOperators(): array @@ -116,7 +127,7 @@ public function comparisonOperators(): array ["greaterThanOrEqual", 100, "200", false], ["greaterThanOrEqual", 100, true, false], ["greaterThanOrEqual", true, 100, false], - ["greaterThanOrEqual", true, true, false], + ["greaterThanOrEqual", true, true, false] ]; } diff --git a/tests/Impl/Model/RolloutRandomizationConsistencyTest.php b/tests/Impl/Evaluation/RolloutRandomizationConsistencyTest.php similarity index 57% rename from tests/Impl/Model/RolloutRandomizationConsistencyTest.php rename to tests/Impl/Evaluation/RolloutRandomizationConsistencyTest.php index 4b92c32c..523b98c1 100644 --- a/tests/Impl/Model/RolloutRandomizationConsistencyTest.php +++ b/tests/Impl/Evaluation/RolloutRandomizationConsistencyTest.php @@ -1,14 +1,14 @@ buildFlag(); - $eventFactory = new EventFactory(false); $evaluationReasonInExperiment = EvaluationReason::fallthrough(true); $evaluationReasonNotInExperiment = EvaluationReason::fallthrough(false); $expectedEvalResult1 = new EvalResult( new EvaluationDetail('fall', 0, $evaluationReasonInExperiment), - [] + true ); $expectedEvalResult2 = new EvalResult( new EvaluationDetail('off', 1, $evaluationReasonInExperiment), - [] + true ); $expectedEvalResult3 = new EvalResult( new EvaluationDetail('fall', 0, $evaluationReasonNotInExperiment), - [] + false ); - $ub1 = new LDUserBuilder('userKeyA'); - $user1 = $ub1->build(); - $result1 = $flag->evaluate($user1, static::$requester, $eventFactory); + $evaluator = new Evaluator(static::$requester); + + $context1 = LDContext::create('userKeyA'); + $result1 = $evaluator->evaluate($flag, $context1, EvaluatorTestUtil::expectNoPrerequisiteEvals()); $this->assertEquals($expectedEvalResult1, $result1); - $ub2 = new LDUserBuilder('userKeyB'); - $user2 = $ub2->build(); - $result2 = $flag->evaluate($user2, static::$requester, $eventFactory); + $context2 = LDContext::create('userKeyB'); + $result2 = $evaluator->evaluate($flag, $context2, EvaluatorTestUtil::expectNoPrerequisiteEvals()); $this->assertEquals($expectedEvalResult2, $result2); - $ub3 = new LDUserBuilder('userKeyC'); - $user3 = $ub3->build(); - $result3 = $flag->evaluate($user3, static::$requester, $eventFactory); + $context3 = LDContext::create('userKeyC'); + $result3 = $evaluator->evaluate($flag, $context3, EvaluatorTestUtil::expectNoPrerequisiteEvals()); $this->assertEquals($expectedEvalResult3, $result3); } - public function testBucketUserByKey() + public function testBucketContextByKey() { - $vr = ['rollout' => [ - 'variations' => [ - ['variation' => 1, 'weight' => 50000], - ['variation' => 2, 'weight' => 50000] - ] - ]]; - - $decodedVr = call_user_func(VariationOrRollout::getDecoder(), $vr); - - $ub1 = new LDUserBuilder('userKeyA'); - $user1 = $ub1->build(); - $point1 = $decodedVr->bucketUser($user1, 'hashKey', 'key', 'saltyA', null); + $context1 = LDContext::create('userKeyA'); + $point1 = EvaluatorBucketing::getBucketValueForContext($context1, null, 'hashKey', 'key', 'saltyA', null); $difference1 = abs($point1 - 0.42157587); $this->assertTrue($difference1 <= 0.0000001); - $ub2 = new LDUserBuilder('userKeyB'); - $user2 = $ub2->build(); - $point2 = $decodedVr->bucketUser($user2, 'hashKey', 'key', 'saltyA', null); + $context2 = LDContext::create('userKeyB'); + $point2 = EvaluatorBucketing::getBucketValueForContext($context2, null, 'hashKey', 'key', 'saltyA', null); $difference2 = abs($point2 - 0.6708485); $this->assertTrue($difference2 <= 0.0000001); - $ub3 = new LDUserBuilder('userKeyC'); - $user3 = $ub3->build(); - $point3 = $decodedVr->bucketUser($user3, 'hashKey', 'key', 'saltyA', null); + $context3 = LDContext::create('userKeyC'); + $point3 = EvaluatorBucketing::getBucketValueForContext($context3, null, 'hashKey', 'key', 'saltyA', null); $difference3 = abs($point3 - 0.10343106); $this->assertTrue($difference3 <= 0.0000001); } - public function testBucketUserBySeed() + public function testBucketContextBySeed() { $seed = 61; - $vr = ['rollout' => [ - 'variations' => [ - ['variation' => 1, 'weight' => 50000], - ['variation' => 2, 'weight' => 50000] - ] - ]]; - - $decodedVr = call_user_func(VariationOrRollout::getDecoder(), $vr); - - $ub1 = new LDUserBuilder('userKeyA'); - $user1 = $ub1->build(); - $point1 = $decodedVr->bucketUser($user1, 'hashKey', 'key', 'saltyA', $seed); + $context1 = LDContext::create('userKeyA'); + $point1 = EvaluatorBucketing::getBucketValueForContext($context1, null, 'hashKey', 'key', 'saltyA', $seed); $difference1 = abs($point1 - 0.09801207); $this->assertTrue($difference1 <= 0.0000001); - $ub2 = new LDUserBuilder('userKeyB'); - $user2 = $ub2->build(); - $point2 = $decodedVr->bucketUser($user2, 'hashKey', 'key', 'saltyA', $seed); + $context2 = LDContext::create('userKeyB'); + $point2 = EvaluatorBucketing::getBucketValueForContext($context2, null, 'hashKey', 'key', 'saltyA', $seed); $difference2 = abs($point2 - 0.14483777); $this->assertTrue($difference2 <= 0.0000001); - $ub3 = new LDUserBuilder('userKeyC'); - $user3 = $ub3->build(); - $point3 = $decodedVr->bucketUser($user3, 'hashKey', 'key', 'saltyA', $seed); + $context3 = LDContext::create('userKeyC'); + $point3 = EvaluatorBucketing::getBucketValueForContext($context3, null, 'hashKey', 'key', 'saltyA', $seed); $difference3 = abs($point3 - 0.9242641); $this->assertTrue($difference3 <= 0.0000001); } diff --git a/tests/Impl/Events/EventFactoryTest.php b/tests/Impl/Events/EventFactoryTest.php index 6bcb557f..37f88828 100644 --- a/tests/Impl/Events/EventFactoryTest.php +++ b/tests/Impl/Events/EventFactoryTest.php @@ -4,9 +4,10 @@ use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; +use LaunchDarkly\Impl\Evaluation\EvalResult; use LaunchDarkly\Impl\Events\EventFactory; use LaunchDarkly\Impl\Model\FeatureFlag; -use LaunchDarkly\LDUserBuilder; +use LaunchDarkly\LDContext; use PHPUnit\Framework\TestCase; class EventFactoryTest extends TestCase @@ -52,12 +53,11 @@ public function testTrackEventFalse() $ef = new EventFactory(false); $flag = $this->buildFlag(false); - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); + $context = LDContext::create('userkey'); $detail = new EvaluationDetail('off', 1, EvaluationReason::fallthrough()); - $result = $ef->newEvalEvent($flag, $user, $detail, null); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); $this->assertFalse(isset($result['trackEvents'])); } @@ -67,12 +67,11 @@ public function testTrackEventTrue() $ef = new EventFactory(false); $flag = $this->buildFlag(true); - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); + $context = LDContext::create('userkey'); $detail = new EvaluationDetail('off', 1, EvaluationReason::fallthrough()); - $result = $ef->newEvalEvent($flag, $user, $detail, null); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, false), null); $this->assertTrue($result['trackEvents']); } @@ -82,12 +81,11 @@ public function testTrackEventTrueWhenTrackEventsFalseButExperimentFallthroughRe $ef = new EventFactory(false); $flag = $this->buildFlag(false); - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); + $context = LDContext::create('userkey'); $detail = new EvaluationDetail('off', 1, EvaluationReason::fallthrough(true)); - $result = $ef->newEvalEvent($flag, $user, $detail, null); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, true), null); $this->assertTrue($result['trackEvents']); } @@ -97,12 +95,11 @@ public function testTrackEventTrueWhenTrackEventsFalseButExperimentRuleMatchReas $ef = new EventFactory(false); $flag = $this->buildFlag(false); - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); + $context = LDContext::create('userkey'); $detail = new EvaluationDetail('off', 1, EvaluationReason::ruleMatch(1, 'something', true)); - $result = $ef->newEvalEvent($flag, $user, $detail, null); + $result = $ef->newEvalEvent($flag, $context, new EvalResult($detail, true), null); $this->assertTrue($result['trackEvents']); } diff --git a/tests/Impl/Events/EventSerializerTest.php b/tests/Impl/Events/EventSerializerTest.php index cf192a5b..ec75fcc2 100644 --- a/tests/Impl/Events/EventSerializerTest.php +++ b/tests/Impl/Events/EventSerializerTest.php @@ -3,268 +3,165 @@ namespace LaunchDarkly\Tests\Impl\Events; use LaunchDarkly\Impl\Events\EventSerializer; -use LaunchDarkly\LDUserBuilder; +use LaunchDarkly\LDContext; use PHPUnit\Framework\TestCase; class EventSerializerTest extends TestCase { - private function getUser() + private function getContext(): LDContext { - return (new LDUserBuilder('abc')) - ->firstName('Sue') - ->custom(['bizzle' => 'def', 'dizzle' => 'ghi']) + return LDContext::builder('abc') + ->set('bizzle', 'def') + ->set('dizzle', 'ghi') + ->set('firstName', 'Sue') ->build(); } - private function getUserSpecifyingOwnPrivateAttr() + private function getContextSpecifyingOwnPrivateAttr() { - return (new LDUserBuilder('abc')) - ->firstName('Sue') - ->customAttribute('bizzle', 'def') - ->privateCustomAttribute('dizzle', 'ghi') + return LDContext::builder('abc') + ->set('bizzle', 'def') + ->set('dizzle', 'ghi') + ->set('firstName', 'Sue') + ->private('dizzle') ->build(); } - private function getFullUserResult() + private function getFullContextResult() { return [ + 'kind' => 'user', 'key' => 'abc', 'firstName' => 'Sue', - 'custom' => ['bizzle' => 'def', 'dizzle' => 'ghi'] + 'bizzle' => 'def', + 'dizzle' => 'ghi' ]; } - private function getUserResultWithAllAttrsHidden() + private function getContextResultWithAllAttrsHidden() { return [ + 'kind' => 'user', 'key' => 'abc', - 'privateAttrs' => ['bizzle', 'dizzle', 'firstName'] + '_meta' => [ + 'redactedAttributes' => ['bizzle', 'dizzle', 'firstName'] + ] ]; } - private function getUserResultWithSomeAttrsHidden() + private function getContextResultWithSomeAttrsHidden() { return [ + 'kind' => 'user', 'key' => 'abc', - 'custom' => ['dizzle' => 'ghi'], - 'privateAttrs' => ['bizzle', 'firstName'] + 'dizzle' => 'ghi', + '_meta' => [ + 'redactedAttributes' => ['bizzle', 'firstName'] + ] ]; } - private function getUserResultWithOwnSpecifiedAttrHidden() + private function getContextResultWithOwnSpecifiedAttrHidden() { return [ + 'kind' => 'user', 'key' => 'abc', 'firstName' => 'Sue', - 'custom' => ['bizzle' => 'def'], - 'privateAttrs' => ['dizzle'] + 'bizzle' => 'def', + '_meta' => [ + 'redactedAttributes' => ['dizzle'] + ] ]; } - private function makeEvent($user) + private function makeEvent($context) { return [ 'creationDate' => 1000000, 'key' => 'abc', - 'kind' => 'thing', - 'user' => $user + 'kind' => 'identify', + 'context' => $context ]; } - private function getJsonForUserBySerializingEvent($user) + private function getJsonForContextBySerializingEvent($user) { $es = new EventSerializer([]); $event = $this->makeEvent($user); - return json_decode($es->serializeEvents([$event]), true)[0]['user']; + return json_decode($es->serializeEvents([$event]), true)[0]['context']; } - public function testAllUserAttrsSerialized() + public function testAllContextAttrsSerialized() { $es = new EventSerializer([]); - $event = $this->makeEvent($this->getUser()); + $event = $this->makeEvent($this->getContext()); $json = $es->serializeEvents([$event]); - $expected = $this->makeEvent($this->getFullUserResult()); + $expected = $this->makeEvent($this->getFullContextResult()); $this->assertEquals([$expected], json_decode($json, true)); } - public function testAllUserAttrsPrivate() + public function testAllContextAttrsPrivate() { $es = new EventSerializer(['all_attributes_private' => true]); - $event = $this->makeEvent($this->getUser()); + $event = $this->makeEvent($this->getContext()); $json = $es->serializeEvents([$event]); - $expected = $this->makeEvent($this->getUserResultWithAllAttrsHidden()); + $expected = $this->makeEvent($this->getContextResultWithAllAttrsHidden()); $this->assertEquals([$expected], json_decode($json, true)); } - public function testSomeUserAttrsPrivate() + public function testSomeContextAttrsPrivate() { $es = new EventSerializer(['private_attribute_names' => ['firstName', 'bizzle']]); - $event = $this->makeEvent($this->getUser()); + $event = $this->makeEvent($this->getContext()); $json = $es->serializeEvents([$event]); - $expected = $this->makeEvent($this->getUserResultWithSomeAttrsHidden()); + $expected = $this->makeEvent($this->getContextResultWithSomeAttrsHidden()); $this->assertEquals([$expected], json_decode($json, true)); } - public function testPerUserPrivateAttr() + public function testPerContextPrivateAttr() { $es = new EventSerializer([]); - $event = $this->makeEvent($this->getUserSpecifyingOwnPrivateAttr()); + $event = $this->makeEvent($this->getContextSpecifyingOwnPrivateAttr()); $json = $es->serializeEvents([$event]); - $expected = $this->makeEvent($this->getUserResultWithOwnSpecifiedAttrHidden()); + $expected = $this->makeEvent($this->getContextResultWithOwnSpecifiedAttrHidden()); $this->assertEquals([$expected], json_decode($json, true)); } - public function testPerUserPrivateAttrPlusGlobalPrivateAttrs() + public function testPerContextPrivateAttrPlusGlobalPrivateAttrs() { $es = new EventSerializer(['private_attribute_names' => ['firstName', 'bizzle']]); - $event = $this->makeEvent($this->getUserSpecifyingOwnPrivateAttr()); + $event = $this->makeEvent($this->getContextSpecifyingOwnPrivateAttr()); $json = $es->serializeEvents([$event]); - $expected = $this->makeEvent($this->getUserResultWithAllAttrsHidden()); + $expected = $this->makeEvent($this->getContextResultWithAllAttrsHidden()); $this->assertEquals([$expected], json_decode($json, true)); } - public function testUserKey() + public function testObjectPropertyRedaction() { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("foo@bar.com", $json['key']); - } - - public function testEmptyCustom() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertFalse(isset($json['custom'])); - } - - public function testFiltersAttributesCorrectly() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder - ->customAttribute("foo", "") - ->customAttribute("bar", 0) - ->customAttribute("baz", null) - ->customAttribute("qux", false) + $es = new EventSerializer(['private_attribute_names' => ['/b/prop1', '/c/prop2/sub1']]); + $context = LDContext::builder('user-key') + ->name('a') + ->set('b', ['prop1' => true, 'prop2' => 3]) + ->set('c', ['prop1' => ['sub1' => true], 'prop2' => ['sub1' => 4, 'sub2' => 5]]) ->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertEquals(["foo" => "", "bar" => 0, "qux" => false], $json['custom']); - } - - public function testEmptyPrivateCustom() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateCustomAttribute("my-key", "my-value")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertFalse(isset($json['custom'])); - } - - public function testUserSecondary() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->secondary("secondary")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("secondary", $json['secondary']); - } - - public function testUserIP() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->ip("127.0.0.1")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("127.0.0.1", $json['ip']); - } - - public function testUserCountry() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->country("US")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("US", $json['country']); - } - - public function testUserEmail() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->email("foo+test@bar.com")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("foo+test@bar.com", $json['email']); - } - - public function testUserName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->name("Foo Bar")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("Foo Bar", $json['name']); - } - - public function testUserAvatar() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->avatar("http://www.gravatar.com/avatar/1")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("http://www.gravatar.com/avatar/1", $json['avatar']); - } - - public function testUserFirstName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->firstName("Foo")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("Foo", $json['firstName']); - } - - public function testUserLastName() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->lastName("Bar")->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame("Bar", $json['lastName']); - } - - public function testUserAnonymous() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->anonymous(true)->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame(true, $json['anonymous']); - } - - public function testUserNotAnonymous() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->anonymous(false)->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertFalse(isset($json['anonymous'])); // omitted rather than set to false, for efficiency + $json = $es->serializeEvents([$this->makeEvent($context)]); + $expected = $this->makeEvent([ + 'kind' => 'user', + 'key' => 'user-key', + 'name' => 'a', + 'b' => ['prop2' => 3], + 'c' => ['prop1' => ['sub1' => true], 'prop2' => ['sub2' => 5]], + '_meta' => [ + 'redactedAttributes' => ['/b/prop1', '/c/prop2/sub1'] + ] + ]); + $this->assertEquals([$expected], json_decode($json, true)); } - public function testNonStringAttributes() + public function testContextKey() { - $builder = new LDUserBuilder(1); - $user = $builder->secondary(2) - ->ip(3) - ->country(4) - ->email(5) - ->name(6) - ->avatar(7) - ->firstName(8) - ->lastName(9) - ->anonymous(true) - ->customAttribute('foo', 10) - ->build(); - $json = $this->getJsonForUserBySerializingEvent($user); - $this->assertSame('1', $json['key']); - $this->assertSame('2', $json['secondary']); - $this->assertSame('3', $json['ip']); - $this->assertSame('4', $json['country']); - $this->assertSame('5', $json['email']); - $this->assertSame('6', $json['name']); - $this->assertSame('7', $json['avatar']); - $this->assertSame('8', $json['firstName']); - $this->assertSame('9', $json['lastName']); - $this->assertSame(true, $json['anonymous']); // We do NOT want "anonymous" to be stringified - $this->assertSame(10, $json['custom']['foo']); // We do NOT want custom attribute values to be stringified + $context = LDContext::create("foo@bar.com"); + $json = $this->getJsonForContextBySerializingEvent($context); + $this->assertSame("foo@bar.com", $json['key']); } } diff --git a/tests/Impl/Integrations/CurlEventPublisherTest.php b/tests/Impl/Integrations/CurlEventPublisherTest.php index 332d7401..c4397be4 100644 --- a/tests/Impl/Integrations/CurlEventPublisherTest.php +++ b/tests/Impl/Integrations/CurlEventPublisherTest.php @@ -3,9 +3,9 @@ namespace LaunchDarkly\Tests\Impl\Integrations; use GuzzleHttp\Client; -use LaunchDarkly\EventPublisher; use LaunchDarkly\Impl\Integrations; use LaunchDarkly\LDClient; +use LaunchDarkly\Subsystems\EventPublisher; use PHPUnit\Framework\TestCase; class CurlEventPublisherTest extends TestCase diff --git a/tests/Impl/Model/FeatureFlagTest.php b/tests/Impl/Model/FeatureFlagTest.php index 3c3a917d..f7bfa2cb 100644 --- a/tests/Impl/Model/FeatureFlagTest.php +++ b/tests/Impl/Model/FeatureFlagTest.php @@ -2,64 +2,9 @@ namespace LaunchDarkly\Tests\Impl\Model; -use LaunchDarkly\EvaluationDetail; -use LaunchDarkly\EvaluationReason; -use LaunchDarkly\Impl\Events\EventFactory; use LaunchDarkly\Impl\Model\FeatureFlag; -use LaunchDarkly\Impl\Model\Segment; -use LaunchDarkly\Impl\Model\VariationOrRollout; -use LaunchDarkly\LDUser; -use LaunchDarkly\LDUserBuilder; -use LaunchDarkly\Tests\MockFeatureRequester; -use PHPUnit\Framework\TestCase; - -const RULE_ID = 'ruleid'; - -$defaultUser = (new LDUserBuilder('foo'))->build(); - -function makeBooleanFlagWithRules(array $rules) -{ - $flagJson = [ - 'key' => 'feature', - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [], - 'rules' => $rules, - 'offVariation' => 0, - 'fallthrough' => ['variation' => 0], - 'variations' => [false, true], - 'salt' => '' - ]; - return FeatureFlag::decode($flagJson); -} -function makeBooleanFlagWithClauses($clauses) -{ - return makeBooleanFlagWithRules([['clauses' => $clauses, 'variation' => 1]]); -} - -function makeRuleMatchingUser($user, $ruleAttrs = []) -{ - $clause = ['attribute' => 'key', 'op' => 'in', 'values' => [$user->getKey()], 'negate' => false]; - return array_merge(['id' => RULE_ID, 'clauses' => [$clause]], $ruleAttrs); -} - -function makeSegmentMatchClause($segmentKey) -{ - return ['attribute' => '', 'op' => 'segmentMatch', 'values' => [$segmentKey], 'negate' => false]; -} - -// This is our way of verifying that the bucket value for a rollout is within 1.0 of the expected value. -function makeRolloutVariations($targetValue, $targetVariation, $otherVariation) -{ - return [ - ['weight' => $targetValue, 'variation' => $otherVariation], - ['weight' => 1, 'variation' => $targetVariation], - ['weight' => 100000 - ($targetValue + 1), 'variation' => $otherVariation] - ]; -} +use PHPUnit\Framework\TestCase; class FeatureFlagTest extends TestCase { @@ -194,15 +139,6 @@ class FeatureFlagTest extends TestCase \"deleted\": false }"; - private static $eventFactory; - private static $requester; - - public static function setUpBeforeClass(): void - { - static::$eventFactory = new EventFactory(false); - static::$requester = new MockFeatureRequester(); - } - public function testDecode() { $this->assertInstanceOf(FeatureFlag::class, FeatureFlag::decode(\GuzzleHttp\json_decode(FeatureFlagTest::$json1, true))); @@ -255,605 +191,4 @@ public function testDecodeMulti(array $feature) self::assertInstanceOf(FeatureFlag::class, $featureFlag); } - - public function testFlagReturnsOffVariationIfFlagIsOff() - { - $flagJson = [ - 'key' => 'feature', - 'version' => 1, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag = FeatureFlag::decode($flagJson); - - $result = $flag->evaluate(new LDUser('user'), static::$requester, static::$eventFactory); - $detail = new EvaluationDetail('off', 1, EvaluationReason::off()); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() - { - $flagJson = [ - 'key' => 'feature', - 'version' => 1, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => null, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag = FeatureFlag::decode($flagJson); - - $result = $flag->evaluate(new LDUser('user'), static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(null, null, EvaluationReason::off()); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsErrorIfOffVariationIsTooHigh() - { - $flagJson = [ - 'key' => 'feature', - 'version' => 1, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 999, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag = FeatureFlag::decode($flagJson); - - $result = $flag->evaluate(new LDUser('user'), static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsErrorIfOffVariationIsNegative() - { - $flagJson = [ - 'key' => 'feature', - 'version' => 1, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => -1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag = FeatureFlag::decode($flagJson); - - $result = $flag->evaluate(new LDUser('user'), static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsOffVariationIfPrerequisiteIsNotFound() - { - $flagJson = [ - 'key' => 'feature0', - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [ - ['key' => 'feature1', 'variation' => 1] - ], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag = FeatureFlag::decode($flagJson); - $ub = new LDUserBuilder('x'); - $user = $ub->build(); - $requester = new MockFeatureRequesterForFeature(); - - $result = $flag->evaluate($user, $requester, static::$eventFactory); - $detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed('feature1')); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsOff() - { - $flag0Json = [ - 'key' => 'feature0', - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [ - ['key' => 'feature1', 'variation' => 1] - ], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag1Json = [ - 'key' => 'feature1', - 'version' => 2, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - // note that even though it returns the desired variation, it is still off and therefore not a match - 'fallthrough' => ['variation' => 0], - 'variations' => ['nogo', 'go'], - 'salt' => '' - ]; - $flag0 = FeatureFlag::decode($flag0Json); - $flag1 = FeatureFlag::decode($flag1Json); - $ub = new LDUserBuilder('x'); - $user = $ub->build(); - $requester = new MockFeatureRequesterForFeature(); - $requester->key = $flag1->getKey(); - $requester->val = $flag1; - - $result = $flag0->evaluate($user, $requester, static::$eventFactory); - $detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed('feature1')); - self::assertEquals($detail, $result->getDetail()); - - $events = $result->getPrerequisiteEvents(); - self::assertEquals(1, count($events)); - $event = $events[0]; - self::assertEquals('feature', $event['kind']); - self::assertEquals($flag1->getKey(), $event['key']); - self::assertEquals('go', $event['value']); - self::assertEquals($flag1->getVersion(), $event['version']); - self::assertEquals($flag0->getKey(), $event['prereqOf']); - } - - public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() - { - $flag0Json = [ - 'key' => 'feature0', - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [ - ['key' => 'feature1', 'variation' => 1] - ], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag1Json = [ - 'key' => 'feature1', - 'version' => 2, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['nogo', 'go'], - 'salt' => '' - ]; - $flag0 = FeatureFlag::decode($flag0Json); - $flag1 = FeatureFlag::decode($flag1Json); - $ub = new LDUserBuilder('x'); - $user = $ub->build(); - $requester = new MockFeatureRequesterForFeature(); - $requester->key = $flag1->getKey(); - $requester->val = $flag1; - - $result = $flag0->evaluate($user, $requester, static::$eventFactory); - $detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed('feature1')); - self::assertEquals($detail, $result->getDetail()); - - $events = $result->getPrerequisiteEvents(); - self::assertEquals(1, count($events)); - $event = $events[0]; - self::assertEquals('feature', $event['kind']); - self::assertEquals($flag1->getKey(), $event['key']); - self::assertEquals('nogo', $event['value']); - self::assertEquals($flag1->getVersion(), $event['version']); - self::assertEquals($flag0->getKey(), $event['prereqOf']); - } - - public function testFlagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() - { - $flag0Json = [ - 'key' => 'feature0', - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [ - ['key' => 'feature1', 'variation' => 1] - ], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag1Json = [ - 'key' => 'feature1', - 'version' => 2, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 1], - 'variations' => ['nogo', 'go'], - 'salt' => '' - ]; - $flag0 = FeatureFlag::decode($flag0Json); - $flag1 = FeatureFlag::decode($flag1Json); - $ub = new LDUserBuilder('x'); - $user = $ub->build(); - $requester = new MockFeatureRequesterForFeature(); - $requester->key = $flag1->getKey(); - $requester->val = $flag1; - - $result = $flag0->evaluate($user, $requester, static::$eventFactory); - $detail = new EvaluationDetail('fall', 0, EvaluationReason::fallthrough()); - self::assertEquals($detail, $result->getDetail()); - - $events = $result->getPrerequisiteEvents(); - self::assertEquals(1, count($events)); - $event = $events[0]; - self::assertEquals('feature', $event['kind']); - self::assertEquals($flag1->getKey(), $event['key']); - self::assertEquals('go', $event['value']); - self::assertEquals($flag1->getVersion(), $event['version']); - self::assertEquals($flag0->getKey(), $event['prereqOf']); - } - - public function testFlagMatchesUserFromTargets() - { - $flagJson = [ - 'key' => 'feature', - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'targets' => [ - ['values' => ['whoever', 'userkey'], 'variation' => 2] - ], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '' - ]; - $flag = FeatureFlag::decode($flagJson); - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail('on', 2, EvaluationReason::targetMatch()); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagMatchesUserFromRules() - { - global $defaultUser; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($defaultUser, ['variation' => 1])]); - - $result = $flag->evaluate($defaultUser, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsErrorIfRuleVariationIsTooHigh() - { - global $defaultUser; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($defaultUser, ['variation' => 999])]); - - $result = $flag->evaluate($defaultUser, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsErrorIfRuleVariationIsNegative() - { - global $defaultUser; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($defaultUser, ['variation' => -1])]); - - $result = $flag->evaluate($defaultUser, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsErrorIfRuleHasNoVariationOrRollout() - { - global $defaultUser; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($defaultUser, [])]); - - $result = $flag->evaluate($defaultUser, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testFlagReturnsErrorIfRuleHasRolloutWithNoVariations() - { - global $defaultUser; - $rollout = ['variations' => []]; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($defaultUser, ['rollout' => $rollout])]); - - $result = $flag->evaluate($defaultUser, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testRolloutSelectsBucket() - { - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - $flagKey = 'flagkey'; - $salt = 'salt'; - - // First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, - // so we can construct a rollout whose second bucket just barely contains that value - $bucketValue = floor(VariationOrRollout::bucketUser($user, $flagKey, "key", $salt, null) * 100000); - self::assertGreaterThan(0, $bucketValue); - self::assertLessThan(100000, $bucketValue); - - $badVariationA = 0; - $matchedVariation = 1; - $badVariationB = 2; - $rollout = [ - 'variations' => [ - ['variation' => $badVariationA, 'weight' => $bucketValue], // end of bucket range is not inclusive, so it will *not* match the target value - ['variation' => $matchedVariation, 'weight' => 1], // size of this bucket is 1, so it only matches that specific value - ['variation' => $badVariationB, 'weight' => 100000 - ($bucketValue + 1)] - ] - ]; - $flag = FeatureFlag::decode([ - 'key' => $flagKey, - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'offVariation' => null, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'fallthrough' => ['rollout' => $rollout], - 'variations' => ['', '', ''], - 'salt' => $salt - ]); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - self::assertSame($matchedVariation, $result->getDetail()->getVariationIndex()); - } - - public function testRolloutSelectsLastBucketIfBucketValueEqualsTotalWeight() - { - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - $flagKey = 'flagkey'; - $salt = 'salt'; - - $bucketValue = floor(VariationOrRollout::bucketUser($user, $flagKey, "key", $salt, null) * 100000); - - // We'll construct a list of variations that stops right at the target bucket value - $rollout = [ - 'variations' => [ - ['variation' => 0, 'weight' => $bucketValue] - ] - ]; - $flag = FeatureFlag::decode([ - 'key' => $flagKey, - 'version' => 1, - 'deleted' => false, - 'on' => true, - 'offVariation' => null, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'fallthrough' => ['rollout' => $rollout], - 'variations' => [''], - 'salt' => $salt - ]); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - self::assertSame(0, $result->getDetail()->getVariationIndex()); - } - - public function testRolloutCalculationBucketsByUserKeyByDefault() - { - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - $expectedBucketValue = 22464; - $rollout = [ - 'salt' => '', - 'variations' => makeRolloutVariations($expectedBucketValue, 1, 0) - ]; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($user, ['rollout' => $rollout])]); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); - self::assertEquals($detail, $result->getDetail()); - } - - public function testRolloutCalculationCanBucketBySpecificAttribute() - { - $ub = new LDUserBuilder('userkey'); - $ub->name('Bob'); - $user = $ub->build(); - $expectedBucketValue = 95913; - $rollout = [ - 'salt' => '', - 'bucketBy' => 'name', - 'variations' => makeRolloutVariations($expectedBucketValue, 1, 0) - ]; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($user, ['rollout' => $rollout])]); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); - self::assertEquals($detail, $result->getDetail()); - } - - public function testRolloutCalculationIncludesSecondaryKey() - { - $ub = new LDUserBuilder('userkey'); - $ub->secondary('999'); - $user = $ub->build(); - $expectedBucketValue = 31179; - $rollout = [ - 'salt' => '', - 'variations' => makeRolloutVariations($expectedBucketValue, 1, 0) - ]; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($user, ['rollout' => $rollout])]); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testRolloutCalculationCoercesSecondaryKeyToString() - { - // This should produce the same result as the previous test, and should not cause an error (ch35189). - $ub = new LDUserBuilder('userkey'); - $ub->secondary(999); - $user = $ub->build(); - $expectedBucketValue = 31179; - $rollout = [ - 'salt' => '', - 'variations' => makeRolloutVariations($expectedBucketValue, 1, 0) - ]; - $flag = makeBooleanFlagWithRules([makeRuleMatchingUser($user, ['rollout' => $rollout])]); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - $detail = new EvaluationDetail(true, 1, EvaluationReason::ruleMatch(0, RULE_ID)); - self::assertEquals($detail, $result->getDetail()); - self::assertEquals([], $result->getPrerequisiteEvents()); - } - - public function testClauseCanMatchBuiltInAttribute() - { - $clause = ['attribute' => 'name', 'op' => 'in', 'values' => ['Bob'], 'negate' => false]; - $flag = makeBooleanFlagWithClauses([$clause]); - $ub = new LDUserBuilder('userkey'); - $ub->name('Bob'); - $user = $ub->build(); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - self::assertEquals(true, $result->getDetail()->getValue()); - } - - public function testClauseCanMatchCustomAttribute() - { - $clause = ['attribute' => 'legs', 'op' => 'in', 'values' => ['4'], 'negate' => false]; - $flag = makeBooleanFlagWithClauses([$clause]); - $ub = new LDUserBuilder('userkey'); - $ub->customAttribute('legs', 4); - $user = $ub->build(); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - self::assertEquals(true, $result->getDetail()->getValue()); - } - - public function testClauseReturnsFalseForMissingAttribute() - { - $clause = ['attribute' => 'legs', 'op' => 'in', 'values' => ['4'], 'negate' => false]; - $flag = makeBooleanFlagWithClauses([$clause]); - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - self::assertEquals(false, $result->getDetail()->getValue()); - } - - public function testClauseCanBeNegated() - { - $clause = ['attribute' => 'name', 'op' => 'in', 'values' => ['Bob'], 'negate' => true]; - $flag = makeBooleanFlagWithClauses([$clause]); - $ub = new LDUserBuilder('userkey'); - $ub->name('Bob'); - $user = $ub->build(); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - self::assertEquals(false, $result->getDetail()->getValue()); - } - - public function testClauseWithUnknownOperatorDoesNotMatch() - { - $clause = ['attribute' => 'name', 'op' => 'doesSomethingUnsupported', 'values' => ['Bob'], 'negate' => false]; - $flag = makeBooleanFlagWithClauses([$clause]); - $ub = new LDUserBuilder('userkey'); - $ub->name('Bob'); - $user = $ub->build(); - - $result = $flag->evaluate($user, static::$requester, static::$eventFactory); - self::assertEquals(false, $result->getDetail()->getValue()); - } - - public function testSegmentMatchClauseRetrievesSegmentFromStore() - { - global $defaultUser; - $segmentJson = [ - 'key' => 'segkey', - 'version' => 1, - 'deleted' => false, - 'included' => [$defaultUser->getKey()], - 'excluded' => [], - 'rules' => [], - 'salt' => '' - ]; - $segment = Segment::decode($segmentJson); - - $requester = new MockFeatureRequesterForSegment(); - $requester->key = 'segkey'; - $requester->val = $segment; - - $feature = makeBooleanFlagWithClauses([makeSegmentMatchClause('segkey')]); - - $result = $feature->evaluate($defaultUser, $requester, static::$eventFactory); - - self::assertTrue($result->getDetail()->getValue()); - } - - public function testSegmentMatchClauseFallsThroughWithNoErrorsIfSegmentNotFound() - { - global $defaultUser; - $requester = new MockFeatureRequesterForSegment(); - - $feature = makeBooleanFlagWithClauses([makeSegmentMatchClause('segkey')]); - - $result = $feature->evaluate($defaultUser, $requester, static::$eventFactory); - - self::assertFalse($result->getDetail()->getValue()); - } } diff --git a/tests/Impl/Model/MockFeatureRequesterForFeature.php b/tests/Impl/Model/MockFeatureRequesterForFeature.php deleted file mode 100644 index eb4b414f..00000000 --- a/tests/Impl/Model/MockFeatureRequesterForFeature.php +++ /dev/null @@ -1,32 +0,0 @@ -key) ? $this->val : null; - } - - public function getSegment(string $key): ?Segment - { - return null; - } - - public function getAllFeatures(): ?array - { - return null; - } -} diff --git a/tests/Impl/Model/MockFeatureRequesterForSegment.php b/tests/Impl/Model/MockFeatureRequesterForSegment.php deleted file mode 100644 index da48cf3b..00000000 --- a/tests/Impl/Model/MockFeatureRequesterForSegment.php +++ /dev/null @@ -1,32 +0,0 @@ -key) ? $this->val : null; - } - - public function getAllFeatures(): ?array - { - return null; - } -} diff --git a/tests/Impl/Model/SegmentTest.php b/tests/Impl/Model/SegmentTest.php deleted file mode 100644 index 634aea84..00000000 --- a/tests/Impl/Model/SegmentTest.php +++ /dev/null @@ -1,195 +0,0 @@ -build(); - -function makeSegmentMatchingUser($user, $ruleAttrs = []) -{ - $clause = ['attribute' => 'key', 'op' => 'in', 'values' => [$user->getKey()], 'negate' => false]; - $rule = array_merge(['clauses' => [$clause]], $ruleAttrs); - $json = [ - 'key' => 'test', - 'included' => [], - 'excluded' => [], - 'salt' => 'salt', - 'rules' => [$rule], - 'version' => 1, - 'deleted' => false - ]; - return Segment::decode($json); -} - -class SegmentTest extends TestCase -{ - public function testExplicitIncludeUser() - { - global $defaultUser; - $json = [ - 'key' => 'test', - 'included' => [$defaultUser->getKey()], - 'excluded' => [], - 'rules' => [], - 'salt' => 'salt', - 'version' => 1, - 'deleted' => false - ]; - $segment = Segment::decode($json); - $this->assertTrue($segment->matchesUser($defaultUser)); - } - - public function testExplicitExcludeUser() - { - global $defaultUser; - $json = [ - 'key' => 'test', - 'included' => [], - 'excluded' => [$defaultUser->getKey()], - 'rules' => [], - 'salt' => 'salt', - 'version' => 1, - 'deleted' => false - ]; - $segment = Segment::decode($json); - $this->assertFalse($segment->matchesUser($defaultUser)); - } - - public function testExplicitIncludePasPrecedence() - { - global $defaultUser; - $json = [ - 'key' => 'test', - 'included' => [$defaultUser->getKey()], - 'excluded' => [$defaultUser->getKey()], - 'rules' => [], - 'salt' => 'salt', - 'version' => 1, - 'deleted' => false - ]; - $segment = Segment::decode($json); - $ub = new LDUserBuilder('foo'); - $this->assertTrue($segment->matchesUser($ub->build())); - } - - public function testMatchingRuleWithFullRollout() - { - global $defaultUser; - $segment = makeSegmentMatchingUser($defaultUser, ['weight' => 100000]); - $this->assertTrue($segment->matchesUser($defaultUser)); - } - - public function testMatchingRuleWithZeroRollout() - { - global $defaultUser; - $segment = makeSegmentMatchingUser($defaultUser, ['weight' => 0]); - $this->assertFalse($segment->matchesUser($defaultUser)); - } - - public function testRolloutCalculationCanBucketByKey() - { - $user = (new LDUserBuilder('userkey'))->name('Bob')->build(); - $this->verifyRollout($user, 12551); - } - - public function testRolloutCalculationIncludesSecondaryKey() - { - $user = (new LDUserBuilder('userkey'))->secondary('999')->build(); - $this->verifyRollout($user, 81650); - } - - public function testRolloutCalculationCoercesSecondaryKeyToString() - { - $user = (new LDUserBuilder('userkey'))->secondary(999)->build(); - $this->verifyRollout($user, 81650); - } - - public function testRolloutCalculationCanBucketBySpecificAttribute() - { - $user = (new LDUserBuilder('userkey'))->name('Bob')->build(); - $this->verifyRollout($user, 61691, ['bucketBy' => 'name']); - } - - private function verifyRollout($user, $expectedBucketValue, $rolloutAttrs = []) - { - $segment0 = makeSegmentMatchingUser($user, array_merge(['weight' => $expectedBucketValue + 1], $rolloutAttrs)); - $this->assertTrue($segment0->matchesUser($user)); - $segment1 = makeSegmentMatchingUser($user, array_merge(['weight' => $expectedBucketValue], $rolloutAttrs)); - $this->assertFalse($segment1->matchesUser($user)); - } - - public function testMatchingRuleWithMultipleClauses() - { - $json = [ - 'key' => 'test', - 'included' => [], - 'excluded' => [], - 'salt' => 'salt', - 'rules' => [ - [ - 'clauses' => [ - [ - 'attribute' => 'email', - 'op' => 'in', - 'values' => ['test@example.com'], - 'negate' => false - ], - [ - 'attribute' => 'name', - 'op' => 'in', - 'values' => ['bob'], - 'negate' => false - ] - ], - 'weight' => 100000 - ] - ], - 'version' => 1, - 'deleted' => false - ]; - $segment = Segment::decode($json); - $ub = new LDUserBuilder('foo'); - $ub->email('test@example.com'); - $ub->name('bob'); - $this->assertTrue($segment->matchesUser($ub->build())); - } - - public function testNonMatchingRuleWithMultipleClauses() - { - $json = [ - 'key' => 'test', - 'included' => [], - 'excluded' => [], - 'salt' => 'salt', - 'rules' => [ - [ - 'clauses' => [ - [ - 'attribute' => 'email', - 'op' => 'in', - 'values' => ['test@example.com'], - 'negate' => false - ], - [ - 'attribute' => 'name', - 'op' => 'in', - 'values' => ['bill'], - 'negate' => false - ] - ], - 'weight' => 100000 - ] - ], - 'version' => 1, - 'deleted' => false - ]; - $segment = Segment::decode($json); - $ub = new LDUserBuilder('foo'); - $ub->email('test@example.com'); - $ub->name('bob'); - $this->assertFalse($segment->matchesUser($ub->build())); - } -} diff --git a/tests/Impl/Model/VariationOrRolloutTest.php b/tests/Impl/Model/VariationOrRolloutTest.php deleted file mode 100644 index ead9ba9b..00000000 --- a/tests/Impl/Model/VariationOrRolloutTest.php +++ /dev/null @@ -1,80 +0,0 @@ - [ - 'variations' => [ - ['variation' => 1, 'weight' => 50000], - ['variation' => 2, 'weight' => 50000] - ] - ]]; - - $decodedVr = call_user_func(VariationOrRollout::getDecoder(), $vr); - - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - $key = 'flag-key'; - $attr = 'key'; - $salt = 'testing123'; - $userPoint1 = $decodedVr->bucketUser($user, $key, $attr, $salt, null); - $userPoint2 = $decodedVr->bucketUser($user, $key, $attr, $salt, $seed); - - $this->assertNotEquals($userPoint1, $userPoint2); - } - - public function testDifferentSaltsProduceDifferentAssignment() - { - $seed1 = 357; - $seed2 = 13; - $vr = ['rollout' => [ - 'variations' => [ - ['variation' => 1, 'weight' => 50000], - ['variation' => 2, 'weight' => 50000] - ] - ]]; - - $decodedVr = call_user_func(VariationOrRollout::getDecoder(), $vr); - - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - $key = 'flag-key'; - $attr = 'key'; - $salt = 'testing123'; - $userPoint1 = $decodedVr->bucketUser($user, $key, $attr, $salt, $seed1); - $userPoint2 = $decodedVr->bucketUser($user, $key, $attr, $salt, $seed2); - - $this->assertNotEquals($userPoint1, $userPoint2); - } - - public function testSameSeedIsDeterministic() - { - $seed = 357; - $vr = ['rollout' => [ - 'variations' => [ - ['variation' => 1, 'weight' => 50000], - ['variation' => 2, 'weight' => 50000] - ] - ]]; - - $decodedVr = call_user_func(VariationOrRollout::getDecoder(), $vr); - - $ub = new LDUserBuilder('userkey'); - $user = $ub->build(); - $key = 'flag-key'; - $attr = 'key'; - $salt = 'testing123'; - $userPoint1 = $decodedVr->bucketUser($user, $key, $attr, $salt, $seed); - $userPoint2 = $decodedVr->bucketUser($user, $key, $attr, $salt, $seed); - - $this->assertEquals($userPoint1, $userPoint2); - } -} diff --git a/tests/Integrations/FileDataFeatureRequesterTest.php b/tests/Integrations/FileDataFeatureRequesterTest.php index 0d87f1fb..75da1602 100644 --- a/tests/Integrations/FileDataFeatureRequesterTest.php +++ b/tests/Integrations/FileDataFeatureRequesterTest.php @@ -2,10 +2,9 @@ namespace LaunchDarkly\Tests\Integrations; -use LaunchDarkly\Impl\Events\EventFactory; +use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Integrations\Files; -use LaunchDarkly\LDUser; -use LaunchDarkly\Tests\MockFeatureRequester; +use LaunchDarkly\LDContext; use PHPUnit\Framework\TestCase; class FileDataFeatureRequesterTest extends TestCase @@ -33,13 +32,11 @@ public function testLoadsMultipleFiles() public function testShortcutFlagCanBeEvaluated() { - $requester = new MockFeatureRequester(); - $eventFactory = new EventFactory(false); - $fr = Files::featureRequester("./tests/filedata/all-properties.json"); $flag2 = $fr->getFeature("flag2"); $this->assertEquals("flag2", $flag2->getKey()); - $result = $flag2->evaluate(new LDUser("user"), $requester, $eventFactory); + $evaluator = new Evaluator($fr); + $result = $evaluator->evaluate($flag2, LDContext::create("user"), null); $this->assertEquals("value2", $result->getDetail()->getValue()); } } diff --git a/tests/Integrations/TestDataTest.php b/tests/Integrations/TestDataTest.php index 066eeb7c..069f49a9 100644 --- a/tests/Integrations/TestDataTest.php +++ b/tests/Integrations/TestDataTest.php @@ -5,7 +5,7 @@ use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\Integrations\TestData; use LaunchDarkly\LDClient; -use LaunchDarkly\LDUserBuilder; +use LaunchDarkly\LDContext; use LaunchDarkly\Tests; use PHPUnit\Framework\TestCase; @@ -46,363 +46,336 @@ public function testCanReferenceSameFlag() $this->assertEquals(['red','blue'], $flag->build(0)['variations']); } - public function provideFlagConfig() + public function defaultFlagProps() { - $td = new TestData(); return [ - 'flag has fallthrough variation by default' => [ - [ - 'on' => true, - 'fallthrough' => ['variation' => 0] - ], - $td->flag('test-flag-1')->build(0) + "key" => "flagkey", + "version" => 1, + "on" => true, + "prerequisites" => [], + "targets" => [], + "contextTargets" => [], + "rules" => [], + "salt" => "", + "variations" => [true, false], + "offVariation" => 1, + "fallthrough" => ["variation" => 0], + "deleted" => false + ]; + } + + public function flagConfigParameterizedTestParams() + { + return [ + 'defaults' => [ + [], + fn ($f) => $f + ], + 'changing default flag to boolean flag has no effect' => [ + [], + fn ($f) => $f->booleanFlag() + ], + 'non-boolean flag can be changed to boolean flag' => [ + [], + fn ($f) => $f->variations('a', 'b')->booleanFlag() ], - 'explicitly changing empty flag to boolean flag has no effect compared to default flag' => [ + 'flag can be turned off' => [ [ - 'on' => true, - 'fallthrough' => ['variation' => 0] + 'on' => false ], - $td->flag('test-flag-2')->booleanFlag()->build(0) + fn ($f) => $f->on(false) + ], + 'flag can be turned on' => [ + [], + fn ($f) => $f->on(false)->on(true) ], - 'explicitly changing empty flag to be on has no effect compared to default flag' => [ + 'set false boolean variation for all' => [ [ - 'on' => true, - 'fallthrough' => ['variation' => 0] + 'fallthrough' => ['variation' => 1], ], - $td->flag('test-flag-3')->on(true)->build(0) + fn ($f) => $f->variationForAll(false) ], - 'flag can be turned off' => [ + 'set true boolean variation for all' => [ [ - 'on' => false, - 'fallthrough' => ['variation' => 0] + 'variations' => [true, false], + 'fallthrough' => ['variation' => 0], ], - $td->flag('test-flag-4')->on(false)->build(0) + fn ($f) => $f->variationForAll(true) ], - 'flag can set false boolean variation for all users' => [ + 'set false boolean variation for all users' => [ [ - 'on' => true, - 'offVariation' => 1, - 'variations' => [true, false], 'fallthrough' => ['variation' => 1], ], - $td->flag('test-flag-5')->variationForAllUsers(false)->build(0) + fn ($f) => $f->variationForAllUsers(false) ], - 'flag can set true boolean variation for all users' => [ + 'set true boolean variation for all users' => [ [ - 'on' => true, - 'offVariation' => 1, 'variations' => [true, false], 'fallthrough' => ['variation' => 0], ], - $td->flag('test-flag-6')->variationForAllUsers(true)->build(0) + fn ($f) => $f->variationForAllUsers(true) + ], + 'set variation index for all' => [ + [ + 'fallthrough' => ['variation' => 2], + 'variations' => ['a', 'b', 'c'] + ], + fn ($f) => $f->variations('a', 'b', 'c')->variationForAll(2) + ], + 'set variation index for all users' => [ + [ + 'fallthrough' => ['variation' => 2], + 'variations' => ['a', 'b', 'c'] + ], + fn ($f) => $f->variations('a', 'b', 'c')->variationForAllUsers(2) + ], + 'set fallthrough variation boolean' => [ + [ + 'fallthrough' => ['variation' => 1] + ], + fn ($f) => $f->fallthroughVariation(false) + ], + 'set fallthrough variation index' => [ + [ + 'variations' => ['a', 'b', 'c'], + 'fallthrough' => ['variation' => 2] + ], + fn ($f) => $f->variations('a', 'b', 'c')->fallthroughVariation(2) + ], + 'set off variation boolean' => [ + [ + 'offVariation' => 0 + ], + fn ($f) => $f->offVariation(true) + ], + 'set off variation index' => [ + [ + 'variations' => ['a', 'b', 'c'], + 'offVariation' => 2 + ], + fn ($f) => $f->variations('a', 'b', 'c')->offVariation(2) + ], + 'set context targets as boolean' => [ + [ + 'targets' => [ + ['variation' => 0, 'values' => ['key1', 'key2']], + ], + 'contextTargets' => [ + ['contextKind' => 'user', 'variation' => 0, 'values' => []], + ['contextKind' => 'kind1', 'variation' => 0, 'values' => ['key3', 'key4']], + ['contextKind' => 'kind1', 'variation' => 1, 'values' => ['key5', 'key6']], + ] + ], + fn ($f) => $f->variationForKey('user', 'key1', true) + ->variationForKey('user', 'key2', true) + ->variationForKey('kind1', 'key3', true) + ->variationForKey('kind1', 'key5', false) + ->variationForKey('kind1', 'key4', true) + ->variationForKey('kind1', 'key6', false) + ], + 'set context targets as variation index' => [ + [ + 'variations' => ['a', 'b'], + 'targets' => [ + ['variation' => 0, 'values' => ['key1', 'key2']], + ], + 'contextTargets' => [ + ['contextKind' => 'user', 'variation' => 0, 'values' => []], + ['contextKind' => 'kind1', 'variation' => 0, 'values' => ['key3', 'key4']], + ['contextKind' => 'kind1', 'variation' => 1, 'values' => ['key5', 'key6']], + ] + ], + fn ($f) => $f->variations('a', 'b') + ->variationForKey('user', 'key1', 0) + ->variationForKey('user', 'key2', 0) + ->variationForKey('kind1', 'key3', 0) + ->variationForKey('kind1', 'key5', 1) + ->variationForKey('kind1', 'key4', 0) + ->variationForKey('kind1', 'key6', 1) + ], + 'replace existing context target key' => [ + [ + 'contextTargets' => [ + ['contextKind' => 'kind1', 'variation' => 0, 'values' => ['key1', 'key2']], + ['contextKind' => 'kind1', 'variation' => 1, 'values' => ['key3']] + ] + ], + fn ($f) => $f->variationForKey('kind1', 'key1', 0) + ->variationForKey('kind1', 'key2', 1) + ->variationForKey('kind1', 'key3', 1) + ->variationForKey('kind1', 'key2', 0) + ], + 'ignore target for nonexistent variation' => [ + [ + 'variations' => ['a', 'b'], + 'contextTargets' => [ + ['contextKind' => 'kind1', 'variation' => 1, 'values' => ['key1']], + ] + ], + fn ($f) => $f->variations('a', 'b') + ->variationForKey('kind1', 'key1', 1) + ->variationForKey('kind1', 'key2', 3) + ], + 'variationForUser is shortcut for variationForKey' => [ + [ + 'targets' => [ + ['variation' => 0, 'values' => ['key1']] + ], + 'contextTargets' => [ + ['contextKind' => 'user', 'variation' => 0, 'values' => []] + ] + ], + fn ($f) => $f->variationForUser('key1', true) + ], + 'clear targets' => [ + [], + fn ($f) => $f->variationForKey('kind1', 'key1', 0) + ->clearTargets() + ], + 'clearUserTargets is synonym for clearTargets' => [ + [], + fn ($f) => $f->variationForKey('kind1', 'key1', 0) + ->clearUserTargets() + ], + 'ifMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'kind1', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => false] + ] + ] + ] + ], + fn ($f) => $f->ifMatchContext('kind1', 'attr1', 'a', 'b')->thenReturn(1) + ], + 'ifNotMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'kind1', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => true] + ] + ] + ] + ], + fn ($f) => $f->ifNotMatchContext('kind1', 'attr1', 'a', 'b')->thenReturn(1) + ], + 'ifMatch is shortcut for ifMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'user', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => false] + ] + ] + ] + ], + fn ($f) => $f->ifMatch('attr1', 'a', 'b')->thenReturn(1) + ], + 'ifNotMatch is shortcut for ifNotMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'user', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => true] + ] + ] + ] + ], + fn ($f) => $f->ifNotMatch('attr1', 'a', 'b')->thenReturn(1) + ], + 'andMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'kind1', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => false], + ['contextKind' => 'kind1', 'attribute' => 'attr2', 'op' => 'in', 'values' => ['c', 'd'], 'negate' => false] + ] + ] + ] + ], + fn ($f) => $f->ifMatchContext('kind1', 'attr1', 'a', 'b') + ->andMatchContext('kind1', 'attr2', 'c', 'd')->thenReturn(1) + ], + 'andNotMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'kind1', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => false], + ['contextKind' => 'kind1', 'attribute' => 'attr2', 'op' => 'in', 'values' => ['c', 'd'], 'negate' => true] + ] + ] + ] + ], + fn ($f) => $f->ifMatchContext('kind1', 'attr1', 'a', 'b') + ->andNotMatchContext('kind1', 'attr2', 'c', 'd')->thenReturn(1) + ], + 'andMatch is shortcut for andMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'kind1', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => false], + ['contextKind' => 'user', 'attribute' => 'attr2', 'op' => 'in', 'values' => ['c', 'd'], 'negate' => false] + ] + ] + ] + ], + fn ($f) => $f->ifMatchContext('kind1', 'attr1', 'a', 'b') + ->andMatch('attr2', 'c', 'd')->thenReturn(1) ], + 'andNotMatch is shortcut for andNotMatchContext' => [ + [ + 'rules' => [ + [ + 'variation' => 1, + 'id' => 'rule0', + 'clauses' => [ + ['contextKind' => 'kind1', 'attribute' => 'attr1', 'op' => 'in', 'values' => ['a', 'b'], 'negate' => false], + ['contextKind' => 'user', 'attribute' => 'attr2', 'op' => 'in', 'values' => ['c', 'd'], 'negate' => true] + ] + ] + ] + ], + fn ($f) => $f->ifMatchContext('kind1', 'attr1', 'a', 'b') + ->andNotMatch('attr2', 'c', 'd')->thenReturn(1) + ], + 'clearRules' => [ + [], + fn ($f) => $f->ifMatch('kind1', 'attr1', 'a')->thenReturn(1)->clearRules() + ] ]; } /** - * @dataProvider provideFlagConfig + * @dataProvider flagConfigParameterizedTestParams */ - public function testFlagConfigSimpleBoolean($expected, $actual) - { - foreach (array_keys($expected) as $key) { - $this->assertEquals($expected[$key], $actual[$key]); - } - } - - - public function testFlagBuilderBooleanConfigMethodsForcesFlagToBeBoolean() - { - $td = new TestData(); - $overwriteBoolFlag1 = $td->flag('test-flag')->variations(1, 2)->booleanFlag()->build(0); - $this->assertEquals([true, false], $overwriteBoolFlag1['variations']); - $this->assertEquals(true, $overwriteBoolFlag1['on']); - $this->assertEquals(1, $overwriteBoolFlag1['offVariation']); - $this->assertEquals(['variation' => 0], $overwriteBoolFlag1['fallthrough']); - - $overwriteBoolFlag2 = $td->flag('test-flag')->variations(true, 2)->booleanFlag()->build(0); - $this->assertEquals([true, false], $overwriteBoolFlag2['variations']); - $this->assertEquals(true, $overwriteBoolFlag2['on']); - $this->assertEquals(1, $overwriteBoolFlag2['offVariation']); - $this->assertEquals(['variation' => 0], $overwriteBoolFlag2['fallthrough']); - - $boolFlag = $td->flag('test-flag')->booleanFlag()->build(0); - $this->assertEquals([true, false], $boolFlag['variations']); - $this->assertEquals(true, $boolFlag['on']); - $this->assertEquals(1, $boolFlag['offVariation']); - $this->assertEquals(['variation' => 0], $boolFlag['fallthrough']); - } - - public function testFlagConfigStringVariations() - { - $td = new TestData(); - $stringVariationFlag = $td->flag('test-flag') - ->variations('red', 'green', 'blue') - ->offVariation(0) - ->fallthroughVariation(2) - ->build(0); - $this->assertEquals(['red', 'green', 'blue'], $stringVariationFlag['variations']); - $this->assertEquals(true, $stringVariationFlag['on']); - $this->assertEquals(0, $stringVariationFlag['offVariation']); - $this->assertEquals(['variation' => 2], $stringVariationFlag['fallthrough']); - } - - public function testUserTargetsCanSetSameVariationForDistinctUsers() - { - $td = new TestData(); - $flagBool1 = $td->flag('test-flag-1') - ->variationForUser("a", true) - ->variationForUser("b", true) - ->build(0); - $this->assertEquals(true, $flagBool1['on']); - $this->assertEquals([true, false], $flagBool1['variations']); - $this->assertEquals(1, $flagBool1['offVariation']); - $this->assertEquals(['variation' => 0], $flagBool1['fallthrough']); - $expectedTargets = [ - ['variation' => 0, 'values' => ["a", "b"]], - ]; - $this->assertEquals($expectedTargets, $flagBool1['targets']); - } - - public function testUserTargetsWillNotDuplicateSameUser() - { - $td = new TestData(); - - $flagBool2 = $td->flag('test-flag-2') - ->variationForUser("a", true) - ->variationForUser("a", true) - ->build(0); - $this->assertEquals(true, $flagBool2['on']); - $this->assertEquals([true, false], $flagBool2['variations']); - $this->assertEquals(1, $flagBool2['offVariation']); - $this->assertEquals(['variation' => 0], $flagBool2['fallthrough']); - $expectedTargets = [ - ['variation' => 0, 'values' => ["a"]], - ]; - $this->assertEquals($expectedTargets, $flagBool2['targets']); - } - - public function testUserTargetsCanSetDistinctVariationsForDistinctUsers() - { - $td = new TestData(); - - $flagBool3 = $td->flag('test-flag-3') - ->variationForUser("a", false) - ->variationForUser("b", true) - ->variationForUser("c", false) - ->build(0); - $this->assertEquals(true, $flagBool3['on']); - $this->assertEquals([true, false], $flagBool3['variations']); - $this->assertEquals(1, $flagBool3['offVariation']); - $this->assertEquals(['variation' => 0], $flagBool3['fallthrough']); - $expectedTargets = [ - ['variation' => 0, 'values' => ["b"]], - ['variation' => 1, 'values' => ["a", "c"]], - ]; - $this->assertEquals($expectedTargets, $flagBool3['targets']); - } - - public function testUserTargetsCanModifyVariationForSpecificUser() - { - $td = new TestData(); - $flagBool4 = $td->flag('test-flag-3') - ->variationForUser("a", true) - ->variationForUser("b", true) - ->variationForUser("a", false) - ->build(0); - $this->assertEquals(true, $flagBool4['on']); - $this->assertEquals([true, false], $flagBool4['variations']); - $this->assertEquals(1, $flagBool4['offVariation']); - $this->assertEquals(['variation' => 0], $flagBool4['fallthrough']); - $expectedTargets = [ - ['variation' => 0, 'values' => ["b"]], - ['variation' => 1, 'values' => ["a"]], - ]; - $this->assertEquals($expectedTargets, $flagBool4['targets']); - } - - public function testUserTargetsCanSetVariationsWithFallbackValues() - { - $td = new TestData(); - $flagString1 = $td->flag('test-flag-4') - ->variations('red', 'green', 'blue') - ->offVariation(0) - ->fallthroughVariation(2) - ->variationForUser("a", 2) - ->variationForUser("b", 2) - ->build(0); - $this->assertEquals(['red', 'green', 'blue'], $flagString1['variations']); - $this->assertEquals(true, $flagString1['on']); - $this->assertEquals(0, $flagString1['offVariation']); - $this->assertEquals(['variation' => 2], $flagString1['fallthrough']); - $expectedTargets = [ - ['variation' => 2, 'values' => ["a", "b"]], - ]; - $this->assertEquals($expectedTargets, $flagString1['targets']); - } - - public function testUserTargetsCanSetVariationsWithFallbackValuesWithDistinctUserVariations() - { - $td = new TestData(); - - $flagString2 = $td->flag('test-flag-5') - ->variations('red', 'green', 'blue') - ->offVariation(0) - ->fallthroughVariation(2) - ->variationForUser("a", 2) - ->variationForUser("b", 1) - ->variationForUser("c", 2) - ->build(0); - $this->assertEquals(['red', 'green', 'blue'], $flagString2['variations']); - $this->assertEquals(true, $flagString2['on']); - $this->assertEquals(0, $flagString2['offVariation']); - $this->assertEquals(['variation' => 2], $flagString2['fallthrough']); - $expectedTargets = [ - ['variation' => 1, 'values' => ["b"]], - ['variation' => 2, 'values' => ["a", "c"]], - ]; - $this->assertEquals($expectedTargets, $flagString2['targets']); - } - - public function testUserTargetsWillIgnoreSettingNonexistentUserVariation() - { - $td = new TestData(); - - $flagString2 = $td->flag('test-flag-5') - ->variations('red', 'green', 'blue') - ->variationForUser("a", 1) - ->variationForUser("b", 4) - ->build(0); - $this->assertEquals(['red', 'green', 'blue'], $flagString2['variations']); - $this->assertEquals(true, $flagString2['on']); - $expectedTargets = [ - ['variation' => 1, 'values' => ["a"]], - ]; - $this->assertEquals($expectedTargets, $flagString2['targets']); - } - - public function testFlagbuilderCanSetValueForAllUsers() - { - $jsonString1 = " - { - \"boolField\": true, - \"stringField\": \"some val\", - \"intField\": 1, - \"arrayField\": [\"cat\", \"dog\", \"fish\" ], - \"objectField\": {\"animal\": \"dog\" } - } - "; - $testObject = [ - "boolField" => true, - "stringField" => "some val", - "intField" => 1, - "arrayField" => ["cat", "dog", "fish" ], - "objectField" => [ "animal" => "dog" ] - ]; - - $td = new TestData(); - $flagFromJSONString = $td->flag('test-flag'); - $testObjFromStr = json_decode($jsonString1); - $flagFromJSONString->valueForAllUsers($testObjFromStr); - $this->assertEquals([$testObject], $flagFromJSONString->build(0)['variations']); - - $flagBoolean = $td->flag('test-flag'); - $flagBoolean->valueForAllUsers(false); - $this->assertEquals([false], $flagBoolean->build(0)['variations']); - - $flagInt = $td->flag('test-flag'); - $flagInt->valueForAllUsers(4); - $this->assertEquals([4], $flagInt->build(0)['variations']); - - $flagArray = $td->flag('test-flag'); - $flagArray->valueForAllUsers(['cat', 'dog', 'fish']); - $this->assertEquals([ ['cat', 'dog', 'fish'] ], $flagArray->build(0)['variations']); - - $flagAssociatedArray = $td->flag('test-flag'); - $flagAssociatedArray->valueForAllUsers(['animal' => 'dog', 'legs' => 4]); - $this->assertEquals([ ['animal' => 'dog', 'legs' => 4] ], $flagAssociatedArray->build(0)['variations']); - - $flagNull = $td->flag('test-flag'); - $flagNull->valueForAllUsers(null); - $this->assertEquals([null], $flagNull->build(0)['variations']); - - $flagObject = $td->flag('test-flag'); - $flagObject->valueForAllUsers((object) ['animal' => 'dog', 'legs' => 4]); - $this->assertEquals([['animal' => 'dog', 'legs' => 4]], $flagObject->build(0)['variations']); - } - - public function testSetsVariations() - { - $td = new TestData(); - $flag = $td->flag('new-flag')->variations('red', 'green', 'blue'); - $this->assertEquals(['red', 'green', 'blue'], $flag->build(0)['variations']); - - $flag2 = $td->flag('new-flag-2')->variations(['red', 'green', 'blue']); - $this->assertEquals([['red', 'green', 'blue']], $flag2->build(0)['variations']); - - $flag3 = $td->flag('new-flag-3')->variations(['red', 'green', 'blue'], ['cat', 'dog', 'fish']); - $this->assertEquals([['red', 'green', 'blue'], ['cat', 'dog', 'fish']], $flag3->build(0)['variations']); - - $flag4 = $td->flag('new-flag-4')->variations([['red', 'green', 'blue']]); - $this->assertEquals([['red', 'green', 'blue']], $flag4->build(0)['variations'][0]); - } - - public function testFlagBuilderCanSetFallthroughVariation() - { - $td = new TestData(); - $flag = $td->flag('test-flag'); - $flag->fallthroughVariation(2); - - $this->assertEquals(['variation' => 2], $flag->build(0)['fallthrough']); - } - - public function testFlagBuilderClearUserTargets() - { - $td = new TestData(); - $flag = $td->flag('test-flag') - ->variationForUser('user-1', 0) - ->variationForUser('user-2', 1) - ->clearUserTargets() - ->build(0); - $this->assertEquals([], $flag['targets']); - } - - - public function testFlagBuilderDefaultsToBooleanFlag() + public function testFlagConfigParameterized($expected, $builderActions) { $td = new TestData(); - $flag = $td->flag('empty-flag'); - $this->assertEquals([true, false], $flag->build(0)['variations']); - $this->assertEquals(['variation' => 0], $flag->build(0)['fallthrough']); - $this->assertEquals(1, $flag->build(0)['offVariation']); - } - - public function testFlagbuilderCanTurnFlagOff() - { - $td = new TestData(); - $flag = $td->flag('test-flag'); - $flag->on(false); - - $this->assertEquals(false, $flag->build(0)['on']); + $flagBuilder = $builderActions($td->flag("flagkey")); + $actual = $flagBuilder->build(1); + $allExpected = array_merge($this->defaultFlagProps(), $expected); + $this->assertEquals($allExpected, $actual); } - public function testFlagbuilderCanSetVariationWhenTargetingIsOff() - { - $td = new TestData(); - $flag = $td->flag('test-flag')->on(false); - $this->assertEquals(false, $flag->build(0)['on']); - $this->assertEquals([true,false], $flag->build(0)['variations']); - $flag->variations('dog', 'cat'); - $this->assertEquals(['dog','cat'], $flag->build(0)['variations']); - } - - public function testFlagbuilderCanSetVariationForAllUsers() - { - $td = new TestData(); - $flag = $td->flag('test-flag')->variationForAllUsers(true)->build(0); - $this->assertEquals(['variation' => 0], $flag['fallthrough']); - } - - public function testCanSetAndGetFeatureFlag() { $key = 'test-flag'; @@ -418,7 +391,7 @@ public function testCanSetAndGetFeatureFlag() 'variations' => [true, false], /* Required FeatureFlag fields */ - 'salt' => null, + 'salt' => '', 'prerequisites' => [], ]; $expectedFeatureFlag = FeatureFlag::decode($expectedFlagJson); @@ -445,7 +418,7 @@ public function testCanSetAndResetFeatureFlag() 'variations' => ['red', 'amber', 'green'], /* Required FeatureFlag fields */ - 'salt' => null, + 'salt' => '', 'prerequisites' => [], ]; $expectedUpdatedFeatureFlag = FeatureFlag::decode($expectedUpdatedFlagJson); @@ -461,37 +434,6 @@ public function testCanSetAndResetFeatureFlag() $this->assertEquals($expectedUpdatedFeatureFlag, $featureFlag); } - public function testFlagBuilderCanAddAndBuildRules() - { - $td = new TestData(); - $flag = $td->flag("flag") - ->ifMatch("name", "Patsy", "Edina") - ->andNotMatch("country", "gb") - ->thenReturn(true); - $builtFlag = $flag->build(0); - $expectedRule = [ - [ - "id" => "rule0", - "variation" => 0, - "clauses" => [ - [ - "attribute" => "name", - "op" => 'in', - "values" => ["Patsy", "Edina"], - "negate" => false, - ], - [ - "attribute" => "country", - "op" => 'in', - "values" => ["gb"], - "negate" => true, - ] - ] - ] - ]; - $this->assertEquals($expectedRule, $builtFlag['rules']); - } - public function testUsingTestDataInClientEvaluations() { $td = new TestData(); @@ -509,15 +451,13 @@ public function testUsingTestDataInClientEvaluations() ]; $client = new LDClient("someKey", $options); - $userBuilder = new LDUserBuilder("someKey"); - - $userBuilder->firstName("Janet")->lastName("Cline"); - $this->assertFalse($client->variation("flag", $userBuilder->build())); + $context1 = LDContext::builder("x")->set("firstName", "Janet")->set("lastName", "Cline")->build(); + $this->assertFalse($client->variation("flag", $context1)); - $userBuilder->firstName("Patsy")->lastName("Cline"); - $this->assertFalse($client->variation("flag", $userBuilder->build())); + $context2 = LDContext::builder("x")->set("firstName", "Patsy")->set("lastName", "Cline")->build(); + $this->assertFalse($client->variation("flag", $context2)); - $userBuilder->firstName("Patsy")->lastName("Smith"); - $this->assertTrue($client->variation("flag", $userBuilder->build())); + $context3 = LDContext::builder("x")->set("firstName", "Patsy")->set("lastName", "Smith")->build(); + $this->assertTrue($client->variation("flag", $context3)); } } diff --git a/tests/LDClientTest.php b/tests/LDClientTest.php index 4679cae5..36a236bb 100644 --- a/tests/LDClientTest.php +++ b/tests/LDClientTest.php @@ -6,12 +6,20 @@ use LaunchDarkly\EvaluationReason; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\LDClient; +use LaunchDarkly\LDContext; use LaunchDarkly\LDUser; use LaunchDarkly\LDUserBuilder; use Psr\Log\LoggerInterface; class LDClientTest extends \PHPUnit\Framework\TestCase { + private MockFeatureRequester $mockRequester; + + public function setUp(): void + { + $this->mockRequester = new MockFeatureRequester(); + } + public function testDefaultCtor() { $this->assertInstanceOf(LDClient::class, new LDClient("BOGUS_SDK_KEY")); @@ -19,66 +27,52 @@ public function testDefaultCtor() private function makeOffFlagWithValue($key, $value) { - $flagJson = [ - 'key' => $key, - 'version' => 100, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['FALLTHROUGH', $value], - 'salt' => '' - ]; - return FeatureFlag::decode($flagJson); + return ModelBuilders::flagBuilder($key) + ->version(100) + ->on(false) + ->variations('FALLTHROUGH', $value) + ->fallthroughVariation(0) + ->offVariation(1) + ->build(); } private function makeFlagThatEvaluatesToNull($key) { - $flagJson = [ - 'key' => $key, - 'version' => 100, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => null, - 'fallthrough' => ['variation' => 0], - 'variations' => ['none'], - 'salt' => '' - ]; - return FeatureFlag::decode($flagJson); + return ModelBuilders::flagBuilder($key) + ->version(100) + ->on(false) + ->variations('none') + ->fallthroughVariation(0) + ->build(); } private function makeClient($overrideOptions = []) { $options = [ - 'feature_requester_class' => MockFeatureRequester::class, + 'feature_requester' => $this->mockRequester, 'event_processor' => new MockEventProcessor() ]; + $x = array_merge($options, $overrideOptions); return new LDClient("someKey", array_merge($options, $overrideOptions)); } public function testVariationReturnsFlagValue() { $flag = $this->makeOffFlagWithValue('feature', 'value'); - MockFeatureRequester::$flags = ['feature' => $flag]; + $this->mockRequester->addFlag($flag); $client = $this->makeClient(); - $value = $client->variation('feature', new LDUser('userkey'), 'default'); + $value = $client->variation('feature', LDContext::create('userkey'), 'default'); $this->assertEquals('value', $value); } public function testVariationDetailReturnsFlagValue() { $flag = $this->makeOffFlagWithValue('feature', 'value'); - MockFeatureRequester::$flags = ['feature' => $flag]; + $this->mockRequester->addFlag($flag); $client = $this->makeClient(); - $detail = $client->variationDetail('feature', new LDUser('userkey'), 'default'); + $detail = $client->variationDetail('feature', LDContext::create('userkey'), 'default'); $this->assertEquals('value', $detail->getValue()); $this->assertFalse($detail->isDefaultValue()); $this->assertEquals(1, $detail->getVariationIndex()); @@ -88,20 +82,20 @@ public function testVariationDetailReturnsFlagValue() public function testVariationReturnsDefaultIfFlagEvaluatesToNull() { $flag = $this->makeFlagThatEvaluatesToNull('feature'); - MockFeatureRequester::$flags = ['feature' => $flag]; + $this->mockRequester->addFlag($flag); $client = $this->makeClient(); - $value = $client->variation('feature', new LDUser('userkey'), 'default'); + $value = $client->variation('feature', LDContext::create('userkey'), 'default'); $this->assertEquals('default', $value); } public function testVariationDetailReturnsDefaultIfFlagEvaluatesToNull() { $flag = $this->makeFlagThatEvaluatesToNull('feature'); - MockFeatureRequester::$flags = ['feature' => $flag]; + $this->mockRequester->addFlag($flag); $client = $this->makeClient(); - $detail = $client->variationDetail('feature', new LDUser('userkey'), 'default'); + $detail = $client->variationDetail('feature', LDContext::create('userkey'), 'default'); $this->assertEquals('default', $detail->getValue()); $this->assertTrue($detail->isDefaultValue()); $this->assertNull($detail->getVariationIndex()); @@ -110,18 +104,18 @@ public function testVariationDetailReturnsDefaultIfFlagEvaluatesToNull() public function testVariationReturnsDefaultForUnknownFlag() { - MockFeatureRequester::$flags = []; + $this->mockRequester->expectQueryForUnknownFlag('foo'); $client = $this->makeClient(); - $this->assertEquals('argdef', $client->variation('foo', new LDUser('userkey'), 'argdef')); + $this->assertEquals('argdef', $client->variation('foo', LDContext::create('userkey'), 'argdef')); } public function testVariationDetailReturnsDefaultForUnknownFlag() { - MockFeatureRequester::$flags = []; + $this->mockRequester->expectQueryForUnknownFlag('foo'); $client = $this->makeClient(); - $detail = $client->variationDetail('foo', new LDUser('userkey'), 'default'); + $detail = $client->variationDetail('foo', LDContext::create('userkey'), 'default'); $this->assertEquals('default', $detail->getValue()); $this->assertTrue($detail->isDefaultValue()); $this->assertNull($detail->getVariationIndex()); @@ -130,21 +124,41 @@ public function testVariationDetailReturnsDefaultForUnknownFlag() public function testVariationReturnsDefaultFromConfigurationForUnknownFlag() { - MockFeatureRequester::$flags = []; + $this->mockRequester->expectQueryForUnknownFlag('foo'); $client = $this->makeClient(['defaults' => ['foo' => 'fromarray']]); - $this->assertEquals('fromarray', $client->variation('foo', new LDUser('userkey'), 'argdef')); + $this->assertEquals('fromarray', $client->variation('foo', LDContext::create('userkey'), 'argdef')); + } + + public function testVariationPassesContextToEvaluator() + { + $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clause('kind1', 'attr1', 'in', 'value1')); + $this->mockRequester->addFlag($flag); + $client = $this->makeClient(); + + $context = LDContext::builder('key')->kind('kind1')->set('attr1', 'value1')->build(); + $this->assertTrue($client->variation($flag->getKey(), $context, false)); + } + + public function testVariationPassesUserToEvaluator() + { + $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clause('user', 'attr1', 'in', 'value1')); + $this->mockRequester->addFlag($flag); + $client = $this->makeClient(); + + $user = (new LDUserBuilder('key'))->customAttribute('attr1', 'value1')->build(); + $this->assertTrue($client->variation($flag->getKey(), $user, false)); } public function testVariationSendsEvent() { $flag = $this->makeOffFlagWithValue('flagkey', 'flagvalue'); - MockFeatureRequester::$flags = ['flagkey' => $flag]; + $this->mockRequester->addFlag($flag); $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); - $user = new LDUser('userkey'); - $client->variation('flagkey', new LDUser('userkey'), 'default'); + $context = LDContext::create('userkey'); + $client->variation('flagkey', $context, 'default'); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; @@ -153,7 +167,7 @@ public function testVariationSendsEvent() $this->assertEquals($flag->getVersion(), $event['version']); $this->assertEquals('flagvalue', $event['value']); $this->assertEquals(1, $event['variation']); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals('default', $event['default']); $this->assertFalse(isset($event['trackEvents'])); $this->assertFalse(isset($event['reason'])); @@ -162,12 +176,12 @@ public function testVariationSendsEvent() public function testVariationDetailSendsEvent() { $flag = $this->makeOffFlagWithValue('flagkey', 'flagvalue'); - MockFeatureRequester::$flags = ['flagkey' => $flag]; + $this->mockRequester->addFlag($flag); $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); - $user = new LDUser('userkey'); - $client->variationDetail('flagkey', $user, 'default'); + $context = LDContext::create('userkey'); + $client->variationDetail('flagkey', $context, 'default'); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; @@ -176,7 +190,7 @@ public function testVariationDetailSendsEvent() $this->assertEquals($flag->getVersion(), $event['version']); $this->assertEquals('flagvalue', $event['value']); $this->assertEquals(1, $event['variation']); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals('default', $event['default']); $this->assertFalse(isset($event['trackEvents'])); $this->assertEquals(['kind' => 'OFF'], $event['reason']); @@ -184,41 +198,27 @@ public function testVariationDetailSendsEvent() public function testVariationForcesTrackingWhenMatchedRuleHasTrackEventsSet() { - $flagJson = [ - 'key' => 'flagkey', - 'version' => 100, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [ - [ - 'clauses' => [ - [ - 'attribute' => 'key', - 'op' => 'in', - 'values' => ['userkey'], - 'negate' => false - ] - ], - 'id' => 'rule-id', - 'variation' => 1, - 'trackEvents' => true - ] - ], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fellthrough', 'flagvalue'], - 'salt' => '' - ]; - $flag = FeatureFlag::decode($flagJson); - - MockFeatureRequester::$flags = ['flagkey' => $flag]; + $flag = ModelBuilders::flagBuilder('flagkey') + ->version(100) + ->variations('fallthrough', 'flagvalue') + ->on(true) + ->fallthroughVariation(0) + ->rule( + ModelBuilders::flagRuleBuilder() + ->id('rule-id') + ->variation(1) + ->trackEvents(true) + ->clause(ModelBuilders::clause(null, 'key', 'in', 'userkey')) + ->build() + ) + ->build(); + + $this->mockRequester->addFlag($flag); $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); - $user = new LDUser('userkey'); - $client->variation('flagkey', new LDUser('userkey'), 'default'); + $context = LDContext::create('userkey'); + $client->variation('flagkey', $context, 'default'); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; @@ -227,7 +227,7 @@ public function testVariationForcesTrackingWhenMatchedRuleHasTrackEventsSet() $this->assertEquals($flag->getVersion(), $event['version']); $this->assertEquals('flagvalue', $event['value']); $this->assertEquals(1, $event['variation']); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals('default', $event['default']); $this->assertTrue($event['trackEvents']); $this->assertEquals(['kind' => 'RULE_MATCH', 'ruleIndex' => 0, 'ruleId' => 'rule-id'], $event['reason']); @@ -235,28 +235,21 @@ public function testVariationForcesTrackingWhenMatchedRuleHasTrackEventsSet() public function testVariationForcesTrackingForFallthroughWhenTrackEventsFallthroughIsSet() { - $flagJson = [ - 'key' => 'flagkey', - 'version' => 100, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fellthrough', 'flagvalue'], - 'salt' => '', - 'trackEventsFallthrough' => true - ]; - $flag = FeatureFlag::decode($flagJson); + $flag = ModelBuilders::flagBuilder('flagkey') + ->version(100) + ->variations('fellthrough', 'flagvalue') + ->on(true) + ->offVariation(1) + ->fallthroughVariation(0) + ->trackEventsFallthrough(true) + ->build(); - MockFeatureRequester::$flags = ['flagkey' => $flag]; + $this->mockRequester->addFlag($flag); $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); - $user = new LDUser('userkey'); - $client->variation('flagkey', new LDUser('userkey'), 'default'); + $context = LDContext::create('userkey'); + $client->variation('flagkey', $context, 'default'); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; @@ -265,7 +258,7 @@ public function testVariationForcesTrackingForFallthroughWhenTrackEventsFallthro $this->assertEquals($flag->getVersion(), $event['version']); $this->assertEquals('fellthrough', $event['value']); $this->assertEquals(0, $event['variation']); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals('default', $event['default']); $this->assertTrue($event['trackEvents']); $this->assertEquals(['kind' => 'FALLTHROUGH'], $event['reason']); @@ -273,12 +266,12 @@ public function testVariationForcesTrackingForFallthroughWhenTrackEventsFallthro public function testVariationSendsEventForUnknownFlag() { - MockFeatureRequester::$flags = []; + $this->mockRequester->expectQueryForUnknownFlag('flagkey'); $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); - $user = new LDUser('userkey'); - $client->variation('flagkey', new LDUser('userkey'), 'default'); + $context = LDContext::create('userkey'); + $client->variation('flagkey', $context, 'default'); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; @@ -287,19 +280,19 @@ public function testVariationSendsEventForUnknownFlag() $this->assertFalse(isset($event['version'])); $this->assertEquals('default', $event['value']); $this->assertFalse(isset($event['variation'])); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals('default', $event['default']); $this->assertFalse(isset($event['reason'])); } public function testVariationDetailSendsEventForUnknownFlag() { - MockFeatureRequester::$flags = []; + $this->mockRequester->expectQueryForUnknownFlag('flagkey'); $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); - $user = new LDUser('userkey'); - $client->variationDetail('flagkey', new LDUser('userkey'), 'default'); + $context = LDContext::create('userkey'); + $client->variationDetail('flagkey', $context, 'default'); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; @@ -308,57 +301,28 @@ public function testVariationDetailSendsEventForUnknownFlag() $this->assertFalse(isset($event['version'])); $this->assertEquals('default', $event['value']); $this->assertFalse(isset($event['variation'])); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals('default', $event['default']); $this->assertEquals(['kind' => 'ERROR', 'errorKind' => 'FLAG_NOT_FOUND'], $event['reason']); } - - public function testVariationWithAnonymousUserSendsEventWithAnonymousContextKind() - { - $ep = new MockEventProcessor(); - $client = $this->makeClient(['event_processor' => $ep]); - - $flag = $this->makeOffFlagWithValue('feature', 'value'); - - $anon_builder = new LDUserBuilder("anon@email.com"); - $anon = $anon_builder->anonymous(true)->build(); - - $client->variation('feature', $anon, 'default'); - - $queue = $ep->getEvents(); - $this->assertEquals(1, sizeof($queue)); - - $event = $queue[0]; - - $this->assertEquals('anonymousUser', $event['contextKind']); - } - public function testAllFlagsStateReturnsState() { - $flagJson = [ - 'key' => 'feature', - 'version' => 100, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '', - 'trackEvents' => true, - 'debugEventsUntilDate' => 1000 - ]; - $flag = FeatureFlag::decode($flagJson); - - MockFeatureRequester::$flags = ['feature' => $flag]; + $flag = ModelBuilders::flagBuilder('feature') + ->version(100) + ->on(false) + ->variations('fall', 'off', 'on') + ->offVariation(1) + ->fallthroughVariation(0) + ->trackEvents(true) + ->debugEventsUntilDate(1000) + ->build(); + + $this->mockRequester->addFlag($flag); $client = $this->makeClient(); - $builder = new LDUserBuilder(3); - $user = $builder->build(); - $state = $client->allFlagsState($user); + $context = LDContext::create('userkey'); + $state = $client->allFlagsState($context); $this->assertTrue($state->isValid()); $this->assertEquals(['feature' => 'off'], $state->toValuesMap()); @@ -375,34 +339,29 @@ public function testAllFlagsStateReturnsState() '$valid' => true ]; $this->assertEquals($expectedState, $state->jsonSerialize()); + + $user = new LDUser('userkey'); + $state2 = $client->allFlagsState($user); + $this->assertEquals($expectedState, $state2->jsonSerialize()); } public function testAllFlagsStateHandlesExperimentationReasons() { - $flagJson = [ - 'key' => 'feature', - 'version' => 100, - 'deleted' => false, - 'on' => true, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '', - 'trackEvents' => false, - 'trackEventsFallthrough' => true, - 'debugEventsUntilDate' => 1000 - ]; - $flag = FeatureFlag::decode($flagJson); - - MockFeatureRequester::$flags = ['feature' => $flag]; + $flag = ModelBuilders::flagBuilder('feature') + ->version(100) + ->on(true) + ->variations('fall', 'off', 'on') + ->offVariation(1) + ->fallthroughVariation(0) + ->trackEventsFallthrough(true) + ->debugEventsUntilDate(1000) + ->build(); + + $this->mockRequester->addFlag($flag); $client = $this->makeClient(); - $builder = new LDUserBuilder(3); - $user = $builder->build(); - $state = $client->allFlagsState($user); + $context = LDContext::create('userkey'); + $state = $client->allFlagsState($context); $this->assertTrue($state->isValid()); $this->assertEquals(['feature' => 'fall'], $state->toValuesMap()); @@ -427,29 +386,21 @@ public function testAllFlagsStateHandlesExperimentationReasons() public function testAllFlagsStateReturnsStateWithReasons() { - $flagJson = [ - 'key' => 'feature', - 'version' => 100, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 1, - 'fallthrough' => ['variation' => 0], - 'variations' => ['fall', 'off', 'on'], - 'salt' => '', - 'trackEvents' => true, - 'debugEventsUntilDate' => 1000 - ]; - $flag = FeatureFlag::decode($flagJson); - - MockFeatureRequester::$flags = ['feature' => $flag]; + $flag = ModelBuilders::flagBuilder('feature') + ->version(100) + ->on(false) + ->variations('fall', 'off', 'on') + ->offVariation(1) + ->fallthroughVariation(0) + ->trackEvents(true) + ->debugEventsUntilDate(1000) + ->build(); + + $this->mockRequester->addFlag($flag); $client = $this->makeClient(); - $builder = new LDUserBuilder(3); - $user = $builder->build(); - $state = $client->allFlagsState($user, ['withReasons' => true]); + $context = LDContext::create('userkey'); + $state = $client->allFlagsState($context, ['withReasons' => true]); $this->assertTrue($state->isValid()); $this->assertEquals(['feature' => 'off'], $state->toValuesMap()); @@ -484,14 +435,11 @@ public function testAllFlagsStateCanFilterForClientSideFlags() $flagJson['key'] = 'client-side-2'; $flagJson['variations'] = ['value2']; $flag4 = FeatureFlag::decode($flagJson); - MockFeatureRequester::$flags = [ - $flag1->getKey() => $flag1, $flag2->getKey() => $flag2, $flag3->getKey() => $flag3, $flag4->getKey() => $flag4 - ]; + $this->mockRequester->addFlag($flag1)->addFlag($flag2)->addFlag($flag3)->addFlag($flag4); $client = $this->makeClient(); - $builder = new LDUserBuilder(3); - $user = $builder->build(); - $state = $client->allFlagsState($user, ['clientSideOnly' => true]); + $context = LDContext::create('userkey'); + $state = $client->allFlagsState($context, ['clientSideOnly' => true]); $this->assertTrue($state->isValid()); $this->assertEquals(['client-side-1' => 'value1', 'client-side-2' => 'value2'], $state->toValuesMap()); @@ -499,59 +447,32 @@ public function testAllFlagsStateCanFilterForClientSideFlags() public function testAllFlagsStateCanOmitDetailsForUntrackedFlags() { - $flag1Json = [ - 'key' => 'flag1', - 'version' => 100, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 0, - 'fallthrough' => null, - 'variations' => ['value1'], - 'salt' => '', - 'trackEvents' => false - ]; - $flag2Json = [ - 'key' => 'flag2', - 'version' => 200, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 0, - 'fallthrough' => null, - 'variations' => ['value2'], - 'salt' => '', - 'trackEvents' => true - ]; - $flag3Json = [ - 'key' => 'flag3', - 'version' => 300, - 'deleted' => false, - 'on' => false, - 'targets' => [], - 'prerequisites' => [], - 'rules' => [], - 'offVariation' => 0, - 'fallthrough' => null, - 'variations' => ['value3'], - 'salt' => '', - 'trackEvents' => false, - 'debugEventsUntilDate' => 1000 - ]; - $flag1 = FeatureFlag::decode($flag1Json); - $flag2 = FeatureFlag::decode($flag2Json); - $flag3 = FeatureFlag::decode($flag3Json); - - MockFeatureRequester::$flags = ['flag1' => $flag1, 'flag2' => $flag2, 'flag3' => $flag3]; + $flag1 = ModelBuilders::flagBuilder('flag1') + ->version(100) + ->variations('value1') + ->on(false) + ->offVariation(0) + ->build(); + $flag2 = ModelBuilders::flagBuilder('flag2') + ->version(200) + ->variations('value2') + ->on(false) + ->offVariation(0) + ->trackEvents(true) + ->build(); + $flag3 = ModelBuilders::flagBuilder('flag3') + ->version(300) + ->variations('value3') + ->on(false) + ->offVariation(0) + ->debugEventsUntilDate(1000) + ->build(); + + $this->mockRequester->addFlag($flag1)->addFlag($flag2)->addFlag($flag3); $client = $this->makeClient(); - $builder = new LDUserBuilder(3); - $user = $builder->build(); - $state = $client->allFlagsState($user, ['withReasons' => true, 'detailsOnlyForTrackedFlags' => true]); + $context = LDContext::create('userkey'); + $state = $client->allFlagsState($context, ['withReasons' => true, 'detailsOnlyForTrackedFlags' => true]); $this->assertTrue($state->isValid()); $this->assertEquals(['flag1' => 'value1', 'flag2' => 'value2', 'flag3' => 'value3'], $state->toValuesMap()); @@ -581,19 +502,47 @@ public function testAllFlagsStateCanOmitDetailsForUntrackedFlags() $this->assertEquals($expectedState, $state->jsonSerialize()); } - public function testTrackSendsEvent() + public function testIdentifySendsEvent() + { + $ep = new MockEventProcessor(); + $client = $this->makeClient(['event_processor' => $ep]); + + $context = LDContext::create('userkey'); + $client->identify($context); + $queue = $ep->getEvents(); + $this->assertEquals(1, sizeof($queue)); + $event = $queue[0]; + $this->assertEquals('identify', $event['kind']); + $this->assertEquals($context, $event['context']); + } + + public function testIdentifyAcceptsUser() { $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); $user = new LDUser('userkey'); - $client->track('eventkey', $user); + $client->identify($user); + $queue = $ep->getEvents(); + $this->assertEquals(1, sizeof($queue)); + $event = $queue[0]; + $this->assertEquals('identify', $event['kind']); + $this->assertEquals(LDContext::create('userkey'), $event['context']); + } + + public function testTrackSendsEvent() + { + $ep = new MockEventProcessor(); + $client = $this->makeClient(['event_processor' => $ep]); + + $context = LDContext::create('userkey'); + $client->track('eventkey', $context); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; $this->assertEquals('custom', $event['kind']); $this->assertEquals('eventkey', $event['key']); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertFalse(isset($event['data'])); $this->assertFalse(isset($event['metricValue'])); } @@ -604,14 +553,14 @@ public function testTrackSendsEventWithData() $client = $this->makeClient(['event_processor' => $ep]); $data = ['thing' => 'stuff']; - $user = new LDUser('userkey'); - $client->track('eventkey', $user, $data); + $context = LDContext::create('userkey'); + $client->track('eventkey', $context, $data); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; $this->assertEquals('custom', $event['kind']); $this->assertEquals('eventkey', $event['key']); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals($data, $event['data']); $this->assertFalse(isset($event['metricValue'])); } @@ -623,56 +572,33 @@ public function testTrackSendsEventWithDataAndMetricValue() $data = ['thing' => 'stuff']; $metricValue = 1.5; - $user = new LDUser('userkey'); - $client->track('eventkey', $user, $data, $metricValue); + $context = LDContext::create('userkey'); + $client->track('eventkey', $context, $data, $metricValue); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; $this->assertEquals('custom', $event['kind']); $this->assertEquals('eventkey', $event['key']); - $this->assertEquals($user, $event['user']); + $this->assertEquals($context, $event['context']); $this->assertEquals($data, $event['data']); $this->assertEquals($metricValue, $event['metricValue']); } - public function testTrackWithAnonymousUserSendsEventWithAnonymousContextKind() + public function testTrackAcceptsUser() { $ep = new MockEventProcessor(); $client = $this->makeClient(['event_processor' => $ep]); - $anon_builder = new LDUserBuilder("anon@email.com"); - $anon = $anon_builder->anonymous(true)->build(); - - $client->track('eventkey', $anon); + $user = new LDUser('userkey'); + $client->track('eventkey', $user); $queue = $ep->getEvents(); $this->assertEquals(1, sizeof($queue)); $event = $queue[0]; $this->assertEquals('custom', $event['kind']); - $this->assertEquals('anonymousUser', $event['contextKind']); - } - - public function testAliasEventsAreCorrect() - { - $ep = new MockEventProcessor(); - $client = $this->makeClient(['event_processor' => $ep]); - - $user_builder = new LDUserBuilder("user@email.com"); - $user = $user_builder->anonymous(false)->build(); - $anon_builder = new LDUserBuilder("anon@email.com"); - $anon = $anon_builder->anonymous(true)->build(); - - $client->alias($user, $anon); - - $queue = $ep->getEvents(); - $this->assertEquals(1, sizeof($queue)); - - $event = $queue[0]; - - $this->assertEquals('alias', $event['kind']); - $this->assertEquals($user->getKey(), $event['key']); - $this->assertEquals('user', $event['contextKind']); - $this->assertEquals($anon->getKey(), $event['previousKey']); - $this->assertEquals('anonymousUser', $event['previousContextKind']); + $this->assertEquals('eventkey', $event['key']); + $this->assertEquals(LDContext::create('userkey'), $event['context']); + $this->assertFalse(isset($event['data'])); + $this->assertFalse(isset($event['metricValue'])); } public function testEventsAreNotPublishedIfSendEventsIsFalse() @@ -683,12 +609,12 @@ public function testEventsAreNotPublishedIfSendEventsIsFalse() // EventProcessor would forward events to it if send_events were not disabled. $mockPublisher = new MockEventPublisher("", []); $options = [ - 'feature_requester_class' => MockFeatureRequester::class, + 'feature_requester' => $this->mockRequester, 'event_publisher' => $mockPublisher, 'send_events' => false, ]; $client = new LDClient("someKey", $options); - $client->track('eventkey', new LDUser('userkey')); + $client->track('eventkey', LDContext::create('userkey')); // We don't flush the event processor until __destruct is called. Let's // force that by unsetting this variable. @@ -699,14 +625,17 @@ public function testEventsAreNotPublishedIfSendEventsIsFalse() public function testOnlyValidFeatureRequester() { $this->expectException(InvalidArgumentException::class); - new LDClient("BOGUS_SDK_KEY", ['feature_requester_class' => \stdClass::class]); + new LDClient("BOGUS_SDK_KEY", ['feature_requester' => \stdClass::class]); } public function testSecureModeHash() { $client = new LDClient("secret", ['offline' => true]); - $user = new LDUser("Message"); - $this->assertEquals("aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597", $client->secureModeHash($user)); + $context = LDContext::create("Message"); + $user = new LDUser($context->getKey()); + $expected = "aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597"; + $this->assertEquals($expected, $client->secureModeHash($context)); + $this->assertEquals($expected, $client->secureModeHash($user)); } public function testLoggerInterfaceWarn() @@ -718,10 +647,11 @@ public function testLoggerInterfaceWarn() $client = new LDClient('secret', [ 'logger' => $logger, + 'offline' => true ]); - $user = new LDUser(''); + $invalidContext = LDContext::create(''); - $client->variation('MyFeature', $user); + $client->variation('MyFeature', $invalidContext); } } diff --git a/tests/LDContextErrorsTest.php b/tests/LDContextErrorsTest.php new file mode 100644 index 00000000..80b2417a --- /dev/null +++ b/tests/LDContextErrorsTest.php @@ -0,0 +1,72 @@ +build()); + } + + /** + * @dataProvider kindBadStringValues + */ + public function testKindInvalidStrings($value) + { + self::assertContextInvalid(LDContext::create('a', $value)); + self::assertContextInvalid(LDContext::builder('a')->kind($value)->build()); + } + + public function kindBadStringValues() + { + return [['kind'], ['multi'], ['b$c']]; + } + + public function testCreateMultiWithNoContexts() + { + self::assertContextInvalid(LDContext::createMulti()); + } + + public function testMultiBuilderWithNoContexts() + { + self::assertContextInvalid(LDContext::multiBuilder()->build()); + } + + public function testCreateMultiWithDuplicateKind() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create('b', 'kind1'); + self::assertContextInvalid(LDContext::createMulti($c1, $c2)); + } + + public function testMultiBuilderWithDuplicateKind() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create('b', 'kind1'); + self::assertContextInvalid(LDContext::multiBuilder()->add($c1)->add($c2)->build()); + } + + public function testCreateMultiWithInvalidContext() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create(''); + self::assertContextInvalid(LDContext::createMulti($c1, $c2)); + } + + public function testMultiBuilderWithInvalidContext() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create(''); + self::assertContextInvalid(LDContext::multiBuilder()->add($c1)->add($c2)->build()); + } + + private function assertContextInvalid($c) + { + self::assertFalse($c->isValid()); + self::assertNotNull($c->getError()); + } +} diff --git a/tests/LDContextTest.php b/tests/LDContextTest.php new file mode 100644 index 00000000..405ac4ad --- /dev/null +++ b/tests/LDContextTest.php @@ -0,0 +1,414 @@ +isMultiple()); + self::assertEquals('a', $c->getKey()); + self::assertEquals('user', $c->getKind()); + self::assertNull($c->getName()); + self::assertFalse($c->isAnonymous()); + self::assertEquals([], $c->getCustomAttributeNames()); + } + + public function testCreateWithNonDefaultKind() + { + $c = LDContext::create('a', 'b'); + + self::assertContextValid($c); + self::assertFalse($c->isMultiple()); + self::assertEquals('a', $c->getKey()); + self::assertEquals('b', $c->getKind()); + self::assertNull($c->getName()); + self::assertFalse($c->isAnonymous()); + self::assertEquals([], $c->getCustomAttributeNames()); + } + + public function testBuilderWithDefaultKind() + { + $c = LDContext::builder('a')->build(); + + self::assertContextValid($c); + self::assertFalse($c->isMultiple()); + self::assertEquals('a', $c->getKey()); + self::assertEquals('user', $c->getKind()); + self::assertNull($c->getName()); + self::assertFalse($c->isAnonymous()); + self::assertEquals([], $c->getCustomAttributeNames()); + } + + public function testBuilderWithNonDefaultKind() + { + $c = LDContext::builder('a')->kind('b')->build(); + + self::assertContextValid($c); + self::assertFalse($c->isMultiple()); + self::assertEquals('a', $c->getKey()); + self::assertEquals('b', $c->getKind()); + self::assertNull($c->getName()); + self::assertFalse($c->isAnonymous()); + self::assertEquals([], $c->getCustomAttributeNames()); + } + + public function testBuilderName() + { + $c = LDContext::builder('a')->name('b')->build(); + + self::assertContextValid($c); + self::assertEquals('a', $c->getKey()); + self::assertEquals('b', $c->getName()); + } + + public function testBuilderAnonymous() + { + $c = LDContext::builder('a')->anonymous(true)->build(); + + self::assertContextValid($c); + self::assertEquals('a', $c->getKey()); + self::assertTrue($c->isAnonymous()); + } + + public function testBuilderSetCustomAttributes() + { + $c = LDContext::builder('a') + ->set('b', true) + ->set('c', 'd') + ->build(); + + self::assertContextValid($c); + self::assertEquals('a', $c->getKey()); + self::assertEquals(true, $c->get('b')); + self::assertEquals('d', $c->get('c')); + self::assertEquals(['b', 'c'], $c->getCustomAttributeNames()); + } + + public function testBuilderSetBuiltInAttributeByName() + { + $c = LDContext::builder('') + ->set('key', 'a') + ->set('kind', 'b') + ->set('name', 'c') + ->set('anonymous', true) + ->build(); + + self::assertContextValid($c); + self::assertEquals('a', $c->getKey()); + self::assertEquals('b', $c->getKind()); + self::assertEquals('c', $c->getName()); + self::assertTrue($c->isAnonymous()); + + // name is nullable + $c1 = LDContext::builder('a')->name('c')->name(null)->build(); + self::assertNull($c1->getName()); + } + + public function testBuilderSetBuiltInAttributeByNameTypeChecking() + { + $b = LDContext::builder('a')->kind('b')->name('c')->anonymous(true); + + $b->set('key', null); + $b->set('key', 3); + self::assertFalse($b->trySet('key', null)); + self::assertFalse($b->trySet('key', 3)); + self::assertEquals('a', $b->build()->getKey()); + + $b->set('kind', null); + $b->set('kind', 3); + self::assertFalse($b->trySet('kind', null)); + self::assertFalse($b->trySet('kind', 3)); + self::assertEquals('b', $b->build()->getKind()); + + $b->set('name', 3); + self::assertFalse($b->trySet('name', 3)); + self::assertEquals('c', $b->build()->getName()); + + $b->set('anonymous', null); + $b->set('anonymous', 3); + self::assertFalse($b->trySet('anonymous', null)); + self::assertFalse($b->trySet('anonymous', 3)); + self::assertTrue($b->build()->isAnonymous()); + } + + public function testGetBuiltInAttributeByName() + { + $c = LDContext::builder('a')->kind('b')->name('c')->anonymous(true)->build(); + self::assertEquals('a', $c->get('key')); + self::assertEquals('b', $c->get('kind')); + self::assertEquals('c', $c->get('name')); + self::assertTrue($c->get('anonymous')); + } + + public function testGetUnknownAttribute() + { + $c = LDContext::create('a'); + self::assertNull($c->get('b')); + } + + public function testPrivateAttributes() + { + self::assertNull(LDContext::create('a')->getPrivateAttributes()); + + $c = LDContext::builder('a')->private('b', '/c/d')->private(AttributeReference::fromPath('e'))->build(); + self::assertEquals( + [ + AttributeReference::fromPath('b'), + AttributeReference::fromPath('/c/d'), + AttributeReference::fromPath('e') + ], + $c->getPrivateAttributes() + ); + } + + public function testCreateMulti() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create('b', 'kind2'); + $mc = LDContext::createMulti($c1, $c2); + + self::assertContextValid($mc); + self::assertTrue($mc->isMultiple()); + self::assertEquals(2, $mc->getIndividualContextCount()); + + self::assertSame($c1, $mc->getIndividualContext(0)); + self::assertSame($c2, $mc->getIndividualContext(1)); + self::assertNull($mc->getIndividualContext(-1)); + self::assertNull($mc->getIndividualContext(2)); + + self::assertSame($c1, $mc->getIndividualContext('kind1')); + self::assertSame($c2, $mc->getIndividualContext('kind2')); + self::assertNull($mc->getIndividualContext('kind3')); + } + + public function testMultiBuilder() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create('b', 'kind2'); + self::assertEquals( + LDContext::createMulti($c1, $c2), + LDContext::multiBuilder()->add($c1)->add($c2)->build() + ); + } + + public function testCreateMultiFlattensNestedMultiContext() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create('b', 'kind2'); + $c3 = LDContext::create('c', 'kind3'); + $c2Plus3 = LDContext::createMulti($c2, $c3); + self::assertEquals( + LDContext::createMulti($c1, $c2, $c3), + LDContext::createMulti($c1, $c2Plus3) + ); + } + + public function testMultiBuilderFlattensNestedMultiContext() + { + $c1 = LDContext::create('a', 'kind1'); + $c2 = LDContext::create('b', 'kind2'); + $c3 = LDContext::create('c', 'kind3'); + $c2Plus3 = LDContext::createMulti($c2, $c3); + self::assertEquals( + LDContext::createMulti($c1, $c2, $c3), + LDContext::multiBuilder()->add($c1)->add($c2Plus3)->build() + ); + } + + public function testFullyQualifiedKey() + { + self::assertEquals('key1', LDContext::create('key1')->getFullyQualifiedKey()); + self::assertEquals('kind1:key1', LDContext::create('key1', 'kind1')->getFullyQualifiedKey()); + self::assertEquals( + 'kind1:key1:kind2:key2', + LDContext::createMulti( + LDContext::create('key2', 'kind2'), + LDContext::create('key1', 'kind1') + )->getFullyQualifiedKey() + ); + } + + public function testEquals() + { + self::assertContextsFromFactoryEqual(fn () => LDContext::create('a')); + self::assertContextsFromFactoryEqual(fn () => LDContext::create('a', 'kind1')); + self::assertContextsFromFactoryEqual(fn () => LDContext::builder('a')->name('b')->build()); + self::assertContextsFromFactoryEqual(fn () => LDContext::builder('a')->anonymous(true)->build()); + self::assertContextsFromFactoryEqual(fn () => LDContext::builder('a')->set('b', true)->set('c', 3)->build()); + self::assertContextsEqual( + LDContext::builder('a')->set('b', true)->set('c', 3)->build(), + LDContext::builder('a')->set('c', 3)->set('b', true)->build() + ); + self::assertContextsFromFactoryEqual(fn () => LDContext::create('invalid', 'kind')); + + self::assertContextsUnequal(LDContext::create('a', 'kind1'), LDContext::create('a', 'kind2')); + self::assertContextsUnequal(LDContext::create('b', 'kind1'), LDContext::create('a', 'kind1')); + self::assertContextsUnequal( + LDContext::builder('a')->name('b')->build(), + LDContext::builder('a')->name('c')->build() + ); + self::assertContextsUnequal( + LDContext::builder('a')->anonymous(true)->build(), + LDContext::builder('a')->build() + ); + self::assertContextsUnequal( + LDContext::builder('a')->set('b', true)->build(), + LDContext::builder('a')->set('b', false)->build() + ); + self::assertContextsUnequal( + LDContext::builder('a')->set('b', true)->build(), + LDContext::builder('a')->set('b', true)->set('c', false)->build() + ); + + self::assertContextsFromFactoryEqual( + fn () => LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('b', 'kind2')) + ); + self::assertContextsEqual( + LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('b', 'kind2')), + LDContext::createMulti(LDContext::create('b', 'kind2'), LDContext::create('a', 'kind1')) + ); + + self::assertContextsUnequal( + LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('b', 'kind2')), + LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('c', 'kind2')) + ); + self::assertContextsUnequal( + LDContext::createMulti(LDContext::create('a', 'kind1'), LDContext::create('b', 'kind2')), + LDContext::createMulti(LDContext::create('a', 'kind1')) + ); + + self::assertContextsUnequal(LDContext::create('invalid', 'kind'), LDContext::createMulti()); + } + + public function testJsonEncoding() + { + self::assertJsonStringEqualsJsonString( + '{"kind": "kind1", "key": "a"}', + json_encode(LDContext::create('a', 'kind1')) + ); + self::assertJsonStringEqualsJsonString( + '{"kind": "kind1", "key": "a", "name": "b"}', + json_encode(LDContext::builder('a')->kind('kind1')->name('b')->build()) + ); + self::assertJsonStringEqualsJsonString( + '{"kind": "kind1", "key": "a", "anonymous": true}', + json_encode(LDContext::builder('a')->kind('kind1')->anonymous(true)->build()) + ); + self::assertJsonStringEqualsJsonString( + '{"kind": "kind1", "key": "a", "b": true, "c": 3}', + json_encode(LDContext::builder('a')->kind('kind1')->set('b', true)->set('c', 3)->build()) + ); + self::assertJsonStringEqualsJsonString( + '{"kind": "kind1", "key": "a", "_meta": {"privateAttributes": ["b"]}}', + json_encode(LDContext::builder('a')->kind('kind1')->private('b')->build()) + ); + self::assertJsonStringEqualsJsonString( + '{"kind": "multi", "kind1": {"key": "key1"}, "kind2": {"key": "key2"}}', + json_encode(LDContext::createMulti(LDContext::create('key1', 'kind1'), LDContext::create('key2', 'kind2'))) + ); + } + + public function testJsonDecoding() + { + self::assertContextsEqual( + LDContext::create('key1', 'kind1'), + LDContext::fromJson('{"kind": "kind1", "key": "key1"}') + ); + self::assertContextsEqual( + LDContext::create('key1', 'kind1'), + LDContext::fromJson(['kind' => 'kind1', 'key' => 'key1']) + ); + self::assertContextsEqual( + LDContext::builder('key1')->kind('kind1')->name('a')->build(), + LDContext::fromJson('{"kind": "kind1", "key": "key1", "name": "a"}') + ); + self::assertContextsEqual( + LDContext::builder('key1')->kind('kind1')->anonymous(true)->build(), + LDContext::fromJson('{"kind": "kind1", "key": "key1", "anonymous": true}') + ); + self::assertContextsEqual( + LDContext::createMulti(LDContext::create('key1', 'kind1'), LDContext::create('key2', 'kind2')), + LDContext::fromJson('{"kind": "multi", "kind1": {"key": "key1"}, "kind2": {"key": "key2"}}') + ); + } + + public function testContextFromUser() + { + $u1 = (new LDUserBuilder("key")) + ->ip("127.0.0.1") + ->firstName("Bob") + ->lastName("Loblaw") + ->email("bob@example.com") + ->privateName("Bob Loblaw") + ->avatar("image") + ->country("US") + ->anonymous(true) + ->build(); + $c1 = LDContext::fromUser($u1); + $c1Expected = LDContext::builder($u1->getKey()) + ->set("ip", $u1->getIP()) + ->set("firstName", $u1->getFirstName()) + ->set("lastName", $u1->getLastName()) + ->set("email", $u1->getEmail()) + ->set("name", $u1->getName()) + ->set("avatar", $u1->getAvatar()) + ->set("country", $u1->getCountry()) + ->private("name") + ->anonymous(true) + ->build(); + self::assertContextsEqual($c1Expected, $c1); + + // test case where there were no built-in optional attrs, only custom + $u2 = (new LDUserBuilder("key")) + ->customAttribute("c1", "v1") + ->privateCustomAttribute("c2", "v2") + ->build(); + $c2 = LDContext::fromUser($u2); + $c2Expected = LDContext::builder($u2->getKey()) + ->set("c1", "v1") + ->set("c2", "v2") + ->private("c2") + ->build(); + self::assertContextsEqual($c2Expected, $c2); + + // make sure custom attrs can't override built-in ones + $u3 = (new LDUserBuilder("key")) + ->email("good") + ->custom(["email" => "bad"]) + ->build(); + $c3 = LDContext::fromUser($u3); + $c3Expected = LDContext::builder($u3->getKey()) + ->set("email", "good") + ->build(); + self::assertContextsEqual($c3Expected, $c3); + } + + private static function assertContextValid($c) + { + self::assertNull($c->getError()); + self::assertTrue($c->isValid()); + } + + private static function assertContextsFromFactoryEqual($factory) + { + self::assertContextsEqual($factory(), $factory()); + } + + private static function assertContextsEqual($c1, $c2) + { + self::assertTrue($c1->equals($c2), "expected $c1 but got $c2"); + } + + private static function assertContextsUnequal($c1, $c2) + { + self::assertFalse($c1->equals($c2), "$c2 should not have been equal to $c1"); + } +} diff --git a/tests/LDUserTest.php b/tests/LDUserTest.php index b960ed00..5d0e2e47 100644 --- a/tests/LDUserTest.php +++ b/tests/LDUserTest.php @@ -30,21 +30,6 @@ public function testEmptyCustom() $this->assertInstanceOf(LDUser::class, $user); } - public function testLDUserSecondary() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->secondary("secondary")->build(); - $this->assertEquals("secondary", $user->getSecondary()); - } - - public function testLDUserPrivateSecondary() - { - $builder = new LDUserBuilder("foo@bar.com"); - $user = $builder->privateSecondary("secondary")->build(); - $this->assertEquals("secondary", $user->getSecondary()); - $this->assertEquals(["secondary"], $user->getPrivateAttributeNames()); - } - public function testLDUserIP() { $builder = new LDUserBuilder("foo@bar.com"); diff --git a/tests/MockEventPublisher.php b/tests/MockEventPublisher.php index 9415ec81..523f1966 100644 --- a/tests/MockEventPublisher.php +++ b/tests/MockEventPublisher.php @@ -2,7 +2,7 @@ namespace LaunchDarkly\Tests; -class MockEventPublisher implements \LaunchDarkly\EventPublisher +class MockEventPublisher implements \LaunchDarkly\Subsystems\EventPublisher { public $payloads = []; diff --git a/tests/MockFeatureRequester.php b/tests/MockFeatureRequester.php index e4521ab9..d275fb79 100644 --- a/tests/MockFeatureRequester.php +++ b/tests/MockFeatureRequester.php @@ -2,30 +2,75 @@ namespace LaunchDarkly\Tests; -use LaunchDarkly\FeatureRequester; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\Impl\Model\Segment; +use LaunchDarkly\Subsystems\FeatureRequester; +/** + * A mock implementation of FeatureRequester holding preconfigured flags/segments. If + * we expect the test to query a nonexistent flag/segment, we must specify that ahead + * of time with expectQueryForUnknown[Flag/Segment]; otherwise such a query throws an + * exception causing the test to fail. + */ class MockFeatureRequester implements FeatureRequester { - public static $flags = []; + private $_flags = []; + private $_segments = []; public function __construct($baseurl = '', $key = '', $options = []) { } + public function addFlag(FeatureFlag $flag): MockFeatureRequester + { + $this->_flags[$flag->getKey()] = $flag; + return $this; + } + + public function addSegment(Segment $segment): MockFeatureRequester + { + $this->_segments[$segment->getKey()] = $segment; + return $this; + } + + public function expectQueryForUnknownFlag(string $key): MockFeatureRequester + { + $this->_flags[$key] = false; + return $this; + } + + public function expectQueryForUnknownSegment(string $key): MockFeatureRequester + { + $this->_segments[$key] = false; + return $this; + } + public function getFeature(string $key): ?FeatureFlag { - return self::$flags[$key] ?? null; + if (!isset($this->_flags[$key])) { + throw new \InvalidArgumentException("test unexpectedly tried to get flag key: $key"); + } + $ret = $this->_flags[$key]; + return $ret === false ? null : $ret; } public function getSegment(string $key): ?Segment { - return null; + if (!isset($this->_segments[$key])) { + throw new \InvalidArgumentException("test unexpectedly tried to get segment key: $key"); + } + $ret = $this->_segments[$key]; + return $ret === false ? null : $ret; } public function getAllFeatures(): ?array { - return self::$flags; + $ret = []; + foreach ($this->_flags as $k => $v) { + if ($v !== false) { + $ret[$k] = $v; + } + } + return $ret; } } diff --git a/tests/ModelBuilders.php b/tests/ModelBuilders.php new file mode 100644 index 00000000..44da68bc --- /dev/null +++ b/tests/ModelBuilders.php @@ -0,0 +1,91 @@ +on(true)->variations(false, true) + ->offVariation(0)->fallthroughVariation(0) + ->rules($rules)->build(); + } + + public static function booleanFlagWithClauses(Clause ...$clauses): FeatureFlag + { + return self::booleanFlagWithRules(self::flagRuleBuilder()->variation(1)->clauses($clauses)->build()); + } + + public static function clause(?string $contextKind, string $attribute, string $op, ...$values): Clause + { + return new Clause($contextKind, $attribute, $op, $values, false); + } + + public static function clauseMatchingContext($context): Clause + { + return new Clause($context->getKind(), 'key', 'in', [$context->getKey()], false); + } + + public static function clauseMatchingSegment($segment): Clause + { + return new Clause(null, '', 'segmentMatch', [$segment->getKey()], false); + } + + public static function flagRuleMatchingContext(int $variation, LDContext $context): Rule + { + return self::flagRuleWithClauses($variation, self::clauseMatchingContext($context)); + } + + public static function flagRuleWithClauses(int $variation, Clause ...$clauses): Rule + { + return self::flagRuleBuilder()->variation($variation)->clauses($clauses)->build(); + } + + public static function negate(Clause $clause): Clause + { + return new Clause($clause->getContextKind(), $clause->getAttribute(), $clause->getOp(), $clause->getValues(), true); + } + + public static function rolloutWithVariations(WeightedVariation ...$variations) + { + return new Rollout($variations, null); + } + + public static function segmentRuleMatchingContext(LDContext $context): SegmentRule + { + return self::segmentRuleBuilder()->clause(self::clauseMatchingContext($context))->build(); + } + + public static function weightedVariation(int $variation, int $weight, bool $untracked = false): WeightedVariation + { + return new WeightedVariation($variation, $weight, $untracked); + } +} diff --git a/tests/SegmentBuilder.php b/tests/SegmentBuilder.php new file mode 100644 index 00000000..952b0a6e --- /dev/null +++ b/tests/SegmentBuilder.php @@ -0,0 +1,87 @@ +_key = $key; + } + + public function build(): Segment + { + return new Segment( + $this->_key, + $this->_version, + $this->_included, + $this->_excluded, + $this->_includedContexts, + $this->_excludedContexts, + $this->_salt, + $this->_rules, + $this->_deleted + ); + } + + public function excluded(string ...$excluded): SegmentBuilder + { + $this->_excluded = $excluded; + return $this; + } + + public function excludedContexts(string $contextKind, string ...$excluded): SegmentBuilder + { + $this->_excludedContexts[] = new SegmentTarget($contextKind, $excluded); + return $this; + } + + public function included(string ...$included): SegmentBuilder + { + $this->_included = $included; + return $this; + } + + public function includedContexts(string $contextKind, string ...$included): SegmentBuilder + { + $this->_includedContexts[] = new SegmentTarget($contextKind, $included); + return $this; + } + + public function rule(SegmentRule $rule): SegmentBuilder + { + $this->_rules[] = $rule; + return $this; + } + + public function rules(array $rules): SegmentBuilder + { + $this->_rules = $rules; + return $this; + } + + public function salt(string $salt): SegmentBuilder + { + $this->_salt = $salt; + return $this; + } +} diff --git a/tests/SegmentRuleBuilder.php b/tests/SegmentRuleBuilder.php new file mode 100644 index 00000000..29dce3ab --- /dev/null +++ b/tests/SegmentRuleBuilder.php @@ -0,0 +1,50 @@ +_clauses, $this->_weight, $this->_bucketBy, $this->_rolloutContextKind); + } + + public function bucketBy(?string $bucketBy): SegmentRuleBuilder + { + $this->_bucketBy = $bucketBy; + return $this; + } + + public function clause(Clause $clause): SegmentRuleBuilder + { + $this->_clauses[] = $clause; + return $this; + } + + public function clauses(array $clauses): SegmentRuleBuilder + { + $this->_clauses = $clauses; + return $this; + } + + public function rolloutContextKind(?string $rolloutContextKind): SegmentRuleBuilder + { + $this->_rolloutContextKind = $rolloutContextKind; + return $this; + } + + public function weight(?int $weight): SegmentRuleBuilder + { + $this->_weight = $weight; + return $this; + } +} diff --git a/tests/Types/AttributeReferenceTest.php b/tests/Types/AttributeReferenceTest.php new file mode 100644 index 00000000..45d863c4 --- /dev/null +++ b/tests/Types/AttributeReferenceTest.php @@ -0,0 +1,69 @@ +getError()); + self::assertEquals(1, $a->getDepth()); + self::assertEquals($name, $a->getComponent(0)); + self::assertEquals($name, $a->getPath()); + } + + public function testSimplePathWithSlashNotAtStart() + { + $name = 'attr/a~1'; + $a = AttributeReference::fromPath($name); + self::assertNull($a->getError()); + self::assertEquals(1, $a->getDepth()); + self::assertEquals($name, $a->getComponent(0)); + self::assertEquals($name, $a->getPath()); + } + + public function testPathWithMultipleComponents() + { + $path = '/first/second/third'; + $a = AttributeReference::fromPath($path); + self::assertNull($a->getError()); + self::assertEquals(3, $a->getDepth()); + self::assertEquals('first', $a->getComponent(0)); + self::assertEquals('second', $a->getComponent(1)); + self::assertEquals('third', $a->getComponent(2)); + self::assertEquals($path, $a->getPath()); + } + + public function testLiteral() + { + $name = 'attr'; + $a = AttributeReference::fromLiteral($name); + self::assertNull($a->getError()); + self::assertEquals(1, $a->getDepth()); + self::assertEquals($name, $a->getComponent(0)); + self::assertEquals($name, $a->getPath()); + } + + public function testLiteralWithSpecialCharacters() + { + $name = '/attr~'; + $a = AttributeReference::fromLiteral($name); + self::assertNull($a->getError()); + self::assertEquals(1, $a->getDepth()); + self::assertEquals($name, $a->getComponent(0)); + self::assertEquals('/~1attr~0', $a->getPath()); + } + + public function testErrorConditions() + { + foreach (['', '/', '//', '/a//b', '/a/b//', '/a~', '/a~2'] as $s) { + $a = AttributeReference::fromPath($s); + self::assertNotNull($a->getError()); + } + self::assertNotNull(AttributeReference::fromLiteral('')->getError()); + } +}