From aaa2577c1f2bbe6311cbe4bf13585ff5e9c4e13a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 7 Aug 2024 15:46:16 +0200 Subject: [PATCH] Goff PHP Provider initial commit Signed-off-by: Thomas Poignant --- .github/workflows/php-ci.yaml | 1 + .github/workflows/split_monorepo.yaml | 14 + providers/GoFeatureFlag/.gitignore | 3 + providers/GoFeatureFlag/README.md | 143 +++++ providers/GoFeatureFlag/composer.json | 107 ++++ providers/GoFeatureFlag/phpcs.xml.dist | 25 + providers/GoFeatureFlag/phpstan.neon.dist | 11 + providers/GoFeatureFlag/phpunit.xml.dist | 25 + providers/GoFeatureFlag/psalm-baseline.xml | 2 + providers/GoFeatureFlag/psalm.xml | 17 + .../src/GoFeatureFlagProvider.php | 124 +++++ providers/GoFeatureFlag/src/config/Config.php | 36 ++ .../GoFeatureFlag/src/controller/OfrepApi.php | 129 +++++ .../src/exception/BaseGoffException.php | 38 ++ .../src/exception/BaseOfrepException.php | 41 ++ .../src/exception/FlagNotFoundException.php | 24 + .../src/exception/InvalidConfigException.php | 19 + .../src/exception/InvalidContextException.php | 15 + .../src/exception/ParseException.php | 14 + .../src/exception/RateLimitedException.php | 16 + .../src/exception/UnauthorizedException.php | 16 + .../src/exception/UnknownOfrepException.php | 16 + .../src/model/OfrepApiResponse.php | 151 ++++++ .../GoFeatureFlag/src/util/Validator.php | 107 ++++ providers/GoFeatureFlag/tests/TestCase.php | 39 ++ .../tests/unit/GoFeatureFlagProviderTest.php | 488 ++++++++++++++++++ .../tests/unit/controller/OfrepApiTest.php | 440 ++++++++++++++++ release-please-config.json | 4 + 28 files changed, 2065 insertions(+) create mode 100644 providers/GoFeatureFlag/.gitignore create mode 100644 providers/GoFeatureFlag/README.md create mode 100644 providers/GoFeatureFlag/composer.json create mode 100644 providers/GoFeatureFlag/phpcs.xml.dist create mode 100644 providers/GoFeatureFlag/phpstan.neon.dist create mode 100644 providers/GoFeatureFlag/phpunit.xml.dist create mode 100644 providers/GoFeatureFlag/psalm-baseline.xml create mode 100644 providers/GoFeatureFlag/psalm.xml create mode 100644 providers/GoFeatureFlag/src/GoFeatureFlagProvider.php create mode 100644 providers/GoFeatureFlag/src/config/Config.php create mode 100644 providers/GoFeatureFlag/src/controller/OfrepApi.php create mode 100644 providers/GoFeatureFlag/src/exception/BaseGoffException.php create mode 100644 providers/GoFeatureFlag/src/exception/BaseOfrepException.php create mode 100644 providers/GoFeatureFlag/src/exception/FlagNotFoundException.php create mode 100644 providers/GoFeatureFlag/src/exception/InvalidConfigException.php create mode 100644 providers/GoFeatureFlag/src/exception/InvalidContextException.php create mode 100644 providers/GoFeatureFlag/src/exception/ParseException.php create mode 100644 providers/GoFeatureFlag/src/exception/RateLimitedException.php create mode 100644 providers/GoFeatureFlag/src/exception/UnauthorizedException.php create mode 100644 providers/GoFeatureFlag/src/exception/UnknownOfrepException.php create mode 100644 providers/GoFeatureFlag/src/model/OfrepApiResponse.php create mode 100644 providers/GoFeatureFlag/src/util/Validator.php create mode 100644 providers/GoFeatureFlag/tests/TestCase.php create mode 100644 providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php create mode 100644 providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php diff --git a/.github/workflows/php-ci.yaml b/.github/workflows/php-ci.yaml index 28eb2f9..109afb1 100644 --- a/.github/workflows/php-ci.yaml +++ b/.github/workflows/php-ci.yaml @@ -19,6 +19,7 @@ jobs: - hooks/Validators - providers/Flagd - providers/Split + - providers/GoFeatureFlag # - providers/CloudBees fail-fast: false diff --git a/.github/workflows/split_monorepo.yaml b/.github/workflows/split_monorepo.yaml index eac7db8..4d7e1ca 100644 --- a/.github/workflows/split_monorepo.yaml +++ b/.github/workflows/split_monorepo.yaml @@ -87,3 +87,17 @@ jobs: targetRepo: split-provider targetBranch: refs/tags/${{ github.event.release.tag_name }} filterArguments: '--subdirectory-filter providers/Split/ --force' + + split-provider-go-feature-flag: + runs-on: ubuntu-latest + steps: + - name: checkout + run: git clone "$GITHUB_SERVER_URL"/"$GITHUB_REPOSITORY" "$GITHUB_WORKSPACE" && cd "$GITHUB_WORKSPACE" && git checkout "$GITHUB_SHA" + - name: push-provider-split + uses: tcarrio/git-filter-repo-docker-action@v1 + with: + privateKey: ${{ secrets.SSH_PRIVATE_KEY }} + targetOrg: open-feature-php + targetRepo: go-feature-flag-provider + targetBranch: refs/tags/${{ github.event.release.tag_name }} + filterArguments: '--subdirectory-filter providers/GoFeatureFlag/ --force' diff --git a/providers/GoFeatureFlag/.gitignore b/providers/GoFeatureFlag/.gitignore new file mode 100644 index 0000000..e1efd91 --- /dev/null +++ b/providers/GoFeatureFlag/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/build \ No newline at end of file diff --git a/providers/GoFeatureFlag/README.md b/providers/GoFeatureFlag/README.md new file mode 100644 index 0000000..8eb5b80 --- /dev/null +++ b/providers/GoFeatureFlag/README.md @@ -0,0 +1,143 @@ +

