diff --git a/README.md b/README.md index 1728923..b80197e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -761,6 +762,44 @@ $ vendor/bin/featurevisor assess-distribution \ --n=1000 ``` +## OpenFeature + +The provider requires PHP 8 and the official OpenFeature SDK: + +```bash +composer require featurevisor/featurevisor-php open-feature/sdk +``` + +```php +use Featurevisor\OpenFeatureProvider; +use OpenFeature\OpenFeatureAPI; + +$provider = new OpenFeatureProvider([ + 'datafile' => $datafileContent, +]); + +$api = OpenFeatureAPI::getInstance(); +$api->setProvider($provider); + +$client = $api->getClient(); +$enabled = $client->getBooleanValue('checkout', false); +``` + +Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays and JSON variables use the object resolver. + +OpenFeature's targeting key maps to `userId` by default. Constructor arguments can customize the targeting key field, key separator, and reserved variation key. The current OpenFeature PHP provider interface does not expose flag metadata or provider shutdown. The adapter still provides `shutdown()` for applications that own its lifecycle. + +You can also reuse an existing Featurevisor instance: + +```php +$featurevisor = Featurevisor::createFeaturevisor(['datafile' => $datafileContent]); +$provider = new OpenFeatureProvider(featurevisor: $featurevisor); +``` + +The caller owns an instance passed this way. Calling `$provider->shutdown()` does not close it. Call `$featurevisor->close()` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `$featurevisor` takes precedence over `$options`. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, lifecycle, and providers for other languages. + ## Development of this package diff --git a/composer.json b/composer.json index d91408f..922f30d 100644 --- a/composer.json +++ b/composer.json @@ -37,13 +37,17 @@ "require": { "php": "^7.4 || ^8.0", "ext-json": "*", - "psr/log": "^1.1", + "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/polyfill-php80": "^1.33" }, "require-dev": { + "open-feature/sdk": "^2.2", "phpunit/phpunit": "^9", "phpstan/phpstan": "^2.1" }, + "suggest": { + "open-feature/sdk": "Required for Featurevisor\\OpenFeatureProvider (PHP 8.0+)" + }, "bin": [ "featurevisor" ], diff --git a/composer.lock b/composer.lock index e53e202..bb02810 100644 --- a/composer.lock +++ b/composer.lock @@ -4,34 +4,34 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8fd6a427fe3ddfc63a8c85ca1f576421", + "content-hash": "ad09a0b666727f1cfc006a7f13639e4c", "packages": [ { "name": "psr/log", - "version": "1.1.4", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -52,9 +52,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "symfony/polyfill-php80", @@ -272,6 +272,69 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "myclabs/php-enum", + "version": "1.8.5", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2025-01-14T11:49:03+00:00" + }, { "name": "nikic/php-parser", "version": "v5.6.1", @@ -330,6 +393,85 @@ }, "time": "2025-08-13T20:13:15+00:00" }, + { + "name": "open-feature/sdk", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/open-feature/php-sdk.git", + "reference": "40638875cb9050aec75126d7f714290eb034ca24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/open-feature/php-sdk/zipball/40638875cb9050aec75126d7f714290eb034ca24", + "reference": "40638875cb9050aec75126d7f714290eb034ca24", + "shasum": "" + }, + "require": { + "myclabs/php-enum": "^1.8", + "php": "^8", + "psr/log": "^2.0 || ^3.0" + }, + "require-dev": { + "behat/behat": "^3.11", + "captainhook/captainhook": "^5.10", + "captainhook/plugin-composer": "^5.3", + "dg/bypass-finals": "^1.9", + "ergebnis/composer-normalize": "^2.25", + "hamcrest/hamcrest-php": "^2.0", + "mdwheele/zalgo": "^0.3.1", + "mockery/mockery": "^1.5", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "~1.12.0", + "phpstan/phpstan-mockery": "^1.0", + "phpstan/phpstan-phpunit": "^1.1", + "psalm/plugin-mockery": "^1.0.0", + "psalm/plugin-phpunit": "^0.19.0", + "ramsey/coding-standard": "^2.0.3", + "ramsey/composer-repl": "^1.4", + "ramsey/conventional-commits": "^1.3", + "roave/security-advisories": "dev-latest", + "spatie/phpunit-snapshot-assertions": "^4.2", + "vimeo/psalm": "~5.26.0" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "OpenFeature\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Tom Carrio", + "email": "tom@carrio.dev" + } + ], + "description": "PHP implementation of the OpenFeature SDK", + "keywords": [ + "featureflagging", + "featureflags", + "openfeature" + ], + "support": { + "issues": "https://github.com/open-feature/php-sdk/issues", + "source": "https://github.com/open-feature/php-sdk/tree/2.2.0" + }, + "time": "2026-06-08T13:01:00+00:00" + }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -1988,13 +2130,13 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^7.4 || ^8.0", "ext-json": "*" }, - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/src/OpenFeatureProvider.php b/src/OpenFeatureProvider.php new file mode 100644 index 0000000..6f0600c --- /dev/null +++ b/src/OpenFeatureProvider.php @@ -0,0 +1,245 @@ + $options Featurevisor options + * @param callable|null $onTrack function(string, ?EvaluationContext, ?array): void + */ + public function __construct( + array $options = [], + ?Featurevisor $featurevisor = null, + string $targetingKeyField = 'userId', + string $keySeparator = ':', + string $variationKey = 'variation', + ?callable $onTrack = null + ) { + $this->targetingKeyField = $targetingKeyField !== '' ? $targetingKeyField : 'userId'; + $this->keySeparator = $keySeparator !== '' ? $keySeparator : ':'; + $this->variationKey = $variationKey !== '' ? $variationKey : 'variation'; + $this->onTrack = $onTrack; + $this->ownsFeaturevisor = $featurevisor === null; + if ($featurevisor !== null) { + $this->featurevisor = $featurevisor; + } else { + if (isset($options['datafile']) && is_string($options['datafile'])) { + try { + json_decode($options['datafile'], true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $error) { + $this->datafileError = 'Could not parse datafile'; + } + } + $originalHandler = $options['onDiagnostic'] ?? null; + $options['onDiagnostic'] = function (array $diagnostic) use ($originalHandler): void { + if (($diagnostic['code'] ?? null) === 'invalid_datafile') { + $this->datafileError = (string) $diagnostic['message']; + } + if (($diagnostic['code'] ?? null) === 'datafile_set') { + $this->datafileError = null; + } + if ($originalHandler !== null) { + $originalHandler($diagnostic); + } + }; + $this->featurevisor = Featurevisor::createFeaturevisor($options); + } + $this->datafileUnsubscribe = $this->featurevisor->on('datafile_set', function (): void { + $this->datafileError = null; + }); + } + + public function getFeaturevisor(): Featurevisor + { + return $this->featurevisor; + } + + public function shutdown(): void + { + ($this->datafileUnsubscribe)(); + if ($this->ownsFeaturevisor) { + $this->featurevisor->close(); + } + } + + /** @param array|null $details */ + public function track(string $name, ?EvaluationContext $context = null, ?array $details = null): void + { + if ($this->onTrack !== null) { + ($this->onTrack)($name, $context, $details); + } + } + + public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'boolean'); + } + + public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'string'); + } + + public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'integer'); + } + + public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'number'); + } + + /** @param mixed[] $defaultValue */ + public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'object'); + } + + /** @param bool|string|int|float|mixed[] $defaultValue */ + private function resolve(string $flagKey, $defaultValue, ?EvaluationContext $evaluationContext, string $expectedType): ResolutionDetailsInterface + { + if ($this->datafileError !== null) { + return $this->error($defaultValue, ErrorCode::PARSE_ERROR(), $this->datafileError); + } + $position = strpos($flagKey, $this->keySeparator); + $featureKey = $position === false ? $flagKey : substr($flagKey, 0, $position); + $selector = $position === false ? null : substr($flagKey, $position + strlen($this->keySeparator)); + $context = $this->context($evaluationContext); + + if ($selector === null || $selector === '') { + if ($expectedType !== 'boolean') { + return $this->typeMismatch($flagKey, $defaultValue, $expectedType); + } + $evaluation = $this->featurevisor->evaluateFlag($featureKey, $context); + $value = $evaluation['enabled'] ?? null; + } elseif ($selector === $this->variationKey) { + $evaluation = $this->featurevisor->evaluateVariation($featureKey, $context); + $value = $evaluation['variationValue'] ?? ($evaluation['variation']['value'] ?? null); + } else { + $evaluation = $this->featurevisor->evaluateVariable($featureKey, $selector, $context); + $value = $evaluation['variableValue'] ?? null; + if (($evaluation['variableSchema']['type'] ?? null) === 'json' && is_string($value)) { + $parsed = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $parsed; + } + } + } + + $errorCode = $this->errorCode($evaluation['reason']); + if ($errorCode !== null) { + return $this->error($defaultValue, $errorCode, $this->errorMessage($evaluation)); + } + if ($value === null) { + $value = $defaultValue; + } elseif (!$this->matches($value, $expectedType)) { + return $this->typeMismatch($flagKey, $defaultValue, $expectedType); + } + + $details = new ResolutionDetails(); + $details->setValue($value); + $details->setReason($this->reason($evaluation['reason'])); + $variant = $evaluation['variationValue'] ?? ($evaluation['variation']['value'] ?? null); + if ($variant !== null) { + $details->setVariant((string) $variant); + } + return $details; + } + + /** @return array */ + private function context(?EvaluationContext $context): array + { + $result = $context !== null ? $this->normalize($context->getAttributes()->toArray()) : []; + if ($context !== null && $context->getTargetingKey() !== null && $context->getTargetingKey() !== '') { + $result[$this->targetingKeyField] = $context->getTargetingKey(); + } + return $result; + } + + private function reason(string $reason): string + { + if (in_array($reason, ['feature_not_found', 'variable_not_found', 'no_variations', 'error'], true)) return Reason::ERROR; + if (in_array($reason, ['required', 'forced', 'sticky', 'rule', 'variable_override_variation', 'variable_override_rule'], true)) return Reason::TARGETING_MATCH; + if ($reason === 'allocated') return Reason::SPLIT; + if (in_array($reason, ['disabled', 'variation_disabled', 'variable_disabled'], true)) return Reason::DISABLED; + return Reason::DEFAULT; + } + + private function errorCode(string $reason): ?ErrorCode + { + if (in_array($reason, ['feature_not_found', 'variable_not_found', 'no_variations'], true)) return ErrorCode::FLAG_NOT_FOUND(); + if ($reason === 'error') return ErrorCode::GENERAL(); + return null; + } + + /** @param array $evaluation */ + private function errorMessage(array $evaluation): string + { + if (($evaluation['error'] ?? null) instanceof \Throwable) return $evaluation['error']->getMessage(); + if ($evaluation['reason'] === 'feature_not_found') return sprintf('Feature "%s" was not found', $evaluation['featureKey']); + if ($evaluation['reason'] === 'variable_not_found') return sprintf('Variable "%s" was not found for feature "%s"', $evaluation['variableKey'] ?? '', $evaluation['featureKey']); + if ($evaluation['reason'] === 'no_variations') return sprintf('Feature "%s" has no variations', $evaluation['featureKey']); + return 'Featurevisor evaluation failed'; + } + + /** @param mixed $value */ + private function matches($value, string $expectedType): bool + { + if ($expectedType === 'boolean') return is_bool($value); + if ($expectedType === 'string') return is_string($value); + if ($expectedType === 'integer') return is_int($value) || (is_float($value) && floor($value) === $value); + if ($expectedType === 'number') return (is_int($value) || is_float($value)) && is_finite((float) $value); + return is_array($value); + } + + /** @param mixed $value @return mixed */ + private function normalize($value) + { + if ($value instanceof DateTimeInterface) return $value->format(DATE_ATOM); + if (is_array($value)) return array_map(fn($item) => $this->normalize($item), $value); + return $value; + } + + /** @param bool|string|int|float|mixed[] $value */ + private function error($value, ErrorCode $code, string $message): ResolutionDetailsInterface + { + $details = new ResolutionDetails(); + $details->setValue($value); + $details->setReason(Reason::ERROR); + $details->setError(new ResolutionError($code, $message)); + return $details; + } + + /** @param bool|string|int|float|mixed[] $value */ + private function typeMismatch(string $key, $value, string $expected): ResolutionDetailsInterface + { + return $this->error($value, ErrorCode::TYPE_MISMATCH(), sprintf('Flag "%s" did not resolve to a %s value', $key, $expected)); + } +} diff --git a/tests/OpenFeatureProviderTest.php b/tests/OpenFeatureProviderTest.php new file mode 100644 index 0000000..c1b1e28 --- /dev/null +++ b/tests/OpenFeatureProviderTest.php @@ -0,0 +1,126 @@ + */ + private function datafile(): array + { + return [ + 'schemaVersion' => '2', 'revision' => 'openfeature-test', 'segments' => [], + 'features' => [ + 'checkout' => [ + 'bucketBy' => 'userId', + 'variations' => [[ + 'value' => 'on', + 'variables' => [ + 'title' => 'Hello', 'count' => 3, 'ratio' => 1.5, 'visible' => true, + 'items' => ['a'], 'config' => ['color' => 'blue'], 'json' => '{"nested":true}', + ], + ]], + 'variablesSchema' => [ + 'title' => ['type' => 'string', 'defaultValue' => 'Default'], + 'count' => ['type' => 'integer', 'defaultValue' => 0], + 'ratio' => ['type' => 'double', 'defaultValue' => 0], + 'visible' => ['type' => 'boolean', 'defaultValue' => false], + 'items' => ['type' => 'array', 'defaultValue' => []], + 'config' => ['type' => 'object', 'defaultValue' => []], + 'json' => ['type' => 'json', 'defaultValue' => '{}'], + ], + 'force' => [[ + 'conditions' => ['attribute' => 'userId', 'operator' => 'equals', 'value' => 'forced-user'], + 'enabled' => true, 'variation' => 'on', + ]], + 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000, 'variation' => 'on']], + ], + ], + ]; + } + + public function testResolvesEveryTypeAndMapsTargetingKey(): void + { + $provider = new OpenFeatureProvider(['datafile' => $this->datafile(), 'logLevel' => 'fatal']); + $context = new EvaluationContext('forced-user', new Attributes()); + self::assertTrue($provider->resolveBooleanValue('checkout', false, $context)->getValue()); + self::assertSame('on', $provider->resolveStringValue('checkout:variation', 'fallback', $context)->getValue()); + self::assertSame('Hello', $provider->resolveStringValue('checkout:title', 'fallback', $context)->getValue()); + self::assertSame(3, $provider->resolveIntegerValue('checkout:count', 0, $context)->getValue()); + self::assertSame(1.5, $provider->resolveFloatValue('checkout:ratio', 0.0, $context)->getValue()); + self::assertTrue($provider->resolveBooleanValue('checkout:visible', false, $context)->getValue()); + self::assertSame(['a'], $provider->resolveObjectValue('checkout:items', [], $context)->getValue()); + self::assertSame(['color' => 'blue'], $provider->resolveObjectValue('checkout:config', [], $context)->getValue()); + self::assertSame(['nested' => true], $provider->resolveObjectValue('checkout:json', [], $context)->getValue()); + } + + public function testErrorsGrammarTrackingAndLifecycle(): void + { + $tracked = []; + $provider = new OpenFeatureProvider( + ['datafile' => $this->datafile(), 'logLevel' => 'fatal'], + null, + 'userId', + '/', + '$variation', + function (...$args) use (&$tracked): void { $tracked[] = $args; } + ); + self::assertSame('on', $provider->resolveStringValue('checkout/$variation', 'fallback')->getValue()); + self::assertEquals(ErrorCode::TYPE_MISMATCH(), $provider->resolveStringValue('missing', 'fallback')->getError()->getResolutionErrorCode()); + $missing = $provider->resolveBooleanValue('missing', true); + self::assertTrue($missing->getValue()); + self::assertEquals(ErrorCode::FLAG_NOT_FOUND(), $missing->getError()->getResolutionErrorCode()); + $provider->track('purchase'); + self::assertSame('purchase', $tracked[0][0]); + $provider->shutdown(); + } + + public function testMalformedDatafileReportsParseError(): void + { + $provider = new OpenFeatureProvider(['datafile' => '{', 'logLevel' => 'fatal']); + $result = $provider->resolveBooleanValue('checkout', false); + self::assertEquals(ErrorCode::PARSE_ERROR(), $result->getError()->getResolutionErrorCode()); + self::assertSame('Could not parse datafile', $result->getError()->getResolutionErrorMessage()); + $provider->getFeaturevisor()->setDatafile($this->datafile(), true); + self::assertTrue($provider->resolveBooleanValue('checkout', false, new EvaluationContext('forced-user', new Attributes()))->getValue()); + } + + public function testWorksThroughOpenFeatureApi(): void + { + $api = OpenFeatureAPI::getInstance(); + $api->setProvider(new OpenFeatureProvider(['datafile' => $this->datafile(), 'logLevel' => 'fatal'])); + self::assertTrue($api->getClient()->getBooleanValue( + 'checkout', + false, + new EvaluationContext('forced-user', new Attributes()) + )); + } + + public function testBorrowsExistingFeaturevisor(): void + { + $closed = false; + $featurevisor = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(), + 'logLevel' => 'fatal', + 'modules' => [[ + 'name' => 'owner', + 'close' => function () use (&$closed): void { $closed = true; }, + ]], + ]); + $provider = new OpenFeatureProvider(featurevisor: $featurevisor); + + self::assertSame($featurevisor, $provider->getFeaturevisor()); + $provider->shutdown(); + self::assertFalse($closed); + + $featurevisor->close(); + self::assertTrue($closed); + } +}