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/.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 diff --git a/providers/GoFeatureFlag/.gitignore b/providers/GoFeatureFlag/.gitignore new file mode 100644 index 00000000..5c09088b --- /dev/null +++ b/providers/GoFeatureFlag/.gitignore @@ -0,0 +1,5 @@ +/composer.lock +/vendor +/build + +.php-cs-fixer.cache \ No newline at end of file diff --git a/providers/GoFeatureFlag/README.md b/providers/GoFeatureFlag/README.md new file mode 100644 index 00000000..13295546 --- /dev/null +++ b/providers/GoFeatureFlag/README.md @@ -0,0 +1,144 @@ +

+ 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. | +| `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. + +```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..3cf4f307 --- /dev/null +++ b/providers/GoFeatureFlag/composer.json @@ -0,0 +1,123 @@ +{ + "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", + "guzzlehttp/guzzle": "^7.9", + "open-feature/sdk": "^2.0", + "psr/http-message": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "mockery/mockery": "^1.6", + "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, + "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 --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..000a4863 --- /dev/null +++ b/providers/GoFeatureFlag/phpstan.neon.dist @@ -0,0 +1,8 @@ +parameters: + tmpDir: ./build/cache/phpstan + level: max + paths: + - ./src + excludePaths: + - */tests/fixtures/* + - */tests/*/fixtures/* 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..e483119a --- /dev/null +++ b/providers/GoFeatureFlag/src/GoFeatureFlagProvider.php @@ -0,0 +1,154 @@ +getCustomHeaders())) { + $config->addCustomHeader('Content-Type', 'application/json'); + } + $this->ofrepApi = new OfrepApi($config); + } + + public function getMetadata(): Metadata + { + return new Metadata(static::$NAME); + } + + public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, $defaultValue, ['boolean'], $context); + } + + /** + * @param array|array|bool|DateTime|float|int|string|null $defaultValue + * @param array $allowedClasses + */ + private function evaluate(string $flagKey, array | string | bool | DateTime | float | int | null $defaultValue, array $allowedClasses, ?EvaluationContext $evaluationContext = null): ResolutionDetails + { + try { + 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 instanceof OfrepApiErrorResponse) { + $err = new ResolutionError( + $apiResp->getErrorCode(), + $apiResp->getErrorDetails(), + ); + + return (new ResolutionDetailsBuilder()) + ->withValue($defaultValue) + ->withError($err) + ->withReason($apiResp->getReason()) + ->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 (Throwable $e) { + return (new ResolutionDetailsBuilder()) + ->withValue($defaultValue) + ->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) { + 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..510bc9e1 --- /dev/null +++ b/providers/GoFeatureFlag/src/config/Config.php @@ -0,0 +1,60 @@ + + */ + private array $customHeaders = []; + + /** + * @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 = $customHeaders ?? []; + if ($apiKey !== null && $apiKey !== '') { + $this->customHeaders['Authorization'] = 'Bearer ' . $apiKey; + } + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + /** + * @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 new file mode 100644 index 00000000..d5d17edb --- /dev/null +++ b/providers/GoFeatureFlag/src/controller/OfrepApi.php @@ -0,0 +1,149 @@ +options = $config; + $this->client = $config->getHttpClient() ?? 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): OfrepApiSuccessResponse | OfrepApiErrorResponse + { + try { + if ($this->retryAfter !== null) { + if (time() < $this->retryAfter) { + throw new RateLimitedException(); + } else { + $this->retryAfter = null; + } + } + + $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()], + ); + + $requestBody = json_encode(['context' => $fields]); + 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: + 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 | Throwable $e) { + echo $e; + + throw new UnknownOfrepException(null, $e); + } + } + + /** + * @throws ParseException + */ + private function parseSuccessResponse(ResponseInterface $response): OfrepApiSuccessResponse + { + /** @var array $parsed */ + $parsed = json_decode($response->getBody()->getContents(), true); + $parsed = Validator::validateSuccessApiResponse($parsed); + + return new OfrepApiSuccessResponse($parsed); + } + + /** + * @throws ParseException + */ + private function parseErrorResponse(ResponseInterface $response): OfrepApiErrorResponse + { + /** @var array $parsed */ + $parsed = json_decode($response->getBody()->getContents(), true); + $parsed = Validator::validateErrorApiResponse($parsed); + + return new OfrepApiErrorResponse($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 + $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 new file mode 100644 index 00000000..cc2221f3 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/BaseGoffException.php @@ -0,0 +1,40 @@ +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; + } +} diff --git a/providers/GoFeatureFlag/src/exception/BaseOfrepException.php b/providers/GoFeatureFlag/src/exception/BaseOfrepException.php new file mode 100644 index 00000000..fa33d8ae --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/BaseOfrepException.php @@ -0,0 +1,40 @@ +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; + } +} diff --git a/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php b/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php new file mode 100644 index 00000000..caf3b200 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/FlagNotFoundException.php @@ -0,0 +1,26 @@ +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..0458c706 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/InvalidConfigException.php @@ -0,0 +1,24 @@ +customMessage = $message; + parent::__construct($message, $code, $previous); + } + + public function getCustomMessage(): string + { + return $this->customMessage; + } +} diff --git a/providers/GoFeatureFlag/src/exception/InvalidContextException.php b/providers/GoFeatureFlag/src/exception/InvalidContextException.php new file mode 100644 index 00000000..fbaf00b4 --- /dev/null +++ b/providers/GoFeatureFlag/src/exception/InvalidContextException.php @@ -0,0 +1,16 @@ + $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/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 new file mode 100644 index 00000000..37bc62d1 --- /dev/null +++ b/providers/GoFeatureFlag/src/util/Validator.php @@ -0,0 +1,132 @@ +getEndpoint()); + } + + /** + * @param string $endpoint - The endpoint to validate + * + * @throws InvalidConfigException + */ + private static function validateEndpoint(string $endpoint): void + { + if (!filter_var($endpoint, FILTER_VALIDATE_URL)) { + throw new InvalidConfigException('Invalid endpoint URL: ' . $endpoint); + } + } + + /** + * @param mixed $data - The data to validate + * + * @return array{key: string, reason: string, variant: string} + * + * @throws ParseException + */ + 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 (count($missingKeys) > 0) { + 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'); + } + + return $data; + } + + /** + * @param mixed $data - The data to validate + * + * @return array{errorCode: string} + * + * @throws ParseException + */ + 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 (count($missingKeys) > 0) { + 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); + } + + return $data; + } + + /** + * @param string $flagKey - The flag key to validate + * + * @throws InvalidConfigException + */ + public static function validateFlagKey(string $flagKey): void + { + if ($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..e990ec90 --- /dev/null +++ b/providers/GoFeatureFlag/tests/unit/GoFeatureFlagProviderTest.php @@ -0,0 +1,482 @@ +expectException(InvalidConfigException::class); + new GoFeatureFlagProvider( + new Config('invalid'), + ); + } + + // Configuration validation tests + + public function testShouldNotThrowIfValidEndpoint(): void + { + $provider = new GoFeatureFlagProvider( + new Config('https://gofeatureflag.org'), + ); + $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); + } + + public function testShouldRaiseIfEndpointIsNotHttp(): void + { + $this->expectException(InvalidConfigException::class); + $provider = new GoFeatureFlagProvider( + new Config('gofeatureflag.org'), + ); + $this->assertInstanceOf(GoFeatureFlagProvider::class, $provider); + } + + public function testEmptyEndpointShouldThrow(): void + { + $this->expectException(InvalidConfigException::class); + new GoFeatureFlagProvider( + new Config(''), + ); + } + + public function testMetadataNameIsDefined(): void + { + $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 testShouldReturnTheValueOfTheFlagAsInt(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->expects($this->once()) + ->method('sendRequest') + ->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()); + } + + /** + * @throws ReflectionException + */ + private function mockHttpClient(GoFeatureFlagProvider $provider, MockObject $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); + } + + public function testShouldReturnTheValueOfTheFlagAsFloat(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flag-key', + 'value' => 42.2, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->expects($this->once()) + ->method('sendRequest') + ->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 testShouldReturnTheValueOfTheFlagAsString(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flag-key', + 'value' => 'value as string', + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->expects($this->once()) + ->method('sendRequest') + ->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 testShouldReturnTheValueOfTheFlagAsBool(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flag-key', + 'value' => true, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->expects($this->once()) + ->method('sendRequest') + ->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 testShouldReturnTheValueOfTheFlagAsObject(): void + { + $mockClient = $this->createMock(ClientInterface::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('sendRequest') + ->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 testShouldReturnTheDefaultValueIfFlagIsNotTheRightType(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->expects($this->once()) + ->method('sendRequest') + ->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 testShouldReturnTheDefaultValueOfTheFlagIfErrorSendByTheAPIHttpCode403(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(403, [], json_encode([])); + + $mockClient->expects($this->once()) + ->method('sendRequest') + ->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 testShouldReturnTheDefaultValueOfTheFlagIfErrorSendByTheAPIHttpCode400(): void + { + $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', + ])); + + $mockClient->expects($this->once()) + ->method('sendRequest') + ->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 testShouldReturnDefaultValueIfNoEvaluationContext(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->method('sendRequest') + ->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 testShouldReturnDefaultValueIfEvaluationContextHasEmptyStringTargetingKey(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->method('sendRequest') + ->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 testShouldReturnDefaultValueIfEvaluationContextHasNullTargetingKey(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->method('sendRequest') + ->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 testShouldReturnDefaultValueIfFlagKeyEmptyString(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'integer_key', + 'value' => 42, + 'reason' => 'TARGETING_MATCH', + 'variant' => 'default', + ])); + + $mockClient->method('sendRequest') + ->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 testReturnAnErrorAPIResponseIf500(): void + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(500, [], json_encode([])); + + $mockClient + ->expects($this->once()) + ->method('sendRequest') + ->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'])); + } +} diff --git a/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php b/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php new file mode 100644 index 00000000..8caa13f7 --- /dev/null +++ b/providers/GoFeatureFlag/tests/unit/controller/OfrepApiTest.php @@ -0,0 +1,437 @@ +expectException(RateLimitedException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(429, [], json_encode([])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIfNotAuthorized401() + { + $this->expectException(UnauthorizedException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(401, [], json_encode([])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIfNotAuthorized403() + { + $this->expectException(UnauthorizedException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(403, [], json_encode([])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIfFlagNotFound404() + { + $this->expectException(FlagNotFoundException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(404, [], json_encode([])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIfUnknownHttpCode500() + { + $this->expectException(UnknownOfrepException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(500, [], json_encode([])); + $mockClient->method('sendRequest')->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 testShouldReturnAnErrorResponseIf400() + { + $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', + ])); + $mockClient->method('sendRequest')->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(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()); + } + + public function testShouldReturnAValidResponseIf200() + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flagKey', + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', + ])); + $mockClient->method('sendRequest')->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(OfrepApiSuccessResponse::class, $got); + $this->assertEquals(Reason::TARGETING_MATCH, $got->getReason()); + $this->assertEquals(true, $got->getValue()); + } + + public function testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingValue() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flagKey', + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', + ])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingKey() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', + ])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingReason() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flagKey', + 'value' => true, + 'variant' => 'default', + ])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIf200AndJsonDoesNotContainTheRequiredKeysMissingVariant() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flagKey', + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + ])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIf400AndJsonDoesNotContainTheRequiredKeysMissingKey() + { + $this->expectException(ParseException::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', + ])); + $mockClient->method('sendRequest')->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 testShouldRaiseAnErrorIf400AndJsonDoesNotContainTheRequiredKeysMissingErrorCode() + { + $this->expectException(ParseException::class); + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(400, [], json_encode([ + 'key' => 'flagKey', + 'errorDetails' => 'The flag value is not of the expected type', + ])); + $mockClient->method('sendRequest')->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 testShouldNotBeAbleToCallTheApiAgainIfRateLimitedWithRetryAfterInt() + { + $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('sendRequest') + ->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 testShouldBeAbleToCallTheApiAgainIfWeWaitAfterTheRetryAfterAsInt() + { + $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', + ])); + $mockClient->method('sendRequest')->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(OfrepApiSuccessResponse::class, $got); + } + + public function testShouldNotBeAbleToCallTheApiAgainIfRateLimitedWithRetryAfterDate() + { + $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('sendRequest') + ->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 testShouldBeAbleToCallTheApiAgainIfWeWaitAfterTheRetryAfterAsDate() + { + $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', + ])); + $mockClient->method('sendRequest')->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(OfrepApiSuccessResponse::class, $got); + } + + public function testShouldHaveAuthorizationHeaderIfApiKeyInConfig() + { + $mockClient = $this->createMock(ClientInterface::class); + $mockResponse = new Response(200, [], json_encode([ + 'key' => 'flagKey', + 'value' => true, + 'reason' => Reason::TARGETING_MATCH, + 'variant' => 'default', + ])); + + $mockClient->expects($this->once()) + ->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); + $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'); + } +} 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" } } }