diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml index fb9d9e6..c573c04 100644 --- a/.github/workflows/pull-requests.yml +++ b/.github/workflows/pull-requests.yml @@ -2,9 +2,6 @@ name: Flagsmith PHP Pull Request on: pull_request: - branches: - - main - - release** jobs: test: diff --git a/.gitmodules b/.gitmodules index 845c2f1..86388ee 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "tests/Engine/EngineTests/EngineTestData"] path = tests/Engine/EngineTests/EngineTestData url = git@github.com:Flagsmith/engine-test-data.git - branch = v1.0.0 + tag = v2.4.0 diff --git a/composer.json b/composer.json index 4fa38b2..73b986d 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "psr/simple-cache": ">=1.0", "psr/http-factory-implementation": "1.0", "psr/http-client-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "psr/http-message-implementation": "1.0", + "softcreatr/jsonpath": "^0.10.0" }, "require-dev": { "guzzlehttp/guzzle": "^7.3", @@ -25,7 +26,8 @@ "phpunit/phpunit": "^9.5", "symfony/cache": "^5.4.6", "friendsofphp/php-cs-fixer": "^3.6", - "doppiogancio/mocked-client": "^3.0" + "doppiogancio/mocked-client": "^3.0", + "colinodell/json5": "^3.0" }, "autoload": { "psr-4": { diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 9d017ff..ddcd389 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -7,9 +7,419 @@ use Flagsmith\Engine\Identities\IdentityModel; use Flagsmith\Engine\Segments\SegmentEvaluator; use Flagsmith\Engine\Utils\Exceptions\FeatureStateNotFound; +use Flagsmith\Engine\Utils\Hashing; +use Flagsmith\Engine\Utils\Semver; +use Flagsmith\Engine\Utils\Types\Context\EvaluationContext; +use Flagsmith\Engine\Utils\Types\Context\FeatureContext; +use Flagsmith\Engine\Utils\Types\Context\SegmentRuleType; +use Flagsmith\Engine\Utils\Types\Context\SegmentCondition; +use Flagsmith\Engine\Utils\Types\Context\SegmentConditionOperator; +use Flagsmith\Engine\Utils\Types\Context\SegmentContext; +use Flagsmith\Engine\Utils\Types\Context\SegmentRule; +use Flagsmith\Engine\Utils\Types\Result\EvaluationResult; +use Flagsmith\Engine\Utils\Types\Result\FlagResult; +use Flagsmith\Engine\Utils\Types\Result\SegmentResult; +use Flow\JSONPath\JSONPath; +use Flow\JSONPath\JSONPathException; class Engine { + public const STRONGEST_PRIORITY = -INF; + public const WEAKEST_PRIORITY = +INF; + + private const VALID_CONTEXT_VALUE_TYPES = ['string', 'integer', 'boolean', 'double']; + + /** + * Get the evaluation result for a given context. + * + * @param EvaluationContext $context The evaluation context. + * @return EvaluationResult EvaluationResult containing the evaluated flags and matched segments. + */ + public static function getEvaluationResult($context): EvaluationResult + { + /** @var array */ + $evaluatedSegments = []; + + /** @var array */ + $evaluatedFeatures = []; + + /** @var array */ + $matchedSegmentsByFeatureKey = []; + + /** @var array */ + $evaluatedFlags = []; + + foreach ($context->segments as $segment) { + if (!self::isContextInSegment($context, $segment)) { + continue; + } + + $segmentResult = new SegmentResult(); + $segmentResult->key = $segment->key; + $segmentResult->name = $segment->name; + $segmentResult->metadata = $segment->metadata ?? null; + $evaluatedSegments[] = $segmentResult; + + foreach ($segment->overrides as $overrideFeature) { + $featureKey = $overrideFeature->feature_key; + $evaluatedFeature = $evaluatedFeatures[$featureKey] ?? null; + if ($evaluatedFeature) { + $overrideWinsPriority = + ($overrideFeature->priority ?? self::WEAKEST_PRIORITY) < + ($evaluatedFeature->priority ?? self::WEAKEST_PRIORITY); + if (!$overrideWinsPriority) { + continue; + } + } + + $evaluatedFeatures[$featureKey] = $overrideFeature; + $matchedSegmentsByFeatureKey[$featureKey] = $segment; + } + } + + foreach ($context->features as $feature) { + $featureKey = $feature->feature_key; + $featureName = $feature->name; + $evaluatedFeature = $evaluatedFeatures[$featureKey] ?? null; + if ($evaluatedFeature) { + $evaluatedFlags[$featureName] = self::getFlagResultFromSegmentContext( + $evaluatedFeature, + $matchedSegmentsByFeatureKey[$featureKey], + ); + continue; + } + + $evaluatedFlags[$featureName] = self::getFlagResultFromFeatureContext( + $feature, + $context->identity?->key, + ); + } + + $result = new EvaluationResult(); + $result->flags = $evaluatedFlags; + $result->segments = $evaluatedSegments; + return $result; + } + + /** + * @param EvaluationContext $context + * @param SegmentContext $segment + * @return bool + */ + private static function isContextInSegment($context, $segment): bool + { + if (empty($segment->rules)) { + return false; + } + + foreach ($segment->rules as $rule) { + if (!self::_contextMatchesRule($context, $rule, $segment->key)) { + return false; + } + } + + return true; + } + + /** + * @param FeatureContext $feature + * @param ?string $splitKey + * @return FlagResult + */ + private static function getFlagResultFromFeatureContext($feature, $splitKey) + { + if ($splitKey !== null && !empty($feature->variants)) { + $hashing = new Hashing(); + $percentageValue = $hashing->getHashedPercentageForObjectIds([ + $feature->key, + $splitKey, + ]); + + // Ensure variants are selected consistently + $variants = $feature->variants; + usort($variants, fn ($a, $b) => $a->priority <=> $b->priority); + + $startPercentage = 0.0; + foreach ($variants as $variant) { + $limit = $variant->weight + $startPercentage; + if ( + $startPercentage <= $percentageValue && + $percentageValue < $limit + ) { + $flag = new FlagResult(); + $flag->feature_key = $feature->feature_key; + $flag->name = $feature->name; + $flag->enabled = $feature->enabled; + $flag->value = $variant->value; + $flag->reason = "SPLIT; weight={$variant->weight}"; + return $flag; + } + $startPercentage = $limit; + } + } + + $flag = new FlagResult(); + $flag->feature_key = $feature->feature_key; + $flag->name = $feature->name; + $flag->enabled = $feature->enabled; + $flag->value = $feature->value; + $flag->reason = 'DEFAULT'; + return $flag; + } + + /** + * @param FeatureContext $feature + * @param SegmentContext $segment + * @return FlagResult + */ + private static function getFlagResultFromSegmentContext($feature, $segment) + { + $flag = new FlagResult(); + $flag->feature_key = $feature->feature_key; + $flag->name = $feature->name; + $flag->enabled = $feature->enabled; + $flag->value = $feature->value; + $flag->reason = "TARGETING_MATCH; segment={$segment->name}"; + return $flag; + } + + /** + * @param EvaluationContext $context + * @param SegmentRule $rule + * @param string $segmentKey + * @return bool + */ + private static function _contextMatchesRule( + $context, + $rule, + $segmentKey, + ): bool { + $any = false; + foreach ($rule->conditions as $condition) { + $conditionMatches = self::_contextMatchesCondition( + $context, + $condition, + $segmentKey, + ); + + switch ($rule->type) { + case SegmentRuleType::ALL: + if (!$conditionMatches) { + return false; + } + break; + case SegmentRuleType::NONE: + if ($conditionMatches) { + return false; + } + break; + case SegmentRuleType::ANY: + if ($conditionMatches) { + $any = true; + break 2; + } + break; + } + } + + if ($rule->type === SegmentRuleType::ANY && !$any) { + return false; + } + + foreach ($rule->rules as $subRule) { + $ruleMatches = self::_contextMatchesRule( + $context, + $subRule, + $segmentKey, + ); + if (!$ruleMatches) { + return false; + } + } + + return true; + } + + /** + * @param EvaluationContext $context + * @param SegmentCondition $condition + * @param string $segmentKey + * @return bool + */ + private static function _contextMatchesCondition( + $context, + $condition, + $segmentKey, + ): bool { + $contextValue = self::_getContextValue($context, $condition->property); + $cast = self::_getCaster($contextValue); + + switch ($condition->operator) { + case SegmentConditionOperator::IN: + if ($contextValue === null) { + return false; + } + if (is_array($condition->value)) { + $inValues = $condition->value; + } else { + try { + $inValues = json_decode( + $condition->value, + associative: false, // Possibly catch objects + flags: \JSON_THROW_ON_ERROR, + ); + if (!is_array($inValues)) { + throw new \ValueError('Invalid JSON array'); + } + } catch (\JsonException | \ValueError) { + $inValues = explode(',', $condition->value); + } + } + $inValues = array_map($cast, $inValues); + return in_array($contextValue, $inValues, strict: true); + + case SegmentConditionOperator::PERCENTAGE_SPLIT: + if (!is_numeric($condition->value)) { + return false; + } + + /** @var array $objectIds */ + if ($contextValue !== null) { + $objectIds = [$segmentKey, $contextValue]; + } elseif ($context->identity !== null) { + $objectIds = [$segmentKey, $context->identity->key]; + } else { + return false; + } + + $hashing = new Hashing(); + $threshold = $hashing->getHashedPercentageForObjectIds( + $objectIds, + ); + return $threshold <= ((float) $condition->value); + + case SegmentConditionOperator::MODULO: + if (!is_numeric($contextValue)) { + return false; + } + + $parts = explode('|', (string) $condition->value); + if (count($parts) !== 2) { + return false; + } + + [$divisor, $remainder] = $parts; + if (!is_numeric($divisor) || !is_numeric($remainder)) { + return false; + } + + return fmod($contextValue, $divisor) === ((float) $remainder); + + case SegmentConditionOperator::IS_NOT_SET: + return $contextValue === null; + + case SegmentConditionOperator::IS_SET: + return $contextValue !== null; + + case SegmentConditionOperator::CONTAINS: + return is_string($contextValue) && is_string($condition->value) + && str_contains($contextValue, $condition->value); + + case SegmentConditionOperator::NOT_CONTAINS: + return is_string($contextValue) && is_string($condition->value) + && !str_contains($contextValue, $condition->value); + + case SegmentConditionOperator::REGEX: + return (bool) preg_match("/{$condition->value}/", (string) $contextValue); + } + + if ($contextValue === null) { + return false; + } + + $operator = match ($condition->operator) { + SegmentConditionOperator::EQUAL => '==', + SegmentConditionOperator::NOT_EQUAL => '!=', + SegmentConditionOperator::GREATER_THAN => '>', + SegmentConditionOperator::GREATER_THAN_INCLUSIVE => '>=', + SegmentConditionOperator::LESS_THAN => '<', + SegmentConditionOperator::LESS_THAN_INCLUSIVE => '<=', + default => null, + }; + + if ($operator === null) { + return false; + } + + if (Semver::isSemver($condition->value) && is_string($contextValue)) { + $actualVersion = Semver::removeSemverSuffix($condition->value); + return version_compare($contextValue, $actualVersion, $operator); + } + + return match ($operator) { + '==' => $contextValue === $cast($condition->value), + '!=' => $contextValue !== $cast($condition->value), + '>' => $contextValue > $cast($condition->value), + '>=' => $contextValue >= $cast($condition->value), + '<' => $contextValue < $cast($condition->value), + '<=' => $contextValue <= $cast($condition->value), + }; + } + + /** + * Return a trait value by name, or a context value by JSONPath, or null + * @param EvaluationContext $context + * @param string $property + * @return ?mixed + */ + private static function _getContextValue($context, $property) + { + if ($context->identity !== null) { + $hasTrait = array_key_exists($property, $context->identity->traits); + if ($hasTrait) { + return $context->identity->traits[$property]; + } + } + + if (str_starts_with($property, '$.')) { + try { + $jsonpath = new JSONPath($context); + $results = $jsonpath->find($property)->getData(); + } catch (JSONPathException) { + return null; + } + + if (empty($results)) { + return null; + } + + if (in_array(gettype($results[0]), self::VALID_CONTEXT_VALUE_TYPES)) { + return $results[0]; + }; + } + + return null; + } + + /** + * Get a condition value type caster according to a context value + * @param mixed $contextValue + * @return ?callable + */ + private static function _getCaster($contextValue): ?callable + { + if (!in_array(gettype($contextValue), self::VALID_CONTEXT_VALUE_TYPES)) { + return null; + } + + return match (gettype($contextValue)) { + 'boolean' => fn ($v) => !in_array($v, ['False', 'false']), + 'string' => 'strval', + 'integer' => fn ($v) => is_numeric($v) ? (int) $v : $v, + 'double' => fn ($v) => is_numeric($v) ? (float) $v : $v, + }; + } + /** * Get the environment feature states. * @param EnvironmentModel $environment diff --git a/src/Engine/Utils/Types/Context/EnvironmentContext.php b/src/Engine/Utils/Types/Context/EnvironmentContext.php new file mode 100644 index 0000000..20330b0 --- /dev/null +++ b/src/Engine/Utils/Types/Context/EnvironmentContext.php @@ -0,0 +1,12 @@ + */ + public $segments; + + /** @var array */ + public $features; + + /** + * @param object $jsonContext + * @return EvaluationContext + */ + public static function fromJsonObject($jsonContext) + { + $context = new EvaluationContext(); + + $context->environment = new EnvironmentContext(); + $context->environment->key = $jsonContext->environment->key; + $context->environment->name = $jsonContext->environment->name; + + if (!empty($jsonContext->identity)) { + $context->identity = new IdentityContext(); + $context->identity->key = $jsonContext->identity->key; + $context->identity->identifier = $jsonContext->identity->identifier; + $context->identity->traits = (array) ($jsonContext->identity->traits ?? []); + } + + $context->segments = []; + foreach ($jsonContext->segments as $jsonSegment) { + $segment = new SegmentContext(); + $segment->key = $jsonSegment->key; + $segment->name = $jsonSegment->name; + $segment->rules = self::_convertRules($jsonSegment->rules ?? []); + $segment->overrides = array_values(self::_convertFeatures($jsonSegment->overrides ?? [])); + $segment->metadata = (array) ($jsonSegment->metadata ?? []); + $context->segments[$segment->key] = $segment; + } + + $context->features = self::_convertFeatures($jsonContext->features ?? []); + + return $context; + } + + /** + * @param array $jsonRules + * @return array + */ + private static function _convertRules($jsonRules) + { + $rules = []; + foreach ($jsonRules as $jsonRule) { + $rule = new SegmentRule(); + $rule->type = SegmentRuleType::from($jsonRule->type); + + $rule->conditions = []; + foreach ($jsonRule->conditions ?? [] as $jsonCondition) { + $condition = new SegmentCondition(); + $condition->property = $jsonCondition->property; + $condition->operator = SegmentConditionOperator::from( + $jsonCondition->operator, + ); + $condition->value = $jsonCondition->value; + $rule->conditions[] = $condition; + } + + $rule->rules = empty($jsonRule->rules) + ? [] + : self::_convertRules($jsonRule->rules); + + $rules[] = $rule; + } + + return $rules; + } + + /** + * @param array $jsonFeatures + * @return array + */ + private static function _convertFeatures($jsonFeatures): array + { + $features = []; + foreach ($jsonFeatures as $jsonFeature) { + $feature = new FeatureContext(); + $feature->key = $jsonFeature->key; + $feature->feature_key = $jsonFeature->feature_key; + $feature->name = $jsonFeature->name; + $feature->enabled = $jsonFeature->enabled; + $feature->value = $jsonFeature->value; + $feature->priority = $jsonFeature->priority ?? null; + $feature->variants = []; + foreach ($jsonFeature->variants ?? [] as $jsonVariant) { + $variant = new FeatureValue(); + $variant->value = $jsonVariant->value; + $variant->weight = $jsonVariant->weight; + $variant->priority = $jsonVariant->priority; + $feature->variants[] = $variant; + } + + $features[$jsonFeature->name] = $feature; + } + + return $features; + } +} diff --git a/src/Engine/Utils/Types/Context/FeatureContext.php b/src/Engine/Utils/Types/Context/FeatureContext.php new file mode 100644 index 0000000..a7213d8 --- /dev/null +++ b/src/Engine/Utils/Types/Context/FeatureContext.php @@ -0,0 +1,27 @@ + */ + public $variants; +} diff --git a/src/Engine/Utils/Types/Context/FeatureValue.php b/src/Engine/Utils/Types/Context/FeatureValue.php new file mode 100644 index 0000000..077068d --- /dev/null +++ b/src/Engine/Utils/Types/Context/FeatureValue.php @@ -0,0 +1,15 @@ + */ + public $traits; +} diff --git a/src/Engine/Utils/Types/Context/SegmentCondition.php b/src/Engine/Utils/Types/Context/SegmentCondition.php new file mode 100644 index 0000000..be81f96 --- /dev/null +++ b/src/Engine/Utils/Types/Context/SegmentCondition.php @@ -0,0 +1,15 @@ + */ + public $value; +} diff --git a/src/Engine/Utils/Types/Context/SegmentConditionOperator.php b/src/Engine/Utils/Types/Context/SegmentConditionOperator.php new file mode 100644 index 0000000..6a9840d --- /dev/null +++ b/src/Engine/Utils/Types/Context/SegmentConditionOperator.php @@ -0,0 +1,21 @@ + */ + public $rules; + + /** @var array */ + public $overrides; + + /** @var ?array */ + public $metadata; +} diff --git a/src/Engine/Utils/Types/Context/SegmentRule.php b/src/Engine/Utils/Types/Context/SegmentRule.php new file mode 100644 index 0000000..b26998f --- /dev/null +++ b/src/Engine/Utils/Types/Context/SegmentRule.php @@ -0,0 +1,15 @@ + */ + public $conditions; + + /** @var array */ + public $rules; +} diff --git a/src/Engine/Utils/Types/Context/SegmentRuleType.php b/src/Engine/Utils/Types/Context/SegmentRuleType.php new file mode 100644 index 0000000..7e27637 --- /dev/null +++ b/src/Engine/Utils/Types/Context/SegmentRuleType.php @@ -0,0 +1,10 @@ + */ + public array $flags; + + /** @var array */ + public array $segments; +} diff --git a/src/Engine/Utils/Types/Result/FlagResult.php b/src/Engine/Utils/Types/Result/FlagResult.php new file mode 100644 index 0000000..e9f1408 --- /dev/null +++ b/src/Engine/Utils/Types/Result/FlagResult.php @@ -0,0 +1,21 @@ + */ + public $metadata; + + public function jsonSerialize(): array + { + $data = [ + 'key' => $this->key, + 'name' => $this->name, + ]; + + // 'metadata' is only added if there is any + if (!empty($this->metadata)) { + $data['metadata'] = $this->metadata; + } + + return $data; + } +} diff --git a/tests/Engine/EngineTests/EngineDataTest.php b/tests/Engine/EngineTests/EngineDataTest.php index 5938754..1683329 100644 --- a/tests/Engine/EngineTests/EngineDataTest.php +++ b/tests/Engine/EngineTests/EngineDataTest.php @@ -1,69 +1,45 @@ environment); - $parameters = []; - foreach ($contents->identities_and_responses as $testCase) { - $parameters[] = [ - $environmentModel, - IdentityModel::build($testCase->identity), - $testCase->response - ]; + /** @return \Generator>> */ + public function extractTestCases(): \Generator + { + $testCasePaths = glob(__DIR__ . '/EngineTestData/test_cases/test_*.{json,jsonc}', \GLOB_BRACE); + foreach ($testCasePaths as $testCasePath) { + $testCaseJson = file_get_contents($testCasePath); + $testCase = json5_decode($testCaseJson); + + $testName = basename($testCasePath); + yield $testName => [[ + 'context' => EvaluationContext::fromJsonObject($testCase->context), + 'result' => $testCase->result, + ]]; } - - return $parameters; } /** * @dataProvider extractTestCases + * @param array $case + * @return void */ - public function testEngine($environmentModel, $identityModel, $expectedResponse) + public function testEngine($case): void { - $this->attempt++; - $engineResponse = Engine::getIdentityFeatureStates($environmentModel, $identityModel); - - usort( - $engineResponse, - fn ($fs1, $fs2) => $fs1->getFeature()->getName() <=> $fs2->getFeature()->getName() - ); - - $flags = $expectedResponse->flags; - usort( - $flags, - fn ($fs1, $fs2) => $fs1->feature->name <=> $fs2->feature->name - ); + // When + $result = Engine::getEvaluationResult($case['context']); + // Then $this->assertEquals( - count($flags), - count($engineResponse) + json_decode(json_encode($case['result']), associative: true), + json_decode(json_encode($result), associative: true), ); - - foreach ($engineResponse as $index => $featureState) { - $val = $featureState->getValue($identityModel->getDjangoId()); - $expectedVal = $flags[$index]->feature_state_value; - - $this->assertEquals( - $val, - $expectedVal - ); - $this->assertEquals( - count($flags), - count($engineResponse) - ); - } } } diff --git a/tests/Engine/EngineTests/EngineTestData b/tests/Engine/EngineTests/EngineTestData index 933f2ba..6453b03 160000 --- a/tests/Engine/EngineTests/EngineTestData +++ b/tests/Engine/EngineTests/EngineTestData @@ -1 +1 @@ -Subproject commit 933f2ba7aa6430797afc2d053530cfd005b461f6 +Subproject commit 6453b0391344a4d677a97cc4a9d27a8b8e329787