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
+
+[](https://cloud-native.slack.com/archives/C0344AANLA1)
+[](https://packagist.org/packages/open-feature/validators-hook)
+[](https://packagist.org/packages/open-feature/validators-hook)
+
+[](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);
+ }
+}