diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d87a1..828fe39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-06-15 + +### Added + +- Multi-provider support through the SDK `MultiProvider`. The new `providers` configuration key declares several providers (map of provider name => service ID, evaluated in declaration order, mutually exclusive with `provider`), and the `strategy` key selects the evaluation strategy: `first_match` (default), `first_successful`, or `comparison` with a required `fallback` provider name. Configuration is validated at compile time (exclusivity, fallback consistency, case-insensitive provider name uniqueness). +- The profiler panel and toolbar now display the evaluation strategy and the sub-providers in evaluation order (with a `fallback` badge for the `comparison` strategy) when multi-provider is configured. + +### Changed + +- `open-feature/sdk` requirement raised from `^2.0` to `^2.2` (the bundle relies on the `MultiProvider` classes introduced in SDK 2.2.0). +- The `provider` configuration node now defaults to `null` instead of `InMemoryProvider`. When neither `provider` nor `providers` is set, the bundle still falls back to the built-in `InMemoryProvider`: behavior is unchanged, only the output of `config:dump-reference open_feature` differs. + +### Upgrade notes + +- Run `composer update open-feature/sdk` if your lock file pins a version below 2.2.0. + ## [0.2.0] - 2026-04-24 ### Changed @@ -29,7 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial release. -[Unreleased]: https://github.com/aubes/openfeature-bundle/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/aubes/openfeature-bundle/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/aubes/openfeature-bundle/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/aubes/openfeature-bundle/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/aubes/openfeature-bundle/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/aubes/openfeature-bundle/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE index 3ad7336..3b0c0d8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Aurélian Bes +Copyright (c) Aurélian Bes Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3b64ee3..02f92eb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Feature flags, the Symfony way. -Symfony bundle for the [OpenFeature PHP SDK](https://github.com/open-feature/php-sdk) — the [CNCF standard](https://openfeature.dev) for feature flags. +Symfony bundle for the [OpenFeature PHP SDK](https://github.com/open-feature/php-sdk), the [CNCF standard](https://openfeature.dev) for feature flags. ```php class CheckoutController @@ -35,7 +35,7 @@ class CheckoutController - PHP 8.2+ - Symfony 6.4, 7.x or 8.x -- `open-feature/sdk` ^2.0 (implements [OpenFeature spec v0.5.1](https://github.com/open-feature/spec/releases/tag/v0.5.1)) +- `open-feature/sdk` ^2.2 (implements [OpenFeature spec v0.5.1](https://github.com/open-feature/spec/releases/tag/v0.5.1)) ## Quick start @@ -105,6 +105,20 @@ For anything beyond a quick demo (user targeting, percentage rollouts, A/B testi | EnvVarProvider | Kill switches via env vars | `provider` | | RedisProvider | Shared on/off toggles via Redis | `provider` + `redis` | +### Multiple providers + +Combine several providers through the SDK `MultiProvider` with the `providers` key (evaluated in declaration order): + +```yaml +open_feature: + providers: + remote: App\OpenFeature\MyProvider + local: Aubes\OpenFeatureBundle\Provider\InMemoryProvider + strategy: first_match # first_match | first_successful | comparison +``` + +See [Configuration reference](docs/configuration.md#multiple-providers) for strategy details. + ## Documentation Full documentation lives in the [`docs/`](docs/index.md) folder: diff --git a/composer.json b/composer.json index 91747dd..0924ab6 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ }, "require": { "php": ">=8.2", - "open-feature/sdk": "^2.0", + "open-feature/sdk": "^2.2", "symfony/config": "^6.4|^7.4|^8.0", "symfony/dependency-injection": "^6.4|^7.4|^8.0", "symfony/http-kernel": "^6.4|^7.4|^8.0" diff --git a/docs/configuration.md b/docs/configuration.md index 3fa98e4..f3b56d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,8 +8,25 @@ open_feature: # Service ID of the OpenFeature provider # Default: Aubes\OpenFeatureBundle\Provider\InMemoryProvider + # Mutually exclusive with "providers" provider: Aubes\OpenFeatureBundle\Provider\InMemoryProvider + # Multiple providers combined through the SDK MultiProvider + # Keys are provider names, values are service IDs + # Evaluation follows declaration order + # Mutually exclusive with "provider" + providers: + remote: App\OpenFeature\MyProvider + local: Aubes\OpenFeatureBundle\Provider\InMemoryProvider + + # Evaluation strategy for the MultiProvider (only when using "providers") + # Shorthand: strategy: first_match + strategy: + type: first_match # first_match | first_successful | comparison + # Provider name used as fallback on mismatch + # Required (and only allowed) when type is "comparison" + fallback: ~ + # Flags for the InMemoryProvider (dev/test use) flags: new_checkout: true @@ -53,6 +70,40 @@ open_feature: See [Providers](providers/index.md) for available options. +## Multiple providers + +Declare several providers under `providers` to combine them through the SDK `MultiProvider` (requires `open-feature/sdk` >= 2.2). Each key is a provider name, each value a service ID. Providers are evaluated in declaration order: + +```yaml +open_feature: + providers: + remote: App\OpenFeature\MyProvider + local: Aubes\OpenFeatureBundle\Provider\InMemoryProvider + strategy: first_match +``` + +`provider` and `providers` are mutually exclusive. + +### Strategies + +| Strategy | Behavior | +|---|---| +| `first_match` (default) | Sequential. Returns the first provider that knows the flag; `FLAG_NOT_FOUND` moves to the next provider, any other error stops the evaluation. | +| `first_successful` | Sequential. Returns the first result without error; errors do not stop the chain. | +| `comparison` | Evaluates all providers and compares results. If they disagree, the result of the `fallback` provider is used. | + +The `comparison` strategy requires a `fallback` pointing to one of the declared provider names: + +```yaml +open_feature: + providers: + remote: App\OpenFeature\MyProvider + local: Aubes\OpenFeatureBundle\Provider\InMemoryProvider + strategy: + type: comparison + fallback: local +``` + ## Flags The `flags` key is only used by the `InMemoryProvider`. Values can be booleans, integers, floats, strings, or arrays: diff --git a/docs/profiler.md b/docs/profiler.md index 998fc8c..71c52fe 100644 --- a/docs/profiler.md +++ b/docs/profiler.md @@ -5,6 +5,7 @@ The bundle registers an **OpenFeature panel** in the Symfony Web Debug Toolbar showing: - Active provider name +- In multi-provider mode: the evaluation strategy, the fallback marker, and the sub-providers in evaluation order - All flags evaluated during the request (key, type, resolved value, reason, error) - Global EvaluationContext (targeting key and attributes) diff --git a/docs/providers/index.md b/docs/providers/index.md index b425b97..8422429 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -37,6 +37,10 @@ Any other provider from [open-feature/php-sdk-contrib](https://github.com/open-f | [EnvVarProvider](env-var.md) | Kill switches via env vars, no extra infra | | [RedisProvider](redis.md) | Shared on/off toggles when Redis is already in the stack | +## Multiple providers + +Several providers can be combined through the SDK `MultiProvider` using the `providers` key, with a configurable evaluation strategy (`first_match`, `first_successful`, `comparison`). See [Configuration reference](../configuration.md#multiple-providers). + ## Writing a custom provider ```php diff --git a/src/DependencyInjection/Compiler/SetProviderPass.php b/src/DependencyInjection/Compiler/SetProviderPass.php index d64f4b1..f2aa3b1 100644 --- a/src/DependencyInjection/Compiler/SetProviderPass.php +++ b/src/DependencyInjection/Compiler/SetProviderPass.php @@ -4,28 +4,38 @@ namespace Aubes\OpenFeatureBundle\DependencyInjection\Compiler; +use OpenFeature\implementation\multiprovider\MultiProvider; +use OpenFeature\implementation\multiprovider\strategy\ComparisonStrategy; +use OpenFeature\implementation\multiprovider\strategy\FirstMatchStrategy; +use OpenFeature\implementation\multiprovider\strategy\FirstSuccessfulStrategy; use OpenFeature\interfaces\flags\API; +use OpenFeature\interfaces\provider\Provider; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; class SetProviderPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - $providerId = $container->getParameter('open_feature.provider'); + /** @var array $providers */ + $providers = $container->getParameter('open_feature.providers'); + + if ($providers !== []) { + $this->registerMultiProvider($container, $providers); - if ($providerId === null || !\is_string($providerId)) { return; } - $definition = $container->findDefinition($providerId); - $class = $definition->getClass(); + $providerId = $container->getParameter('open_feature.provider'); - if ($class !== null && !\is_subclass_of($class, \OpenFeature\interfaces\provider\Provider::class)) { - throw new \InvalidArgumentException(\sprintf('The service "%s" (class "%s") configured as OpenFeature provider must implement "%s".', $providerId, $class, \OpenFeature\interfaces\provider\Provider::class)); + if (!\is_string($providerId)) { + return; } + $definition = $this->validateProvider($container, $providerId); + if ($container->has('logger')) { $definition->addMethodCall('setLogger', [new Reference('logger')]); } @@ -33,4 +43,53 @@ public function process(ContainerBuilder $container): void $container->getDefinition(API::class) ->addMethodCall('setProvider', [new Reference($providerId)]); } + + /** + * @param array $providers + */ + private function registerMultiProvider(ContainerBuilder $container, array $providers): void + { + $hasLogger = $container->has('logger'); + $providerData = []; + + foreach ($providers as $name => $providerId) { + $definition = $this->validateProvider($container, $providerId); + + if ($hasLogger) { + $definition->addMethodCall('setLogger', [new Reference('logger')]); + } + + $providerData[] = ['name' => (string) $name, 'provider' => new Reference($providerId)]; + } + + /** @var array{type: string, fallback: null|string} $strategy */ + $strategy = $container->getParameter('open_feature.strategy'); + + $strategyDefinition = match ($strategy['type']) { + 'first_successful' => new Definition(FirstSuccessfulStrategy::class), + 'comparison' => new Definition(ComparisonStrategy::class, [new Reference($providers[(string) $strategy['fallback']])]), + default => new Definition(FirstMatchStrategy::class), + }; + + $definition = new Definition(MultiProvider::class, [$providerData, $strategyDefinition]); + + if ($hasLogger) { + $definition->addMethodCall('setLogger', [new Reference('logger')]); + } + + $container->getDefinition(API::class) + ->addMethodCall('setProvider', [$definition]); + } + + private function validateProvider(ContainerBuilder $container, string $providerId): Definition + { + $definition = $container->findDefinition($providerId); + $class = $definition->getClass(); + + if ($class !== null && !\is_subclass_of($class, Provider::class)) { + throw new \InvalidArgumentException(\sprintf('The service "%s" (class "%s") configured as OpenFeature provider must implement "%s".', $providerId, $class, Provider::class)); + } + + return $definition; + } } diff --git a/src/EvaluationContext/EvaluationContextProviderInterface.php b/src/EvaluationContext/EvaluationContextProviderInterface.php index 410d29f..0872dcf 100644 --- a/src/EvaluationContext/EvaluationContextProviderInterface.php +++ b/src/EvaluationContext/EvaluationContextProviderInterface.php @@ -11,8 +11,12 @@ * Implement this interface to contribute attributes to the global OpenFeature * EvaluationContext on each request. * - * Multiple providers are supported and their contexts are merged in priority order - * (highest priority first). Use the "priority" attribute on the tag to control ordering. + * Multiple providers are supported. They are iterated highest priority first + * (set the "priority" attribute on the tag), then their contexts are merged by the + * OpenFeature SDK, which gives precedence to the last context on conflicts. + * As a result, a lower-priority provider overrides a higher-priority one for the + * targeting key and any shared attribute. Register a fallback at high priority so a + * real identity contributed at lower priority wins. */ interface EvaluationContextProviderInterface { diff --git a/src/OpenFeatureBundle.php b/src/OpenFeatureBundle.php index 3e6aac1..f8656a5 100644 --- a/src/OpenFeatureBundle.php +++ b/src/OpenFeatureBundle.php @@ -16,6 +16,7 @@ use OpenFeature\interfaces\flags\Client; use OpenFeature\interfaces\hooks\Hook; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -35,8 +36,31 @@ public function configure(DefinitionConfigurator $definition): void $rootNode ->children() ->scalarNode('provider') - ->info('Service ID of the OpenFeature provider. Defaults to the built-in InMemoryProvider.') - ->defaultValue(Provider\InMemoryProvider::class) + ->info('Service ID of the OpenFeature provider. Defaults to the built-in InMemoryProvider. Mutually exclusive with "providers".') + ->defaultNull() + ->end() + ->arrayNode('providers') + ->info('Multiple providers combined through the SDK MultiProvider. Keys are provider names, values are service IDs. Evaluation follows declaration order.') + ->useAttributeAsKey('name') + ->scalarPrototype()->end() + ->end() + ->arrayNode('strategy') + ->info('Evaluation strategy used by the MultiProvider. Shorthand: a plain string sets "type".') + ->addDefaultsIfNotSet() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v) => ['type' => $v]) + ->end() + ->children() + ->enumNode('type') + ->values(['first_match', 'first_successful', 'comparison']) + ->defaultValue('first_match') + ->end() + ->scalarNode('fallback') + ->info('Provider name (key of "providers") used as fallback on mismatch. Required and only allowed when type is "comparison".') + ->defaultNull() + ->end() + ->end() ->end() ->arrayNode('flags') ->info('Flag values for the built-in InMemoryProvider (local/dev use).') @@ -61,6 +85,10 @@ public function configure(DefinitionConfigurator $definition): void ->children() ->enumNode('user_provider') ->info('Populate EvaluationContext targeting key from the authenticated Symfony user. "auto" enables it if symfony/security-core is available.') + ->beforeNormalization() + ->ifTrue(\is_bool(...)) + ->then(static fn (bool $v): string => $v ? 'true' : 'false') + ->end() ->values(['auto', 'true', 'false']) ->defaultValue('auto') ->end() @@ -88,9 +116,45 @@ public function configure(DefinitionConfigurator $definition): void ; } + /** + * @param array{provider: null|string, providers: array, strategy: array{type: string, fallback: null|string}} $config + */ + private function validateProviderConfig(array $config): void + { + $providers = $config['providers']; + $strategy = $config['strategy']; + + if ($config['provider'] !== null && $providers !== []) { + throw new InvalidConfigurationException('Invalid "open_feature" configuration: "provider" and "providers" are mutually exclusive.'); + } + + if ($providers === [] && $strategy['type'] !== 'first_match') { + throw new InvalidConfigurationException('Invalid "open_feature" configuration: "strategy" requires "providers".'); + } + + if ($strategy['type'] === 'comparison' && $strategy['fallback'] === null) { + throw new InvalidConfigurationException('Invalid "open_feature" configuration: the "comparison" strategy requires "strategy.fallback".'); + } + + if ($strategy['type'] !== 'comparison' && $strategy['fallback'] !== null) { + throw new InvalidConfigurationException('Invalid "open_feature" configuration: "strategy.fallback" is only allowed when "strategy.type" is "comparison".'); + } + + if ($strategy['fallback'] !== null && !isset($providers[$strategy['fallback']])) { + throw new InvalidConfigurationException('Invalid "open_feature" configuration: "strategy.fallback" must be one of the names declared in "providers".'); + } + + $names = \array_map(\strtolower(...), \array_keys($providers)); + if (\count($names) !== \count(\array_unique($names))) { + throw new InvalidConfigurationException('Invalid "open_feature" configuration: provider names in "providers" must be unique (case-insensitive, the SDK normalizes them to lowercase).'); + } + } + /** * @param array{ - * provider: string, + * provider: null|string, + * providers: array, + * strategy: array{type: string, fallback: null|string}, * flags: array, * redis?: array{client: string, prefix: string}, * feature_flag: array{on_disabled: string, status_code: int}, @@ -99,6 +163,8 @@ public function configure(DefinitionConfigurator $definition): void */ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { + $this->validateProviderConfig($config); + $loader = new PhpFileLoader($builder, new FileLocator(__DIR__ . '/Resources/config')); $loader->load('services.php'); @@ -108,7 +174,14 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->registerForAutoconfiguration(EvaluationContextProviderInterface::class) ->addTag('openfeature.evaluation_context_provider'); - $builder->setParameter('open_feature.provider', $config['provider']); + $provider = $config['provider']; + if ($provider === null && $config['providers'] === []) { + $provider = Provider\InMemoryProvider::class; + } + + $builder->setParameter('open_feature.provider', $provider); + $builder->setParameter('open_feature.providers', $config['providers']); + $builder->setParameter('open_feature.strategy', $config['strategy']); $builder->setParameter('open_feature.flags', $config['flags']); if (isset($config['redis'])) { @@ -174,6 +247,8 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->addArgument(new Reference(ProfilerHook::class)) ->addArgument(new Reference(API::class)) ->addArgument(new Reference(ContextProviderRecorder::class)) + ->addArgument($config['providers']) + ->addArgument($config['providers'] === [] ? null : $config['strategy']) ->addTag('data_collector', [ 'template' => '@OpenFeature/Collector/openfeature.html.twig', 'id' => 'open_feature', diff --git a/src/Profiler/OpenFeatureDataCollector.php b/src/Profiler/OpenFeatureDataCollector.php index 4d51e42..2a433e3 100644 --- a/src/Profiler/OpenFeatureDataCollector.php +++ b/src/Profiler/OpenFeatureDataCollector.php @@ -11,10 +11,16 @@ class OpenFeatureDataCollector extends DataCollector { + /** + * @param array $providers + * @param null|array{type: string, fallback: null|string} $strategy + */ public function __construct( private readonly ProfilerHook $hook, private readonly API $api, private readonly ?ContextProviderRecorder $recorder = null, + private readonly array $providers = [], + private readonly ?array $strategy = null, ) { } @@ -23,6 +29,8 @@ public function collect(Request $request, Response $response, ?\Throwable $excep $this->data = [ 'evaluations' => $this->hook->getEvaluations(), 'provider' => $this->api->getProviderMetadata()->getName(), + 'providers' => $this->providers, + 'strategy' => $this->providers === [] ? null : $this->strategy, 'evaluation_context' => $this->serializeContext(), 'hooks' => $this->collectHooks(), 'context_providers' => $this->collectContextProviders(), @@ -51,6 +59,24 @@ public function getProvider(): string return $provider; } + /** @return array */ + public function getProviders(): array + { + /** @var array $providers */ + $providers = $this->data['providers'] ?? []; + + return $providers; + } + + /** @return null|array{type: string, fallback: null|string} */ + public function getStrategy(): ?array + { + /** @var null|array{type: string, fallback: null|string} $strategy */ + $strategy = $this->data['strategy'] ?? null; + + return $strategy; + } + /** @return array */ public function getEvaluationContext(): array { diff --git a/src/Resources/views/Collector/openfeature.html.twig b/src/Resources/views/Collector/openfeature.html.twig index 11025e0..0a2b8d2 100644 --- a/src/Resources/views/Collector/openfeature.html.twig +++ b/src/Resources/views/Collector/openfeature.html.twig @@ -13,6 +13,16 @@ Provider {{ collector.provider }} + {% if collector.providers is not empty %} +
+ Providers + {{ collector.providers|keys|join(' → ') }} +
+
+ Strategy + {{ collector.strategy.type }} +
+ {% endif %}
Flags evaluated {{ collector.evaluationCount }} @@ -46,12 +56,46 @@ {{ collector.provider }} Provider
+ {% if collector.strategy %} +
+ {{ collector.strategy.type }} + Strategy +
+ {% endif %}
{{ collector.evaluationCount }} Flags evaluated
+ {% if collector.providers is not empty %} +

Providers

+

Providers are evaluated in declaration order by the {{ collector.strategy.type }} strategy.

+ + + + + + + + + + {% for name, service in collector.providers %} + + + + + + {% endfor %} + +
OrderNameService
{{ loop.index }} + {{ name }} + {% if collector.strategy.fallback == name %} + fallback + {% endif %} + {{ service }}
+ {% endif %} + {% if collector.evaluationContext is not empty %}

Evaluation Context