diff --git a/.github/workflows/php-ci.yaml b/.github/workflows/php-ci.yaml index 26f4d021..d6e2b61a 100644 --- a/.github/workflows/php-ci.yaml +++ b/.github/workflows/php-ci.yaml @@ -15,6 +15,8 @@ jobs: php-version: ['7.4', '8.0', '8.1', '8.2'] project-dir: - hooks/OpenTelemetry + - hooks/DDTrace + - hooks/Validators - providers/Flagd - providers/Split - providers/CloudBees diff --git a/.gitsplit.yml b/.gitsplit.yml index 4c5e4013..ee802707 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -10,6 +10,8 @@ splits: target: "https://${GH_TOKEN}@github.com/open-feature-php/otel-hook.git" - prefix: "hooks/DDTrace" target: "https://${GH_TOKEN}@github.com/open-feature-php/dd-trace-hook.git" + - prefix: "hooks/Validators" + target: "https://${GH_TOKEN}@github.com/open-feature-php/validators-hook.git" - prefix: "providers/Flagd" target: "https://${GH_TOKEN}@github.com/open-feature-php/flagd-provider.git" - prefix: "providers/Split" diff --git a/hooks/Validators/.github/workflows/release-please.yml b/hooks/Validators/.github/workflows/release-please.yml new file mode 100644 index 00000000..93659057 --- /dev/null +++ b/hooks/Validators/.github/workflows/release-please.yml @@ -0,0 +1,18 @@ +on: + push: + branches: + - main + +name: Run Release Please +jobs: + release-please: + runs-on: ubuntu-latest + + # Release-please creates a PR that tracks all changes + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + command: manifest + token: ${{secrets.GITHUB_TOKEN}} + default-branch: main diff --git a/hooks/Validators/.gitignore b/hooks/Validators/.gitignore new file mode 100644 index 00000000..e1efd914 --- /dev/null +++ b/hooks/Validators/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/build \ No newline at end of file diff --git a/hooks/Validators/.release-please-manifest.json b/hooks/Validators/.release-please-manifest.json new file mode 100644 index 00000000..1332969b --- /dev/null +++ b/hooks/Validators/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1" +} \ No newline at end of file diff --git a/hooks/Validators/README.md b/hooks/Validators/README.md new file mode 100644 index 00000000..13a4e2b5 --- /dev/null +++ b/hooks/Validators/README.md @@ -0,0 +1,67 @@ +# OpenFeature Validator Hooks + +[![a](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) +[![Latest Stable Version](http://poser.pugx.org/open-feature/validators-hook/v)](https://packagist.org/packages/open-feature/validators-hook) +[![Total Downloads](http://poser.pugx.org/open-feature/validators-hook/downloads)](https://packagist.org/packages/open-feature/validators-hook) +![PHP 7.4+](https://img.shields.io/badge/php->=7.4-blue.svg) +[![License](http://poser.pugx.org/open-feature/validators-hook/license)](https://packagist.org/packages/open-feature/validators-hook) + +## Overview + +Validator Hook constructs that provide means to execute validation against resolved feature flag values. + +This package also builds on various PSRs (PHP Standards Recommendations) such as the Logger interfaces (PSR-3) and the Basic and Extended Coding Standards (PSR-1 and PSR-12). + +## Installation + +``` +$ composer require open-feature/validators-hook // installs the latest version +``` + +## Usage + +The following validator hook constructs are available, but more are being worked on over time: + +- `RegexpValidatorHoook` + + +```php +use OpenFeature\Hooks\Validators\RegexpValidatorHook; + +$alphanumericValidator = new RegexpValidatorHook('/^[A-Za-z0-9]+$/'); +$hexadecimalValidator = new RegexpValidatorHook('/^[0-9a-f]+$/'); +$asciiValidator = new RegexpValidatorHook('/^[ -~]$/'); + +// hooks can be applied to the global API, clients, providers, and resolution invocations + +// all feature flag resolutions will use this validator +$api = OpenFeatureAPI::getInstance(); +$api->addHooks($asciiValidator); + +// invocations from this client will use this validator also +$client = $api->getClient('example'); +$client->setHooks([$alphanumericValidator]); + +// this specific invocation will use this validator also +$client->resolveBooleanValue('test-flag', 'deadbeef', null, new EvaluationOptions([$hexadecimalValidator])); +``` + +For more examples, see the [examples](./examples/). + +## Development + +### PHP Versioning + +This library targets PHP version 7.4 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`. `composer install` 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. + +### Testing + +Run tests with `composer run test`. \ No newline at end of file diff --git a/hooks/Validators/composer.json b/hooks/Validators/composer.json new file mode 100644 index 00000000..6864239f --- /dev/null +++ b/hooks/Validators/composer.json @@ -0,0 +1,133 @@ +{ + "name": "open-feature/validators-hook", + "description": "A validator hooks package for OpenFeature", + "license": "Apache-2.0", + "type": "library", + "keywords": [ + "featureflags", + "featureflagging", + "openfeature", + "validator", + "hook" + ], + "authors": [ + { + "name": "OpenFeature PHP Maintainers", + "homepage": "https://github.com/orgs/open-feature/teams/php-maintainer" + }, + { + "name": "open-feature/php-sdk-contrib Contributors", + "homepage": "https://github.com/open-feature/php-sdk-contrib/graphs/contributors" + } + ], + "require": { + "php": "^7.4 || ^8", + "open-feature/sdk": "^1.2.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.25", + "friendsofphp/php-cs-fixer": "^3.13", + "hamcrest/hamcrest-php": "^2.0", + "mdwheele/zalgo": "^0.3.1", + "mikey179/vfsstream": "v1.6.11", + "mockery/mockery": "^1.5", + "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.9.0", + "phpstan/phpstan-mockery": "^1.0", + "phpstan/phpstan-phpunit": "^1.1", + "psalm/plugin-mockery": "^0.9.1", + "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\\Hooks\\Validators\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "OpenFeature\\Hooks\\Validators\\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 + }, + "extra": { + "captainhook": { + "force-install": false + } + }, + "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/hooks/Validators/examples/.gitignore b/hooks/Validators/examples/.gitignore new file mode 100644 index 00000000..149cf08d --- /dev/null +++ b/hooks/Validators/examples/.gitignore @@ -0,0 +1,2 @@ +/*/vendor +/*/composer.lock \ No newline at end of file diff --git a/hooks/Validators/examples/ExampleRegexpValidators/README.md b/hooks/Validators/examples/ExampleRegexpValidators/README.md new file mode 100644 index 00000000..50d8fcc5 --- /dev/null +++ b/hooks/Validators/examples/ExampleRegexpValidators/README.md @@ -0,0 +1,3 @@ +# OpenFeature Validators Hook example + +This example provides an example of using the validators hooks for OpenFeature. diff --git a/hooks/Validators/examples/ExampleRegexpValidators/composer.json b/hooks/Validators/examples/ExampleRegexpValidators/composer.json new file mode 100644 index 00000000..12f72d99 --- /dev/null +++ b/hooks/Validators/examples/ExampleRegexpValidators/composer.json @@ -0,0 +1,27 @@ +{ + "name": "open-feature/validators-hook-example", + "description": "An example of using the validator hooks for OpenFeature", + "type": "project", + "license": "Apache-2.0", + "authors": [ + { + "name": "Tom Carrio", + "email": "tom@carrio.dev" + } + ], + "require": { + "open-feature/sdk": "^1.2.0", + "open-feature/validators-hook": "dev-main" + }, + "repositories": [ + { + "type": "path", + "url": "../../", + "options": { + "versions": { + "open-feature/validators-hook": "dev-main" + } + } + } + ] +} diff --git a/hooks/Validators/examples/ExampleRegexpValidators/src/main.php b/hooks/Validators/examples/ExampleRegexpValidators/src/main.php new file mode 100644 index 00000000..16627b34 --- /dev/null +++ b/hooks/Validators/examples/ExampleRegexpValidators/src/main.php @@ -0,0 +1,31 @@ +getClient('split-example', '1.0'); + +// create some example hook validators + +$alphanumericValidator = new RegexpValidatorHook('/^[A-Za-z0-9]+$/'); +$hexadecimalValidator = new RegexpValidatorHook('/^[0-9a-f]+$/'); +$asciiValidator = new RegexpValidatorHook('/^[ -~]$/'); + +$client->setHooks([ + $alphanumericValidator, + $hexadecimalValidator, + $asciiValidator +]); + +$flagValue = $client->getBooleanDetails('dev.openfeature.example_flag', true, null, null); diff --git a/hooks/Validators/phpcs.xml.dist b/hooks/Validators/phpcs.xml.dist new file mode 100644 index 00000000..5ba48465 --- /dev/null +++ b/hooks/Validators/phpcs.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + ./src + ./tests + + */tests/fixtures/* + */tests/*/fixtures/* + + + + + + + + + + + + + + + + diff --git a/hooks/Validators/phpstan.neon.dist b/hooks/Validators/phpstan.neon.dist new file mode 100644 index 00000000..93c5b2d2 --- /dev/null +++ b/hooks/Validators/phpstan.neon.dist @@ -0,0 +1,9 @@ +parameters: + tmpDir: ./build/cache/phpstan + level: max + paths: + - ./src + - ./tests + excludePaths: + - */tests/fixtures/* + - */tests/*/fixtures/* diff --git a/hooks/Validators/phpunit.xml.dist b/hooks/Validators/phpunit.xml.dist new file mode 100644 index 00000000..9d4740e1 --- /dev/null +++ b/hooks/Validators/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + ./tests/unit + + + ./tests/integration + + + + + + ./src + + + + + + + + diff --git a/hooks/Validators/psalm-baseline.xml b/hooks/Validators/psalm-baseline.xml new file mode 100644 index 00000000..ceaa5778 --- /dev/null +++ b/hooks/Validators/psalm-baseline.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/hooks/Validators/psalm.xml b/hooks/Validators/psalm.xml new file mode 100644 index 00000000..c3e6c03c --- /dev/null +++ b/hooks/Validators/psalm.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/hooks/Validators/release-please-config.json b/hooks/Validators/release-please-config.json new file mode 100644 index 00000000..5e3fe497 --- /dev/null +++ b/hooks/Validators/release-please-config.json @@ -0,0 +1,12 @@ +{ + "bootstrap-sha": "0a4beadbad2b40f14063f2ba9bcda258595119e9", + "packages": { + ".": { + "release-type": "php", + "prerelease": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-v-in-tag": false + } + } +} diff --git a/hooks/Validators/src/Exceptions/ValidationException.php b/hooks/Validators/src/Exceptions/ValidationException.php new file mode 100644 index 00000000..285236c7 --- /dev/null +++ b/hooks/Validators/src/Exceptions/ValidationException.php @@ -0,0 +1,18 @@ +regexp = self::validateRegexp($regexp); + } + + public function before(HookContext $context, HookHints $hints): ?EvaluationContext + { + return null; + } + + public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void + { + /** @var string $resolvedValue */ + $resolvedValue = $details->getValue(); + + if ($this->testResolvedValue($resolvedValue)) { + return; + } + + throw new ValidationException(); + } + + public function error(HookContext $context, Throwable $error, HookHints $hints): void + { + // no-op + } + + public function finally(HookContext $context, HookHints $hints): void + { + // no-op + } + + public function supportsFlagValueType(string $flagValueType): bool + { + return $flagValueType === FlagValueType::STRING; + } + + private function testResolvedValue(string $resolvedValue): bool + { + return preg_match($this->regexp, $resolvedValue) === 1; + } + + private static function validateRegexp(string $regexp): string + { + if (self::isValidRegexp($regexp)) { + return $regexp; + } + + throw new InvalidRegularExpressionException($regexp); + } + + private static function isValidRegexp(string $regexp): bool + { + return is_int(@preg_match($regexp, '')); + } +} diff --git a/hooks/Validators/tests/TestCase.php b/hooks/Validators/tests/TestCase.php new file mode 100644 index 00000000..584c5e69 --- /dev/null +++ b/hooks/Validators/tests/TestCase.php @@ -0,0 +1,40 @@ + $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/hooks/Validators/tests/integration/RegexpValidatorHookTest.php b/hooks/Validators/tests/integration/RegexpValidatorHookTest.php new file mode 100644 index 00000000..d174818d --- /dev/null +++ b/hooks/Validators/tests/integration/RegexpValidatorHookTest.php @@ -0,0 +1,19 @@ +assertInstanceOf(Hook::class, $hook); + } +} diff --git a/hooks/Validators/tests/unit/RegexpValidatorHookTest.php b/hooks/Validators/tests/unit/RegexpValidatorHookTest.php new file mode 100644 index 00000000..a1caa6de --- /dev/null +++ b/hooks/Validators/tests/unit/RegexpValidatorHookTest.php @@ -0,0 +1,111 @@ +assertInstanceOf(Hook::class, $hook); + } + + public function testCannotCreateInvalidRegexpForHook(): void + { + $this->expectException(InvalidRegularExpressionException::class); + + new RegexpValidatorHook('/[\\]/'); + } + + public function testAlphanumericRegexpHookPasses(): void + { + $hook = new RegexpValidatorHook('/^[A-Za-z0-9]+$/'); + + $this->assertInstanceOf(Hook::class, $hook); + + $this->executeHook($hook, 'Abc123'); + } + + public function testAlphanumericRegexpHookFails(): void + { + $hook = new RegexpValidatorHook('/^[A-Za-z0-9]+$/'); + + $this->assertInstanceOf(Hook::class, $hook); + + $this->expectException(ValidationException::class); + + $this->executeHook($hook, 'This, a sentence, has other invalid characters.'); + } + + public function testHexadecimalRegexpHookPasses(): void + { + $hook = new RegexpValidatorHook('/^[0-9a-f]+$/'); + + $this->assertInstanceOf(Hook::class, $hook); + + $this->executeHook($hook, 'deadbeef007'); + } + + public function testHexadecimalRegexpHookFails(): void + { + $hook = new RegexpValidatorHook('/^[0-9a-f]+$/'); + + $this->assertInstanceOf(Hook::class, $hook); + + $this->expectException(ValidationException::class); + + $this->executeHook($hook, '0123456789abcdefg'); + } + + public function testAsciiRegexpHookPasses(): void + { + $hook = new RegexpValidatorHook('/^[ -~]+$/'); + + $this->assertInstanceOf(Hook::class, $hook); + + $this->executeHook($hook, 'Only ASCII characters get used here: See?'); + } + + public function testAsciiRegexpHookFails(): void + { + $hook = new RegexpValidatorHook('/^[ -~]+$/'); + + $this->assertInstanceOf(Hook::class, $hook); + + $this->expectException(ValidationException::class); + + $this->executeHook($hook, '死'); + } + + private function executeHook(Hook $hook, string $resolvedValue): void + { + $ctx = HookContextFactory::from( + 'any-key', + FlagValueType::STRING, + 'default-value', + null, + new Metadata('client'), + new Metadata('provider'), + ); + + $details = ResolutionDetailsFactory::fromSuccess($resolvedValue); + + $nullHints = new HookHints(); + + $hook->after($ctx, $details, $nullHints); + } +}