From 77d389d258f6e5381d99d5784f71566563409f84 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 7 Aug 2024 15:45:50 +0200 Subject: [PATCH 1/7] Exclude jetbrains IDE folder from GIT Signed-off-by: Thomas Poignant --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b3f34e10..d3bb74fb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ composer.lock /proto/ -/.devenv* \ No newline at end of file +/.devenv* + +.idea/ \ No newline at end of file From aaa2577c1f2bbe6311cbe4bf13585ff5e9c4e13a Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Wed, 7 Aug 2024 15:46:16 +0200 Subject: [PATCH 2/7] 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 28eb2f9d..109afb1f 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 eac7db84..4d7e1caf 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 00000000..e1efd914 --- /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 00000000..8eb5b805 --- /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 00000000..8f1f8994 --- /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 00000000..55d9d3a1 --- /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 00000000..2b2f33d0 --- /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 00000000..ecad4cce --- /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 00000000..ceaa5778 --- /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 00000000..c3e6c03c --- /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 00000000..784629f9 --- /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 00000000..41bb1656 --- /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 00000000..ef42abd5 --- /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 00000000..b5124e74 --- /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 00000000..0e9e1320 --- /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 00000000..475f44f7 --- /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 00000000..9b1e108c --- /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 00000000..59d11018 --- /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 00000000..9027ec5f --- /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 00000000..9d7a833b --- /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 00000000..d42dcccc --- /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 00000000..4445aac6 --- /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 a7d73257..84279a5e 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" } } } From cb5035f4b64fd350774f36beaba03f5dc52d5d6d Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 8 Aug 2024 17:45:33 +0200 Subject: [PATCH 3/7] fix linter issue Signed-off-by: Thomas Poignant --- providers/GoFeatureFlag/composer.json | 17 +++++++- .../src/GoFeatureFlagProvider.php | 14 +++++-- .../GoFeatureFlag/src/util/Validator.php | 5 ++- .../tests/unit/GoFeatureFlagProviderTest.php | 40 +++++++++---------- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/providers/GoFeatureFlag/composer.json b/providers/GoFeatureFlag/composer.json index 8f1f8994..48a0a509 100644 --- a/providers/GoFeatureFlag/composer.json +++ b/providers/GoFeatureFlag/composer.json @@ -24,7 +24,22 @@ "require-dev": { "phpunit/phpunit": "^9", "mockery/mockery": "^1.6", - "spatie/phpunit-snapshot-assertions": "^4.2" + "spatie/phpunit-snapshot-assertions": "^4.2", + "phan/phan": "^5.4", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "~1.10.0", + "phpstan/phpstan-mockery": "^1.0", + "phpstan/phpstan-phpunit": "^1.1", + "psalm/plugin-mockery": "^0.11.0", + "psalm/plugin-phpunit": "^0.18.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": "~4.30.0" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php b/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php index 784629f9..a0384d82 100644 --- a/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php +++ b/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php @@ -13,6 +13,7 @@ use OpenFeature\interfaces\provider\Provider; use OpenFeature\interfaces\provider\Reason; use OpenFeature\interfaces\provider\ResolutionDetails; +use OpenFeature\Providers\GoFeatureFlag\config\Config; use OpenFeature\Providers\GoFeatureFlag\controller\OfrepApi; use OpenFeature\Providers\GoFeatureFlag\exception\BaseOfrepException; use OpenFeature\Providers\GoFeatureFlag\exception\InvalidConfigException; @@ -26,7 +27,7 @@ class GoFeatureFlagProvider extends AbstractProvider implements Provider /** * @throws InvalidConfigException */ - public function __construct($config = null) + public function __construct(Config $config) { Validator::validateConfig($config); if (is_array($config->getCustomHeaders()) && !array_key_exists("Content-Type", $config->getCustomHeaders())) { @@ -45,15 +46,22 @@ public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?Evalua return $this->evaluate($flagKey, $defaultValue, ['boolean'], $context); } - private function evaluate(string $flagKey, mixed $defaultValue, array $allowedClasses, ?EvaluationContext $evaluationContext = null): ResolutionDetails + /** + * @param array $allowedClasses + */ + 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()); + $err = new ResolutionError( + $apiResp->getErrorCode() ?? ErrorCode::GENERAL(), + $apiResp->getErrorDetails() + ); return (new ResolutionDetailsBuilder()) ->withValue($defaultValue) ->withError($err) diff --git a/providers/GoFeatureFlag/src/util/Validator.php b/providers/GoFeatureFlag/src/util/Validator.php index 9027ec5f..9eea7cac 100644 --- a/providers/GoFeatureFlag/src/util/Validator.php +++ b/providers/GoFeatureFlag/src/util/Validator.php @@ -18,8 +18,11 @@ class Validator * @return void * @throws InvalidConfigException - if the config is invalid we return an error */ - public static function validateConfig(Config $config): void + public static function validateConfig(?Config $config): void { + if ($config === null) { + throw new InvalidConfigException('Config is null'); + } self::validateEndpoint($config->getEndpoint()); } diff --git a/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php b/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php index d42dcccc..4d308b9d 100644 --- a/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php +++ b/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php @@ -22,7 +22,7 @@ class GoFeatureFlagProviderTest extends TestCase { private EvaluationContext $defaultEvaluationContext; - public function test_should_throw_if_invalid_endpoint() + public function test_should_throw_if_invalid_endpoint(): void { $this->expectException(InvalidConfigException::class); new GoFeatureFlagProvider( @@ -32,7 +32,7 @@ public function test_should_throw_if_invalid_endpoint() // Configuration validation tests - public function test_should_not_throw_if_valid_endpoint() + public function test_should_not_throw_if_valid_endpoint(): void { $provider = new GoFeatureFlagProvider( new Config('https://gofeatureflag.org') @@ -40,7 +40,7 @@ public function test_should_not_throw_if_valid_endpoint() $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); } - public function test_should_raise_if_endpoint_is_not_http() + public function test_should_raise_if_endpoint_is_not_http(): void { $this->expectException(InvalidConfigException::class); $provider = new GoFeatureFlagProvider( @@ -49,7 +49,7 @@ public function test_should_raise_if_endpoint_is_not_http() $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); } - public function test_empty_endpoint_should_throw() + public function test_empty_endpoint_should_throw(): void { $this->expectException(InvalidConfigException::class); new GoFeatureFlagProvider( @@ -57,7 +57,7 @@ public function test_empty_endpoint_should_throw() ); } - public function test_metadata_name_is_defined() + public function test_metadata_name_is_defined(): void { $config = new Config('http://localhost:1031'); $provider = new GoFeatureFlagProvider($config); @@ -68,7 +68,7 @@ public function test_metadata_name_is_defined() // Metadata tests - public function test_should_return_the_value_of_the_flag_as_int() + public function test_should_return_the_value_of_the_flag_as_int(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -98,7 +98,7 @@ public function test_should_return_the_value_of_the_flag_as_int() assertEquals('integer_key', $got->getFlagKey()); } - private function mockHttpClient($provider, $mockClient) + private function mockHttpClient($provider, $mockClient): void { $providerReflection = new \ReflectionClass($provider); $ofrepApiProperty = $providerReflection->getProperty('ofrepApi'); @@ -111,7 +111,7 @@ private function mockHttpClient($provider, $mockClient) $clientProperty->setValue($ofrepApi, $mockClient); } - public function test_should_return_the_value_of_the_flag_as_float() + public function test_should_return_the_value_of_the_flag_as_float(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -141,7 +141,7 @@ public function test_should_return_the_value_of_the_flag_as_float() assertEquals('flag-key', $got->getFlagKey()); } - public function test_should_return_the_value_of_the_flag_as_string() + public function test_should_return_the_value_of_the_flag_as_string(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -171,7 +171,7 @@ public function test_should_return_the_value_of_the_flag_as_string() assertEquals('flag-key', $got->getFlagKey()); } - public function test_should_return_the_value_of_the_flag_as_bool() + public function test_should_return_the_value_of_the_flag_as_bool(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -201,7 +201,7 @@ public function test_should_return_the_value_of_the_flag_as_bool() assertEquals('flag-key', $got->getFlagKey()); } - public function test_should_return_the_value_of_the_flag_as_object() + public function test_should_return_the_value_of_the_flag_as_object(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -231,7 +231,7 @@ public function test_should_return_the_value_of_the_flag_as_object() assertEquals('flag-key', $got->getFlagKey()); } - public function test_should_return_the_default_value_if_flag_is_not_the_right_type() + public function test_should_return_the_default_value_if_flag_is_not_the_right_type(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -262,7 +262,7 @@ public function test_should_return_the_default_value_if_flag_is_not_the_right_ty 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() + public function test_should_return_the_default_value_of_the_flag_if_error_send_by_the_API_http_code_403(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(403, [], json_encode([])); @@ -288,7 +288,7 @@ public function test_should_return_the_default_value_of_the_flag_if_error_send_b 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__() + public function test_should_return_the_default_value_of_the_flag_if_error_send_by_the_API__http_code_400(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(400, [], json_encode([ @@ -319,7 +319,7 @@ public function test_should_return_the_default_value_of_the_flag_if_error_send_b assertEquals('boolean_key', $got->getFlagKey()); } - public function test_should_return_default_value_if_no_evaluation_context() + public function test_should_return_default_value_if_no_evaluation_context(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -349,7 +349,7 @@ public function test_should_return_default_value_if_no_evaluation_context() assertEquals('boolean_key', $got->getFlagKey()); } - public function test_should_return_default_value_if_evaluation_context_has_empty_string_targetingKey() + public function test_should_return_default_value_if_evaluation_context_has_empty_string_targetingKey(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -379,7 +379,7 @@ public function test_should_return_default_value_if_evaluation_context_has_empty assertEquals('boolean_key', $got->getFlagKey()); } - public function test_should_return_default_value_if_evaluation_context_has_null_targetingKey() + public function test_should_return_default_value_if_evaluation_context_has_null_targetingKey(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -409,7 +409,7 @@ public function test_should_return_default_value_if_evaluation_context_has_null_ assertEquals('boolean_key', $got->getFlagKey()); } - public function test_should_return_default_value_if_flag_key_empty_string() + public function test_should_return_default_value_if_flag_key_empty_string(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(200, [], json_encode([ @@ -439,7 +439,7 @@ public function test_should_return_default_value_if_flag_key_empty_string() assertEquals('', $got->getFlagKey()); } - public function test_return_an_error_API_response_if_500() + public function test_return_an_error_API_response_if_500(): void { $mockClient = $this->createMock(Client::class); $mockResponse = new Response(500, [], json_encode([])); @@ -472,7 +472,7 @@ protected function setUp(): void $this->defaultEvaluationContext = new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37", new Attributes(["email" => "contact@gofeatureflag.org"])); } - private function mockClient($provider, $mockClient) + private function mockClient($provider, $mockClient): void { $providerReflection = new \ReflectionClass($provider); $ofrepApiProperty = $providerReflection->getProperty('ofrepApi'); From 542e9ecd5811c7b9611c1ae863f7510c19c34c11 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 8 Aug 2024 21:49:38 +0200 Subject: [PATCH 4/7] fix linter issue Signed-off-by: Thomas Poignant --- providers/GoFeatureFlag/.php-cs-fixer.cache | 1 + providers/GoFeatureFlag/README.md | 3 +- providers/GoFeatureFlag/composer.json | 5 +- providers/GoFeatureFlag/phpstan.neon.dist | 3 - .../src/GoFeatureFlagProvider.php | 60 ++-- providers/GoFeatureFlag/src/config/Config.php | 36 ++- .../GoFeatureFlag/src/controller/OfrepApi.php | 74 +++-- .../src/exception/BaseGoffException.php | 8 +- .../src/exception/BaseOfrepException.php | 11 +- .../src/exception/FlagNotFoundException.php | 2 + .../src/exception/InvalidConfigException.php | 11 +- .../src/exception/InvalidContextException.php | 3 +- .../src/exception/ParseException.php | 5 +- .../src/exception/RateLimitedException.php | 6 +- .../src/exception/UnauthorizedException.php | 4 +- .../src/exception/UnknownOfrepException.php | 7 +- .../src/model/OfrepApiErrorResponse.php | 46 +++ .../src/model/OfrepApiResponse.php | 151 --------- .../src/model/OfrepApiSuccessResponse.php | 82 +++++ providers/GoFeatureFlag/src/util/Mapper.php | 36 +++ .../GoFeatureFlag/src/util/Validator.php | 70 +++-- .../tests/unit/GoFeatureFlagProviderTest.php | 252 ++++++++------- .../tests/unit/controller/OfrepApiTest.php | 293 +++++++++--------- 23 files changed, 640 insertions(+), 529 deletions(-) create mode 100644 providers/GoFeatureFlag/.php-cs-fixer.cache create mode 100644 providers/GoFeatureFlag/src/model/OfrepApiErrorResponse.php delete mode 100644 providers/GoFeatureFlag/src/model/OfrepApiResponse.php create mode 100644 providers/GoFeatureFlag/src/model/OfrepApiSuccessResponse.php create mode 100644 providers/GoFeatureFlag/src/util/Mapper.php diff --git a/providers/GoFeatureFlag/.php-cs-fixer.cache b/providers/GoFeatureFlag/.php-cs-fixer.cache new file mode 100644 index 00000000..45962ef3 --- /dev/null +++ b/providers/GoFeatureFlag/.php-cs-fixer.cache @@ -0,0 +1 @@ +{"php":"8.0.30","version":"3.57.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_parentheses":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true},"hashes":{"tests\/unit\/controller\/OfrepApiTest.php":"ffc7e3ab94b4c58eb9b413fef1bef9de","tests\/unit\/GoFeatureFlagProviderTest.php":"062d381339c3bb95bd08b9fa05d1462a","tests\/TestCase.php":"efc0a05509f7101e640d29ec146c0aff","src\/util\/Validator.php":"527ab649217b08b455e0180e91817197","src\/config\/Config.php":"7a6642e67f641b823c4b1be1db0fc7d6","src\/controller\/OfrepApi.php":"bce6659287817694140d38bb23de0b60","src\/GoFeatureFlagProvider.php":"0535e5b9a10f7095225b332dadb6948b","src\/model\/OfrepApiResponse.php":"b34e7d13b252e0c6a722dbaefcbf2862","src\/exception\/RateLimitedException.php":"ac00860fc0c226c57774ddf246b7c5e0","src\/exception\/BaseGoffException.php":"18c57afd49dec676d9693c6bf3c014aa","src\/exception\/UnknownOfrepException.php":"0e429ddf9505f315e00bdb1991d4b0a9","src\/exception\/BaseOfrepException.php":"88dee34dc35e403219477ff521d7f225","src\/exception\/UnauthorizedException.php":"efb5188ddad635e8ee03bb887fa12fea","src\/exception\/InvalidConfigException.php":"34c2b8ba731cb801298ef08ddcb93f75","src\/exception\/ParseException.php":"f1a06dad98c1912857a72da31272a7d5","src\/exception\/InvalidContextException.php":"49da7c5d08ae5067d79fd053ab6adf7d","src\/exception\/FlagNotFoundException.php":"8910ff8e0f0ed12acbd5d414a5b12d5b","src\/util\/Mapper.php":"5e3e560cdad10c83da46e3e976f54bbb","src\/model\/OfrepApiSuccessResponse.php":"64ed4fbac2a5ebaa5765cc767b291c5f","src\/model\/OfrepApiErrorResponse.php":"ce96a809b1d4ec87b2794a9535a514d2"}} \ No newline at end of file diff --git a/providers/GoFeatureFlag/README.md b/providers/GoFeatureFlag/README.md index 8eb5b805..d775603f 100644 --- a/providers/GoFeatureFlag/README.md +++ b/providers/GoFeatureFlag/README.md @@ -44,6 +44,7 @@ The constructor of the config object has the following options: | `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. | +| `httpclient` | The HTTP Client to use (if you want to use a custom one). _It has to be a `PSR-7` compliant implementation._ | The only required option to create a `GoFeatureFlagProvider` is the URL _(`endpoint`)_ to your GO Feature Flag relay-proxy instance. @@ -62,7 +63,7 @@ $api->setProvider($provider); $client = $api->getClient(); $evaluationContext = new MutableEvaluationContext( "214b796a-807b-4697-b3a3-42de0ec10a37", - new Attributes(["email" => "contact@gofeatureflag.org"]) + new Attributes(["email" => 'contact@gofeatureflag.org']) ); $value = $client->getBooleanDetails('integer_key', false, $evaluationContext); diff --git a/providers/GoFeatureFlag/composer.json b/providers/GoFeatureFlag/composer.json index 48a0a509..3cf4f307 100644 --- a/providers/GoFeatureFlag/composer.json +++ b/providers/GoFeatureFlag/composer.json @@ -18,8 +18,9 @@ ], "require": { "php": "^8", + "guzzlehttp/guzzle": "^7.9", "open-feature/sdk": "^2.0", - "guzzlehttp/guzzle": "^7.9" + "psr/http-message": "^2.0" }, "require-dev": { "phpunit/phpunit": "^9", @@ -68,7 +69,7 @@ "@dev:analyze:phpstan", "@dev:analyze:psalm" ], - "dev:analyze:phpstan": "phpstan analyse --ansi --debug --memory-limit=512M", + "dev:analyze:phpstan": "phpstan --ansi --debug --memory-limit=512M", "dev:analyze:psalm": "psalm", "dev:build:clean": "git clean -fX build/", "dev:lint": [ diff --git a/providers/GoFeatureFlag/phpstan.neon.dist b/providers/GoFeatureFlag/phpstan.neon.dist index 2b2f33d0..000a4863 100644 --- a/providers/GoFeatureFlag/phpstan.neon.dist +++ b/providers/GoFeatureFlag/phpstan.neon.dist @@ -3,9 +3,6 @@ parameters: level: max paths: - ./src - - ./tests excludePaths: - */tests/fixtures/* - */tests/*/fixtures/* - # TODO: Implement gRPC Completely - - ./src/grpc diff --git a/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php b/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php index a0384d82..e483119a 100644 --- a/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php +++ b/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php @@ -4,6 +4,14 @@ namespace OpenFeature\Providers\GoFeatureFlag; +use DateTime; +use OpenFeature\Providers\GoFeatureFlag\config\Config; +use OpenFeature\Providers\GoFeatureFlag\controller\OfrepApi; +use OpenFeature\Providers\GoFeatureFlag\exception\BaseOfrepException; +use OpenFeature\Providers\GoFeatureFlag\exception\InvalidConfigException; +use OpenFeature\Providers\GoFeatureFlag\exception\InvalidContextException; +use OpenFeature\Providers\GoFeatureFlag\model\OfrepApiErrorResponse; +use OpenFeature\Providers\GoFeatureFlag\util\Validator; use OpenFeature\implementation\common\Metadata; use OpenFeature\implementation\provider\AbstractProvider; use OpenFeature\implementation\provider\ResolutionDetailsBuilder; @@ -13,15 +21,15 @@ use OpenFeature\interfaces\provider\Provider; use OpenFeature\interfaces\provider\Reason; use OpenFeature\interfaces\provider\ResolutionDetails; -use OpenFeature\Providers\GoFeatureFlag\config\Config; -use OpenFeature\Providers\GoFeatureFlag\controller\OfrepApi; -use OpenFeature\Providers\GoFeatureFlag\exception\BaseOfrepException; -use OpenFeature\Providers\GoFeatureFlag\exception\InvalidConfigException; -use OpenFeature\Providers\GoFeatureFlag\util\Validator; +use Throwable; + +use function array_key_exists; +use function gettype; +use function implode; class GoFeatureFlagProvider extends AbstractProvider implements Provider { - protected static string $CLIENT_NAME = 'GO Feature Flag Provider'; + protected static string $NAME = 'GO Feature Flag Provider'; private OfrepApi $ofrepApi; /** @@ -30,15 +38,15 @@ class GoFeatureFlagProvider extends AbstractProvider implements Provider public function __construct(Config $config) { Validator::validateConfig($config); - if (is_array($config->getCustomHeaders()) && !array_key_exists("Content-Type", $config->getCustomHeaders())) { - $config->getCustomHeaders()["Content-Type"] = "application/json"; + if (!array_key_exists('Content-Type', $config->getCustomHeaders())) { + $config->addCustomHeader('Content-Type', 'application/json'); } $this->ofrepApi = new OfrepApi($config); } public function getMetadata(): Metadata { - return new Metadata(self::$CLIENT_NAME); + return new Metadata(static::$NAME); } public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails @@ -47,25 +55,33 @@ public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?Evalua } /** + * @param array|array|bool|DateTime|float|int|string|null $defaultValue * @param array $allowedClasses */ - private function evaluate(string $flagKey, mixed $defaultValue, array $allowedClasses, EvaluationContext $evaluationContext = null): ResolutionDetails + private function evaluate(string $flagKey, array | string | bool | DateTime | float | int | null $defaultValue, array $allowedClasses, ?EvaluationContext $evaluationContext = null): ResolutionDetails { try { - Validator::validateEvaluationContext($evaluationContext); Validator::validateFlagKey($flagKey); + if ($evaluationContext === null) { + throw new InvalidContextException('Evaluation context is null'); + } + if ($evaluationContext->getTargetingKey() === null || $evaluationContext->getTargetingKey() === '') { + throw new InvalidContextException('Missing targetingKey in evaluation context'); + } + $apiResp = $this->ofrepApi->evaluate($flagKey, $evaluationContext); - if ($apiResp->isError()) { + if ($apiResp instanceof OfrepApiErrorResponse) { $err = new ResolutionError( - $apiResp->getErrorCode() ?? ErrorCode::GENERAL(), - $apiResp->getErrorDetails() + $apiResp->getErrorCode(), + $apiResp->getErrorDetails(), ); + return (new ResolutionDetailsBuilder()) ->withValue($defaultValue) ->withError($err) - ->withReason(Reason::ERROR) + ->withReason($apiResp->getReason()) ->build(); } @@ -74,32 +90,37 @@ private function evaluate(string $flagKey, mixed $defaultValue, array $allowedCl ->withReason(Reason::ERROR) ->withError(new ResolutionError( ErrorCode::TYPE_MISMATCH(), - "Invalid type for $flagKey, got " . gettype($apiResp->getValue()) . " expected " . implode(", ", $allowedClasses))) + "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) { + } catch (Throwable $e) { return (new ResolutionDetailsBuilder()) ->withValue($defaultValue) - ->withError(new ResolutionError(ErrorCode::GENERAL(), "An error occurred while evaluating the flag: " . $e->getMessage())) + ->withError(new ResolutionError(ErrorCode::GENERAL(), 'An error occurred while evaluating the flag: ' . $e->getMessage())) ->withReason(Reason::ERROR) ->build(); } } + /** + * @param array $allowedClasses + */ private function isValidType(mixed $value, array $allowedClasses): bool { foreach ($allowedClasses as $class) { @@ -107,6 +128,7 @@ private function isValidType(mixed $value, array $allowedClasses): bool return true; } } + return false; } diff --git a/providers/GoFeatureFlag/src/config/Config.php b/providers/GoFeatureFlag/src/config/Config.php index 41bb1656..510bc9e1 100644 --- a/providers/GoFeatureFlag/src/config/Config.php +++ b/providers/GoFeatureFlag/src/config/Config.php @@ -4,33 +4,57 @@ namespace OpenFeature\Providers\GoFeatureFlag\config; +use Psr\Http\Client\ClientInterface; + class Config { private string $endpoint; + /** + * @var array + */ private array $customHeaders = []; - public function __construct(string $endpoint, ?string $apiKey = '', ?array $custom_headers = []) + /** + * @var ClientInterface|null - The HTTP Client to use (if you want to use a custom one) + */ + private ?ClientInterface $httpclient; + + /** + * @param string $endpoint - The endpoint to your GO Feature Flag Instance + * @param string|null $apiKey - API Key to use to connect to GO Feature Flag + * @param array|null $customHeaders - Custom headers you want to send + * @param ClientInterface|null $httpclient - The HTTP Client to use (if you want to use a custom one) + */ + public function __construct(string $endpoint, ?string $apiKey = '', ?array $customHeaders = [], ?ClientInterface $httpclient = null) { + $this->httpclient = $httpclient; $this->endpoint = $endpoint; - $this->customHeaders = $custom_headers; + $this->customHeaders = $customHeaders ?? []; if ($apiKey !== null && $apiKey !== '') { $this->customHeaders['Authorization'] = 'Bearer ' . $apiKey; } } - /** - * @return string - */ public function getEndpoint(): string { return $this->endpoint; } /** - * @return array + * @return array */ public function getCustomHeaders(): array { return $this->customHeaders; } + + public function addCustomHeader(string $key, string $value): void + { + $this->customHeaders[$key] = $value; + } + + public function getHttpClient(): ?ClientInterface + { + return $this->httpclient; + } } diff --git a/providers/GoFeatureFlag/src/controller/OfrepApi.php b/providers/GoFeatureFlag/src/controller/OfrepApi.php index ef42abd5..d5d17edb 100644 --- a/providers/GoFeatureFlag/src/controller/OfrepApi.php +++ b/providers/GoFeatureFlag/src/controller/OfrepApi.php @@ -4,10 +4,9 @@ namespace OpenFeature\Providers\GoFeatureFlag\controller; -use Exception; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; -use OpenFeature\interfaces\flags\EvaluationContext; +use GuzzleHttp\Psr7\Request; use OpenFeature\Providers\GoFeatureFlag\config\Config; use OpenFeature\Providers\GoFeatureFlag\exception\BaseOfrepException; use OpenFeature\Providers\GoFeatureFlag\exception\FlagNotFoundException; @@ -15,19 +14,32 @@ use OpenFeature\Providers\GoFeatureFlag\exception\RateLimitedException; use OpenFeature\Providers\GoFeatureFlag\exception\UnauthorizedException; use OpenFeature\Providers\GoFeatureFlag\exception\UnknownOfrepException; -use OpenFeature\Providers\GoFeatureFlag\model\OfrepApiResponse; +use OpenFeature\Providers\GoFeatureFlag\model\OfrepApiErrorResponse; +use OpenFeature\Providers\GoFeatureFlag\model\OfrepApiSuccessResponse; +use OpenFeature\Providers\GoFeatureFlag\util\Validator; +use OpenFeature\interfaces\flags\EvaluationContext; +use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; +use Throwable; + +use function array_merge; +use function is_numeric; +use function json_decode; +use function json_encode; +use function rtrim; +use function strtotime; +use function time; class OfrepApi { private ?int $retryAfter = null; private Config $options; - private Client $client; + private ClientInterface $client; public function __construct(Config $config) { $this->options = $config; - $this->client = new Client([ + $this->client = $config->getHttpClient() ?? new Client([ 'base_uri' => $config->getEndpoint(), ]); } @@ -40,7 +52,7 @@ public function __construct(Config $config) * @throws UnknownOfrepException * @throws BaseOfrepException */ - public function evaluate(string $flagKey, EvaluationContext $evaluationContext): OfrepApiResponse + public function evaluate(string $flagKey, EvaluationContext $evaluationContext): OfrepApiSuccessResponse | OfrepApiErrorResponse { try { if ($this->retryAfter !== null) { @@ -51,26 +63,24 @@ public function evaluate(string $flagKey, EvaluationContext $evaluationContext): } } - $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()); - } + $baseUri = $this->options->getEndpoint(); + $evaluateApiPath = rtrim($baseUri, '/') . "/ofrep/v1/evaluate/flags/{$flagKey}"; + $headers = array_merge( + ['Content-Type' => 'application/json'], + $this->options->getCustomHeaders(), + ); $fields = array_merge( $evaluationContext->getAttributes()->toArray(), - ['targetingKey' => $evaluationContext->getTargetingKey()] + ['targetingKey' => $evaluationContext->getTargetingKey()], ); $requestBody = json_encode(['context' => $fields]); - $response = $this->client->post($evaluateApiPath, [ - 'headers' => $headers, - 'body' => $requestBody - ]); + if ($requestBody === false) { + throw new ParseException('failed to encode request body'); + } + $req = new Request('POST', $evaluateApiPath, $headers, $requestBody); + $response = $this->client->sendRequest($req); switch ($response->getStatusCode()) { case 200: @@ -84,13 +94,16 @@ public function evaluate(string $flagKey, EvaluationContext $evaluationContext): 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) { + } catch (GuzzleException | Throwable $e) { + echo $e; + throw new UnknownOfrepException(null, $e); } } @@ -98,19 +111,25 @@ public function evaluate(string $flagKey, EvaluationContext $evaluationContext): /** * @throws ParseException */ - private function parseSuccessResponse(ResponseInterface $response): OfrepApiResponse + private function parseSuccessResponse(ResponseInterface $response): OfrepApiSuccessResponse { + /** @var array $parsed */ $parsed = json_decode($response->getBody()->getContents(), true); - return OfrepApiResponse::createSuccessResponse($parsed); + $parsed = Validator::validateSuccessApiResponse($parsed); + + return new OfrepApiSuccessResponse($parsed); } /** * @throws ParseException */ - private function parseErrorResponse(ResponseInterface $response): OfrepApiResponse + private function parseErrorResponse(ResponseInterface $response): OfrepApiErrorResponse { + /** @var array $parsed */ $parsed = json_decode($response->getBody()->getContents(), true); - return OfrepApiResponse::createErrorResponse($parsed); + $parsed = Validator::validateErrorApiResponse($parsed); + + return new OfrepApiErrorResponse($parsed); } private function parseRetryLaterHeader(ResponseInterface $response): void @@ -119,10 +138,11 @@ private function parseRetryLaterHeader(ResponseInterface $response): void if ($retryAfterHeader) { if (is_numeric($retryAfterHeader)) { // Retry-After is in seconds - $this->retryAfter = time() + (int)$retryAfterHeader; + $this->retryAfter = time() + (int) $retryAfterHeader; } else { // Retry-After is in HTTP-date format - $this->retryAfter = strtotime($retryAfterHeader); + $retryTime = strtotime($retryAfterHeader); + $this->retryAfter = $retryTime !== false ? $retryTime : null; } } } diff --git a/providers/GoFeatureFlag/src/exception/BaseGoffException.php b/providers/GoFeatureFlag/src/exception/BaseGoffException.php index b5124e74..cc2221f3 100644 --- a/providers/GoFeatureFlag/src/exception/BaseGoffException.php +++ b/providers/GoFeatureFlag/src/exception/BaseGoffException.php @@ -4,16 +4,18 @@ namespace OpenFeature\Providers\GoFeatureFlag\exception; +use Exception; use OpenFeature\interfaces\provider\ErrorCode; use Psr\Http\Message\ResponseInterface; +use Throwable; -abstract class BaseGoffException extends \Exception +abstract class BaseGoffException extends Exception { private string $customMessage; private ?ResponseInterface $response; private ErrorCode $errorCode; - public function __construct(string $message, ErrorCode $errorCode, ?ResponseInterface $response, int $code = 0, \Exception $previous = null) + public function __construct(string $message, ErrorCode $errorCode, ?ResponseInterface $response, int $code = 0, ?Throwable $previous = null) { $this->customMessage = $message; $this->response = $response; @@ -35,4 +37,4 @@ 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 index 0e9e1320..fa33d8ae 100644 --- a/providers/GoFeatureFlag/src/exception/BaseOfrepException.php +++ b/providers/GoFeatureFlag/src/exception/BaseOfrepException.php @@ -4,16 +4,18 @@ namespace OpenFeature\Providers\GoFeatureFlag\exception; +use Exception; use OpenFeature\interfaces\provider\ErrorCode; use Psr\Http\Message\ResponseInterface; +use Throwable; -abstract class BaseOfrepException extends \Exception +abstract class BaseOfrepException extends Exception { private string $customMessage; private ?ResponseInterface $response; private ErrorCode $errorCode; - public function __construct(string $message, ErrorCode $errorCode, ?ResponseInterface $response, int $code = 0, \Exception $previous = null) + public function __construct(string $message, ErrorCode $errorCode, ?ResponseInterface $response, int $code = 0, ?Throwable $previous = null) { $this->customMessage = $message; $this->response = $response; @@ -31,11 +33,8 @@ 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 index 475f44f7..caf3b200 100644 --- a/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php +++ b/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php @@ -1,5 +1,7 @@ customMessage = $message; parent::__construct($message, $code, $previous); @@ -16,4 +21,4 @@ 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 index 59d11018..fbaf00b4 100644 --- a/providers/GoFeatureFlag/src/exception/InvalidContextException.php +++ b/providers/GoFeatureFlag/src/exception/InvalidContextException.php @@ -1,12 +1,13 @@ $apiData + * + * @throws ParseException + */ + public function __construct(array $apiData) + { + $this->reason = Reason::ERROR; + $this->errorCode = Mapper::errorCode(is_string($apiData['errorCode']) ? $apiData['errorCode'] : ''); + $this->errorDetails = is_string($apiData['errorDetails']) ? $apiData['errorDetails'] : ''; + } + + public function getReason(): string + { + return $this->reason; + } + + public function getErrorCode(): ErrorCode + { + return $this->errorCode; + } + + public function getErrorDetails(): string + { + return $this->errorDetails; + } +} diff --git a/providers/GoFeatureFlag/src/model/OfrepApiResponse.php b/providers/GoFeatureFlag/src/model/OfrepApiResponse.php deleted file mode 100644 index d1f074e4..00000000 --- a/providers/GoFeatureFlag/src/model/OfrepApiResponse.php +++ /dev/null @@ -1,151 +0,0 @@ -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/model/OfrepApiSuccessResponse.php b/providers/GoFeatureFlag/src/model/OfrepApiSuccessResponse.php new file mode 100644 index 00000000..7e8d6555 --- /dev/null +++ b/providers/GoFeatureFlag/src/model/OfrepApiSuccessResponse.php @@ -0,0 +1,82 @@ +|array|bool|DateTime|float|int|string|null + */ + private array | bool | DateTime | float | int | string | null $value; + private string $reason; + private string $variant; + + // TODO: Commenting Metadata here because it is not supported by the SDK yet. + // private array $metadata; + + /** + * @param array $apiData + * + * @throws ParseException + */ + public function __construct( + array $apiData, + ) { + if ( + is_null($apiData['value']) + || is_array($apiData['value']) + || is_bool($apiData['value']) + || $apiData['value'] instanceof DateTime + || is_float($apiData['value']) + || is_int($apiData['value']) + || is_string($apiData['value']) + ) { + $this->value = $apiData['value']; + } else { + throw new ParseException('Invalid type for value'); + } + + $this->variant = is_string($apiData['variant']) ? $apiData['variant'] : 'error in provider'; + $this->reason = Mapper::reason(is_string($apiData['reason']) ? $apiData['reason'] : ''); + // $this->metadata = $apiData['metadata'] ?? []; + } + + /** + * @return array|array|bool|DateTime|float|int|string|null + */ + public function getValue(): array | bool | DateTime | float | int | string | null + { + return $this->value; + } + + public function getReason(): string + { + return $this->reason; + } + + public function getVariant(): string + { + return $this->variant; + } + + // /** + // * @return array + // */ + // public function getMetadata(): array + // { + // return $this->metadata; + // } +} diff --git a/providers/GoFeatureFlag/src/util/Mapper.php b/providers/GoFeatureFlag/src/util/Mapper.php new file mode 100644 index 00000000..6d55f97d --- /dev/null +++ b/providers/GoFeatureFlag/src/util/Mapper.php @@ -0,0 +1,36 @@ + 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() + }; + } + + public static function reason(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 + }; + } +} diff --git a/providers/GoFeatureFlag/src/util/Validator.php b/providers/GoFeatureFlag/src/util/Validator.php index 9eea7cac..37bc62d1 100644 --- a/providers/GoFeatureFlag/src/util/Validator.php +++ b/providers/GoFeatureFlag/src/util/Validator.php @@ -4,18 +4,26 @@ namespace OpenFeature\Providers\GoFeatureFlag\util; - -use OpenFeature\interfaces\flags\EvaluationContext; use OpenFeature\Providers\GoFeatureFlag\config\Config; use OpenFeature\Providers\GoFeatureFlag\exception\InvalidConfigException; -use OpenFeature\Providers\GoFeatureFlag\exception\InvalidContextException; use OpenFeature\Providers\GoFeatureFlag\exception\ParseException; +use function array_diff; +use function array_keys; +use function count; +use function filter_var; +use function implode; +use function is_array; +use function is_string; +use function key_exists; + +use const FILTER_VALIDATE_URL; + class Validator { /** - * @param Config $config - The configuration object to validate - * @return void + * @param ?Config $config - The configuration object to validate + * * @throws InvalidConfigException - if the config is invalid we return an error */ public static function validateConfig(?Config $config): void @@ -27,8 +35,8 @@ public static function validateConfig(?Config $config): void } /** - * @param string $endpoint - * @return void + * @param string $endpoint - The endpoint to validate + * * @throws InvalidConfigException */ private static function validateEndpoint(string $endpoint): void @@ -39,15 +47,23 @@ private static function validateEndpoint(string $endpoint): void } /** + * @param mixed $data - The data to validate + * + * @return array{key: string, reason: string, variant: string} + * * @throws ParseException */ - public static function validateSuccessApiResponse(array $data): void + public static function validateSuccessApiResponse(mixed $data): array { + if (!is_array($data)) { + throw new ParseException('invalid json object, expected associative array'); + } + $requiredKeys = ['key', 'value', 'reason', 'variant']; $missingKeys = array_diff($requiredKeys, array_keys($data)); - if (!empty($missingKeys)) { + if (count($missingKeys) > 0) { throw new ParseException( - "missing keys in the success response: " . implode(', ', $missingKeys) + 'missing keys in the success response: ' . implode(', ', $missingKeys), ); } @@ -66,18 +82,28 @@ public static function validateSuccessApiResponse(array $data): void if (key_exists('metadata', $data) && !is_array($data['metadata'])) { throw new ParseException('metadata is not an array'); } + + return $data; } /** + * @param mixed $data - The data to validate + * + * @return array{errorCode: string} + * * @throws ParseException */ - public static function validateErrorApiResponse(array $data): void + public static function validateErrorApiResponse(mixed $data): array { + if (!is_array($data)) { + throw new ParseException('invalid json object, expected associative array'); + } + $requiredKeys = ['key', 'errorCode']; $missingKeys = array_diff($requiredKeys, array_keys($data)); - if (!empty($missingKeys)) { + if (count($missingKeys) > 0) { throw new ParseException( - "missing keys in the error response: " . implode(', ', $missingKeys) + 'missing keys in the error response: ' . implode(', ', $missingKeys), ); } @@ -88,22 +114,18 @@ public static function validateErrorApiResponse(array $data): void 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'); - } + return $data; } + /** + * @param string $flagKey - The flag key to validate + * + * @throws InvalidConfigException + */ public static function validateFlagKey(string $flagKey): void { - if ($flagKey === null || $flagKey === '') { + if ($flagKey === '') { throw new InvalidConfigException('Flag key is null or empty'); } } diff --git a/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php b/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php index 4d308b9d..e990ec90 100644 --- a/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php +++ b/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php @@ -4,60 +4,65 @@ namespace OpenFeature\Providers\GoFeatureFlag\Test\unit; -use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; +use OpenFeature\OpenFeatureAPI; +use OpenFeature\Providers\GoFeatureFlag\GoFeatureFlagProvider; +use OpenFeature\Providers\GoFeatureFlag\Test\TestCase; +use OpenFeature\Providers\GoFeatureFlag\config\Config; +use OpenFeature\Providers\GoFeatureFlag\exception\InvalidConfigException; use OpenFeature\implementation\flags\Attributes; use OpenFeature\implementation\flags\MutableEvaluationContext; use OpenFeature\interfaces\flags\EvaluationContext; use OpenFeature\interfaces\provider\ErrorCode; use OpenFeature\interfaces\provider\Reason; -use OpenFeature\OpenFeatureAPI; -use OpenFeature\Providers\GoFeatureFlag\config\Config; -use OpenFeature\Providers\GoFeatureFlag\exception\InvalidConfigException; -use OpenFeature\Providers\GoFeatureFlag\GoFeatureFlagProvider; -use OpenFeature\Providers\GoFeatureFlag\Test\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Http\Client\ClientInterface; +use ReflectionClass; +use ReflectionException; + use function PHPUnit\Framework\assertEquals; +use function json_encode; class GoFeatureFlagProviderTest extends TestCase { private EvaluationContext $defaultEvaluationContext; - public function test_should_throw_if_invalid_endpoint(): void + public function testShouldThrowIfInvalidEndpoint(): void { $this->expectException(InvalidConfigException::class); new GoFeatureFlagProvider( - new Config('invalid') + new Config('invalid'), ); } // Configuration validation tests - public function test_should_not_throw_if_valid_endpoint(): void + public function testShouldNotThrowIfValidEndpoint(): void { $provider = new GoFeatureFlagProvider( - new Config('https://gofeatureflag.org') + new Config('https://gofeatureflag.org'), ); $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); } - public function test_should_raise_if_endpoint_is_not_http(): void + public function testShouldRaiseIfEndpointIsNotHttp(): void { $this->expectException(InvalidConfigException::class); $provider = new GoFeatureFlagProvider( - new Config('gofeatureflag.org') + new Config('gofeatureflag.org'), ); $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); } - public function test_empty_endpoint_should_throw(): void + public function testEmptyEndpointShouldThrow(): void { $this->expectException(InvalidConfigException::class); new GoFeatureFlagProvider( - new Config('') + new Config(''), ); } - public function test_metadata_name_is_defined(): void + public function testMetadataNameIsDefined(): void { $config = new Config('http://localhost:1031'); $provider = new GoFeatureFlagProvider($config); @@ -68,18 +73,18 @@ public function test_metadata_name_is_defined(): void // Metadata tests - public function test_should_return_the_value_of_the_flag_as_int(): void + public function testShouldReturnTheValueOfTheFlagAsInt(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "integer_key", - "value" => 42, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -98,31 +103,34 @@ public function test_should_return_the_value_of_the_flag_as_int(): void assertEquals('integer_key', $got->getFlagKey()); } - private function mockHttpClient($provider, $mockClient): void + /** + * @throws ReflectionException + */ + private function mockHttpClient(GoFeatureFlagProvider $provider, MockObject $mockClient): void { - $providerReflection = new \ReflectionClass($provider); + $providerReflection = new ReflectionClass($provider); $ofrepApiProperty = $providerReflection->getProperty('ofrepApi'); $ofrepApiProperty->setAccessible(true); $ofrepApi = $ofrepApiProperty->getValue($provider); - $ofrepApiReflection = new \ReflectionClass($ofrepApi); + $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(): void + public function testShouldReturnTheValueOfTheFlagAsFloat(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flag-key", - "value" => 42.2, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'flag-key', + 'value' => 42.2, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -141,18 +149,18 @@ public function test_should_return_the_value_of_the_flag_as_float(): void assertEquals('flag-key', $got->getFlagKey()); } - public function test_should_return_the_value_of_the_flag_as_string(): void + public function testShouldReturnTheValueOfTheFlagAsString(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flag-key", - "value" => "value as string", - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'flag-key', + 'value' => 'value as string', + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -163,26 +171,26 @@ public function test_should_return_the_value_of_the_flag_as_string(): void $api = OpenFeatureAPI::getInstance(); $api->setProvider($provider); $client = $api->getClient(); - $got = $client->getStringDetails('flag-key', "default", $this->defaultEvaluationContext); - assertEquals("value as string", $got->getValue()); + $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(): void + public function testShouldReturnTheValueOfTheFlagAsBool(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flag-key", - "value" => true, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'flag-key', + 'value' => true, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -201,18 +209,18 @@ public function test_should_return_the_value_of_the_flag_as_bool(): void assertEquals('flag-key', $got->getFlagKey()); } - public function test_should_return_the_value_of_the_flag_as_object(): void + public function testShouldReturnTheValueOfTheFlagAsObject(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flag-key", - "value" => ["value" => "value as object"], - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'flag-key', + 'value' => ['value' => 'value as object'], + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -223,26 +231,26 @@ public function test_should_return_the_value_of_the_flag_as_object(): void $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()); + $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(): void + public function testShouldReturnTheDefaultValueIfFlagIsNotTheRightType(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "integer_key", - "value" => 42, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -258,17 +266,17 @@ public function test_should_return_the_default_value_if_flag_is_not_the_right_ty 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('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(): void + public function testShouldReturnTheDefaultValueOfTheFlagIfErrorSendByTheAPIHttpCode403(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(403, [], json_encode([])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -284,22 +292,22 @@ public function test_should_return_the_default_value_of_the_flag_if_error_send_b 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('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(): void + public function testShouldReturnTheDefaultValueOfTheFlagIfErrorSendByTheAPIHttpCode400(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(400, [], json_encode([ - "key" => "integer_key", - "reason" => "ERROR", - "errorCode" => "INVALID_CONTEXT", - "errorDetails" => "Error Details for invalid context" + 'key' => 'integer_key', + 'reason' => 'ERROR', + 'errorCode' => 'INVALID_CONTEXT', + 'errorDetails' => 'Error Details for invalid context', ])); $mockClient->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -315,21 +323,21 @@ public function test_should_return_the_default_value_of_the_flag_if_error_send_b 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('Error Details for invalid context', $got->getError()->getResolutionErrorMessage()); assertEquals('boolean_key', $got->getFlagKey()); } - public function test_should_return_default_value_if_no_evaluation_context(): void + public function testShouldReturnDefaultValueIfNoEvaluationContext(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "integer_key", - "value" => 42, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); - $mockClient->method('post') + $mockClient->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -345,21 +353,21 @@ public function test_should_return_default_value_if_no_evaluation_context(): voi 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('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(): void + public function testShouldReturnDefaultValueIfEvaluationContextHasEmptyStringTargetingKey(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "integer_key", - "value" => 42, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); - $mockClient->method('post') + $mockClient->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -370,26 +378,26 @@ public function test_should_return_default_value_if_evaluation_context_has_empty $api = OpenFeatureAPI::getInstance(); $api->setProvider($provider); $client = $api->getClient(); - $got = $client->getBooleanDetails('boolean_key', false, new MutableEvaluationContext("")); + $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('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(): void + public function testShouldReturnDefaultValueIfEvaluationContextHasNullTargetingKey(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "integer_key", - "value" => 42, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); - $mockClient->method('post') + $mockClient->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -405,21 +413,21 @@ public function test_should_return_default_value_if_evaluation_context_has_null_ 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('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(): void + public function testShouldReturnDefaultValueIfFlagKeyEmptyString(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "integer_key", - "value" => 42, - "reason" => "TARGETING_MATCH", - "variant" => "default" + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', ])); - $mockClient->method('post') + $mockClient->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -435,18 +443,18 @@ public function test_should_return_default_value_if_flag_key_empty_string(): voi 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('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(): void + public function testReturnAnErrorAPIResponseIf500(): void { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(500, [], json_encode([])); $mockClient ->expects($this->once()) - ->method('post') + ->method('sendRequest') ->willReturn($mockResponse); $config = new Config('http://gofeatureflag.org'); @@ -462,27 +470,13 @@ public function test_return_an_error_API_response_if_500(): void assertEquals(Reason::ERROR, $got->getReason()); assertEquals(null, $got->getVariant()); assertEquals(ErrorCode::GENERAL(), $got->getError()->getResolutionErrorCode()); - assertEquals("Unknown error occurred", $got->getError()->getResolutionErrorMessage()); + 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"])); + $this->defaultEvaluationContext = new MutableEvaluationContext('214b796a-807b-4697-b3a3-42de0ec10a37', new Attributes(['email' => 'contact@gofeatureflag.org'])); } - - private function mockClient($provider, $mockClient): void - { - $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 index 4445aac6..8caa13f7 100644 --- a/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php +++ b/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php @@ -4,13 +4,8 @@ namespace OpenFeature\Providers\GoFeatureFlag\Test\unit\controller; - -use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; -use OpenFeature\implementation\flags\MutableEvaluationContext; -use OpenFeature\interfaces\flags\EvaluationContext; -use OpenFeature\interfaces\provider\ErrorCode; -use OpenFeature\interfaces\provider\Reason; +use OpenFeature\Providers\GoFeatureFlag\Test\TestCase; use OpenFeature\Providers\GoFeatureFlag\config\Config; use OpenFeature\Providers\GoFeatureFlag\controller\OfrepApi; use OpenFeature\Providers\GoFeatureFlag\exception\FlagNotFoundException; @@ -18,22 +13,33 @@ use OpenFeature\Providers\GoFeatureFlag\exception\RateLimitedException; use OpenFeature\Providers\GoFeatureFlag\exception\UnauthorizedException; use OpenFeature\Providers\GoFeatureFlag\exception\UnknownOfrepException; -use OpenFeature\Providers\GoFeatureFlag\model\OfrepApiResponse; -use OpenFeature\Providers\GoFeatureFlag\Test\TestCase; +use OpenFeature\Providers\GoFeatureFlag\model\OfrepApiErrorResponse; +use OpenFeature\Providers\GoFeatureFlag\model\OfrepApiSuccessResponse; +use OpenFeature\implementation\flags\MutableEvaluationContext; +use OpenFeature\interfaces\flags\EvaluationContext; +use OpenFeature\interfaces\provider\ErrorCode; +use OpenFeature\interfaces\provider\Reason; +use Psr\Http\Client\ClientInterface; +use ReflectionClass; + +use function gmdate; +use function json_encode; +use function time; +use function usleep; class OfrepApiTest extends TestCase { private EvaluationContext $defaultEvaluationContext; - public function test_should_raise_an_error_if_rate_limited() + public function testShouldRaiseAnErrorIfRateLimited() { $this->expectException(RateLimitedException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(429, [], json_encode([])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -41,15 +47,15 @@ public function test_should_raise_an_error_if_rate_limited() $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_not_authorized_401() + public function testShouldRaiseAnErrorIfNotAuthorized401() { $this->expectException(UnauthorizedException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(401, [], json_encode([])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -57,15 +63,15 @@ public function test_should_raise_an_error_if_not_authorized_401() $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_not_authorized_403() + public function testShouldRaiseAnErrorIfNotAuthorized403() { $this->expectException(UnauthorizedException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(403, [], json_encode([])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -73,15 +79,15 @@ public function test_should_raise_an_error_if_not_authorized_403() $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_flag_not_found_404() + public function testShouldRaiseAnErrorIfFlagNotFound404() { $this->expectException(FlagNotFoundException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(404, [], json_encode([])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -89,15 +95,15 @@ public function test_should_raise_an_error_if_flag_not_found_404() $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_unknown_http_code_500() + public function testShouldRaiseAnErrorIfUnknownHttpCode500() { $this->expectException(UnknownOfrepException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(500, [], json_encode([])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -105,69 +111,65 @@ public function test_should_raise_an_error_if_unknown_http_code_500() $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_return_an_error_response_if_400() + public function testShouldReturnAnErrorResponseIf400() { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(400, [], json_encode([ - "key" => "flagKey", - "errorCode" => "TYPE_MISMATCH", - "errorDetails" => "The flag value is not of the expected type" + 'key' => 'flagKey', + 'errorCode' => 'TYPE_MISMATCH', + 'errorDetails' => 'The flag value is not of the expected type', ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $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->assertInstanceOf(OfrepApiErrorResponse::class, $got); $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()); + $this->assertEquals('The flag value is not of the expected type', $got->getErrorDetails()); } - public function test_should_return_a_valid_response_if_200() + public function testShouldReturnAValidResponseIf200() { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flagKey", - "value" => true, - "reason" => Reason::TARGETING_MATCH, - "variant" => "default" + 'key' => 'flagKey', + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $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->assertInstanceOf(OfrepApiSuccessResponse::class, $got); $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() + public function testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingValue() { $this->expectException(ParseException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flagKey", - "reason" => Reason::TARGETING_MATCH, - "variant" => "default" + 'key' => 'flagKey', + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -175,19 +177,19 @@ public function test_should_raise_an_error_if_200_and_json_does_not_contains_the $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_200_and_json_does_not_contains_the_required_keys_missing_key() + public function testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingKey() { $this->expectException(ParseException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "value" => true, - "reason" => Reason::TARGETING_MATCH, - "variant" => "default" + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -195,19 +197,19 @@ public function test_should_raise_an_error_if_200_and_json_does_not_contains_the $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_200_and_json_does_not_contains_the_required_keys_missing_reason() + public function testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingReason() { $this->expectException(ParseException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flagKey", - "value" => true, - "variant" => "default" + 'key' => 'flagKey', + 'value' => true, + 'variant' => 'default', ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -215,19 +217,19 @@ public function test_should_raise_an_error_if_200_and_json_does_not_contains_the $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_200_and_json_does_not_contains_the_required_keys_missing_variant() + public function testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingVariant() { $this->expectException(ParseException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flagKey", - "value" => true, - "reason" => Reason::TARGETING_MATCH, + 'key' => 'flagKey', + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -235,18 +237,18 @@ public function test_should_raise_an_error_if_200_and_json_does_not_contains_the $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_raise_an_error_if_400_and_json_does_not_contains_the_required_keys_missing_key() + public function testShouldRaiseAnErrorIf400AndJsonDoesNotContainTheRequiredKeysMissingKey() { $this->expectException(ParseException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(400, [], json_encode([ - "errorCode" => "TYPE_MISMATCH", - "errorDetails" => "The flag value is not of the expected type" + 'errorCode' => 'TYPE_MISMATCH', + 'errorDetails' => 'The flag value is not of the expected type', ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -254,18 +256,18 @@ public function test_should_raise_an_error_if_400_and_json_does_not_contains_the $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() + public function testShouldRaiseAnErrorIf400AndJsonDoesNotContainTheRequiredKeysMissingErrorCode() { $this->expectException(ParseException::class); - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(400, [], json_encode([ - "key" => "flagKey", - "errorDetails" => "The flag value is not of the expected type" + 'key' => 'flagKey', + 'errorDetails' => 'The flag value is not of the expected type', ])); - $mockClient->method('post')->willReturn($mockResponse); + $mockClient->method('sendRequest')->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -273,21 +275,21 @@ public function test_should_raise_an_error_if_400_and_json_does_not_contains_the $api->evaluate('flagKey', $this->defaultEvaluationContext); } - public function test_should_not_be_able_to_call_the_API_again_if_rate_limited_with_retry_after_int() + public function testShouldNotBeAbleToCallTheApiAgainIfRateLimitedWithRetryAfterInt() { - $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 = $this->createMock(ClientInterface::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') + ->method('sendRequest') ->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -305,21 +307,20 @@ public function test_should_not_be_able_to_call_the_API_again_if_rate_limited_wi } } - public function test_should_be_able_to_call_the_API_again_if_we_wait_after_the_retry_after_as_int() + public function testShouldBeAbleToCallTheApiAgainIfWeWaitAfterTheRetryAfterAsInt() { - $mockClient = $this->createMock(Client::class); - $mockResponseRateLimited = new Response(429, ["Retry-After" => "1"], json_encode([])); + $mockClient = $this->createMock(ClientInterface::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" + 'key' => 'flagKey', + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', ])); - $mockClient->method('post')->will($this->onConsecutiveCalls($mockResponseRateLimited, $mockResponseSuccess)); - + $mockClient->method('sendRequest')->will($this->onConsecutiveCalls($mockResponseRateLimited, $mockResponseSuccess)); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -334,24 +335,24 @@ public function test_should_be_able_to_call_the_API_again_if_we_wait_after_the_r usleep(1500000); $got = $api->evaluate('another-flag', $this->defaultEvaluationContext); - $this->assertInstanceOf(OfrepApiResponse::class, $got); + $this->assertInstanceOf(OfrepApiSuccessResponse::class, $got); } - public function test_should_not_be_able_to_call_the_API_again_if_rate_limited_with_retry_after_date() + public function testShouldNotBeAbleToCallTheApiAgainIfRateLimitedWithRetryAfterDate() { - $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 = $this->createMock(ClientInterface::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') + ->method('sendRequest') ->willReturn($mockResponse); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -369,21 +370,20 @@ public function test_should_not_be_able_to_call_the_API_again_if_rate_limited_wi } } - public function test_should_be_able_to_call_the_API_again_if_we_wait_after_the_retry_after_as_date() + public function testShouldBeAbleToCallTheApiAgainIfWeWaitAfterTheRetryAfterAsDate() { - $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([])); + $mockClient = $this->createMock(ClientInterface::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" + 'key' => 'flagKey', + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', ])); - $mockClient->method('post')->will($this->onConsecutiveCalls($mockResponseRateLimited, $mockResponseSuccess)); - + $mockClient->method('sendRequest')->will($this->onConsecutiveCalls($mockResponseRateLimited, $mockResponseSuccess)); $api = new OfrepApi(new Config('https://gofeatureflag.org')); - $reflection = new \ReflectionClass($api); + $reflection = new ReflectionClass($api); $property = $reflection->getProperty('client'); $property->setAccessible(true); $property->setValue($api, $mockClient); @@ -398,33 +398,30 @@ public function test_should_be_able_to_call_the_API_again_if_we_wait_after_the_r usleep(1500000); $got = $api->evaluate('another-flag', $this->defaultEvaluationContext); - $this->assertInstanceOf(OfrepApiResponse::class, $got); + $this->assertInstanceOf(OfrepApiSuccessResponse::class, $got); } - public function test_should_have_autorization_header_if_api_key_in_config() + public function testShouldHaveAuthorizationHeaderIfApiKeyInConfig() { - $mockClient = $this->createMock(Client::class); + $mockClient = $this->createMock(ClientInterface::class); $mockResponse = new Response(200, [], json_encode([ - "key" => "flagKey", - "value" => true, - "reason" => Reason::TARGETING_MATCH, - "variant" => "default" + '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']); + ->method('sendRequest') + ->willReturnCallback(function ($req) use ($mockResponse) { + $this->assertArrayHasKey('Authorization', $req->getHeaders()); + $this->assertEquals('Bearer your-secure-api-key', $req->getHeader('Authorization')[0]); + return $mockResponse; }); - - $api = new OfrepApi(new Config('https://gofeatureflag.org', apiKey: "your-secure-api-key")); - $reflection = new \ReflectionClass($api); + $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); @@ -435,6 +432,6 @@ public function test_should_have_autorization_header_if_api_key_in_config() protected function setUp(): void { parent::setUp(); - $this->defaultEvaluationContext = new MutableEvaluationContext("214b796a-807b-4697-b3a3-42de0ec10a37"); + $this->defaultEvaluationContext = new MutableEvaluationContext('214b796a-807b-4697-b3a3-42de0ec10a37'); } -} \ No newline at end of file +} From e350b6adabd65b5c022a6f317861f9d6405e6f71 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 12 Aug 2024 22:12:18 +0200 Subject: [PATCH 5/7] removing phpcs cache file Signed-off-by: Thomas Poignant --- providers/GoFeatureFlag/.php-cs-fixer.cache | 1 - 1 file changed, 1 deletion(-) delete mode 100644 providers/GoFeatureFlag/.php-cs-fixer.cache diff --git a/providers/GoFeatureFlag/.php-cs-fixer.cache b/providers/GoFeatureFlag/.php-cs-fixer.cache deleted file mode 100644 index 45962ef3..00000000 --- a/providers/GoFeatureFlag/.php-cs-fixer.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"8.0.30","version":"3.57.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_parentheses":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true},"hashes":{"tests\/unit\/controller\/OfrepApiTest.php":"ffc7e3ab94b4c58eb9b413fef1bef9de","tests\/unit\/GoFeatureFlagProviderTest.php":"062d381339c3bb95bd08b9fa05d1462a","tests\/TestCase.php":"efc0a05509f7101e640d29ec146c0aff","src\/util\/Validator.php":"527ab649217b08b455e0180e91817197","src\/config\/Config.php":"7a6642e67f641b823c4b1be1db0fc7d6","src\/controller\/OfrepApi.php":"bce6659287817694140d38bb23de0b60","src\/GoFeatureFlagProvider.php":"0535e5b9a10f7095225b332dadb6948b","src\/model\/OfrepApiResponse.php":"b34e7d13b252e0c6a722dbaefcbf2862","src\/exception\/RateLimitedException.php":"ac00860fc0c226c57774ddf246b7c5e0","src\/exception\/BaseGoffException.php":"18c57afd49dec676d9693c6bf3c014aa","src\/exception\/UnknownOfrepException.php":"0e429ddf9505f315e00bdb1991d4b0a9","src\/exception\/BaseOfrepException.php":"88dee34dc35e403219477ff521d7f225","src\/exception\/UnauthorizedException.php":"efb5188ddad635e8ee03bb887fa12fea","src\/exception\/InvalidConfigException.php":"34c2b8ba731cb801298ef08ddcb93f75","src\/exception\/ParseException.php":"f1a06dad98c1912857a72da31272a7d5","src\/exception\/InvalidContextException.php":"49da7c5d08ae5067d79fd053ab6adf7d","src\/exception\/FlagNotFoundException.php":"8910ff8e0f0ed12acbd5d414a5b12d5b","src\/util\/Mapper.php":"5e3e560cdad10c83da46e3e976f54bbb","src\/model\/OfrepApiSuccessResponse.php":"64ed4fbac2a5ebaa5765cc767b291c5f","src\/model\/OfrepApiErrorResponse.php":"ce96a809b1d4ec87b2794a9535a514d2"}} \ No newline at end of file From d93231808357bdf71bd7ab206497c1639650388e Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 12 Aug 2024 22:13:19 +0200 Subject: [PATCH 6/7] Adding phpcs file to gitignore Signed-off-by: Thomas Poignant --- providers/GoFeatureFlag/.gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/providers/GoFeatureFlag/.gitignore b/providers/GoFeatureFlag/.gitignore index e1efd914..5c09088b 100644 --- a/providers/GoFeatureFlag/.gitignore +++ b/providers/GoFeatureFlag/.gitignore @@ -1,3 +1,5 @@ /composer.lock /vendor -/build \ No newline at end of file +/build + +.php-cs-fixer.cache \ No newline at end of file From 2a6a9affaa279474c9c7293b48e223e2df945642 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Mon, 12 Aug 2024 23:58:13 +0200 Subject: [PATCH 7/7] Fix typo Signed-off-by: Thomas Poignant --- providers/GoFeatureFlag/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/GoFeatureFlag/README.md b/providers/GoFeatureFlag/README.md index d775603f..13295546 100644 --- a/providers/GoFeatureFlag/README.md +++ b/providers/GoFeatureFlag/README.md @@ -55,7 +55,7 @@ use OpenFeature\implementation\flags\MutableEvaluationContext; use OpenFeature\implementation\flags\Attributes; use OpenFeature\OpenFeatureAPI; -$config = new Config('http://gofeatureflag.org', 'my-api-key); +$config = new Config('http://gofeatureflag.org', 'my-api-key'); $provider = new GoFeatureFlagProvider($config); $api = OpenFeatureAPI::getInstance();