+ go-feature-flag logo + +

+ +# GO Feature Flag - OpenFeature PHP provider +

+ + + Packagist Version + Documentation + Issues + Join us on slack +

+ +This repository contains the official PHP OpenFeature provider for accessing your feature flags with [GO Feature Flag](https://gofeatureflag.org). + +In conjunction with the [OpenFeature SDK](https://openfeature.dev/docs/reference/concepts/provider) you will be able +to evaluate your feature flags in your Ruby applications. + +For documentation related to flags management in GO Feature Flag, +refer to the [GO Feature Flag documentation website](https://gofeatureflag.org/docs). + +### Functionalities: +- Manage the integration of the OpenFeature PHP SDK and GO Feature Flag relay-proxy. + +## Dependency Setup + +### Composer + +```shell +composer require open-feature/go-feature-flag-provider +``` +## Getting started + +### Initialize the provider + +The `GoFeatureFlagProvider` takes a config object as parameter to be initialized. + +The constructor of the config object has the following options: + +| **Option** | **Description** | +|-----------------|------------------------------------------------------------------------------------------------------------------| +| `endpoint` | **(mandatory)** The URL to access to the relay-proxy.
*(example: `https://relay.proxy.gofeatureflag.org/`)* | +| `apiKey` | The token used to call the relay proxy. | +| `customHeaders` | Any headers you want to add to call the relay-proxy. | + +The only required option to create a `GoFeatureFlagProvider` is the URL _(`endpoint`)_ to your GO Feature Flag relay-proxy instance. + +```php +use OpenFeature\Providers\GoFeatureFlag\config\Config; +use OpenFeature\Providers\GoFeatureFlag\GoFeatureFlagProvider; +use OpenFeature\implementation\flags\MutableEvaluationContext; +use OpenFeature\implementation\flags\Attributes; +use OpenFeature\OpenFeatureAPI; + +$config = new Config('http://gofeatureflag.org', 'my-api-key); +$provider = new GoFeatureFlagProvider($config); + +$api = OpenFeatureAPI::getInstance(); +$api->setProvider($provider); +$client = $api->getClient(); +$evaluationContext = new MutableEvaluationContext( + "214b796a-807b-4697-b3a3-42de0ec10a37", + new Attributes(["email" => "contact@gofeatureflag.org"]) + ); + +$value = $client->getBooleanDetails('integer_key', false, $evaluationContext); +if ($value) { + echo "The flag is enabled"; +} else { + echo "The flag is disabled"; +} +``` + +The evaluation context is the way for the client to specify contextual data that GO Feature Flag uses to evaluate the feature flags, it allows to define rules on the flag. + +The `targeting_key` is mandatory for GO Feature Flag to evaluate the feature flag, it could be the id of a user, a session ID or anything you find relevant to use as identifier during the evaluation. + + +### Evaluate a feature flag +The client is used to retrieve values for the current `EvaluationContext`. +For example, retrieving a boolean value for the flag **"my-flag"**: + +```php +$value = $client->getBooleanDetails('integer_key', false, $evaluationContext); +if ($value) { + echo "The flag is enabled"; +} else { + echo "The flag is disabled"; +} +``` + +GO Feature Flag supports different all OpenFeature supported types of feature flags, it means that you can use all the accessor directly +```php +// Bool +$client->getBooleanDetails('my-flag-key', false, new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); +$client->getBooleanValue('my-flag-key', false, new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); + +// String +$client->getStringDetails('my-flag-key', "default", new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); +$client->getStringValue('my-flag-key', "default", new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); + +// Integer +$client->getIntegerDetails('my-flag-key', 1, new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); +$client->getIntegerValue('my-flag-key', 1, new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); + +// Float +$client->getFloatDetails('my-flag-key', 1.1, new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); +$client->getFloatValue('my-flag-key', 1.1, new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); + +// Object +$client->getObjectDetails('my-flag-key', ["default" => true], new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); +$client->getObjectValue('my-flag-key', ["default" => true], new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37")); +``` + +## Features status + +| Status | Feature | Description | +|-------|-----------------|----------------------------------------------------------------------------| +| ✅ | Flag evaluation | It is possible to evaluate all the type of flags | +| ❌ | Caching | Mechanism is in place to refresh the cache in case of configuration change | +| ❌ | Event Streaming | Not supported by the SDK | +| ❌ | Logging | Not supported by the SDK | +| ❌ | Flag Metadata | Not supported by the SDK | + + +**Implemented**: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ + +## Contributing +This project welcomes contributions from the community. +If you're interested in contributing, see the [contributors' guide](https://github.com/thomaspoignant/go-feature-flag/blob/main/CONTRIBUTING.md) for some helpful tips. + +### PHP Versioning +This library targets PHP version 8.0 and newer. As long as you have any compatible version of PHP on your system you should be able to utilize the OpenFeature SDK. + +This package also has a .tool-versions file for use with PHP version managers like asdf. + +### Installation and Dependencies +Install dependencies with `composer install`, it will update the `composer.lock` with the most recent compatible versions. + +We value having as few runtime dependencies as possible. The addition of any dependencies requires careful consideration and review. + diff --git a/providers/GoFeatureFlag/composer.json b/providers/GoFeatureFlag/composer.json new file mode 100644 index 0000000..8f1f899 --- /dev/null +++ b/providers/GoFeatureFlag/composer.json @@ -0,0 +1,107 @@ +{ + "name": "open-feature/go-feature-flag-provider", + "description": "The GO Feature Flag provider package for open-feature", + "license": "Apache-2.0", + "type": "library", + "keywords": [ + "featureflags", + "featureflagging", + "openfeature", + "gofeatureflag", + "provider" + ], + "authors": [ + { + "name": "Thomas Poignant", + "homepage": "https://github.com/thomaspoignant/go-feature-flag" + } + ], + "require": { + "php": "^8", + "open-feature/sdk": "^2.0", + "guzzlehttp/guzzle": "^7.9" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "mockery/mockery": "^1.6", + "spatie/phpunit-snapshot-assertions": "^4.2" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "OpenFeature\\Providers\\GoFeatureFlag\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OpenFeature\\Providers\\GoFeatureFlag\\Test\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true, + "dealerdirect/phpcodesniffer-composer-installer": true, + "ergebnis/composer-normalize": true, + "captainhook/plugin-composer": true, + "ramsey/composer-repl": true + }, + "sort-packages": true + }, + "scripts": { + "dev:analyze": [ + "@dev:analyze:phpstan", + "@dev:analyze:psalm" + ], + "dev:analyze:phpstan": "phpstan analyse --ansi --debug --memory-limit=512M", + "dev:analyze:psalm": "psalm", + "dev:build:clean": "git clean -fX build/", + "dev:lint": [ + "@dev:lint:syntax", + "@dev:lint:style" + ], + "dev:lint:fix": "phpcbf", + "dev:lint:style": "phpcs --colors", + "dev:lint:syntax": "parallel-lint --colors src/ tests/", + "dev:test": [ + "@dev:lint", + "@dev:analyze", + "@dev:test:unit", + "@dev:test:integration" + ], + "dev:test:coverage:ci": "phpunit --colors=always --coverage-text --coverage-clover build/coverage/clover.xml --coverage-cobertura build/coverage/cobertura.xml --coverage-crap4j build/coverage/crap4j.xml --coverage-xml build/coverage/coverage-xml --log-junit build/junit.xml", + "dev:test:coverage:html": "phpunit --colors=always --coverage-html build/coverage/coverage-html/", + "dev:test:unit": [ + "@dev:test:unit:setup", + "phpunit --colors=always --testdox --testsuite=unit", + "@dev:test:unit:teardown" + ], + "dev:test:unit:debug": "phpunit --colors=always --testdox -d xdebug.profiler_enable=on", + "dev:test:unit:setup": "echo 'Setup for unit tests...'", + "dev:test:unit:teardown": "echo 'Tore down for unit tests...'", + "dev:test:integration": [ + "@dev:test:integration:setup", + "phpunit --colors=always --testdox --testsuite=integration", + "@dev:test:integration:teardown" + ], + "dev:test:integration:debug": "phpunit --colors=always --testdox -d xdebug.profiler_enable=on", + "dev:test:integration:setup": "echo 'Setup for integration tests...'", + "dev:test:integration:teardown": "echo 'Tore down integration tests...'", + "test": "@dev:test" + }, + "scripts-descriptions": { + "dev:analyze": "Runs all static analysis checks.", + "dev:analyze:phpstan": "Runs the PHPStan static analyzer.", + "dev:analyze:psalm": "Runs the Psalm static analyzer.", + "dev:build:clean": "Cleans the build/ directory.", + "dev:lint": "Runs all linting checks.", + "dev:lint:fix": "Auto-fixes coding standards issues, if possible.", + "dev:lint:style": "Checks for coding standards issues.", + "dev:lint:syntax": "Checks for syntax errors.", + "dev:test": "Runs linting, static analysis, and unit tests.", + "dev:test:coverage:ci": "Runs unit tests and generates CI coverage reports.", + "dev:test:coverage:html": "Runs unit tests and generates HTML coverage report.", + "dev:test:unit": "Runs unit tests.", + "test": "Runs linting, static analysis, and unit tests." + } +} diff --git a/providers/GoFeatureFlag/phpcs.xml.dist b/providers/GoFeatureFlag/phpcs.xml.dist new file mode 100644 index 0000000..55d9d3a --- /dev/null +++ b/providers/GoFeatureFlag/phpcs.xml.dist @@ -0,0 +1,25 @@ + + + + + + + + ./src + ./tests + + */tests/fixtures/* + */tests/*/fixtures/* + + + + + + + + + + + + + diff --git a/providers/GoFeatureFlag/phpstan.neon.dist b/providers/GoFeatureFlag/phpstan.neon.dist new file mode 100644 index 0000000..2b2f33d --- /dev/null +++ b/providers/GoFeatureFlag/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + tmpDir: ./build/cache/phpstan + level: max + paths: + - ./src + - ./tests + excludePaths: + - */tests/fixtures/* + - */tests/*/fixtures/* + # TODO: Implement gRPC Completely + - ./src/grpc diff --git a/providers/GoFeatureFlag/phpunit.xml.dist b/providers/GoFeatureFlag/phpunit.xml.dist new file mode 100644 index 0000000..ecad4cc --- /dev/null +++ b/providers/GoFeatureFlag/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + ./tests/unit + + + + + + ./src + + + + + + + + diff --git a/providers/GoFeatureFlag/psalm-baseline.xml b/providers/GoFeatureFlag/psalm-baseline.xml new file mode 100644 index 0000000..ceaa577 --- /dev/null +++ b/providers/GoFeatureFlag/psalm-baseline.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/providers/GoFeatureFlag/psalm.xml b/providers/GoFeatureFlag/psalm.xml new file mode 100644 index 0000000..c3e6c03 --- /dev/null +++ b/providers/GoFeatureFlag/psalm.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php b/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php new file mode 100644 index 0000000..784629f --- /dev/null +++ b/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php @@ -0,0 +1,124 @@ +getCustomHeaders()) && !array_key_exists("Content-Type", $config->getCustomHeaders())) { + $config->getCustomHeaders()["Content-Type"] = "application/json"; + } + $this->ofrepApi = new OfrepApi($config); + } + + public function getMetadata(): Metadata + { + return new Metadata(self::$CLIENT_NAME); + } + + public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, $defaultValue, ['boolean'], $context); + } + + private function evaluate(string $flagKey, mixed $defaultValue, array $allowedClasses, ?EvaluationContext $evaluationContext = null): ResolutionDetails + { + try { + Validator::validateEvaluationContext($evaluationContext); + Validator::validateFlagKey($flagKey); + $apiResp = $this->ofrepApi->evaluate($flagKey, $evaluationContext); + + if ($apiResp->isError()) { + $err = new ResolutionError($apiResp->getErrorCode(), $apiResp->getErrorDetails()); + return (new ResolutionDetailsBuilder()) + ->withValue($defaultValue) + ->withError($err) + ->withReason(Reason::ERROR) + ->build(); + } + + if (!$this->isValidType($apiResp->getValue(), $allowedClasses)) { + return (new ResolutionDetailsBuilder()) + ->withReason(Reason::ERROR) + ->withError(new ResolutionError( + ErrorCode::TYPE_MISMATCH(), + "Invalid type for $flagKey, got " . gettype($apiResp->getValue()) . " expected " . implode(", ", $allowedClasses))) + ->withValue($defaultValue) + ->build(); + } + return (new ResolutionDetailsBuilder()) + ->withValue($apiResp->getValue()) + ->withReason($apiResp->getReason()) + ->withVariant($apiResp->getVariant()) + ->build(); + + } catch (BaseOfrepException $e) { + $err = new ResolutionError($e->getErrorCode(), $e->getMessage()); + return (new ResolutionDetailsBuilder()) + ->withValue($defaultValue) + ->withError($err) + ->withReason(Reason::ERROR) + ->build(); + } catch (\Exception $e) { + return (new ResolutionDetailsBuilder()) + ->withValue($defaultValue) + ->withError(new ResolutionError(ErrorCode::GENERAL(), "An error occurred while evaluating the flag: " . $e->getMessage())) + ->withReason(Reason::ERROR) + ->build(); + } + } + + private function isValidType(mixed $value, array $allowedClasses): bool + { + foreach ($allowedClasses as $class) { + if ($value instanceof $class || gettype($value) === $class) { + return true; + } + } + return false; + } + + public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, $defaultValue, ['string'], $context); + } + + public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, $defaultValue, ['integer'], $context); + } + + public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, $defaultValue, ['double'], $context); + } + + public function resolveObjectValue(string $flagKey, mixed $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, $defaultValue, ['array'], $context); + } +} diff --git a/providers/GoFeatureFlag/src/config/Config.php b/providers/GoFeatureFlag/src/config/Config.php new file mode 100644 index 0000000..41bb165 --- /dev/null +++ b/providers/GoFeatureFlag/src/config/Config.php @@ -0,0 +1,36 @@ +endpoint = $endpoint; + $this->customHeaders = $custom_headers; + if ($apiKey !== null && $apiKey !== '') { + $this->customHeaders['Authorization'] = 'Bearer ' . $apiKey; + } + } + + /** + * @return string + */ + public function getEndpoint(): string + { + return $this->endpoint; + } + + /** + * @return array + */ + public function getCustomHeaders(): array + { + return $this->customHeaders; + } +} diff --git a/providers/GoFeatureFlag/src/controller/OfrepApi.php b/providers/GoFeatureFlag/src/controller/OfrepApi.php new file mode 100644 index 0000000..ef42abd --- /dev/null +++ b/providers/GoFeatureFlag/src/controller/OfrepApi.php @@ -0,0 +1,129 @@ +options = $config; + $this->client = new Client([ + 'base_uri' => $config->getEndpoint(), + ]); + } + + /** + * @throws ParseException + * @throws FlagNotFoundException + * @throws RateLimitedException + * @throws UnauthorizedException + * @throws UnknownOfrepException + * @throws BaseOfrepException + */ + public function evaluate(string $flagKey, EvaluationContext $evaluationContext): OfrepApiResponse + { + try { + if ($this->retryAfter !== null) { + if (time() < $this->retryAfter) { + throw new RateLimitedException(); + } else { + $this->retryAfter = null; + } + } + + $base_uri = $this->options->getEndpoint(); + $evaluateApiPath = rtrim($base_uri, '/') . "/ofrep/v1/evaluate/flags/{$flagKey}"; + $headers = [ + 'Content-Type' => 'application/json' + ]; + + if ($this->options->getCustomHeaders() !== null) { + $headers = array_merge($headers, $this->options->getCustomHeaders()); + } + + $fields = array_merge( + $evaluationContext->getAttributes()->toArray(), + ['targetingKey' => $evaluationContext->getTargetingKey()] + ); + + $requestBody = json_encode(['context' => $fields]); + $response = $this->client->post($evaluateApiPath, [ + 'headers' => $headers, + 'body' => $requestBody + ]); + + switch ($response->getStatusCode()) { + case 200: + return $this->parseSuccessResponse($response); + case 400: + return $this->parseErrorResponse($response); + case 401: + case 403: + throw new UnauthorizedException($response); + case 404: + throw new FlagNotFoundException($flagKey, $response); + case 429: + $this->parseRetryLaterHeader($response); + throw new RateLimitedException($response); + default: + throw new UnknownOfrepException($response); + } + } catch (BaseOfrepException $e) { + throw $e; + } catch (GuzzleException|Exception $e) { + throw new UnknownOfrepException(null, $e); + } + } + + /** + * @throws ParseException + */ + private function parseSuccessResponse(ResponseInterface $response): OfrepApiResponse + { + $parsed = json_decode($response->getBody()->getContents(), true); + return OfrepApiResponse::createSuccessResponse($parsed); + } + + /** + * @throws ParseException + */ + private function parseErrorResponse(ResponseInterface $response): OfrepApiResponse + { + $parsed = json_decode($response->getBody()->getContents(), true); + return OfrepApiResponse::createErrorResponse($parsed); + } + + private function parseRetryLaterHeader(ResponseInterface $response): void + { + $retryAfterHeader = $response->getHeaderLine('Retry-After'); + if ($retryAfterHeader) { + if (is_numeric($retryAfterHeader)) { + // Retry-After is in seconds + $this->retryAfter = time() + (int)$retryAfterHeader; + } else { + // Retry-After is in HTTP-date format + $this->retryAfter = strtotime($retryAfterHeader); + } + } + } +} diff --git a/providers/GoFeatureFlag/src/exception/BaseGoffException.php b/providers/GoFeatureFlag/src/exception/BaseGoffException.php new file mode 100644 index 0000000..b5124e7 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/BaseGoffException.php @@ -0,0 +1,38 @@ +customMessage = $message; + $this->response = $response; + $this->errorCode = $errorCode; + parent::__construct($message, $code, $previous); + } + + public function getCustomMessage(): string + { + return $this->customMessage; + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + public function getErrorCode(): ErrorCode + { + return $this->errorCode; + } +} \ No newline at end of file diff --git a/providers/GoFeatureFlag/src/exception/BaseOfrepException.php b/providers/GoFeatureFlag/src/exception/BaseOfrepException.php new file mode 100644 index 0000000..0e9e132 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/BaseOfrepException.php @@ -0,0 +1,41 @@ +customMessage = $message; + $this->response = $response; + $this->errorCode = $errorCode; + parent::__construct($message, $code, $previous); + } + + public function getCustomMessage(): string + { + return $this->customMessage; + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + /** + * @return ErrorCode + */ + public function getErrorCode(): ErrorCode + { + return $this->errorCode; + } +} \ No newline at end of file diff --git a/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php b/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php new file mode 100644 index 0000000..475f44f --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php @@ -0,0 +1,24 @@ +flagKey = $flagKey; + $message = "Flag with key $flagKey not found"; + $code = 1002; + parent::__construct($message, ErrorCode::FLAG_NOT_FOUND(), $response, $code); + } + + public function getFlagKey(): string + { + return $this->flagKey; + } +} diff --git a/providers/GoFeatureFlag/src/exception/InvalidConfigException.php b/providers/GoFeatureFlag/src/exception/InvalidConfigException.php new file mode 100644 index 0000000..9b1e108 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/InvalidConfigException.php @@ -0,0 +1,19 @@ +customMessage = $message; + parent::__construct($message, $code, $previous); + } + + public function getCustomMessage(): string + { + return $this->customMessage; + } +} \ No newline at end of file diff --git a/providers/GoFeatureFlag/src/exception/InvalidContextException.php b/providers/GoFeatureFlag/src/exception/InvalidContextException.php new file mode 100644 index 0000000..59d1101 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/InvalidContextException.php @@ -0,0 +1,15 @@ +value = $value; + $this->key = $key; + $this->reason = $reason; + $this->variant = $variant; + $this->errorCode = $errorCode; + $this->errorDetails = $errorDetails; + $this->metadata = $metadata; + } + + /** + * @throws ParseException + */ + public static function createErrorResponse(array $apiData): OfrepApiResponse + { + Validator::validateErrorApiResponse($apiData); + return new OfrepApiResponse( + null, + $apiData["key"], + Reason::ERROR, + null, + OfrepApiResponse::errorCodeMapper($apiData["errorCode"]), + $apiData["errorDetails"], + [] + ); + } + + private static function errorCodeMapper(string $errorCode): ErrorCode + { + return match ($errorCode) { + 'PROVIDER_NOT_READY' => ErrorCode::PROVIDER_NOT_READY(), + 'FLAG_NOT_FOUND' => ErrorCode::FLAG_NOT_FOUND(), + 'PARSE_ERROR' => ErrorCode::PARSE_ERROR(), + 'TYPE_MISMATCH' => ErrorCode::TYPE_MISMATCH(), + 'TARGETING_KEY_MISSING' => ErrorCode::TARGETING_KEY_MISSING(), + 'INVALID_CONTEXT' => ErrorCode::INVALID_CONTEXT(), + default => ErrorCode::GENERAL() + }; + } + + /** + * @throws ParseException + */ + public static function createSuccessResponse(array $apiData): OfrepApiResponse + { + Validator::validateSuccessApiResponse($apiData); + $value = $apiData['value']; + $key = $apiData['key']; + $variant = $apiData['variant']; + $reason = OfrepApiResponse::reasonMapper($apiData['reason']); + $metadata = $apiData['metadata'] ?? []; + return new OfrepApiResponse($value, $key, $reason, $variant, null, null, $metadata); + } + + private static function reasonMapper(string $reason): string + { + return match ($reason) { + 'ERROR' => Reason::ERROR, + 'DEFAULT' => Reason::DEFAULT, + 'TARGETING_MATCH' => Reason::TARGETING_MATCH, + 'SPLIT' => Reason::SPLIT, + 'DISABLED' => Reason::DISABLED, + default => Reason::UNKNOWN + }; + } + + public function isError(): bool + { + return $this->errorCode !== null; + } + + /** + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return string + */ + public function getReason(): string + { + return $this->reason; + } + + /** + * @return ?string + */ + public function getVariant(): ?string + { + return $this->variant; + } + + /** + * @return ?ErrorCode + */ + public function getErrorCode(): ?ErrorCode + { + return $this->errorCode; + } + + /** + * @return ?string + */ + public function getErrorDetails(): ?string + { + return $this->errorDetails; + } + + /** + * @return ?array + */ + public function getMetadata(): ?array + { + return $this->metadata; + } +} diff --git a/providers/GoFeatureFlag/src/util/Validator.php b/providers/GoFeatureFlag/src/util/Validator.php new file mode 100644 index 0000000..9027ec5 --- /dev/null +++ b/providers/GoFeatureFlag/src/util/Validator.php @@ -0,0 +1,107 @@ +getEndpoint()); + } + + /** + * @param string $endpoint + * @return void + * @throws InvalidConfigException + */ + private static function validateEndpoint(string $endpoint): void + { + if (!filter_var($endpoint, FILTER_VALIDATE_URL)) { + throw new InvalidConfigException('Invalid endpoint URL: ' . $endpoint); + } + } + + /** + * @throws ParseException + */ + public static function validateSuccessApiResponse(array $data): void + { + $requiredKeys = ['key', 'value', 'reason', 'variant']; + $missingKeys = array_diff($requiredKeys, array_keys($data)); + if (!empty($missingKeys)) { + throw new ParseException( + "missing keys in the success response: " . implode(', ', $missingKeys) + ); + } + + if (!is_string($data['key'])) { + throw new ParseException('key is not a string'); + } + + if (!is_string($data['variant'])) { + throw new ParseException('variant is not a string'); + } + + if (!is_string($data['reason'])) { + throw new ParseException('reason is not a string'); + } + + if (key_exists('metadata', $data) && !is_array($data['metadata'])) { + throw new ParseException('metadata is not an array'); + } + } + + /** + * @throws ParseException + */ + public static function validateErrorApiResponse(array $data): void + { + $requiredKeys = ['key', 'errorCode']; + $missingKeys = array_diff($requiredKeys, array_keys($data)); + if (!empty($missingKeys)) { + throw new ParseException( + "missing keys in the error response: " . implode(', ', $missingKeys) + ); + } + + if (!is_string($data['errorCode'])) { + throw new ParseException('key is not a string', null); + } + + if (key_exists('errorDetails', $data) && !is_string($data['errorDetails'])) { + throw new ParseException('errorDetails is not a string', null); + } + } + + public static function validateEvaluationContext(?EvaluationContext $context): void + { + if ($context === null) { + throw new InvalidContextException('Evaluation context is null'); + } + + if ($context->getTargetingKey() === null || $context->getTargetingKey() === '') { + throw new InvalidContextException('Missing targetingKey in evaluation context'); + } + } + + public static function validateFlagKey(string $flagKey): void + { + if ($flagKey === null || $flagKey === '') { + throw new InvalidConfigException('Flag key is null or empty'); + } + } +} diff --git a/providers/GoFeatureFlag/tests/TestCase.php b/providers/GoFeatureFlag/tests/TestCase.php new file mode 100644 index 0000000..9d7a833 --- /dev/null +++ b/providers/GoFeatureFlag/tests/TestCase.php @@ -0,0 +1,39 @@ + $class + * @param mixed ...$arguments + * + * @return T & MockInterface + * + * @template T + * + * phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + public function mockery(string $class, ...$arguments) + { + /** @var T & MockInterface $mock */ + $mock = Mockery::mock($class, ...$arguments); + + return $mock; + } +} diff --git a/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php b/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php new file mode 100644 index 0000000..d42dccc --- /dev/null +++ b/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php @@ -0,0 +1,488 @@ +expectException(InvalidConfigException::class); + new GoFeatureFlagProvider( + new Config('invalid') + ); + } + + // Configuration validation tests + + public function test_should_not_throw_if_valid_endpoint() + { + $provider = new GoFeatureFlagProvider( + new Config('https://gofeatureflag.org') + ); + $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); + } + + public function test_should_raise_if_endpoint_is_not_http() + { + $this->expectException(InvalidConfigException::class); + $provider = new GoFeatureFlagProvider( + new Config('gofeatureflag.org') + ); + $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); + } + + public function test_empty_endpoint_should_throw() + { + $this->expectException(InvalidConfigException::class); + new GoFeatureFlagProvider( + new Config('') + ); + } + + public function test_metadata_name_is_defined() + { + $config = new Config('http://localhost:1031'); + $provider = new GoFeatureFlagProvider($config); + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + assertEquals('GO Feature Flag Provider', $api->getProviderMetadata()->getName()); + } + + // Metadata tests + + public function test_should_return_the_value_of_the_flag_as_int() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "integer_key", + "value" => 42, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getIntegerDetails('integer_key', 1, $this->defaultEvaluationContext); + assertEquals(42, $got->getValue()); + assertEquals(Reason::TARGETING_MATCH, $got->getReason()); + assertEquals('default', $got->getVariant()); + assertEquals(null, $got->getError()); + assertEquals('integer_key', $got->getFlagKey()); + } + + private function mockHttpClient($provider, $mockClient) + { + $providerReflection = new \ReflectionClass($provider); + $ofrepApiProperty = $providerReflection->getProperty('ofrepApi'); + $ofrepApiProperty->setAccessible(true); + $ofrepApi = $ofrepApiProperty->getValue($provider); + + $ofrepApiReflection = new \ReflectionClass($ofrepApi); + $clientProperty = $ofrepApiReflection->getProperty('client'); + $clientProperty->setAccessible(true); + $clientProperty->setValue($ofrepApi, $mockClient); + } + + public function test_should_return_the_value_of_the_flag_as_float() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flag-key", + "value" => 42.2, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getFloatDetails('flag-key', 1.0, $this->defaultEvaluationContext); + assertEquals(42.2, $got->getValue()); + assertEquals(Reason::TARGETING_MATCH, $got->getReason()); + assertEquals('default', $got->getVariant()); + assertEquals(null, $got->getError()); + assertEquals('flag-key', $got->getFlagKey()); + } + + public function test_should_return_the_value_of_the_flag_as_string() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flag-key", + "value" => "value as string", + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getStringDetails('flag-key', "default", $this->defaultEvaluationContext); + assertEquals("value as string", $got->getValue()); + assertEquals(Reason::TARGETING_MATCH, $got->getReason()); + assertEquals('default', $got->getVariant()); + assertEquals(null, $got->getError()); + assertEquals('flag-key', $got->getFlagKey()); + } + + public function test_should_return_the_value_of_the_flag_as_bool() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flag-key", + "value" => true, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('flag-key', false, $this->defaultEvaluationContext); + assertEquals(true, $got->getValue()); + assertEquals(Reason::TARGETING_MATCH, $got->getReason()); + assertEquals('default', $got->getVariant()); + assertEquals(null, $got->getError()); + assertEquals('flag-key', $got->getFlagKey()); + } + + public function test_should_return_the_value_of_the_flag_as_object() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flag-key", + "value" => ["value" => "value as object"], + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getObjectDetails('flag-key', ["default" => true], $this->defaultEvaluationContext); + assertEquals(["value" => "value as object"], $got->getValue()); + assertEquals(Reason::TARGETING_MATCH, $got->getReason()); + assertEquals('default', $got->getVariant()); + assertEquals(null, $got->getError()); + assertEquals('flag-key', $got->getFlagKey()); + } + + public function test_should_return_the_default_value_if_flag_is_not_the_right_type() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "integer_key", + "value" => 42, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('integer_key', false, $this->defaultEvaluationContext); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::TYPE_MISMATCH(), $got->getError()->getResolutionErrorCode()); + assertEquals("Invalid type for integer_key, got integer expected boolean", $got->getError()->getResolutionErrorMessage()); + assertEquals('integer_key', $got->getFlagKey()); + } + + public function test_should_return_the_default_value_of_the_flag_if_error_send_by_the_API_http_code_403() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(403, [], json_encode([])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('boolean_key', false, $this->defaultEvaluationContext); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::GENERAL(), $got->getError()->getResolutionErrorCode()); + assertEquals("Unauthorized access to the API", $got->getError()->getResolutionErrorMessage()); + assertEquals('boolean_key', $got->getFlagKey()); + } + + public function test_should_return_the_default_value_of_the_flag_if_error_send_by_the_API__http_code_400__() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(400, [], json_encode([ + "key" => "integer_key", + "reason" => "ERROR", + "errorCode" => "INVALID_CONTEXT", + "errorDetails" => "Error Details for invalid context" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('boolean_key', false, $this->defaultEvaluationContext); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::INVALID_CONTEXT(), $got->getError()->getResolutionErrorCode()); + assertEquals("Error Details for invalid context", $got->getError()->getResolutionErrorMessage()); + assertEquals('boolean_key', $got->getFlagKey()); + } + + public function test_should_return_default_value_if_no_evaluation_context() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "integer_key", + "value" => 42, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('boolean_key', false); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::INVALID_CONTEXT(), $got->getError()->getResolutionErrorCode()); + assertEquals("Missing targetingKey in evaluation context", $got->getError()->getResolutionErrorMessage()); + assertEquals('boolean_key', $got->getFlagKey()); + } + + public function test_should_return_default_value_if_evaluation_context_has_empty_string_targetingKey() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "integer_key", + "value" => 42, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('boolean_key', false, new MutableEvaluationContext("")); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::INVALID_CONTEXT(), $got->getError()->getResolutionErrorCode()); + assertEquals("Missing targetingKey in evaluation context", $got->getError()->getResolutionErrorMessage()); + assertEquals('boolean_key', $got->getFlagKey()); + } + + public function test_should_return_default_value_if_evaluation_context_has_null_targetingKey() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "integer_key", + "value" => 42, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('boolean_key', false, new MutableEvaluationContext(null)); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::INVALID_CONTEXT(), $got->getError()->getResolutionErrorCode()); + assertEquals("Missing targetingKey in evaluation context", $got->getError()->getResolutionErrorMessage()); + assertEquals('boolean_key', $got->getFlagKey()); + } + + public function test_should_return_default_value_if_flag_key_empty_string() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "integer_key", + "value" => 42, + "reason" => "TARGETING_MATCH", + "variant" => "default" + ])); + + $mockClient->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('', false, $this->defaultEvaluationContext); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::GENERAL(), $got->getError()->getResolutionErrorCode()); + assertEquals("An error occurred while evaluating the flag: Flag key is null or empty", $got->getError()->getResolutionErrorMessage()); + assertEquals('', $got->getFlagKey()); + } + + public function test_return_an_error_API_response_if_500() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(500, [], json_encode([])); + + $mockClient + ->expects($this->once()) + ->method('post') + ->willReturn($mockResponse); + + $config = new Config('http://gofeatureflag.org'); + $provider = new GoFeatureFlagProvider($config); + + $this->mockHttpClient($provider, $mockClient); + + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(); + $got = $client->getBooleanDetails('boolean_flag', false, $this->defaultEvaluationContext); + assertEquals(false, $got->getValue()); + assertEquals(Reason::ERROR, $got->getReason()); + assertEquals(null, $got->getVariant()); + assertEquals(ErrorCode::GENERAL(), $got->getError()->getResolutionErrorCode()); + assertEquals("Unknown error occurred", $got->getError()->getResolutionErrorMessage()); + assertEquals('boolean_flag', $got->getFlagKey()); + } + + protected function setUp(): void + { + parent::setUp(); + $this->defaultEvaluationContext = new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37", new Attributes(["email" => "contact@gofeatureflag.org"])); + } + + private function mockClient($provider, $mockClient) + { + $providerReflection = new \ReflectionClass($provider); + $ofrepApiProperty = $providerReflection->getProperty('ofrepApi'); + $ofrepApiProperty->setAccessible(true); + $ofrepApi = $ofrepApiProperty->getValue($provider); + + $ofrepApiReflection = new \ReflectionClass($ofrepApi); + $clientProperty = $ofrepApiReflection->getProperty('client'); + $clientProperty->setAccessible(true); + $clientProperty->setValue($ofrepApi, $mockClient); + } + +} diff --git a/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php b/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php new file mode 100644 index 0000000..4445aac --- /dev/null +++ b/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php @@ -0,0 +1,440 @@ +expectException(RateLimitedException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(429, [], json_encode([])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_not_authorized_401() + { + $this->expectException(UnauthorizedException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(401, [], json_encode([])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_not_authorized_403() + { + $this->expectException(UnauthorizedException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(403, [], json_encode([])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_flag_not_found_404() + { + $this->expectException(FlagNotFoundException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(404, [], json_encode([])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_unknown_http_code_500() + { + $this->expectException(UnknownOfrepException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(500, [], json_encode([])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_return_an_error_response_if_400() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(400, [], json_encode([ + "key" => "flagKey", + "errorCode" => "TYPE_MISMATCH", + "errorDetails" => "The flag value is not of the expected type" + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $got = $api->evaluate('flagKey', $this->defaultEvaluationContext); + $this->assertInstanceOf(OfrepApiResponse::class, $got); + $this->assertEquals("flagKey", $got->getKey()); + $this->assertEquals(Reason::ERROR, $got->getReason()); + $this->assertEquals(ErrorCode::TYPE_MISMATCH(), $got->getErrorCode()); + $this->assertEquals("The flag value is not of the expected type", $got->getErrorDetails()); + } + + public function test_should_return_a_valid_response_if_200() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flagKey", + "value" => true, + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $got = $api->evaluate('flagKey', $this->defaultEvaluationContext); + $this->assertInstanceOf(OfrepApiResponse::class, $got); + $this->assertEquals("flagKey", $got->getKey()); + $this->assertEquals(Reason::TARGETING_MATCH, $got->getReason()); + $this->assertNull($got->getErrorDetails()); + $this->assertNull($got->getErrorCode()); + $this->assertEquals(true, $got->getValue()); + } + + public function test_should_raise_an_error_if_200_and_json_does_not_contains_the_required_keys_missing_value() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flagKey", + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_200_and_json_does_not_contains_the_required_keys_missing_key() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "value" => true, + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_200_and_json_does_not_contains_the_required_keys_missing_reason() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flagKey", + "value" => true, + "variant" => "default" + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_200_and_json_does_not_contains_the_required_keys_missing_variant() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flagKey", + "value" => true, + "reason" => Reason::TARGETING_MATCH, + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_400_and_json_does_not_contains_the_required_keys_missing_key() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(400, [], json_encode([ + "errorCode" => "TYPE_MISMATCH", + "errorDetails" => "The flag value is not of the expected type" + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_raise_an_error_if_400_and_json_does_not_contains_the_required_keys_missing_error_code() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(400, [], json_encode([ + "key" => "flagKey", + "errorDetails" => "The flag value is not of the expected type" + ])); + $mockClient->method('post')->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + public function test_should_not_be_able_to_call_the_API_again_if_rate_limited_with_retry_after_int() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(429, ["Retry-After" => "1"], json_encode([ + "key" => "flagKey", + "value" => true, + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + $mockClient->expects($this->exactly(1)) + ->method('post') + ->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + try { + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } catch (RateLimitedException $e) { + $this->assertInstanceOf(RateLimitedException::class, $e); + } + + try { + $api->evaluate('another-flag', $this->defaultEvaluationContext); + } catch (RateLimitedException $e) { + $this->assertInstanceOf(RateLimitedException::class, $e); + } + } + + public function test_should_be_able_to_call_the_API_again_if_we_wait_after_the_retry_after_as_int() + { + $mockClient = $this->createMock(Client::class); + $mockResponseRateLimited = new Response(429, ["Retry-After" => "1"], json_encode([])); + $mockResponseSuccess = new Response(200, [], json_encode([ + "key" => "flagKey", + "value" => true, + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + $mockClient->method('post')->will($this->onConsecutiveCalls($mockResponseRateLimited, $mockResponseSuccess)); + + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + try { + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } catch (RateLimitedException $e) { + $this->assertInstanceOf(RateLimitedException::class, $e); + } + + // Wait for 1.5 seconds + usleep(1500000); + + $got = $api->evaluate('another-flag', $this->defaultEvaluationContext); + $this->assertInstanceOf(OfrepApiResponse::class, $got); + } + + public function test_should_not_be_able_to_call_the_API_again_if_rate_limited_with_retry_after_date() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(429, ["Retry-After" => gmdate('D, d M Y H:i:s \G\M\T', time() + 1)], json_encode([ + "key" => "flagKey", + "value" => true, + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + $mockClient->expects($this->exactly(1)) + ->method('post') + ->willReturn($mockResponse); + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + try { + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } catch (RateLimitedException $e) { + $this->assertInstanceOf(RateLimitedException::class, $e); + } + + try { + $api->evaluate('another-flag', $this->defaultEvaluationContext); + } catch (RateLimitedException $e) { + $this->assertInstanceOf(RateLimitedException::class, $e); + } + } + + public function test_should_be_able_to_call_the_API_again_if_we_wait_after_the_retry_after_as_date() + { + $mockClient = $this->createMock(Client::class); + $mockResponseRateLimited = new Response(429, ["Retry-After" => gmdate('D, d M Y H:i:s \G\M\T', time() + 1)], json_encode([])); + $mockResponseSuccess = new Response(200, [], json_encode([ + "key" => "flagKey", + "value" => true, + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + $mockClient->method('post')->will($this->onConsecutiveCalls($mockResponseRateLimited, $mockResponseSuccess)); + + + $api = new OfrepApi(new Config('https://gofeatureflag.org')); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + try { + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } catch (RateLimitedException $e) { + $this->assertInstanceOf(RateLimitedException::class, $e); + } + + // Wait for 1.5 seconds + usleep(1500000); + + $got = $api->evaluate('another-flag', $this->defaultEvaluationContext); + $this->assertInstanceOf(OfrepApiResponse::class, $got); + } + + public function test_should_have_autorization_header_if_api_key_in_config() + { + $mockClient = $this->createMock(Client::class); + $mockResponse = new Response(200, [], json_encode([ + "key" => "flagKey", + "value" => true, + "reason" => Reason::TARGETING_MATCH, + "variant" => "default" + ])); + + $mockClient->expects($this->once()) + ->method('post') + ->willReturnCallback(function ($uri, $options) use ($mockResponse) { + // Check headers here + echo sizeof($options['headers']); + $this->assertArrayHasKey('headers', $options); + $this->assertArrayHasKey('Authorization', $options['headers']); + $this->assertEquals('Bearer your-secure-api-key', $options['headers']['Authorization']); + return $mockResponse; + }); + + + $api = new OfrepApi(new Config('https://gofeatureflag.org', apiKey: "your-secure-api-key")); + $reflection = new \ReflectionClass($api); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($api, $mockClient); + + $api->evaluate('flagKey', $this->defaultEvaluationContext); + } + + protected function setUp(): void + { + parent::setUp(); + $this->defaultEvaluationContext = new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37"); + } +} \ No newline at end of file diff --git a/release-please-config.json b/release-please-config.json index a7d7325..84279a5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -29,6 +29,10 @@ "providers/Split": { "package-name": "open-feature/split-provider", "release-as": "0.3.0" + }, + "providers/GoFeatureFlag": { + "package-name": "open-feature/go-feature-flag-provider", + "release-as": "0.1.0" } } }