├── .gitignore ├── docs ├── images │ ├── cookie.png │ └── profiler.png ├── attribute.md ├── twig.md ├── route_condition.md ├── profiler.md ├── environment.md ├── install.md ├── activator.md ├── cookie.md ├── usage.md ├── database.md ├── route.md └── constraint.md ├── UPGRADE-5.0.md ├── phpcs.xml ├── UPGRADE-4.0.md ├── src ├── Attribute │ └── Feature.php ├── Resources │ ├── views │ │ ├── collector │ │ │ └── icon.svg │ │ └── profiler │ │ │ └── layout.html.twig │ └── config │ │ ├── configurators.yml │ │ └── services.yml ├── Route │ └── IsFeature.php ├── FlagceptionBundle.php ├── DependencyInjection │ ├── CompilerPass │ │ ├── ExpressionProviderPass.php │ │ ├── ActivatorPass.php │ │ └── ContextDecoratorPass.php │ ├── Configurator │ │ ├── ActivatorConfiguratorInterface.php │ │ ├── ArrayConfigurator.php │ │ ├── EnvironmentConfigurator.php │ │ ├── ConstraintConfigurator.php │ │ ├── CookieConfigurator.php │ │ └── DatabaseConfigurator.php │ ├── FlagceptionExtension.php │ └── Configuration.php ├── Activator │ └── TraceableChainActivator.php ├── Twig │ └── ToggleExtension.php ├── Listener │ ├── RoutingMetadataSubscriber.php │ └── AttributeSubscriber.php └── Profiler │ └── FeatureDataCollector.php ├── UPGRADE-6.0.md ├── tests ├── Fixtures │ └── Helper │ │ └── AttributeTestClass.php ├── DependencyInjection │ ├── ConfigurationTest.php │ ├── CompilerPass │ │ ├── ActivatorPassTest.php │ │ ├── ExpressionProviderPassTest.php │ │ └── ContextDecoratorPassTest.php │ ├── FlagceptionExtensionTest.php │ └── Configurator │ │ ├── ArrayConfiguratorTest.php │ │ ├── EnvironmentConfiguratorTest.php │ │ ├── ConstraintConfiguratorTest.php │ │ ├── CookieConfiguratorTest.php │ │ └── DatabaseConfiguratorTest.php ├── FlagceptionBundleTest.php ├── Route │ └── IsFeatureTest.php ├── Twig │ └── ToggleExtensionTest.php ├── Activator │ └── TraceableChainActivatorTest.php ├── Listener │ ├── AttributeSubscriberTest.php │ └── RoutingMetadataSubscriberTest.php └── Profiler │ └── FeatureDataCollectorTest.php ├── LICENSE ├── phpunit.xml ├── .github └── workflows │ ├── php_coverage_badge.yml │ └── php.yml ├── composer.json ├── UPGRADE-3.0.md ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | ./vendor 2 | composer.lock 3 | build/logs/* 4 | !build/logs/.gitkeep 5 | -------------------------------------------------------------------------------- /docs/images/cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playox/flagception-bundle/HEAD/docs/images/cookie.png -------------------------------------------------------------------------------- /docs/images/profiler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playox/flagception-bundle/HEAD/docs/images/profiler.png -------------------------------------------------------------------------------- /UPGRADE-5.0.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 4.x to 5.0 2 | Minimum requirement for PHP has been increased to 8.0. No interfaces or services changed. 3 | 4 | Have fun with PHP 8 :-) 5 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | src/ 8 | tests/ 9 | 10 | -------------------------------------------------------------------------------- /UPGRADE-4.0.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 3.x to 4.0 2 | Version 4 only removes compatibility for Symfony 2, Symfony 3 and Symfony <= 4.3. No interfaces or services changed. 3 | Please notice that the support for [Contentful Activator](https://packagist.org/packages/flagception/contentful-activator) was dropped. 4 | 5 | Have fun with Symfony 5 :-) 6 | -------------------------------------------------------------------------------- /src/Attribute/Feature.php: -------------------------------------------------------------------------------- 1 | name = $name; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Resources/views/collector/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/attribute.md: -------------------------------------------------------------------------------- 1 | Attribute 2 | ------------------------- 3 | We recommend to use the route attribute solution. 4 | A `NotFoundHttpException` will be thrown if you request an action or class with inactive feature flag. 5 | 6 | 7 | ```php 8 | # FooController.php 9 | 10 | use Flagception\Bundle\FlagceptionBundle\Attribute\Feature; 11 | 12 | #[Feature("feature_123")] 13 | class FooController 14 | { 15 | 16 | #[Feature("feature_789")] 17 | public function barAction() 18 | { 19 | } 20 | 21 | public function fooAction() 22 | { 23 | } 24 | } 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/twig.md: -------------------------------------------------------------------------------- 1 | Twig 2 | ------------------------- 3 | You can check the feature flag state with the following twig methods. 4 | 5 | Simple check: 6 | ```twig 7 | {% if feature('feature_123') %} 8 | {# ... #} 9 | {% endif %} 10 | ``` 11 | 12 | Same check with other syntax: 13 | ```twig 14 | {% if 'feature_123' is active feature %} 15 | {# ... #} 16 | {% endif %} 17 | ``` 18 | 19 | Check with context data (see [constraint documentation](constraint.md)) 20 | ```twig 21 | {% if feature('feature_123', {'role': 'ROLE_ADMIN'}) %} 22 | {# ... #} 23 | {% endif %} 24 | ``` 25 | -------------------------------------------------------------------------------- /src/Route/IsFeature.php: -------------------------------------------------------------------------------- 1 | featureManager = $featureManager; 16 | } 17 | 18 | public function __invoke($feature, ?Context $context = null): bool 19 | { 20 | return $this->featureManager->isActive($feature, $context); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/route_condition.md: -------------------------------------------------------------------------------- 1 | Route condition 2 | ------------------------- 3 | You can use route attributes for checking the feature state in controllers. This is activated. 4 | 5 | ```php 6 | // src/AppBundle/Controller/BlogController.php 7 | // src/Controller/BlogController.php 8 | namespace AppBundle\Controller; 9 | 10 | use Symfony\Bundle\FrameworkBundle\Controller\Controller; 11 | use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; 12 | 13 | class BlogController extends Controller 14 | { 15 | /** 16 | * @Route("/blog/{page}", condition="is_feature('feature_123')") 17 | */ 18 | public function listAction($page) 19 | { 20 | // ... 21 | } 22 | 23 | /** 24 | * @Route("/blog/{slug}") 25 | */ 26 | public function showAction($slug) 27 | { 28 | // ... 29 | } 30 | } 31 | ``` -------------------------------------------------------------------------------- /UPGRADE-6.0.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 5.x to 6.0 2 | Support for annotations has been removed. Use PHP 8 attributes instead. In addition, the Flagception SDK and Flagception Database Activator has been updated to version 2.0. 3 | The updated SDK added some types for the interfaces. The updated Database Activator added support for DBAL 4.0 and dropped support for DBAL 3.5 and below. 4 | 5 | ## Upgrade steps 6 | ### Custom Activators 7 | If you have created custom activators, you need to update interface implementations to match the new SDK version. Only types are added. 8 | 9 | ### Database Activator 10 | The database activator now requires DBAL 3.6 or higher. If you are using DBAL 3.5 or below, you need to update your dependencies. 11 | In addition, a PDO instance is no longer supported. You need to pass the connection options as an array, string or DBAL instance. 12 | 13 | Have fun with Flagception :-) 14 | -------------------------------------------------------------------------------- /tests/Fixtures/Helper/AttributeTestClass.php: -------------------------------------------------------------------------------- 1 | 11 | * @package Flagception\Tests\FlagceptionBundle\Fixtures\Helper 12 | */ 13 | #[Feature("feature_abc")] 14 | class AttributeTestClass 15 | { 16 | /** 17 | * Normal test method 18 | * 19 | * @return void 20 | */ 21 | public function normalMethod() 22 | { 23 | } 24 | 25 | /** 26 | * Valid test method 27 | * 28 | * @return void 29 | */ 30 | public function validMethod() 31 | { 32 | } 33 | 34 | /** 35 | * Method with feature flag 36 | */ 37 | #[Feature("feature_def")] 38 | public function invalidMethod() 39 | { 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/profiler.md: -------------------------------------------------------------------------------- 1 | Profiler 2 | ------------------------- 3 | 4 | Take a look at our new profiler tab: 5 | 6 | ![Image of Profiler](images/profiler.png) 7 | 8 | You can see which feature was activated by which activator. In addition, you can see how many activators were asked until it came to a conclusion. 9 | Here is a small listing which activator belongs to which config: 10 | 11 | ```yml 12 | # config.yml 13 | 14 | flagception: 15 | features: 16 | feature_123: 17 | 18 | # The default property belongs to 'array' 19 | default: false 20 | 21 | # The constraint property belongs to 'constraint' 22 | constraint: 'user_id === 12' 23 | 24 | # The env property belongs to 'environment' 25 | env: 'FEATURE_123' 26 | 27 | # The cookie property belongs to 'cookie' 28 | cookie: true 29 | 30 | # Contentful fields are defined in Contentful and not in your config 31 | # The activator called "contentful" 32 | ``` 33 | -------------------------------------------------------------------------------- /src/FlagceptionBundle.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Bundle\FlagceptionBundle 16 | */ 17 | class FlagceptionBundle extends Bundle 18 | { 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function build(ContainerBuilder $container): void 23 | { 24 | parent::build($container); 25 | 26 | $container->addCompilerPass(new ActivatorPass()); 27 | $container->addCompilerPass(new ContextDecoratorPass()); 28 | $container->addCompilerPass(new ExpressionProviderPass()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 best it GmbH & Co. KG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ./src 7 | 8 | 9 | src/*Bundle/Resources 10 | src/*/*Bundle/Resources 11 | src/*/Bundle/*Bundle/Resources 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ./tests 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/DependencyInjection/CompilerPass/ExpressionProviderPass.php: -------------------------------------------------------------------------------- 1 | 13 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\CompilerPass 14 | */ 15 | class ExpressionProviderPass implements CompilerPassInterface 16 | { 17 | /** 18 | * {@inheritdoc} 19 | */ 20 | public function process(ContainerBuilder $container): void 21 | { 22 | $factory = $container->getDefinition('flagception.factory.expression_language_factory'); 23 | 24 | foreach ($container->findTaggedServiceIds('flagception.expression_language_provider') as $id => $tags) { 25 | foreach ($tags as $attributes) { 26 | $factory->addMethodCall('addProvider', [new Reference($id)]); 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Resources/config/configurators.yml: -------------------------------------------------------------------------------- 1 | services: 2 | flagception.configurator.array_configurator: 3 | class: Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator\ArrayConfigurator 4 | tags: 5 | - { name: flagception.configurator } 6 | 7 | flagception.configurator.constraint_configurator: 8 | class: Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator\ConstraintConfigurator 9 | tags: 10 | - { name: flagception.configurator } 11 | 12 | flagception.configurator.environment_configurator: 13 | class: Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator\EnvironmentConfigurator 14 | tags: 15 | - { name: flagception.configurator } 16 | 17 | flagception.configurator.cookie_configurator: 18 | class: Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator\CookieConfigurator 19 | tags: 20 | - { name: flagception.configurator } 21 | 22 | flagception.configurator.database_configurator: 23 | class: Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator\DatabaseConfigurator 24 | tags: 25 | - { name: flagception.configurator } 26 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configurator/ActivatorConfiguratorInterface.php: -------------------------------------------------------------------------------- 1 | 12 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator 13 | */ 14 | interface ActivatorConfiguratorInterface 15 | { 16 | /** 17 | * Get configurator key 18 | * 19 | * @return string 20 | */ 21 | public function getKey(); 22 | 23 | /** 24 | * Add activator 25 | * 26 | * @param ContainerBuilder $container 27 | * @param array $config 28 | * @param array $features 29 | * 30 | * @return void 31 | */ 32 | public function addActivator(ContainerBuilder $container, array $config, array $features); 33 | 34 | /** 35 | * Add configuration to node 36 | * 37 | * @param ArrayNodeDefinition $node 38 | * 39 | * @return void 40 | */ 41 | public function addConfiguration(ArrayNodeDefinition $node); 42 | } 43 | -------------------------------------------------------------------------------- /tests/DependencyInjection/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | 17 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection 18 | */ 19 | class ConfigurationTest extends TestCase 20 | { 21 | /** 22 | * Test builder (only if a tree returned) 23 | * 24 | * @return void 25 | */ 26 | public function testBuilder() 27 | { 28 | static::assertInstanceOf(TreeBuilder::class, (new Configuration([ 29 | new ArrayConfigurator(), 30 | new ConstraintConfigurator(), 31 | new CookieConfigurator(), 32 | new EnvironmentConfigurator() 33 | ]))->getConfigTreeBuilder()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/environment.md: -------------------------------------------------------------------------------- 1 | Environment variables 2 | ------------------------- 3 | Maybe you want to set a feature flag by an environment variable. In Symfony 3.4 you can set and cast env variables 4 | very simple. But in symfony versions before that, it's not that easy. Therefore, you can set in the config whether the 5 | active status is to be pulled from an environment variable. 6 | 7 | Just give the variable name in the `env` parameter: 8 | 9 | ```yml 10 | # config.yml 11 | 12 | flagception: 13 | features: 14 | 15 | # This feature check the env var 'FEATURE_NAME_FROM_ENV' 16 | # setenv('FEATURE_NAME_FROM_ENV=false') 17 | feature_123: 18 | env: FEATURE_NAME_FROM_ENV 19 | ``` 20 | 21 | You can combine all parameter together. First the `default` value is checked. 22 | If the value is false, the value from `env` is checked. If this also returns false, the constraints are checked. 23 | 24 | ```yml 25 | # config.yml 26 | 27 | flagception: 28 | features: 29 | feature_123: 30 | default: false 31 | env: FEATURE_NAME_FROM_ENV 32 | constraint: 'user_role == ROLE_ADMIN' 33 | ``` 34 | 35 | As alternative, you can use the `%env()%` syntax for the default field: 36 | 37 | ```yml 38 | # config.yml 39 | 40 | flagception: 41 | features: 42 | feature_123: 43 | default: '%env(FEATURE_NAME_FROM_ENV)%' 44 | ``` 45 | 46 | -------------------------------------------------------------------------------- /tests/FlagceptionBundleTest.php: -------------------------------------------------------------------------------- 1 | 16 | * @package Flagception\Tests\FlagceptionBundle 17 | */ 18 | class FlagceptionBundleTest extends TestCase 19 | { 20 | /** 21 | * Test build method 22 | * 23 | * @return void 24 | */ 25 | public function testBuild() 26 | { 27 | $bundle = new FlagceptionBundle(); 28 | 29 | $builder = $this->createMock(ContainerBuilder::class); 30 | $builder 31 | ->expects(static::exactly(3)) 32 | ->method('addCompilerPass') 33 | ->withConsecutive( 34 | [static::isInstanceOf(ActivatorPass::class)], 35 | [static::isInstanceOf(ContextDecoratorPass::class)], 36 | [static::isInstanceOf(ExpressionProviderPass::class)] 37 | ); 38 | 39 | $bundle->build($builder); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Activator/TraceableChainActivator.php: -------------------------------------------------------------------------------- 1 | 12 | * @package Flagception\Bundle\FlagceptionBundle\Activator 13 | */ 14 | class TraceableChainActivator extends ChainActivator 15 | { 16 | /** 17 | * Trace of this chain activator 18 | * 19 | * @var array 20 | */ 21 | private $trace = []; 22 | 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function isActive($name, Context $context): bool 27 | { 28 | $stack = []; 29 | $result = false; 30 | foreach ($this->getActivators() as $activator) { 31 | if ($activator->isActive($name, $context) === true) { 32 | $result = $stack[$activator->getName()] = true; 33 | 34 | break; 35 | } 36 | 37 | $stack[$activator->getName()] = $result; 38 | } 39 | 40 | $this->trace[] = [ 41 | 'feature' => $name, 42 | 'context' => $context, 43 | 'result' => $result, 44 | 'stack' => $stack 45 | ]; 46 | 47 | return $result; 48 | } 49 | 50 | /** 51 | * Get trace 52 | * 53 | * @return array 54 | */ 55 | public function getTrace(): array 56 | { 57 | return $this->trace; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/php_coverage_badge.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | jobs: 7 | coverage: 8 | strategy: 9 | matrix: 10 | php: ['8.3'] 11 | symfony: ['7.4.0'] 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | tools: composer 22 | coverage: xdebug 23 | - name: Setup Dependencies 24 | run: composer require "symfony/symfony:${{ matrix.symfony }}" --no-update 25 | - name: Install dependencies 26 | run: composer install --prefer-dist --no-progress 27 | 28 | - name: run tests 29 | run: ./vendor/bin/simple-phpunit 30 | 31 | - name: Make code coverage badge 32 | uses: timkrase/phpunit-coverage-badge@v1.2.1 33 | with: 34 | coverage_badge_path: output/coverage.svg 35 | push_badge: false 36 | 37 | - name: Git push to image-data branch 38 | uses: peaceiris/actions-gh-pages@v3 39 | with: 40 | publish_dir: ./output 41 | publish_branch: image-data 42 | github_token: ${{ secrets.GITHUB_TOKEN }} 43 | user_name: 'github-actions[bot]' 44 | user_email: 'github-actions[bot]@users.noreply.github.com' 45 | -------------------------------------------------------------------------------- /tests/Route/IsFeatureTest.php: -------------------------------------------------------------------------------- 1 | createMock(FeatureManagerInterface::class); 22 | $manager 23 | ->expects(static::once()) 24 | ->method('isActive') 25 | ->with('feature_abc') 26 | ->willReturn(false); 27 | 28 | $isFeature = new IsFeature($manager); 29 | $this->assertFalse($isFeature->__invoke('feature_abc')); 30 | } 31 | 32 | /** 33 | * Test feature is active 34 | * 35 | * @return void 36 | */ 37 | public function testFeatureIsActive() 38 | { 39 | $manager = $this->createMock(FeatureManagerInterface::class); 40 | $manager 41 | ->expects(static::once()) 42 | ->method('isActive') 43 | ->with('feature_abc') 44 | ->willReturn(true); 45 | 46 | $isFeature = new IsFeature($manager); 47 | $this->assertTrue($isFeature->__invoke('feature_abc')); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | Download the Bundle 2 | --------------------------- 3 | 4 | Open a command console, enter your project directory and execute the 5 | following command to download the latest stable version of this bundle: 6 | 7 | ```console 8 | $ composer require flagception/flagception-bundle 9 | ``` 10 | 11 | This command requires you to have Composer installed globally, as explained 12 | in the [installation chapter](https://getcomposer.org/doc/00-intro.md) 13 | of the Composer documentation. 14 | 15 | Enable the Bundle (Symfony 2.x / 3.x) 16 | ------------------------- 17 | 18 | Then, enable the bundle by adding it to the list of registered bundles 19 | in the `app/AppKernel.php` file of your project: 20 | 21 | ```php 22 | ['all' => true], 57 | ]; 58 | ``` 59 | 60 | Use the bundle 61 | ------------------------- 62 | That's all. You can now [use feature flags](usage.md). 63 | -------------------------------------------------------------------------------- /src/DependencyInjection/CompilerPass/ActivatorPass.php: -------------------------------------------------------------------------------- 1 | 13 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\CompilerPass 14 | */ 15 | class ActivatorPass implements CompilerPassInterface 16 | { 17 | /** 18 | * {@inheritdoc} 19 | */ 20 | public function process(ContainerBuilder $container): void 21 | { 22 | $bag = $container->getDefinition('flagception.activator.chain_activator'); 23 | 24 | $collection = []; 25 | foreach ($container->findTaggedServiceIds('flagception.activator') as $id => $tags) { 26 | foreach ($tags as $attributes) { 27 | $collection[] = [ 28 | 'service' => new Reference($id), 29 | 'priority' => isset($attributes['priority']) ? $attributes['priority'] : 0 30 | ]; 31 | } 32 | } 33 | 34 | // Sorting services 35 | usort($collection, function ($a, $b) { 36 | return ($b['priority'] < $a['priority']) ? -1 : (($b['priority'] > $a['priority']) ? 1 : 0); 37 | }); 38 | 39 | // At least ... add ordered list 40 | foreach ($collection as $item) { 41 | $bag->addMethodCall('add', [$item['service']]); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/DependencyInjection/CompilerPass/ContextDecoratorPass.php: -------------------------------------------------------------------------------- 1 | 13 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\CompilerPass 14 | */ 15 | class ContextDecoratorPass implements CompilerPassInterface 16 | { 17 | /** 18 | * {@inheritdoc} 19 | */ 20 | public function process(ContainerBuilder $container): void 21 | { 22 | $bag = $container->getDefinition('flagception.decorator.chain_decorator'); 23 | 24 | $collection = []; 25 | foreach ($container->findTaggedServiceIds('flagception.context_decorator') as $id => $tags) { 26 | foreach ($tags as $attributes) { 27 | $collection[] = [ 28 | 'service' => new Reference($id), 29 | 'priority' => isset($attributes['priority']) ? $attributes['priority'] : 0 30 | ]; 31 | } 32 | } 33 | 34 | // Sorting services 35 | usort($collection, function ($a, $b) { 36 | return ($b['priority'] < $a['priority']) ? -1 : (($b['priority'] > $a['priority']) ? 1 : 0); 37 | }); 38 | 39 | // At least ... add ordered list 40 | foreach ($collection as $item) { 41 | $bag->addMethodCall('add', [$item['service']]); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flagception/flagception-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Feature toggle bundle on steroids.", 5 | "keywords": ["feature", "feature-toggle", "feature-flags", "flags", "flagception", "symfony", "bundle", "rollout", "toggle"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Michel Chowanski", 10 | "email": "michel.chowanski@bestit-online.de" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.0", 15 | "symfony/dependency-injection": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", 16 | "symfony/yaml": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", 17 | "symfony/config": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", 18 | "symfony/http-kernel": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", 19 | "twig/twig": "^1.18|^2.0|^3.0", 20 | "flagception/flagception": "^2.0" 21 | }, 22 | "require-dev": { 23 | "symfony/phpunit-bridge": "^5.0 | ^6.0 | ^7.0 | ^8.0", 24 | "symfony/twig-bridge": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0", 25 | "flagception/database-activator": "^2.0", 26 | "squizlabs/php_codesniffer": "^3.3.1", 27 | "php-coveralls/php-coveralls": "^2.0" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Flagception\\Bundle\\FlagceptionBundle\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Flagception\\Tests\\FlagceptionBundle\\": "tests" 37 | } 38 | }, 39 | "extra": { 40 | "thanks": { 41 | "name": "flagception/flagception", 42 | "url": "https://github.com/playox/flagception-sdk" 43 | } 44 | }, 45 | "suggest": { 46 | "flagception/database-activator": "Fetch feature flags from a database." 47 | }, 48 | "scripts": { 49 | "tests": [ 50 | "./vendor/bin/phpcs", 51 | "./vendor/bin/simple-phpunit --coverage-text" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Twig/ToggleExtension.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Bundle\FlagceptionBundle\Twig 16 | */ 17 | class ToggleExtension extends AbstractExtension 18 | { 19 | /** 20 | * The manager 21 | * 22 | * @var FeatureManagerInterface 23 | */ 24 | private $manager; 25 | 26 | /** 27 | * ToggleExtension constructor. 28 | * 29 | * @param FeatureManagerInterface $manager 30 | */ 31 | public function __construct(FeatureManagerInterface $manager) 32 | { 33 | $this->manager = $manager; 34 | } 35 | 36 | /** 37 | * Is feature name active 38 | * 39 | * @param string $name 40 | * @param array $contextValues 41 | * 42 | * @return bool 43 | */ 44 | public function isActive(string $name, array $contextValues = []): bool 45 | { 46 | $context = new Context(); 47 | foreach ($contextValues as $contextKey => $contextValue) { 48 | $context->add($contextKey, $contextValue); 49 | } 50 | 51 | return $this->manager->isActive($name, $context); 52 | } 53 | 54 | /** 55 | * {@inheritDoc} 56 | */ 57 | public function getFunctions(): array 58 | { 59 | return [ 60 | new TwigFunction('feature', [$this, 'isActive']), 61 | ]; 62 | } 63 | 64 | /** 65 | * {@inheritDoc} 66 | */ 67 | public function getTests(): array 68 | { 69 | return [ 70 | new TwigTest('active feature', [$this, 'isActive']), 71 | ]; 72 | } 73 | 74 | /** 75 | * Returns the name 76 | * (needed for supporting twig <1.26) 77 | * 78 | * @return string 79 | */ 80 | public function getName(): string 81 | { 82 | return 'flagception'; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/DependencyInjection/CompilerPass/ActivatorPassTest.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\CompilerPass 16 | */ 17 | class ActivatorPassTest extends TestCase 18 | { 19 | /** 20 | * Test process 21 | * 22 | * @return void 23 | */ 24 | public function testProcess() 25 | { 26 | $container = new ContainerBuilder(); 27 | 28 | $bag = $this->createMock(Definition::class); 29 | $container->setDefinition('flagception.activator.chain_activator', $bag); 30 | 31 | $bag 32 | ->expects(static::exactly(3)) 33 | ->method('addMethodCall') 34 | ->withConsecutive( 35 | [static::equalTo('add'), static::equalTo([new Reference('foo')])], 36 | [static::equalTo('add'), static::equalTo([new Reference('bazz')])], 37 | [static::equalTo('add'), static::equalTo([new Reference('bar')])] 38 | ); 39 | 40 | $container->setDefinition( 41 | 'foo', 42 | (new Definition(__CLASS__))->addTag('flagception.activator', [ 43 | 'priority' => 255 44 | ]) 45 | ); 46 | $container->setDefinition( 47 | 'bar', 48 | (new Definition(__CLASS__))->addTag('flagception.activator') 49 | ); 50 | $container->setDefinition( 51 | 'bazz', 52 | (new Definition(__CLASS__))->addTag('flagception.activator', [ 53 | 'priority' => 25 54 | ]) 55 | ); 56 | 57 | (new ActivatorPass())->process($container); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/DependencyInjection/CompilerPass/ExpressionProviderPassTest.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\CompilerPass 16 | */ 17 | class ExpressionProviderPassTest extends TestCase 18 | { 19 | /** 20 | * Test process 21 | * 22 | * @return void 23 | */ 24 | public function testProcess() 25 | { 26 | $container = new ContainerBuilder(); 27 | 28 | $factory = $this->createMock(Definition::class); 29 | $container->setDefinition('flagception.factory.expression_language_factory', $factory); 30 | 31 | $factory 32 | ->expects(static::exactly(3)) 33 | ->method('addMethodCall') 34 | ->withConsecutive( 35 | [static::equalTo('addProvider'), static::equalTo([new Reference('foo')])], 36 | [static::equalTo('addProvider'), static::equalTo([new Reference('bar')])], 37 | [static::equalTo('addProvider'), static::equalTo([new Reference('bazz')])] 38 | ); 39 | 40 | $container->setDefinition( 41 | 'foo', 42 | (new Definition(__CLASS__))->addTag('flagception.expression_language_provider') 43 | ); 44 | $container->setDefinition( 45 | 'bar', 46 | (new Definition(__CLASS__))->addTag('flagception.expression_language_provider') 47 | ); 48 | $container->setDefinition( 49 | 'bazz', 50 | (new Definition(__CLASS__))->addTag('flagception.expression_language_provider') 51 | ); 52 | 53 | (new ExpressionProviderPass())->process($container); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/DependencyInjection/CompilerPass/ContextDecoratorPassTest.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\CompilerPass 16 | */ 17 | class ContextDecoratorPassTest extends TestCase 18 | { 19 | /** 20 | * Test process 21 | * 22 | * @return void 23 | */ 24 | public function testProcess() 25 | { 26 | $container = new ContainerBuilder(); 27 | 28 | $bag = $this->createMock(Definition::class); 29 | $container->setDefinition('flagception.decorator.chain_decorator', $bag); 30 | 31 | $bag 32 | ->expects(static::exactly(3)) 33 | ->method('addMethodCall') 34 | ->withConsecutive( 35 | [static::equalTo('add'), static::equalTo([new Reference('foo')])], 36 | [static::equalTo('add'), static::equalTo([new Reference('bazz')])], 37 | [static::equalTo('add'), static::equalTo([new Reference('bar')])] 38 | ); 39 | 40 | $container->setDefinition( 41 | 'foo', 42 | (new Definition(__CLASS__))->addTag('flagception.context_decorator', [ 43 | 'priority' => 255 44 | ]) 45 | ); 46 | $container->setDefinition( 47 | 'bar', 48 | (new Definition(__CLASS__))->addTag('flagception.context_decorator') 49 | ); 50 | $container->setDefinition( 51 | 'bazz', 52 | (new Definition(__CLASS__))->addTag('flagception.context_decorator', [ 53 | 'priority' => 25 54 | ]) 55 | ); 56 | 57 | (new ContextDecoratorPass())->process($container); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Listener/RoutingMetadataSubscriber.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Bundle\FlagceptionBundle\Listener 16 | */ 17 | class RoutingMetadataSubscriber implements EventSubscriberInterface 18 | { 19 | /** 20 | * Feature key for routing annotation 21 | * 22 | * @var string 23 | */ 24 | public const FEATURE_KEY = '_feature'; 25 | 26 | /** 27 | * The feature manager 28 | * 29 | * @var FeatureManagerInterface 30 | */ 31 | private $manager; 32 | 33 | /** 34 | * RoutingMetadataSubscriber constructor. 35 | * 36 | * @param FeatureManagerInterface $manager 37 | */ 38 | public function __construct(FeatureManagerInterface $manager) 39 | { 40 | $this->manager = $manager; 41 | } 42 | 43 | /** 44 | * Filter by routing metadata 45 | * 46 | * @param ControllerEvent $event 47 | * 48 | * @return void 49 | * @throws NotFoundHttpException 50 | */ 51 | public function onKernelController(ControllerEvent $event) 52 | { 53 | if (!$event->getRequest()->attributes->has(static::FEATURE_KEY)) { 54 | return; 55 | } 56 | 57 | $featureNames = (array) $event->getRequest()->attributes->get(static::FEATURE_KEY); 58 | foreach ($featureNames as $featureName) { 59 | if (!$this->manager->isActive($featureName)) { 60 | throw new NotFoundHttpException('Feature for this class is not active.'); 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public static function getSubscribedEvents(): array 69 | { 70 | return [ 71 | KernelEvents::CONTROLLER => 'onKernelController', 72 | ]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configurator/ArrayConfigurator.php: -------------------------------------------------------------------------------- 1 | 14 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator 15 | */ 16 | class ArrayConfigurator implements ActivatorConfiguratorInterface 17 | { 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function getKey() 22 | { 23 | return 'array'; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function addActivator(ContainerBuilder $container, array $config, array $features) 30 | { 31 | if ($config['enable'] === false) { 32 | return; 33 | } 34 | 35 | $definition = new Definition(ArrayActivator::class); 36 | $definition->addArgument( 37 | array_combine(array_keys($features), array_column($features, 'default')) 38 | ); 39 | 40 | $definition->addTag('flagception.activator', [ 41 | 'priority' => $config['priority'] 42 | ]); 43 | 44 | $container->setDefinition('flagception.activator.array_activator', $definition); 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function addConfiguration(ArrayNodeDefinition $node) 51 | { 52 | $node 53 | ->addDefaultsIfNotSet(['enable' => true]) 54 | ->children() 55 | ->booleanNode('enable') 56 | ->beforeNormalization() 57 | ->ifString() 58 | ->then(function ($value) { 59 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 60 | }) 61 | ->end() 62 | ->defaultTrue() 63 | ->end() 64 | ->integerNode('priority') 65 | ->defaultValue(255) 66 | ->end() 67 | ->end(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/activator.md: -------------------------------------------------------------------------------- 1 | Activators 2 | ------------------------- 3 | We use 'activators' for resolve the feature state. This bundle contains the `ConfigActivator` which fetch 4 | your features from the config.yml. But sometimes you want to fetch the feature state from another source. eg. from a remote server. You can create your own 5 | activators with a few lines. 6 | 7 | Just create a service class, implements `FeatureActivatorInterface`, tag it with `flagception.activator` and an optional 8 | priority tag. The feature manager iterate through all activators and check the state with the `isActive` method until one activator 9 | returns true. If an activator returns true, no further activators will be requested. 10 | 11 | This bundle supports [autoconfiguration](https://symfony.com/blog/new-in-symfony-3-3-service-autoconfiguration) for `FeatureActivatorInterface` from Symfony 3.3. 12 | 13 | Example class to activate all features for admins: 14 | ```php 15 | # AdminActivator.php 16 | 17 | class AdminActivator implements FeatureActivatorInterface 18 | { 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function getName() 23 | { 24 | # Return an unqiue name for this activator 25 | return 'admin'; 26 | } 27 | 28 | /** 29 | * @var string $name The requested feature name (eg. 'feature_123') 30 | * @var Context $context The context object which all key / values 31 | */ 32 | public function isActive($name, Context $context) 33 | { 34 | # Always return true if the user role contain 'ROLE_ADMIN' 35 | return in_array('ROLE_ADMIN', $context->get('user_roles'), true); 36 | } 37 | } 38 | ``` 39 | 40 | The service declaration: 41 | ```yml 42 | flagception.activator.config_activator: 43 | class: Flagception\Bundle\FlagceptionBundle\Activator\ConfigActivator 44 | arguments: 45 | - '@flagception.constraint.constraint_resolver' 46 | tags: 47 | - { name: flagception.activator, priority: 100 } 48 | ``` 49 | 50 | Now we declare one feature in our config (ConfigActivator) and disabled it. The manager will check the ConfigActivator 51 | which return false (see config.yml). After that, the manager will call the AdminActivator - which return true for admins. 52 | ```yml 53 | # config.yml 54 | 55 | flagception: 56 | features: 57 | feature_123: 58 | default: false 59 | ``` 60 | -------------------------------------------------------------------------------- /tests/Twig/ToggleExtensionTest.php: -------------------------------------------------------------------------------- 1 | 14 | * @package Flagception\Tests\FlagceptionBundle\Twig 15 | */ 16 | class ToggleExtensionTest extends TestCase 17 | { 18 | /** 19 | * Test functions 20 | * 21 | * @return void 22 | */ 23 | public function testFunctions() 24 | { 25 | $extension = new ToggleExtension($this->createMock(FeatureManagerInterface::class)); 26 | 27 | static::assertEquals('feature', $extension->getFunctions()[0]->getName()); 28 | static::assertEquals('active feature', $extension->getTests()[0]->getName()); 29 | } 30 | 31 | /** 32 | * Test is active 33 | * 34 | * @return void 35 | */ 36 | public function testIsActive() 37 | { 38 | $extension = new ToggleExtension($manager = $this->createMock(FeatureManagerInterface::class)); 39 | 40 | $manager 41 | ->method('isActive') 42 | ->with('feature_foo') 43 | ->willReturn(true); 44 | 45 | static::assertEquals(true, $extension->isActive('feature_foo')); 46 | } 47 | 48 | /** 49 | * Test is active with context 50 | * 51 | * @return void 52 | */ 53 | public function testIsActiveWithContext() 54 | { 55 | $extension = new ToggleExtension($manager = $this->createMock(FeatureManagerInterface::class)); 56 | 57 | $context = new Context(); 58 | $context->add('role', 'ROLE_ADMIN'); 59 | 60 | $manager 61 | ->method('isActive') 62 | ->with('feature_foo', $context) 63 | ->willReturn(true); 64 | 65 | static::assertEquals(true, $extension->isActive('feature_foo', ['role' => 'ROLE_ADMIN'])); 66 | } 67 | 68 | /** 69 | * Test get name 70 | * 71 | * @return void 72 | */ 73 | public function testGetName() 74 | { 75 | $extension = new ToggleExtension($this->createMock(FeatureManagerInterface::class)); 76 | static::assertEquals('flagception', $extension->getName()); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /UPGRADE-3.0.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 2.x to 3.0 2 | The new feature toggle bundle is a complete rework but the public interfaces doesn't change so much. 3 | It use the new [Flagception](https://packagist.org/packages/flagception/flagception) library under the hood. 4 | 5 | Generally, the namespace changed to `Flagception\` or `Flagception\Bundle\FlagceptionBundle`. 6 | So all namespaces and service id's are renamed. 7 | 8 | Additional, the composer package renamed from `bestit/feature-toggle-bundle` to `flagception/flagception-bundle`. 9 | 10 | FeatureManager 11 | --------------------------- 12 | The feature manager interface renamed from `BestIt\FeatureToggleBundle\Manager\FeatureManagerInterface` to `Flagception\Manager\FeatureManagerInterface`. 13 | The default feature manager (`Flagception\Manager\FeatureManager`) is accessible via the service id `flagception.manager.feature_manager`. 14 | Instead of bags it expects a `FeatureActivatorInterface` and a optional `ContextDecoratorInterface` as constructor argument. 15 | 16 | Stashes (Activators) 17 | --------------------------- 18 | The stashes are renamed to "activators" and implement the `Flagception\Activator\FeatureActivatorInterface` instead of the 19 | `BestIt\FeatureToggleBundle\Stash\StashInterface`. 20 | 21 | The compiler pass tag also changed from `best_it_feature_toggle.stash` to `flagception.activator`. The stash bag was removed - a `ChainActivator` holds 22 | all activator (stashes) now. 23 | 24 | Decorators 25 | --------------------------- 26 | Decorators implement the renamed `Flagception\Decorator\ContextDecoratorInterface` now (old: `BestIt\FeatureToggleBundle\Decorator\ContextDecoratorInterface`). 27 | The compiler pass tag changed from `best_it_feature_toggle.context_decorator` to `flagception.context_decorator`. 28 | The decorator bag was removed - a `ChainDecorator` holds all decorators now. 29 | 30 | Events 31 | --------------------------- 32 | All events are removed. 33 | 34 | Config 35 | --------------------------- 36 | A few fields have been renamed. 37 | 38 | For example - the old config: 39 | ```yml 40 | best_it_feature_toggle: 41 | features: 42 | feature_123: 43 | active: true 44 | ``` 45 | 46 | The new config (`active` to `default`): 47 | ```yml 48 | flagception: 49 | features: 50 | feature_123: 51 | default: true 52 | ``` 53 | 54 | I think every single point listed here will be more confusing than helping. Just have a look at the new and detailed [readme](README.md). 55 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | build: 9 | strategy: 10 | matrix: 11 | php: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] 12 | symfony: ['5.4.50', '6.4.29', '7.4', '8.0'] 13 | exclude: 14 | - php: 8.0 15 | symfony: 6.4.29 16 | - php: 8.0 17 | symfony: 7.4 18 | - php: 8.1 19 | symfony: 7.4 20 | - php: 8.0 21 | symfony: 8.0 22 | - php: 8.1 23 | symfony: 8.0 24 | - php: 8.2 25 | symfony: 8.0 26 | - php: 8.3 27 | symfony: 8.0 28 | 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v3 33 | 34 | - uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{ matrix.php }} 37 | tools: composer 38 | coverage: none 39 | - name: Setup Dependencies 40 | run: composer require "symfony/symfony:${{ matrix.symfony }}" --no-update 41 | - name: Install dependencies 42 | run: composer install --prefer-dist --no-progress 43 | 44 | - name: run tests 45 | run: ./vendor/bin/simple-phpunit 46 | coverage: 47 | strategy: 48 | matrix: 49 | php: ['8.3'] 50 | symfony: ['7.4.0'] 51 | 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - uses: actions/checkout@v3 56 | 57 | - uses: shivammathur/setup-php@v2 58 | with: 59 | php-version: ${{ matrix.php }} 60 | tools: composer 61 | coverage: xdebug 62 | - name: Setup Dependencies 63 | run: composer require "symfony/symfony:${{ matrix.symfony }}" --no-update 64 | - name: Install dependencies 65 | run: composer install --prefer-dist --no-progress 66 | 67 | - name: run tests 68 | run: ./vendor/bin/simple-phpunit 69 | 70 | - name: Make code coverage badge 71 | uses: timkrase/phpunit-coverage-badge@v1.2.1 72 | with: 73 | coverage_badge_path: output/coverage.svg 74 | push_badge: false -------------------------------------------------------------------------------- /src/DependencyInjection/Configurator/EnvironmentConfigurator.php: -------------------------------------------------------------------------------- 1 | 14 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator 15 | */ 16 | class EnvironmentConfigurator implements ActivatorConfiguratorInterface 17 | { 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function getKey() 22 | { 23 | return 'environment'; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function addActivator(ContainerBuilder $container, array $config, array $features) 30 | { 31 | if ($config['enable'] === false) { 32 | return; 33 | } 34 | 35 | $filteredFeatures = array_filter($features, function ($feature) { 36 | return $feature['env'] !== false; 37 | }); 38 | 39 | $environmentVariables = []; 40 | foreach ($filteredFeatures as $name => $value) { 41 | $environmentVariables[$name] = $value['env']; 42 | } 43 | 44 | $definition = new Definition(EnvironmentActivator::class); 45 | $definition->addArgument($environmentVariables); 46 | 47 | $definition->addTag('flagception.activator', [ 48 | 'priority' => $config['priority'] 49 | ]); 50 | 51 | $container->setDefinition('flagception.activator.environment_activator', $definition); 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function addConfiguration(ArrayNodeDefinition $node) 58 | { 59 | $node 60 | ->addDefaultsIfNotSet(['enable' => true]) 61 | ->children() 62 | ->booleanNode('enable') 63 | ->beforeNormalization() 64 | ->ifString() 65 | ->then(function ($value) { 66 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 67 | }) 68 | ->end() 69 | ->defaultTrue() 70 | ->end() 71 | ->integerNode('priority') 72 | ->defaultValue(230) 73 | ->end() 74 | ->end(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configurator/ConstraintConfigurator.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator 16 | */ 17 | class ConstraintConfigurator implements ActivatorConfiguratorInterface 18 | { 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function getKey() 23 | { 24 | return 'constraint'; 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function addActivator(ContainerBuilder $container, array $config, array $features) 31 | { 32 | if ($config['enable'] === false) { 33 | return; 34 | } 35 | 36 | $filteredFeatures = array_filter($features, function ($feature) { 37 | return $feature['constraint'] !== false; 38 | }); 39 | 40 | $constraintVariables = []; 41 | foreach ($filteredFeatures as $name => $value) { 42 | $constraintVariables[$name] = $value['constraint']; 43 | } 44 | 45 | $definition = new Definition(ConstraintActivator::class); 46 | $definition->addArgument(new Reference('flagception.constraint.constraint_resolver')); 47 | $definition->addArgument($constraintVariables); 48 | 49 | $definition->addTag('flagception.activator', [ 50 | 'priority' => $config['priority'] 51 | ]); 52 | 53 | $container->setDefinition('flagception.activator.constraint_activator', $definition); 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function addConfiguration(ArrayNodeDefinition $node) 60 | { 61 | $node 62 | ->addDefaultsIfNotSet(['enable' => true]) 63 | ->children() 64 | ->booleanNode('enable') 65 | ->beforeNormalization() 66 | ->ifString() 67 | ->then(function ($value) { 68 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 69 | }) 70 | ->end() 71 | ->defaultTrue() 72 | ->end() 73 | ->integerNode('priority') 74 | ->defaultValue(210) 75 | ->end() 76 | ->end(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Listener/AttributeSubscriber.php: -------------------------------------------------------------------------------- 1 | 19 | * @package Flagception\Bundle\FlagceptionBundle\Listener 20 | */ 21 | class AttributeSubscriber implements EventSubscriberInterface 22 | { 23 | private FeatureManagerInterface $manager; 24 | 25 | public function __construct( 26 | FeatureManagerInterface $manager, 27 | ) { 28 | $this->manager = $manager; 29 | } 30 | 31 | /** 32 | * Filter on controller / method 33 | * 34 | * @param ControllerEvent $event 35 | * 36 | * @return void 37 | * 38 | * @throws NotFoundHttpException 39 | * @throws ReflectionException 40 | */ 41 | public function onKernelController(ControllerEvent $event): void 42 | { 43 | $eventController = $event->getController(); 44 | 45 | $controller = is_array($eventController) === false && method_exists($eventController, '__invoke') 46 | ? [$eventController, '__invoke'] 47 | : $eventController; 48 | 49 | /* 50 | * $controller passed can be either a class or a Closure. 51 | * This is not usual in Symfony2 but it may happen. 52 | * If it is a class, it comes in array format 53 | */ 54 | if (!is_array($controller)) { 55 | return; 56 | } 57 | 58 | $object = new ReflectionClass($controller[0]); 59 | foreach ($object->getAttributes(Feature::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 60 | if (!$this->manager->isActive($attribute->newInstance()->name)) { 61 | throw new NotFoundHttpException('Feature for this class is not active.'); 62 | } 63 | } 64 | 65 | $method = $object->getMethod($controller[1]); 66 | foreach ($method->getAttributes(Feature::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 67 | if (!$this->manager->isActive($attribute->newInstance()->name)) { 68 | throw new NotFoundHttpException('Feature for this method is not active.'); 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * {@inheritdoc} 75 | */ 76 | public static function getSubscribedEvents(): array 77 | { 78 | return [ 79 | KernelEvents::CONTROLLER => 'onKernelController', 80 | ]; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /docs/cookie.md: -------------------------------------------------------------------------------- 1 | Cookies 2 | ------------------------- 3 | You can test your features with cookies. This is by default 4 | disabled - so you have to enabled it in your config. You can also set a cookie name and a separator. 5 | 6 | ```yml 7 | # config.yml 8 | 9 | flagception: 10 | features: 11 | feature_123: 12 | default: false 13 | 14 | activators: 15 | # Cookie settings 16 | cookie: 17 | 18 | # Enable cookie activator (default: false) 19 | enable: true 20 | 21 | # Cookie name - should be a secret key (default: 'flagception') 22 | name: 'flagception' 23 | 24 | # Cookie value separator for using with mutiple features (default: ',') 25 | separator: ',' 26 | ``` 27 | 28 | Now you can set a cookie (eg. in chrome, firefox etc) and set the feature names (with separator) as value: 29 | 30 | ![Image of Chrome cookies](images/cookie.png) 31 | 32 | No matter what is set in the config - if the feature name exists in the cookie, the feature is enabled for you. Notice that features 33 | can only be enabled by cookie if they exists in your config.yml. In addition, you can explicitly disable some features for cookies: 34 | 35 | ```yml 36 | # config.yml 37 | 38 | flagception: 39 | features: 40 | 41 | # Activatable via cookie 42 | feature_123: 43 | default: false 44 | 45 | # Not activatable via cookie 46 | feature_456: 47 | cookie: false 48 | 49 | # Feature "feature_wyz" isn't activatable via cookie because the feature isn't defined in your config.yml 50 | 51 | # Cookie settings 52 | cookie: 53 | 54 | # Enable cookie activator (default: false) 55 | enable: true 56 | 57 | # Cookie name - should be a secret key (default: 'flagception') 58 | name: 'flagception' 59 | 60 | # Cookie value separator for using with mutiple features (default: ',') 61 | separator: ',' 62 | ``` 63 | 64 | Normally this acts as whitelist. That means, that only features listed in your config.yml can be enabled 65 | by cookie (due security reasons). But you can change this behavior to a blacklist. Then you can enable 66 | every feature by cookie unless you explicitly disabled it in your config.yml: 67 | 68 | ```yml 69 | # config.yml 70 | 71 | flagception: 72 | features: 73 | 74 | # Activatable via cookie 75 | feature_123: 76 | default: false 77 | 78 | # Not activatable via cookie 79 | feature_456: 80 | cookie: false 81 | 82 | # Feature "feature_wyz" isn't defined in this config.yml but is activatable by cookie 83 | 84 | # Cookie settings 85 | cookie: 86 | enable: true 87 | mode: 'blacklist' 88 | 89 | ``` 90 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configurator/CookieConfigurator.php: -------------------------------------------------------------------------------- 1 | 14 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator 15 | */ 16 | class CookieConfigurator implements ActivatorConfiguratorInterface 17 | { 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function getKey() 22 | { 23 | return 'cookie'; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function addActivator(ContainerBuilder $container, array $config, array $features) 30 | { 31 | if ($config['enable'] === false) { 32 | return; 33 | } 34 | 35 | $cookieFeatures = []; 36 | 37 | // Add only features which are allowed 38 | if ($config['mode'] === CookieActivator::WHITELIST) { 39 | $cookieFeatures = array_keys(array_filter($features, function ($feature) { 40 | return $feature['cookie'] === true; 41 | })); 42 | } 43 | 44 | // Add only features which are disallowed 45 | if ($config['mode'] === CookieActivator::BLACKLIST) { 46 | $cookieFeatures = array_keys(array_filter($features, function ($feature) { 47 | return $feature['cookie'] === false; 48 | })); 49 | } 50 | 51 | $definition = new Definition(CookieActivator::class); 52 | $definition->addArgument($cookieFeatures); 53 | $definition->addArgument($config['name']); 54 | $definition->addArgument($config['separator']); 55 | $definition->addArgument($config['mode']); 56 | 57 | $definition->addTag('flagception.activator', [ 58 | 'priority' => $config['priority'] 59 | ]); 60 | 61 | $container->setDefinition('flagception.activator.cookie_activator', $definition); 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function addConfiguration(ArrayNodeDefinition $node) 68 | { 69 | $node 70 | ->children() 71 | ->booleanNode('enable') 72 | ->beforeNormalization() 73 | ->ifString() 74 | ->then(function ($value) { 75 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 76 | }) 77 | ->end() 78 | ->defaultFalse() 79 | ->end() 80 | ->integerNode('priority') 81 | ->defaultValue(200) 82 | ->end() 83 | ->scalarNode('name') 84 | ->defaultValue('flagception') 85 | ->cannotBeEmpty() 86 | ->end() 87 | ->scalarNode('separator') 88 | ->defaultValue(',') 89 | ->cannotBeEmpty() 90 | ->end() 91 | ->enumNode('mode') 92 | ->values([CookieActivator::WHITELIST, CookieActivator::BLACKLIST]) 93 | ->defaultValue(CookieActivator::WHITELIST) 94 | ->end() 95 | ->end(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/DependencyInjection/FlagceptionExtension.php: -------------------------------------------------------------------------------- 1 | 19 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection 20 | */ 21 | class FlagceptionExtension extends Extension 22 | { 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function load(array $configs, ContainerBuilder $container): void 27 | { 28 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 29 | $loader->load('configurators.yml'); 30 | $loader->load('services.yml'); 31 | 32 | $configurators = $this->getConfigurators($container); 33 | $configuration = new Configuration($configurators); 34 | $config = $this->processConfiguration($configuration, $configs); 35 | 36 | // Enable / disable routing metadata subscriber 37 | if ($config['routing_metadata']['enable'] === false) { 38 | $container->removeDefinition('flagception.listener.routing_metadata_subscriber'); 39 | } 40 | 41 | // Enable / disable activators 42 | foreach ($configurators as $name => $configurator) { 43 | if (!array_key_exists($name, $config['activators'])) { 44 | continue; 45 | } 46 | 47 | $configurator->addActivator($container, $config['activators'][$name], $config['features']); 48 | } 49 | 50 | if ($container->hasParameter('kernel.debug') && $container->getParameter('kernel.debug')) { 51 | $chainDefinition = $container->getDefinition('flagception.activator.chain_activator'); 52 | $chainDefinition->setClass(TraceableChainActivator::class); 53 | } 54 | 55 | if (method_exists($container, 'registerForAutoconfiguration') === true) { 56 | $container 57 | ->registerForAutoconfiguration(FeatureActivatorInterface::class) 58 | ->addTag('flagception.activator'); 59 | 60 | $container 61 | ->registerForAutoconfiguration(ContextDecoratorInterface::class) 62 | ->addTag('flagception.context_decorator'); 63 | } 64 | } 65 | 66 | public function getConfiguration(array $config, ContainerBuilder $container): Configuration 67 | { 68 | return new Configuration($this->getConfigurators($container)); 69 | } 70 | 71 | /** 72 | * Get configurators 73 | * 74 | * @param ContainerBuilder $container 75 | * 76 | * @return ActivatorConfiguratorInterface[] 77 | * 78 | * @throws Exception 79 | */ 80 | private function getConfigurators(ContainerBuilder $container): array 81 | { 82 | $configurators = []; 83 | 84 | $services = $container->findTaggedServiceIds('flagception.configurator'); 85 | foreach (array_keys($services) as $id) { 86 | $configurator = $container->get($id); 87 | $configurators[str_replace('-', '_', $configurator->getKey())] = $configurator; 88 | } 89 | 90 | return $configurators; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | flagception.manager.feature_manager: 3 | class: Flagception\Manager\FeatureManager 4 | arguments: 5 | - '@flagception.activator.chain_activator' 6 | - '@flagception.decorator.chain_decorator' 7 | public: true 8 | 9 | Flagception\Manager\FeatureManagerInterface: '@flagception.manager.feature_manager' 10 | 11 | flagception.expression_language: 12 | class: Symfony\Component\ExpressionLanguage\ExpressionLanguage 13 | factory: ['@flagception.factory.expression_language_factory', 'create'] 14 | public: false 15 | 16 | flagception.twig.toggle_extension: 17 | class: Flagception\Bundle\FlagceptionBundle\Twig\ToggleExtension 18 | arguments: 19 | - '@flagception.manager.feature_manager' 20 | tags: 21 | - { name: twig.extension } 22 | public: false 23 | 24 | flagception.profiler.feature_data_collector: 25 | class: Flagception\Bundle\FlagceptionBundle\Profiler\FeatureDataCollector 26 | arguments: 27 | - '@flagception.activator.chain_activator' 28 | - '@flagception.decorator.chain_decorator' 29 | tags: 30 | - 31 | name: data_collector 32 | template: '@Flagception/profiler/layout.html.twig' 33 | id: 'flagception' 34 | public: false 35 | 36 | flagception.constraint.constraint_resolver: 37 | class: Flagception\Constraint\ConstraintResolver 38 | arguments: 39 | - '@flagception.expression_language' 40 | public: false 41 | 42 | flagception.factory.expression_language_factory: 43 | class: Flagception\Factory\ExpressionLanguageFactory 44 | public: false 45 | 46 | flagception.constraint_provider.date_provider: 47 | class: Flagception\Constraint\Provider\DateProvider 48 | tags: 49 | - { name: flagception.expression_language_provider } 50 | public: false 51 | 52 | flagception.constraint_provider.match_provider: 53 | class: Flagception\Constraint\Provider\MatchProvider 54 | tags: 55 | - { name: flagception.expression_language_provider } 56 | public: false 57 | 58 | flagception.activator.chain_activator: 59 | class: Flagception\Activator\ChainActivator 60 | public: false 61 | 62 | flagception.decorator.chain_decorator: 63 | class: Flagception\Decorator\ChainDecorator 64 | public: false 65 | 66 | # Maybe removed by your configuration 67 | flagception.listener.routing_metadata_subscriber: 68 | class: Flagception\Bundle\FlagceptionBundle\Listener\RoutingMetadataSubscriber 69 | arguments: 70 | - '@flagception.manager.feature_manager' 71 | tags: 72 | - { name: kernel.event_subscriber } 73 | public: true 74 | 75 | flagception.listener.attribute_subscriber: 76 | class: Flagception\Bundle\FlagceptionBundle\Listener\AttributeSubscriber 77 | arguments: 78 | - '@flagception.manager.feature_manager' 79 | tags: 80 | - { name: kernel.event_subscriber } 81 | public: true 82 | 83 | flagception.route.is_feature: 84 | class: Flagception\Bundle\FlagceptionBundle\Route\IsFeature 85 | arguments: 86 | - '@flagception.manager.feature_manager' 87 | 88 | flagception.route.expression_language_function.is_feature: 89 | class: \Closure 90 | factory: [ \Closure, 'fromCallable' ] 91 | arguments: [ [ '@flagception.route.is_feature', '__invoke' ] ] 92 | tags: 93 | - { name: 'routing.expression_language_function', function: 'is_feature' } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flagception 2 | **Feature toggle bundle on steroids!** Flagception is a simple and powerful feature toggle system for php. 3 | This bundle integrates the [Flagception PHP Library](https://packagist.org/packages/flagception/flagception) for symfony 2.7 to 7.* (and php 5.6 to php 8.*). 4 | 5 | [![Latest Stable Version](https://poser.pugx.org/flagception/flagception-bundle/v/stable)](https://packagist.org/packages/flagception/flagception-bundle) 6 | ![Coverage Status](https://raw.githubusercontent.com/playox/flagception-bundle/image-data/coverage.svg) 7 | [![Build Status](https://github.com/playox/flagception-bundle/actions/workflows/php.yml/badge.svg)](https://github.com/playox/flagception-bundle/actions) 8 | [![Total Downloads](https://poser.pugx.org/flagception/flagception-bundle/downloads)](https://packagist.org/packages/flagception/flagception-bundle) 9 | [![License](https://poser.pugx.org/flagception/flagception-bundle/license)](https://packagist.org/packages/flagception/flagception-bundle) 10 | 11 | | Bundle Version (Tag) | Support Symfony | 12 | |----------------------|-----------------| 13 | | <=3 | 2.7 - 4.4 | 14 | | >=4 | 4.4 - 7.4 | 15 | | >=5 | 4.4 - 7.4 | 16 | | >=6 | 4.4 - current | 17 | 18 | ```console 19 | $ composer require flagception/flagception-bundle 20 | ``` 21 | 22 | Documentation 23 | --------------------------- 24 | * [Installation](docs/install.md) 25 | * [Upgrade from 2.x](UPGRADE-3.0.md) 26 | * [Upgrade from 3.x](UPGRADE-4.0.md) 27 | * [Upgrade from 4.x](UPGRADE-5.0.md) 28 | * [Upgrade from 5.x](UPGRADE-6.0.md) 29 | * [Usage](docs/usage.md) 30 | * [Twig flags](docs/twig.md) 31 | * [Route flags](docs/route.md) 32 | * [Route condition flags](docs/route_condition.md) 33 | * [Attribute flags](docs/attribute.md) 34 | * [Annotation flags](docs/annotation.md) 35 | * [Constraints](docs/constraint.md) 36 | * [Environment variables](docs/environment.md) 37 | * [Cookies](docs/cookie.md) 38 | * [Database](docs/database.md) 39 | * [Activators](docs/activator.md) 40 | * [Profiler](docs/profiler.md) 41 | 42 | Quick example 43 | --------------------------- 44 | Set some feature in your config (or use your own [activator](docs/activator.md) for fetching features from wherever you want) ... 45 | 46 | ```yml 47 | flagception: 48 | 49 | # Your Features (optional you left it empty) 50 | features: 51 | 52 | # Feature name as key 53 | feature_123: 54 | # Default flag if inactive or active (default: false) 55 | default: true 56 | 57 | # Feature state from an environment variable 58 | feature_abc: 59 | env: FEATURE_ENV_ABC 60 | 61 | # Feature with constraint (active if user id is 12 OR it is between 8 am and 6 pm) 62 | feature_def: 63 | constraint: 'user_id == 12 or (date("H") > 8 and date("H") < 18)' 64 | 65 | # All togther (chain) 66 | feature_def: 67 | default: false 68 | env: FEATURE_ENV_ABC 69 | constraint: 'user_id == 12 or (date("H") > 8 and date("H") < 18)' 70 | ``` 71 | 72 | ... and use it in controller, services or twig: 73 | 74 | ```twig 75 | {% if feature('feature_123') %} 76 | {# Execute if feature is active ... #} 77 | {% endif %} 78 | ``` 79 | 80 | See [usage documentation](docs/usage.md) for detailed examples. 81 | 82 | Profiler 83 | --------------------------- 84 | This bundle ships a profiler tab, where you can see how often a feature was requested, which results it returns (active or inactive) and 85 | the given context. 86 | 87 | ![Image of Profiler](docs/images/profiler.png) 88 | 89 | Credits 90 | ------------------------- 91 | Profiler icon from https://github.com/tabler/tabler-icons 92 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 15 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection 16 | */ 17 | class Configuration implements ConfigurationInterface 18 | { 19 | /** 20 | * Configurators 21 | * 22 | * @var ActivatorConfiguratorInterface[] 23 | */ 24 | private $configurators; 25 | 26 | /** 27 | * Configuration constructor. 28 | * 29 | * @param ActivatorConfiguratorInterface[] $configurators 30 | */ 31 | public function __construct(array $configurators) 32 | { 33 | $this->configurators = $configurators; 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function getConfigTreeBuilder(): TreeBuilder 40 | { 41 | $treeBuilder = new TreeBuilder('flagception'); 42 | $rootNode = $treeBuilder->getRootNode(); 43 | 44 | $rootNode 45 | ->children() 46 | ->arrayNode('features') 47 | ->useAttributeAsKey('name') 48 | ->prototype('array') 49 | ->children() 50 | ->scalarNode('default') 51 | ->defaultFalse() 52 | ->end() 53 | ->scalarNode('env') 54 | ->defaultFalse() 55 | ->end() 56 | ->booleanNode('cookie') 57 | ->beforeNormalization() 58 | ->ifString() 59 | ->then(function ($value) { 60 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 61 | }) 62 | ->end() 63 | ->defaultTrue() 64 | ->end() 65 | ->scalarNode('constraint') 66 | ->defaultFalse() 67 | ->end() 68 | ->end() 69 | ->end() 70 | ->defaultValue([]) 71 | ->end() 72 | ->arrayNode('routing_metadata') 73 | ->addDefaultsIfNotSet() 74 | ->children() 75 | ->booleanNode('enable') 76 | ->beforeNormalization() 77 | ->ifString() 78 | ->then(function ($value) { 79 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 80 | }) 81 | ->end() 82 | ->defaultTrue() 83 | ->end() 84 | ->end() 85 | ->end() 86 | ->append($this->appendActivators()) 87 | ->end(); 88 | 89 | return $treeBuilder; 90 | } 91 | 92 | /** 93 | * Add activators 94 | * 95 | * @return NodeDefinition 96 | */ 97 | public function appendActivators(): NodeDefinition 98 | { 99 | $builder = new TreeBuilder('activators'); 100 | $node = $builder->getRootNode(); 101 | $node->addDefaultsIfNotSet(); 102 | 103 | $activatorNodeBuilder = $node->children(); 104 | 105 | foreach ($this->configurators as $name => $configurator) { 106 | $configuratorNode = $activatorNodeBuilder->arrayNode($name)->canBeUnset(); 107 | $configurator->addConfiguration($configuratorNode); 108 | } 109 | 110 | return $node; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/DependencyInjection/FlagceptionExtensionTest.php: -------------------------------------------------------------------------------- 1 | 17 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection 18 | */ 19 | class FlagceptionExtensionTest extends TestCase 20 | { 21 | /** 22 | * The container 23 | * 24 | * @var ContainerBuilder 25 | */ 26 | private $container; 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | protected function setUp(): void 32 | { 33 | $container = new ContainerBuilder(); 34 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../../src/Resources/config')); 35 | $loader->load('configurators.yml'); 36 | 37 | $this->container = $container; 38 | } 39 | 40 | /** 41 | * Test that routing metadata subscriber is disabled 42 | * 43 | * @return void 44 | */ 45 | public function testRoutingMetadataSubscriberDisabled() 46 | { 47 | $config = [ 48 | [ 49 | 'routing_metadata' => [ 50 | 'enable' => false 51 | ] 52 | ] 53 | ]; 54 | 55 | $extension = new FlagceptionExtension(); 56 | $extension->load($config, $this->container); 57 | 58 | static::assertFalse($this->container->hasDefinition('flagception.listener.routing_metadata_subscriber')); 59 | } 60 | 61 | /** 62 | * Test that routing metadata subscriber is enabled 63 | * 64 | * @return void 65 | */ 66 | public function testRoutingMetadataSubscriberEnabled() 67 | { 68 | $config = []; 69 | 70 | $extension = new FlagceptionExtension(); 71 | $extension->load($config, $this->container); 72 | 73 | $definition = $this->container->getDefinition('flagception.listener.routing_metadata_subscriber'); 74 | static::assertTrue($definition->hasTag('kernel.event_subscriber')); 75 | } 76 | 77 | /** 78 | * Test that routing metadata subscriber is enabled by string 79 | * 80 | * @return void 81 | */ 82 | public function testRoutingMetadataSubscriberEnabledByString() 83 | { 84 | $config = [ 85 | [ 86 | 'routing_metadata' => [ 87 | 'enable' => 'true' 88 | ] 89 | ] 90 | ]; 91 | 92 | $extension = new FlagceptionExtension(); 93 | $extension->load($config, $this->container); 94 | 95 | $definition = $this->container->getDefinition('flagception.listener.routing_metadata_subscriber'); 96 | static::assertTrue($definition->hasTag('kernel.event_subscriber')); 97 | } 98 | 99 | /** 100 | * Test that annotation subscriber is disabled 101 | * 102 | * @return void 103 | */ 104 | public function testAutConfiguration() 105 | { 106 | if (method_exists($this->container, 'registerForAutoconfiguration') === false) { 107 | $this->markTestSkipped('Only since Symfony 3.3'); 108 | } 109 | 110 | $config = []; 111 | 112 | $extension = new FlagceptionExtension(); 113 | $extension->load($config, $this->container); 114 | 115 | $activatorChildDefinition = $this->container->getAutoconfiguredInstanceof()[FeatureActivatorInterface::class]; 116 | static::assertEquals([ 117 | 'flagception.activator' => [[]] 118 | ], $activatorChildDefinition->getTags()); 119 | 120 | $contextChildDefinition = $this->container->getAutoconfiguredInstanceof()[ContextDecoratorInterface::class]; 121 | static::assertEquals([ 122 | 'flagception.context_decorator' => [[]] 123 | ], $contextChildDefinition->getTags()); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ------------------------- 3 | You can define your features in your config files. Just give your feature a name and the active or inactive flag. 4 | 5 | Minimal example with two features: 6 | 7 | ```yml 8 | flagception: 9 | 10 | # Your Features (optional you left it empty) 11 | features: 12 | 13 | # Feature name as key 14 | feature_123: 15 | # Default flag if inactive or active (default: false) 16 | default: true 17 | 18 | feature_abc: 19 | default: false 20 | ``` 21 | 22 | Now you can check the feature state in twig templates, controllers or services. 23 | 24 | ##### Twig usage 25 | ```twig 26 | {% if feature('feature_123') %} 27 | {# Execute if feature is active ... #} 28 | {% endif %} 29 | ``` 30 | 31 | ##### Service usage 32 | ```php 33 | # FooService.php 34 | 35 | class FooService 36 | { 37 | /** 38 | * @var FeatureManagerInterface 39 | */ 40 | private $manager; 41 | 42 | /** 43 | * @param FeatureManagerInterface $manager 44 | * Service id: flagception.manager.feature_manager 45 | */ 46 | public function __construct(FeatureManagerInterface $manager) 47 | { 48 | $this->manager = $manager; 49 | } 50 | 51 | public function do() 52 | { 53 | // ... 54 | if ($this->manager->isActive('feature_123')) { 55 | // ... 56 | } 57 | // ... 58 | } 59 | } 60 | ``` 61 | 62 | ##### Controller usage 63 | ```php 64 | // src/AppBundle/Controller/BlogController.php 65 | namespace AppBundle\Controller; 66 | 67 | use Symfony\Bundle\FrameworkBundle\Controller\Controller; 68 | use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; 69 | 70 | class BlogController extends Controller 71 | { 72 | /** 73 | * @Route("/blog/{page}", name="blog_list", defaults={"_feature": "feature_123"}) 74 | */ 75 | public function listAction($page) 76 | { 77 | // ... 78 | } 79 | 80 | /** 81 | * @Route("/blog/{slug}", name="blog_show") 82 | */ 83 | public function showAction($slug) 84 | { 85 | // ... 86 | } 87 | } 88 | ``` 89 | 90 | ##### Attribute usage 91 | ```php 92 | # FooController.php 93 | 94 | use Flagception\Bundle\FlagceptionBundle\Attribute\Feature; 95 | 96 | #[Feature("feature_123")] 97 | class FooController 98 | { 99 | #[Feature("feature_789")] 100 | public function barAction() 101 | { 102 | } 103 | 104 | public function fooAction() 105 | { 106 | } 107 | } 108 | ``` 109 | 110 | Take a look to the detail documentation for [Twig](twig.md), [Route](route.md) or [Attribute](attribute.md) usage. 111 | 112 | ##### Feature names 113 | You can name your features as you like. But we recommend using [snake case](https://en.wikipedia.org/wiki/Snake_case). 114 | Especially because Symfony normalizes values from your YML or XML into snake case (see [here](http://symfony.com/doc/current/components/config/definition.html#normalization)). 115 | Your feature "flag-a" becomes "flag_a". So you have to check for "flag_a" everywhere in your code, even if you've 116 | actually maintained "flag-a" in your YML / XML. Consider this with your feature naming. 117 | 118 | Constraint usage 119 | ------------------------- 120 | In some cases will you need more instead of a simple true / false. So you can define constraints to enable or disable a feature. 121 | A constraint should return true or false. 122 | 123 | An example: 124 | 125 | ```yml 126 | # config.yml 127 | 128 | flagception: 129 | features: 130 | 131 | # This feature will only be active, if the current user has id 12 132 | feature_123: 133 | default: false 134 | constraint: 'user_id === 12' 135 | 136 | # This feature will only be active, if the user_role array contains "ROLE_ADMIN" 137 | feature_abc: 138 | default: false 139 | constraint: '"ROLE_ADMIN" in user_role' 140 | 141 | # This feature will only be active between 8am and 6pm. 142 | # OR if the user_role array contains "ROLE_ADMIN" 143 | feature_abc: 144 | default: false 145 | constraint: '(date("H") > 8 and date("H") < 18) or "ROLE_ADMIN" in user_role' 146 | ``` 147 | 148 | You can extend constraints with your own variables and functions. Read the [constraint documentation](constraint.md) for more details. 149 | -------------------------------------------------------------------------------- /tests/Activator/TraceableChainActivatorTest.php: -------------------------------------------------------------------------------- 1 | 18 | * @package Flagception\Tests\FlagceptionBundle\Activator 19 | */ 20 | class TraceableChainActivatorTest extends TestCase 21 | { 22 | /** 23 | * Test implement interface 24 | * 25 | * @return void 26 | */ 27 | public function testImplementInterface() 28 | { 29 | $activator = new TraceableChainActivator(); 30 | static::assertInstanceOf(FeatureActivatorInterface::class, $activator); 31 | } 32 | 33 | /** 34 | * Test extends chain activator 35 | * 36 | * @return void 37 | */ 38 | public function testExtendsChainActivator() 39 | { 40 | $activator = new TraceableChainActivator(); 41 | static::assertInstanceOf(ChainActivator::class, $activator); 42 | } 43 | 44 | /** 45 | * Test name 46 | * 47 | * @return void 48 | */ 49 | public function testName() 50 | { 51 | $activator = new TraceableChainActivator(); 52 | static::assertEquals('chain', $activator->getName()); 53 | } 54 | 55 | /** 56 | * Test with no activators 57 | * 58 | * @return void 59 | */ 60 | public function testNoActivators() 61 | { 62 | $activator = new TraceableChainActivator(); 63 | static::assertFalse($activator->isActive('feature_abc', new Context())); 64 | } 65 | 66 | /** 67 | * Test no activators return true 68 | * 69 | * @return void 70 | */ 71 | public function testNoActivatorsReturnTrue() 72 | { 73 | $activator = new TraceableChainActivator(); 74 | $activator->add(new ArrayActivator([ 75 | 'feature_def' 76 | ])); 77 | $activator->add(new CookieActivator([ 78 | 'feature_ghi' 79 | ])); 80 | 81 | static::assertFalse($activator->isActive('feature_abc', $context = new Context())); 82 | 83 | static::assertEquals([ 84 | [ 85 | 'feature' => 'feature_abc', 86 | 'context' => $context, 87 | 'result' => false, 88 | 'stack' => [ 89 | 'array' => false, 90 | 'cookie' => false 91 | ] 92 | ] 93 | ], $activator->getTrace()); 94 | } 95 | 96 | /** 97 | * Test one activator return true 98 | * 99 | * @return void 100 | */ 101 | public function testOneActivatorsReturnTrue() 102 | { 103 | $activator = new TraceableChainActivator(); 104 | $activator->add(new CookieActivator([ 105 | 'feature_def' 106 | ])); 107 | $activator->add(new ArrayActivator([ 108 | 'feature_abc' 109 | ])); 110 | $activator->add(new EnvironmentActivator([ 111 | 'feature_hij' 112 | ])); 113 | 114 | static::assertTrue($activator->isActive('feature_abc', $context = new Context())); 115 | 116 | static::assertEquals([ 117 | [ 118 | 'feature' => 'feature_abc', 119 | 'context' => $context, 120 | 'result' => true, 121 | 'stack' => [ 122 | 'cookie' => false, 123 | 'array' => true 124 | ] 125 | ] 126 | ], $activator->getTrace()); 127 | } 128 | 129 | /** 130 | * Test add and get activators 131 | * 132 | * @return void 133 | */ 134 | public function testAddAndGet() 135 | { 136 | $decorator = new TraceableChainActivator(); 137 | $decorator->add($fakeActivator1 = new ArrayActivator()); 138 | $decorator->add($fakeActivator2 = new ArrayActivator([])); 139 | 140 | // Should be the same sorting 141 | static::assertSame($fakeActivator1, $decorator->getActivators()[0]); 142 | static::assertSame($fakeActivator2, $decorator->getActivators()[1]); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /docs/database.md: -------------------------------------------------------------------------------- 1 | Database 2 | ------------------------- 3 | You can manage your feature toggles via a database. The following database vendors are currently supported: 4 | * MySQL 5 | * Oracle 6 | * Microsoft SQL Server 7 | * PostgreSQL 8 | * SAP Sybase SQL Anywhere 9 | * SQLite 10 | 11 | The bundle will use the [database activator](https://packagist.org/packages/flagception/database-activator). 12 | 13 | Download the the activator 14 | --------------------------- 15 | The `DatabaseActivator` is not included by default. Therefore, you must first insert this via Composer as a dependency. 16 | 17 | Open a command console, enter your project directory and execute the 18 | following command to download the latest stable version of this bundle: 19 | 20 | ```console 21 | $ composer require flagception/database-activator 22 | ``` 23 | 24 | This command requires you to have Composer installed globally, as explained 25 | in the [installation chapter](https://getcomposer.org/doc/00-intro.md) 26 | of the Composer documentation. 27 | 28 | Enable the activator 29 | ------------------------- 30 | Then, enable the activator in your config and set database credentials. 31 | 32 | ```yml 33 | # config.yml 34 | 35 | flagception: 36 | activators: 37 | database: 38 | 39 | # Enable database activator (default: false) 40 | enable: true 41 | 42 | # Connection string 43 | url: 'pdo-mysql://user:secret@localhost/mydb' 44 | ``` 45 | 46 | You can fill a connection string (url), a PDO instance, a DBAL instance or old-fashioned the individual 47 | credentials fields. 48 | 49 | ###### Connection string: 50 | ```yml 51 | # config.yml 52 | 53 | flagception: 54 | activators: 55 | database: 56 | 57 | # Enable database activator (default: false) 58 | enable: true 59 | 60 | # Connection string 61 | url: 'pdo-mysql://user:secret@localhost/mydb' 62 | ``` 63 | 64 | ###### DBAL instance: 65 | ```yml 66 | # config.yml 67 | 68 | flagception: 69 | activators: 70 | database: 71 | 72 | # Enable database activator (default: false) 73 | enable: true 74 | 75 | # By dbal instance 76 | dbal: 'dbal.service.id' 77 | ``` 78 | 79 | ###### Credential fields: 80 | ```yml 81 | # config.yml 82 | 83 | flagception: 84 | activators: 85 | database: 86 | 87 | # Enable database activator (default: false) 88 | enable: true 89 | 90 | # By credentials field 91 | credentials: 92 | dbname: 'mydb', 93 | user: 'user', 94 | password: 'secret', # You can use env too (%env(MYSQL_DATABASE)%) 95 | host: 'localhost', 96 | driver: 'pdo_mysql' 97 | ``` 98 | 99 | Table 100 | ------------------------- 101 | The `DatabaseActivator` will automatically create a table for storing the feature states if this not already exists. 102 | By default, the table name is `flagception_features` which contains the columns `feature` and `state`. 103 | 104 | You can change the tables and / or column names in your option config: 105 | ```yml 106 | # config.yml 107 | 108 | flagception: 109 | activators: 110 | database: 111 | 112 | # Enable database activator (default: false) 113 | enable: true 114 | 115 | # Connection string 116 | url: 'mysql://user:secret@localhost/mydb' 117 | 118 | # Rename table and columns 119 | options: 120 | db_table: 'my_table' 121 | db_column_feature: 'my_cool_feature_name' 122 | db_column_state: 'my_current_feature_state' 123 | ``` 124 | 125 | Enable caching 126 | ------------------------- 127 | This queries status from a database, which _can_ negatively impact performance. Therefore, you can set up a cache for 128 | this activator. Identical feature queries are then loaded from the cache instead of being retrieved from the 129 | database. All you have to do is specify a cache pool and cache interval. 130 | 131 | By default, the cache is disabled. 132 | 133 | Example: 134 | 135 | ```yml 136 | # config.yml 137 | 138 | flagception: 139 | activators: 140 | database: 141 | enable: true 142 | # ... 143 | cache: 144 | 145 | # Enable the cache option (default: false) 146 | enable: true 147 | 148 | # Set cache pool (default: cache.app) 149 | pool: cache.app 150 | 151 | # Set lifetime for cache in seconds (default: 3600) 152 | lifetime: 3600 153 | ``` 154 | -------------------------------------------------------------------------------- /docs/route.md: -------------------------------------------------------------------------------- 1 | Route 2 | ------------------------- 3 | You can use route attributes for checking the feature state in controllers. This is per default activated. 4 | You can enable or disable it via the config. 5 | 6 | ```yml 7 | # config.yml 8 | 9 | flagception: 10 | features: 11 | feature_123: 12 | default: true 13 | 14 | # Use route attributes? (optional) 15 | routing_metadata: 16 | 17 | # Enable controller annotation (default: true) 18 | enable: true 19 | ``` 20 | 21 | If route metadata is enabled, you can define the feature name in your route attributes. 22 | A `NotFoundHttpException` will be thrown if you request an action or class with inactive feature flag. 23 | 24 | ```php 25 | // src/AppBundle/Controller/BlogController.php 26 | // src/Controller/BlogController.php 27 | namespace AppBundle\Controller; 28 | 29 | use Symfony\Bundle\FrameworkBundle\Controller\Controller; 30 | use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; 31 | 32 | class BlogController extends Controller 33 | { 34 | /** 35 | * @Route("/blog/{page}", defaults={"_feature": "feature_123"}) 36 | */ 37 | public function listAction($page) 38 | { 39 | // ... 40 | } 41 | 42 | /** 43 | * @Route("/blog/{slug}") 44 | */ 45 | public function showAction($slug) 46 | { 47 | // ... 48 | } 49 | } 50 | ``` 51 | or via yml 52 | 53 | ```yml 54 | # app/config/routing.yml 55 | blog_list: 56 | path: /blog/{page} 57 | defaults: { _controller: AppBundle:Blog:list, _feature: 'feature_789' } 58 | 59 | # Symfony 3.4 / 4.0 60 | blog_list: 61 | path: /blog/{page} 62 | controller: AppBundle:Blog:list 63 | defaults: { _feature: 'feature_789' } 64 | 65 | blog_show: 66 | ``` 67 | 68 | or via xml 69 | 70 | ```xml 71 | 72 | 73 | 77 | 78 | 79 | AppBundle:Blog:list 80 | feature_123 81 | 82 | 83 | 84 | 85 | AppBundle:Blog:list 86 | feature_123 87 | 88 | 89 | 90 | 91 | ``` 92 | 93 | To check for multiple features, pass them as an array to the route definition. 94 | 95 | ```php 96 | // src/AppBundle/Controller/BlogController.php 97 | // src/Controller/BlogController.php 98 | namespace AppBundle\Controller; 99 | 100 | use Symfony\Bundle\FrameworkBundle\Controller\Controller; 101 | use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; 102 | 103 | class BlogController extends Controller 104 | { 105 | /** 106 | * @Route("/blog/{page}", defaults={"_feature": {"feature_123", "feature_456"}}) 107 | */ 108 | public function listAction($page) 109 | { 110 | // ... 111 | } 112 | 113 | /** 114 | * @Route("/blog/{slug}") 115 | */ 116 | public function showAction($slug) 117 | { 118 | // ... 119 | } 120 | } 121 | ``` 122 | or via yml 123 | 124 | ```yml 125 | # app/config/routing.yml 126 | blog_list: 127 | path: /blog/{page} 128 | defaults: { _controller: AppBundle:Blog:list, _feature: ['feature_456', 'feature_789'] } 129 | 130 | # Symfony 3.4 / 4.0 131 | blog_list: 132 | path: /blog/{page} 133 | controller: AppBundle:Blog:list 134 | defaults: { _feature: ['feature_456', 'feature_789'] } 135 | 136 | blog_show: 137 | ``` 138 | 139 | or via xml 140 | 141 | ```xml 142 | 143 | 144 | 148 | 149 | 150 | AppBundle:Blog:list 151 | 152 | 153 | feature_123 154 | feature_456 155 | 156 | 157 | 158 | 159 | 160 | 161 | AppBundle:Blog:list 162 | 163 | 164 | feature_123 165 | feature_456 166 | 167 | 168 | 169 | 170 | 171 | 172 | ``` 173 | -------------------------------------------------------------------------------- /docs/constraint.md: -------------------------------------------------------------------------------- 1 | Constraint 2 | ------------------------- 3 | You have to defining variables and functions if you want to use it in your constraint. 4 | You can define variables locally or globally. We recommend to define variables always globally. 5 | 6 | We use the symfony [expression language](https://symfony.com/doc/current/components/expression_language.html) for 7 | parsing the constraints. 8 | 9 | ##### Define locally variable 10 | Just fill the second argument of your twig or service method with an array. Beware that the variable only exists 11 | for this one feature request. 12 | 13 | Given we use following constraint: 14 | ```yml 15 | # config.yml 16 | 17 | flagception: 18 | features: 19 | 20 | # This feature will only be active, if the current user has id 12 21 | feature_123: 22 | default: false 23 | constraint: 'user_id == 12' 24 | ``` 25 | 26 | For defining the `user_id`, we just add this as second argument. 27 | 28 | In twig: 29 | ```twig 30 | {% if feature('feature_123', {'user_id': '12', 'user_role': 'ROLE_ADMIN'}) %} 31 | {# ... #} 32 | {% endif %} 33 | ``` 34 | 35 | In a service: 36 | ```php 37 | # FooService.php 38 | 39 | class FooService 40 | { 41 | /** 42 | * @var FeatureManagerInterface 43 | */ 44 | private $manager; 45 | 46 | /** 47 | * @param FeatureManagerInterface $manager 48 | * Service id: flagception.manager.feature_manager 49 | */ 50 | public function __construct(FeatureManagerInterface $manager) 51 | { 52 | $this->manager = $manager; 53 | } 54 | 55 | public function do() 56 | { 57 | // ... 58 | $context = new Context(); 59 | $context->add('user_id', 12); 60 | $context->add('user_role', 'ROLE_ADMIN'); 61 | 62 | if ($this->manager->isActive('feature_123', $context)) { 63 | // ... 64 | } 65 | // ... 66 | } 67 | } 68 | ``` 69 | 70 | ##### Define globally variable 71 | For adding a global variable, just create a ContextDecorator class and implement `ContextDecoratorInterface`. 72 | You have to create two methods. The `getName` method return the ContextDecorator name and the `decorate` method 73 | will extend the context data with your variables. Remember to tag the service with `flagception.context_decorator`. 74 | 75 | This bundle supports [autoconfiguration](https://symfony.com/blog/new-in-symfony-3-3-service-autoconfiguration) for `ContextDecoratorInterface` from Symfony 3.3. 76 | 77 | As the feature manager may serializes context data in future (eg. for caching), 78 | you should not store objects that cannot be serialized (like PDO objects) or you need to provide your own serialize() method. 79 | 80 | Example for adding the `user_id`: 81 | ```php 82 | # UserContextDecorator.php 83 | 84 | class UserContextDecorator implements ContextDecoratorInterface 85 | { 86 | private $user; 87 | 88 | public function __construct(User $user) 89 | { 90 | $this->user = $user; 91 | } 92 | 93 | public function getName(): string 94 | { 95 | return 'user_context_decorator'; 96 | } 97 | 98 | public function decorate(Context $context): Context 99 | { 100 | $context->add('user_id', $this->user->getId); 101 | $context->add('user_role', $this->user->getRole()); 102 | 103 | return $context; 104 | } 105 | } 106 | ``` 107 | 108 | ##### Methods for constraints 109 | We have some methods you can use for your constraint expression. But you can always create own methods if you like (see below). 110 | 111 | | Method | Description | Example call | 112 | |--------|----------------------------------------------|----------------------------------| 113 | | date | Use php `date` function with current timestamp | `date("H") > 8 and date("H") < 18` | 114 | | match | Use php `preg_match` with pattern and value. This method return true or false | `match("/foo/i", "FOO") == true` | 115 | | ratio | Random true / false value based on your given ratio between 0 - 1 | `ratio(0.5) == true` | 116 | 117 | 118 | ##### Define own methods 119 | All what you have to do is to [create a provider](http://symfony.com/doc/current/components/expression_language/extending.html) 120 | for the expression language (like you already know from symfony). 121 | Then tag your provider with the `flagception.expression_language_provider` and you can use your function in constraints. 122 | 123 | Example provider for creating a format date: 124 | ```php 125 | // DateProvider.php 126 | 127 | class DateProvider implements ExpressionFunctionProviderInterface 128 | { 129 | /** 130 | * {@inheritdoc} 131 | */ 132 | public function getFunctions() 133 | { 134 | return [ 135 | new ExpressionFunction( 136 | 'date', 137 | function ($value) { 138 | return sprintf('date(%1$s, time())', $value); 139 | }, 140 | function ($arguments, $str) { 141 | return date($str, time()); 142 | } 143 | ], 144 | ); 145 | } 146 | } 147 | ``` 148 | 149 | Now you can use `date`: 150 | ```yml 151 | flagception: 152 | features: 153 | feature_abc: 154 | default: false 155 | constraint: 'date("H") > 8 and date("H") < 18' 156 | ``` 157 | -------------------------------------------------------------------------------- /tests/Listener/AttributeSubscriberTest.php: -------------------------------------------------------------------------------- 1 | 20 | * @package Flagception\Tests\FlagceptionBundle\Listener 21 | */ 22 | class AttributeSubscriberTest extends TestCase 23 | { 24 | /** 25 | * Test implement interface 26 | * 27 | * @return void 28 | */ 29 | public function testImplementInterface() 30 | { 31 | $manager = $this->createMock(FeatureManagerInterface::class); 32 | $subscriber = new AttributeSubscriber($manager); 33 | 34 | static::assertInstanceOf(EventSubscriberInterface::class, $subscriber); 35 | } 36 | 37 | /** 38 | * Test subscribed events 39 | * 40 | * @return void 41 | */ 42 | public function testSubscribedEvents() 43 | { 44 | static::assertEquals( 45 | [KernelEvents::CONTROLLER => 'onKernelController',], 46 | AttributeSubscriber::getSubscribedEvents() 47 | ); 48 | } 49 | 50 | /** 51 | * Test controller is closure 52 | * 53 | * @return void 54 | */ 55 | public function testControllerIsClosure() 56 | { 57 | $manager = $this->createMock(FeatureManagerInterface::class); 58 | $manager->expects(static::never())->method('isActive'); 59 | 60 | $event = $this->createControllerEvent( 61 | function () { 62 | } 63 | ); 64 | 65 | $subscriber = new AttributeSubscriber($manager); 66 | $subscriber->onKernelController($event); 67 | } 68 | 69 | /** 70 | * Test on class with active feature 71 | * 72 | * @return void 73 | */ 74 | public function testOnClassIsActive() 75 | { 76 | $manager = $this->createMock(FeatureManagerInterface::class); 77 | $manager->method('isActive')->with('feature_abc')->willReturn(true); 78 | 79 | $event = $this->createControllerEvent([ 80 | new AttributeTestClass(), 81 | 'normalMethod' 82 | ]); 83 | 84 | $subscriber = new AttributeSubscriber($manager); 85 | $subscriber->onKernelController($event); 86 | } 87 | 88 | /** 89 | * Test on class with inactive feature 90 | * 91 | * @return void 92 | */ 93 | public function testOnClassIsInactive() 94 | { 95 | $this->expectException(NotFoundHttpException::class); 96 | 97 | $manager = $this->createMock(FeatureManagerInterface::class); 98 | $manager->method('isActive')->with('feature_abc')->willReturn(false); 99 | 100 | $event = $this->createControllerEvent([ 101 | new AttributeTestClass(), 102 | 'normalMethod' 103 | ]); 104 | 105 | $subscriber = new AttributeSubscriber($manager); 106 | $subscriber->onKernelController($event); 107 | } 108 | 109 | /** 110 | * Test on method with active feature 111 | * 112 | * @return void 113 | */ 114 | public function testOnMethodIsActive() 115 | { 116 | $manager = $this->createMock(FeatureManagerInterface::class); 117 | $manager 118 | ->method('isActive') 119 | ->withConsecutive(['feature_abc'], ['feature_def']) 120 | ->willReturnOnConsecutiveCalls(true, true); 121 | 122 | $event = $this->createControllerEvent([ 123 | new AttributeTestClass(), 124 | 'invalidMethod' 125 | ]); 126 | 127 | $subscriber = new AttributeSubscriber($manager); 128 | $subscriber->onKernelController($event); 129 | } 130 | 131 | /** 132 | * Test on method with inactive feature 133 | * 134 | * @return void 135 | */ 136 | public function testOnMethodIsInactive() 137 | { 138 | $this->expectException(NotFoundHttpException::class); 139 | 140 | $manager = $this->createMock(FeatureManagerInterface::class); 141 | $manager 142 | ->method('isActive') 143 | ->withConsecutive(['feature_abc'], ['feature_def']) 144 | ->willReturnOnConsecutiveCalls(true, false); 145 | 146 | $event = $this->createControllerEvent([ 147 | new AttributeTestClass(), 148 | 'invalidMethod' 149 | ]); 150 | 151 | $subscriber = new AttributeSubscriber($manager); 152 | $subscriber->onKernelController($event); 153 | } 154 | 155 | /** 156 | * Create ControllerEvent 157 | * 158 | * @param $controller 159 | * 160 | * @return ControllerEvent 161 | */ 162 | private function createControllerEvent($controller): ControllerEvent 163 | { 164 | return new ControllerEvent( 165 | $this->createMock(HttpKernelInterface::class), 166 | $controller, 167 | new Request(), 168 | HttpKernelInterface::MAIN_REQUEST 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/DependencyInjection/Configurator/ArrayConfiguratorTest.php: -------------------------------------------------------------------------------- 1 | 16 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\Configurator 17 | */ 18 | class ArrayConfiguratorTest extends TestCase 19 | { 20 | /** 21 | * The container 22 | * 23 | * @var ContainerBuilder 24 | */ 25 | private $container; 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | protected function setUp(): void 31 | { 32 | $container = new ContainerBuilder(); 33 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../../../src/Resources/config')); 34 | $loader->load('configurators.yml'); 35 | 36 | $this->container = $container; 37 | } 38 | 39 | /** 40 | * Test key 41 | * 42 | * @return void 43 | */ 44 | public function testKey() 45 | { 46 | static::assertEquals('array', (new ArrayConfigurator())->getKey()); 47 | } 48 | 49 | /** 50 | * Test activator default state 51 | * 52 | * @return void 53 | */ 54 | public function testActivatorDefaultState() 55 | { 56 | $config = []; 57 | $extension = new FlagceptionExtension(); 58 | $extension->load($config, $this->container); 59 | 60 | static::assertTrue($this->container->hasDefinition('flagception.activator.array_activator')); 61 | } 62 | 63 | /** 64 | * Test activator default state 65 | * 66 | * @return void 67 | */ 68 | public function testActivatorDefaultPriority() 69 | { 70 | $config = [ 71 | [ 72 | 'activators' => [ 73 | 'array' => [ 74 | 'enable' => true 75 | ] 76 | ] 77 | ] 78 | ]; 79 | $extension = new FlagceptionExtension(); 80 | $extension->load($config, $this->container); 81 | 82 | $definition = $this->container->getDefinition('flagception.activator.array_activator'); 83 | static::assertEquals(255, $definition->getTag('flagception.activator')[0]['priority']); 84 | } 85 | 86 | /** 87 | * Test activator can be disabled 88 | * 89 | * @return void 90 | */ 91 | public function testActivatorCanByDisabled() 92 | { 93 | $config = [ 94 | [ 95 | 'activators' => [ 96 | 'array' => [ 97 | 'enable' => false 98 | ] 99 | ] 100 | ] 101 | ]; 102 | $extension = new FlagceptionExtension(); 103 | $extension->load($config, $this->container); 104 | 105 | static::assertFalse($this->container->hasDefinition('flagception.activator.array_activator')); 106 | } 107 | 108 | /** 109 | * Test activator can be disabled by string 110 | * 111 | * @return void 112 | */ 113 | public function testActivatorCanByDisabledByString() 114 | { 115 | $config = [ 116 | [ 117 | 'activators' => [ 118 | 'array' => [ 119 | 'enable' => 'false' 120 | ] 121 | ] 122 | ] 123 | ]; 124 | $extension = new FlagceptionExtension(); 125 | $extension->load($config, $this->container); 126 | 127 | static::assertFalse($this->container->hasDefinition('flagception.activator.array_activator')); 128 | } 129 | 130 | /** 131 | * Test set activator priority 132 | * 133 | * @return void 134 | */ 135 | public function testActivatorSetPriority() 136 | { 137 | $config = [ 138 | [ 139 | 'activators' => [ 140 | 'array' => [ 141 | 'enable' => true, 142 | 'priority' => 10 143 | ] 144 | ] 145 | ] 146 | ]; 147 | $extension = new FlagceptionExtension(); 148 | $extension->load($config, $this->container); 149 | 150 | $definition = $this->container->getDefinition('flagception.activator.array_activator'); 151 | static::assertEquals(10, $definition->getTag('flagception.activator')[0]['priority']); 152 | } 153 | 154 | /** 155 | * Test add features 156 | * 157 | * @return void 158 | */ 159 | public function testAddFeatures() 160 | { 161 | $config = [ 162 | [ 163 | 'features' => [ 164 | 'feature_foo' => [ 165 | 'default' => true 166 | ], 167 | 'feature_bar' => [ 168 | 'default' => false 169 | ], 170 | 'feature_bazz' => [ 171 | 'default' => 'true' 172 | ] 173 | ] 174 | ] 175 | ]; 176 | 177 | $extension = new FlagceptionExtension(); 178 | $extension->load($config, $this->container); 179 | 180 | static::assertEquals( 181 | [ 182 | 'feature_foo' => true, 183 | 'feature_bar' => false, 184 | 'feature_bazz' => 'true' 185 | ], 186 | $this->container->getDefinition('flagception.activator.array_activator')->getArgument(0) 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /tests/DependencyInjection/Configurator/EnvironmentConfiguratorTest.php: -------------------------------------------------------------------------------- 1 | 16 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\Configurator 17 | */ 18 | class EnvironmentConfiguratorTest extends TestCase 19 | { 20 | /** 21 | * The container 22 | * 23 | * @var ContainerBuilder 24 | */ 25 | private $container; 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | protected function setUp(): void 31 | { 32 | $container = new ContainerBuilder(); 33 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../../../src/Resources/config')); 34 | $loader->load('configurators.yml'); 35 | 36 | $this->container = $container; 37 | } 38 | 39 | /** 40 | * Test key 41 | * 42 | * @return void 43 | */ 44 | public function testKey() 45 | { 46 | static::assertEquals('environment', (new EnvironmentConfigurator())->getKey()); 47 | } 48 | 49 | /** 50 | * Test activator default state 51 | * 52 | * @return void 53 | */ 54 | public function testActivatorDefaultState() 55 | { 56 | $config = []; 57 | $extension = new FlagceptionExtension(); 58 | $extension->load($config, $this->container); 59 | 60 | static::assertTrue($this->container->hasDefinition('flagception.activator.environment_activator')); 61 | } 62 | 63 | /** 64 | * Test activator default state 65 | * 66 | * @return void 67 | */ 68 | public function testActivatorDefaultPriority() 69 | { 70 | $config = [ 71 | [ 72 | 'activators' => [ 73 | 'environment' => [ 74 | 'enable' => true 75 | ] 76 | ] 77 | ] 78 | ]; 79 | $extension = new FlagceptionExtension(); 80 | $extension->load($config, $this->container); 81 | 82 | $definition = $this->container->getDefinition('flagception.activator.environment_activator'); 83 | static::assertEquals(230, $definition->getTag('flagception.activator')[0]['priority']); 84 | } 85 | 86 | /** 87 | * Test activator can be disabled 88 | * 89 | * @return void 90 | */ 91 | public function testActivatorCanByDisabled() 92 | { 93 | $config = [ 94 | [ 95 | 'activators' => [ 96 | 'environment' => [ 97 | 'enable' => false 98 | ] 99 | ] 100 | ] 101 | ]; 102 | $extension = new FlagceptionExtension(); 103 | $extension->load($config, $this->container); 104 | 105 | static::assertFalse($this->container->hasDefinition('flagception.activator.environment_activator')); 106 | } 107 | 108 | /** 109 | * Test activator can be disabled by string 110 | * 111 | * @return void 112 | */ 113 | public function testActivatorCanByDisabledByString() 114 | { 115 | $config = [ 116 | [ 117 | 'activators' => [ 118 | 'environment' => [ 119 | 'enable' => 'false' 120 | ] 121 | ] 122 | ] 123 | ]; 124 | $extension = new FlagceptionExtension(); 125 | $extension->load($config, $this->container); 126 | 127 | static::assertFalse($this->container->hasDefinition('flagception.activator.environment_activator')); 128 | } 129 | 130 | /** 131 | * Test set activator priority 132 | * 133 | * @return void 134 | */ 135 | public function testActivatorSetPriority() 136 | { 137 | $config = [ 138 | [ 139 | 'activators' => [ 140 | 'environment' => [ 141 | 'enable' => true, 142 | 'priority' => 10 143 | ] 144 | ] 145 | ] 146 | ]; 147 | $extension = new FlagceptionExtension(); 148 | $extension->load($config, $this->container); 149 | 150 | $definition = $this->container->getDefinition('flagception.activator.environment_activator'); 151 | static::assertEquals(10, $definition->getTag('flagception.activator')[0]['priority']); 152 | } 153 | 154 | /** 155 | * Test add features 156 | * 157 | * @return void 158 | */ 159 | public function testAddFeatures() 160 | { 161 | $config = [ 162 | [ 163 | 'features' => [ 164 | 'feature_foo' => [ 165 | 'env' => $env1 = uniqid() 166 | ], 167 | 'feature_bar' => [ 168 | 'env' => false 169 | ], 170 | 'feature_bazz' => [ 171 | 'env' => $env2 = uniqid() 172 | ], 173 | 'feature_foobazz' => [] 174 | ] 175 | ] 176 | ]; 177 | 178 | $extension = new FlagceptionExtension(); 179 | $extension->load($config, $this->container); 180 | 181 | static::assertEquals( 182 | [ 183 | 'feature_foo' => $env1, 184 | 'feature_bazz' => $env2 185 | ], 186 | $this->container->getDefinition('flagception.activator.environment_activator')->getArgument(0) 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /tests/Listener/RoutingMetadataSubscriberTest.php: -------------------------------------------------------------------------------- 1 | 20 | * @package Flagception\Tests\FlagceptionBundle\Listener 21 | */ 22 | class RoutingMetadataSubscriberTest extends TestCase 23 | { 24 | /** 25 | * Test implement interface 26 | * 27 | * @return void 28 | */ 29 | public function testImplementInterface() 30 | { 31 | $subscriber = new RoutingMetadataSubscriber($this->createMock(FeatureManagerInterface::class)); 32 | static::assertInstanceOf(EventSubscriberInterface::class, $subscriber); 33 | } 34 | 35 | /** 36 | * Test subscribed events 37 | * 38 | * @return void 39 | */ 40 | public function testSubscribedEvents() 41 | { 42 | static::assertEquals( 43 | [KernelEvents::CONTROLLER => 'onKernelController'], 44 | RoutingMetadataSubscriber::getSubscribedEvents() 45 | ); 46 | } 47 | 48 | /** 49 | * Test request has no feature 50 | * 51 | * @return void 52 | */ 53 | public function testRequestHasNoFeature() 54 | { 55 | $request = new Request(); 56 | $event = $this->createControllerEvent($request); 57 | 58 | $manager = $this->createMock(FeatureManagerInterface::class); 59 | $manager 60 | ->expects(static::never()) 61 | ->method('isActive'); 62 | 63 | $subscriber = new RoutingMetadataSubscriber($manager); 64 | $subscriber->onKernelController($event); 65 | } 66 | 67 | /** 68 | * Test feature is not active 69 | * 70 | * @return void 71 | */ 72 | public function testFeatureIsNotActive() 73 | { 74 | $this->expectException(NotFoundHttpException::class); 75 | 76 | $request = new Request([], [], ['_feature' => 'feature_abc']); 77 | $event = $this->createControllerEvent($request); 78 | 79 | $manager = $this->createMock(FeatureManagerInterface::class); 80 | $manager 81 | ->expects(static::once()) 82 | ->method('isActive') 83 | ->with('feature_abc') 84 | ->willReturn(false); 85 | 86 | $subscriber = new RoutingMetadataSubscriber($manager); 87 | $subscriber->onKernelController($event); 88 | } 89 | 90 | /** 91 | * Test feature is active 92 | * 93 | * @return void 94 | */ 95 | public function testFeatureIsActive() 96 | { 97 | $request = new Request([], [], ['_feature' => 'feature_abc']); 98 | $event = $this->createControllerEvent($request); 99 | 100 | $manager = $this->createMock(FeatureManagerInterface::class); 101 | $manager 102 | ->expects(static::once()) 103 | ->method('isActive') 104 | ->with('feature_abc') 105 | ->willReturn(true); 106 | 107 | $subscriber = new RoutingMetadataSubscriber($manager); 108 | $subscriber->onKernelController($event); 109 | } 110 | 111 | /** 112 | * Create ControllerEvent 113 | * 114 | * @param $controller 115 | * 116 | * @return ControllerEvent 117 | */ 118 | private function createControllerEvent($request): ControllerEvent 119 | { 120 | return new ControllerEvent( 121 | $this->createMock(HttpKernelInterface::class), 122 | [$this, 'testFeatureIsActive'], 123 | $request, 124 | 1 /* HttpKernelInterface::MAIN_REQUEST */ 125 | ); 126 | } 127 | 128 | /** 129 | * Test features are not active 130 | * 131 | * @return void 132 | */ 133 | public function testRouteWithMultipleFeaturesIsNotActive() 134 | { 135 | $this->expectException(NotFoundHttpException::class); 136 | 137 | $request = new Request([], [], ['_feature' => ['feature_abc', 'feature_def']]); 138 | 139 | $event = new ControllerEvent( 140 | $this->createMock(HttpKernelInterface::class), 141 | function () { 142 | return new Response(); 143 | }, 144 | $request, 145 | 1 /* HttpKernelInterface::MAIN_REQUEST */ 146 | ); 147 | 148 | $manager = $this->createMock(FeatureManagerInterface::class); 149 | $manager 150 | ->expects(static::exactly(2)) 151 | ->method('isActive') 152 | ->withConsecutive(['feature_abc'], ['feature_def']) 153 | ->willReturnOnConsecutiveCalls(true, false); 154 | 155 | $subscriber = new RoutingMetadataSubscriber($manager); 156 | $subscriber->onKernelController($event); 157 | } 158 | 159 | /** 160 | * Test features are active 161 | * 162 | * @return void 163 | */ 164 | public function testRouteWithMultipleFeaturesIsActive() 165 | { 166 | $request = new Request([], [], ['_feature' => ['feature_abc', 'feature_def']]); 167 | 168 | $event = new ControllerEvent( 169 | $this->createMock(HttpKernelInterface::class), 170 | function () { 171 | return new Response(); 172 | }, 173 | $request, 174 | 1 /* HttpKernelInterface::MAIN_REQUEST */ 175 | ); 176 | 177 | $manager = $this->createMock(FeatureManagerInterface::class); 178 | $manager 179 | ->expects(static::exactly(2)) 180 | ->method('isActive') 181 | ->withConsecutive(['feature_abc'], ['feature_def']) 182 | ->willReturnOnConsecutiveCalls(true, true); 183 | 184 | $subscriber = new RoutingMetadataSubscriber($manager); 185 | $subscriber->onKernelController($event); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /tests/DependencyInjection/Configurator/ConstraintConfiguratorTest.php: -------------------------------------------------------------------------------- 1 | 17 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\Configurator 18 | */ 19 | class ConstraintConfiguratorTest extends TestCase 20 | { 21 | /** 22 | * The container 23 | * 24 | * @var ContainerBuilder 25 | */ 26 | private $container; 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | protected function setUp(): void 32 | { 33 | $container = new ContainerBuilder(); 34 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../../../src/Resources/config')); 35 | $loader->load('configurators.yml'); 36 | 37 | $this->container = $container; 38 | } 39 | 40 | /** 41 | * Test key 42 | * 43 | * @return void 44 | */ 45 | public function testKey() 46 | { 47 | static::assertEquals('constraint', (new ConstraintConfigurator())->getKey()); 48 | } 49 | 50 | /** 51 | * Test activator default state 52 | * 53 | * @return void 54 | */ 55 | public function testActivatorDefaultState() 56 | { 57 | $config = []; 58 | $extension = new FlagceptionExtension(); 59 | $extension->load($config, $this->container); 60 | 61 | static::assertTrue($this->container->hasDefinition('flagception.activator.constraint_activator')); 62 | } 63 | 64 | /** 65 | * Test activator default state 66 | * 67 | * @return void 68 | */ 69 | public function testActivatorDefaultPriority() 70 | { 71 | $config = [ 72 | [ 73 | 'activators' => [ 74 | 'constraint' => [ 75 | 'enable' => true 76 | ] 77 | ] 78 | ] 79 | ]; 80 | $extension = new FlagceptionExtension(); 81 | $extension->load($config, $this->container); 82 | 83 | $definition = $this->container->getDefinition('flagception.activator.constraint_activator'); 84 | static::assertEquals(210, $definition->getTag('flagception.activator')[0]['priority']); 85 | } 86 | 87 | /** 88 | * Test activator can be disabled 89 | * 90 | * @return void 91 | */ 92 | public function testActivatorCanByDisabled() 93 | { 94 | $config = [ 95 | [ 96 | 'activators' => [ 97 | 'constraint' => [ 98 | 'enable' => false 99 | ] 100 | ] 101 | ] 102 | ]; 103 | $extension = new FlagceptionExtension(); 104 | $extension->load($config, $this->container); 105 | 106 | static::assertFalse($this->container->hasDefinition('flagception.activator.constraint_activator')); 107 | } 108 | 109 | /** 110 | * Test activator can be disabled by string 111 | * 112 | * @return void 113 | */ 114 | public function testActivatorCanByDisabledByString() 115 | { 116 | $config = [ 117 | [ 118 | 'activators' => [ 119 | 'constraint' => [ 120 | 'enable' => 'false' 121 | ] 122 | ] 123 | ] 124 | ]; 125 | $extension = new FlagceptionExtension(); 126 | $extension->load($config, $this->container); 127 | 128 | static::assertFalse($this->container->hasDefinition('flagception.activator.constraint_activator')); 129 | } 130 | 131 | /** 132 | * Test set activator priority 133 | * 134 | * @return void 135 | */ 136 | public function testActivatorSetPriority() 137 | { 138 | $config = [ 139 | [ 140 | 'activators' => [ 141 | 'constraint' => [ 142 | 'enable' => true, 143 | 'priority' => 10 144 | ] 145 | ] 146 | ] 147 | ]; 148 | $extension = new FlagceptionExtension(); 149 | $extension->load($config, $this->container); 150 | 151 | $definition = $this->container->getDefinition('flagception.activator.constraint_activator'); 152 | static::assertEquals(10, $definition->getTag('flagception.activator')[0]['priority']); 153 | } 154 | 155 | /** 156 | * Test add features 157 | * 158 | * @return void 159 | */ 160 | public function testAddFeatures() 161 | { 162 | $config = [ 163 | [ 164 | 'features' => [ 165 | 'feature_foo' => [ 166 | 'constraint' => 'false == true' 167 | ], 168 | 'feature_bar' => [ 169 | 'constraint' => false 170 | ], 171 | 'feature_bazz' => [ 172 | 'constraint' => 'true == false' 173 | ], 174 | 'feature_foobazz' => [] 175 | ] 176 | ] 177 | ]; 178 | 179 | $extension = new FlagceptionExtension(); 180 | $extension->load($config, $this->container); 181 | 182 | static::assertEquals( 183 | new Reference('flagception.constraint.constraint_resolver'), 184 | $this->container->getDefinition('flagception.activator.constraint_activator')->getArgument(0) 185 | ); 186 | 187 | static::assertEquals( 188 | [ 189 | 'feature_foo' => 'false == true', 190 | 'feature_bazz' => 'true == false' 191 | ], 192 | $this->container->getDefinition('flagception.activator.constraint_activator')->getArgument(1) 193 | ); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Profiler/FeatureDataCollector.php: -------------------------------------------------------------------------------- 1 | 17 | * @package Flagception\Bundle\FlagceptionBundle\Profiler 18 | */ 19 | class FeatureDataCollector extends DataCollector 20 | { 21 | /** 22 | * The profiler chain activator 23 | * 24 | * @var TraceableChainActivator 25 | */ 26 | private $chainActivator; 27 | 28 | /** 29 | * The chain decorator 30 | * 31 | * @var ChainDecorator 32 | */ 33 | private $chainDecorator; 34 | 35 | /** 36 | * FeatureDataCollector constructor. 37 | * 38 | * @param TraceableChainActivator $chainActivator 39 | * @param ChainDecorator $chainDecorator 40 | */ 41 | public function __construct(ChainActivator $chainActivator, ChainDecorator $chainDecorator) 42 | { 43 | $this->chainActivator = $chainActivator; 44 | $this->chainDecorator = $chainDecorator; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function collect(Request $request, Response $response, ?Throwable $exception = null): void 51 | { 52 | $this->data = [ 53 | 'summary' => [ 54 | 'features' => 0, 55 | 'activeFeatures' => 0, 56 | 'inactiveFeatures' => 0, 57 | 'corruptFeatures' => 0 58 | ], 59 | 'requests' => [], 60 | 'activators' => [], 61 | 'decorators' => [], 62 | 'trace' => [] 63 | ]; 64 | 65 | // Activators 66 | foreach ($this->chainActivator->getActivators() as $offset => $activator) { 67 | $name = $activator->getName(); 68 | 69 | $this->data['activators'][$name] = [ 70 | 'priority' => ++$offset, 71 | 'name' => $name, 72 | 'requests' => 0, 73 | 'activeRequests' => 0, 74 | 'inactiveRequests' => 0, 75 | ]; 76 | } 77 | 78 | // Decorators 79 | foreach ($this->chainDecorator->getDecorators() as $offset => $decorator) { 80 | $name = $decorator->getName(); 81 | 82 | $this->data['decorators'][$name] = [ 83 | 'priority' => ++$offset, 84 | 'name' => $name 85 | ]; 86 | } 87 | 88 | // Analyze trace 89 | if (!$this->chainActivator instanceof TraceableChainActivator) { 90 | return; 91 | } 92 | 93 | $this->data['trace'] = $this->chainActivator->getTrace(); 94 | 95 | foreach ($this->chainActivator->getTrace() as $trace) { 96 | if (!isset($this->data['requests'][$trace['feature']])) { 97 | $this->data['requests'][$trace['feature']] = [ 98 | 'requests' => 0, 99 | 'activeRequests' => 0, 100 | 'inactiveRequests' => 0, 101 | 'activators' => [] 102 | ]; 103 | } 104 | 105 | $featureTrace = $this->data['requests'][$trace['feature']]; 106 | $featureTrace['requests']++; 107 | 108 | if ($trace['result'] === true) { 109 | $featureTrace['activeRequests']++; 110 | } else { 111 | $featureTrace['inactiveRequests']++; 112 | } 113 | 114 | foreach ($trace['stack'] as $activator => $result) { 115 | if ($result === true && !in_array($activator, $featureTrace['activators'], true)) { 116 | $featureTrace['activators'][] = $activator; 117 | } 118 | 119 | $this->data['activators'][$activator]['requests']++; 120 | 121 | if ($result === true) { 122 | $this->data['activators'][$activator]['activeRequests']++; 123 | } else { 124 | $this->data['activators'][$activator]['inactiveRequests']++; 125 | } 126 | } 127 | $this->data['requests'][$trace['feature']] = $featureTrace; 128 | } 129 | 130 | $this->data['summary']['features'] = count($this->data['requests']); 131 | foreach ($this->data['requests'] as $trace) { 132 | if ($trace['activeRequests'] > 0 && $trace['inactiveRequests'] === 0) { 133 | $this->data['summary']['activeFeatures']++; 134 | } elseif ($trace['inactiveRequests'] > 0 && $trace['activeRequests'] === 0) { 135 | $this->data['summary']['inactiveFeatures']++; 136 | } else { 137 | $this->data['summary']['corruptFeatures']++; 138 | } 139 | } 140 | } 141 | 142 | /** 143 | * Get all results 144 | * 145 | * @return array 146 | */ 147 | public function getRequests(): array 148 | { 149 | return $this->data['requests']; 150 | } 151 | 152 | /** 153 | * Get all activators 154 | * 155 | * @return array 156 | */ 157 | public function getActivators(): array 158 | { 159 | return $this->data['activators'] ?? []; 160 | } 161 | 162 | /** 163 | * Get all decorators 164 | * 165 | * @return array 166 | */ 167 | public function getDecorators(): array 168 | { 169 | return $this->data['decorators'] ?? []; 170 | } 171 | 172 | /** 173 | * Get trace 174 | * 175 | * @return array 176 | */ 177 | public function getTrace(): array 178 | { 179 | return $this->data['trace'] ?? []; 180 | } 181 | 182 | /** 183 | * Get summary 184 | * 185 | * @return array 186 | */ 187 | public function getSummary(): array 188 | { 189 | return $this->data['summary'] ?? []; 190 | } 191 | 192 | /** 193 | * {@inheritdoc} 194 | */ 195 | public function getName(): string 196 | { 197 | return 'flagception'; 198 | } 199 | 200 | /** 201 | * {@inheritdoc} 202 | */ 203 | public function reset(): void 204 | { 205 | $this->data = []; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [6.0.0] 2 | ### Removed 3 | - \#27 Remove deprecated `doctrine/annotations` implementation @migo315 4 | - Support for pdo instance for database-activator dropped @migo315 5 | 6 | ### Changed 7 | - Update to Flagception SDK Version ^2.0 @migo315 8 | - Update to Flagception Database Activator Version ^2.0 @migo315 9 | 10 | ### Fix 11 | - Fix deprecations @calderholding-r 12 | 13 | ## [5.0.0] 14 | ### Added 15 | - \#3 Support for PHP8 attributes @axwel13 16 | - Support for route condition expression @axwel13 17 | 18 | ### Changed 19 | - Change minimum php version to 8.0 @axwel13 20 | 21 | ## [4.1.0] 22 | ### Added 23 | - \#80 Support for multiple features in route definition @ajgarlag 24 | 25 | ### Changed 26 | - \#82 Restore support for Symfony 4.4 LTS @ajgarlag 27 | 28 | ## [4.0.2] 29 | ### Changed 30 | - Enabled support for twig v3 31 | 32 | ## [4.0.1] 33 | ### Fix 34 | - Fix subscriber for invokable controllers @vitsadecky 35 | 36 | ## [4.0.0] 37 | ### Changed 38 | - \#67 Add compatibility for Symfony 5 @migo315 39 | - \#67 Drop compatibility for Symfony 2, Symfony 3 and Symfony 4 @migo315 @hanishsingla 40 | - Drop Contentful Activator @migo315 41 | - \#64 Add Travis tests for Symony 5 @migo315 42 | - \#59 Update PHP requirements @migo315 43 | 44 | ## [3.6.0] 45 | ### Added 46 | - Support for multiple features in route definition @ajgarlag 47 | 48 | ## [3.5.1] 49 | ### Fix 50 | - Fix subscriber for invokable controllers @vitsadecky 51 | 52 | ## [3.5.0] 53 | ### Changed 54 | - \#69 Support for twig 3 @migo315 55 | 56 | ## [3.4.0] 57 | ### Changed 58 | - Update to Flagception SDK Version ^1.5 @migo315 59 | - Allow `%env()` syntax for `default` field in feature list @c33s \ @migo315 60 | 61 | ### Fix 62 | - Fix env handling for `DotEnv` component >=5.0 @c33s \ @migo315 63 | 64 | ## [3.3.0] 65 | ### Added 66 | - Add support for Symfony 5.0 @migo315 67 | - Extend Travis tests for Symfony 4.3 and 4.4 @migo315 68 | 69 | ### Fix 70 | - \#61 Replace deprecated service factory shortcut with array syntax @bretrzaun / @migo315 71 | 72 | ## [3.2.0] 73 | ### Added 74 | - \#53 Update flagception sdk to version 1.4 and implement whitelist / blacklist mode for cookies @migo315 75 | 76 | ## [3.1.2] 77 | ### Fix 78 | - \#47 Fix tree builder deprecation for symfony >= 4.2 @migo316 79 | 80 | ### Added 81 | - \#49 Add support for Symfony 4.2 @migo315 82 | - \#48 Add support for PHP7.3 @migo315 83 | 84 | ## [3.1.1] 85 | ### Changed 86 | - \#45 Replace `symfony/framework-bundle` and `doctrine/common` with following dependencies: 87 | - `doctrine/annotations` 88 | - `symfony/dependency-injection` 89 | - `symfony/yaml` 90 | - `symfony/config` 91 | - `symfony/http-kernel` 92 | - `twig/twig` 93 | 94 | ### Removed 95 | - \#45 Remove using `ClassUtils` for getting controller class 96 | 97 | ## [3.1.0] 98 | ### Fix 99 | - \#27 Fix route xml in documentation @migo315 100 | 101 | ### Changed 102 | - \#35 Swap own cookie activator with the new flagception sdk cookie activator @migo315 103 | - Refactor profiler and data collector @migo315 104 | - Swap old `ProfilerChainActivator` with new `TraceableChainActivator` @migo315 105 | - Update [Flagception SDK](https://packagist.org/packages/flagception/flagception) to version 1.3.0 @migo315 106 | 107 | ### Added 108 | - \#26 Add feature name advice in documentation @migo315 109 | - Add `php-mock` as dev dependency and add missing contentful configurator test @migo315 110 | - \#31 Add support for auto configuration for `FeatureActivatorInterface` @migo315 111 | - \#32 Add support for auto configuration for `ContectDecoratorInterface` @migo315 112 | - Add caching option for `ContentfulActivator` @migo315 113 | - Add configuration for the new [DatabaseActivator](https://packagist.org/packages/flagception/database-activator) @migo315 114 | 115 | ### Removed 116 | - Remove unneeded models and bags (just internal stuff) @migo315 117 | 118 | ## [3.0.1] 119 | ### Fix 120 | - Add service alias for `Flagception\Manager\FeatureManagerInterface` for fixing autowiring @hanishsingla 121 | 122 | ## [3.0.0] 123 | ### Refactored 124 | - Complete refactoring and renaming to `flagception` @migo315 125 | - See [Upgrade from 2.x](UPGRADE-3.0.md) 126 | 127 | ## [2.1.2] - 2017-11-13 128 | ### Fix 129 | - Bug #13 / Fix bool cast for configuration @teiling88 130 | 131 | ## [2.1.1] - 2017-11-09 132 | ### Fix 133 | - Fix variables for configuration @RedactedProfile 134 | 135 | ## [2.1.0] - 2017-10-26 136 | ### Added 137 | - Add ContextDecoratorInterface, CompilerPass and Tag for modify the context object globally @migo315 138 | - All context content are available as own variable for expression constraints @migo315 139 | - Add profiler icon @migo315 140 | 141 | ## [2.0.0] - 2017-10-11 142 | ### Added 143 | - Add events before and after feature is searched / requested @migo315 144 | - Add optional context object for features (breaking change!) @migo315 145 | - Add 'isActive' method for stashes @migo315 146 | - Add configuration option for routing metadata subscriber @migo315 147 | - Add configuration for cookie stash separator @migo315 148 | - Add constraints for ConfigStash @migo315 149 | 150 | ### Changed 151 | - Changed license to MIT @bestit 152 | - Fix phpunit whitelist @migo315 153 | - Stashes are now explicitly queried for the status of a feature and not every time for all features (breaking change!) @migo315 154 | - Move tests to root 'tests' directory @migo315 155 | - Profiler shows inactive features too @migo315 156 | - Profiler shows given context for features @migo315 157 | - Update readme @migo315 158 | - Chang configuration option of annotation subscriber @migo315 159 | 160 | ### Removed 161 | - Remove 'getActiveFeatures' method for stashes. Use 'isActive' instead @migo315 162 | 163 | ## [1.0.4] - 2017-08-07 164 | ### Added 165 | - Configuration for enable / disable annotation check @espendiller / @migo315 166 | - Add feature handling via route metadata @espendiller / @migo315 167 | 168 | ### Changed 169 | - Remove feature bag and move logic to ConfigStash @espendiller / @migo315 170 | 171 | ## [1.0.3] - 2017-08-03 172 | ### Added 173 | - Add error message for annotation subscriber if feature is inactive @migo315 174 | 175 | ### Change 176 | - Fix 'Cannot use object of type Closure as array' error @migo315 177 | 178 | ## [1.0.2] - 2017-08-02 179 | ### Added 180 | - Add getName method in twig extension for supporting twig version < 1.26 @migo315 181 | 182 | ## [1.0.1] - 2017-08-02 183 | ### Removed 184 | - Removed obsolete repository in composer.json @migo315 185 | 186 | ## [1.0.0] - 2017-08-02 187 | ### Changed 188 | - Symfony min version downgraded from ^3.2 to ^3.1 @migo315 189 | - Profiler toolbar hides feature section when 0 features are active 190 | - Fix readme typo @migo315 191 | 192 | ## [0.0.1] - 2017-06-23 193 | ### Added 194 | - Initial commit and relase @migo315 195 | - Add annotation feature check @migo315 196 | - Add twig extension for active check @migo315 197 | - Add FilterManager @migo315 198 | - Add StashBag / FeatureBag @migo315 199 | - Add Profiler @migo315 200 | - Add ConfigStash @migo315 201 | - Add CookieStash @migo315 202 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configurator/DatabaseConfigurator.php: -------------------------------------------------------------------------------- 1 | 17 | * @package Flagception\Bundle\FlagceptionBundle\DependencyInjection\Configurator 18 | */ 19 | class DatabaseConfigurator implements ActivatorConfiguratorInterface 20 | { 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function getKey() 25 | { 26 | return 'database'; 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function addActivator(ContainerBuilder $container, array $config, array $features) 33 | { 34 | if ($config['enable'] === false) { 35 | return; 36 | } 37 | 38 | if (!class_exists('Flagception\Database\Activator\DatabaseActivator')) { 39 | throw new LogicException('For using database you have to load "flagception/database-activator"'); 40 | } 41 | 42 | $definition = new Definition(DatabaseActivator::class); 43 | $credentials = null; 44 | if (isset($config['dbal'])) { 45 | $credentials = new Reference($config['dbal']); 46 | } elseif (isset($config['url'])) { 47 | $credentials = $config['url']; 48 | } else { 49 | $credentials = $config['credentials']; 50 | } 51 | 52 | $definition->addArgument($credentials); 53 | $definition->addArgument($config['options']); 54 | 55 | $definition->addTag('flagception.activator', [ 56 | 'priority' => $config['priority'] 57 | ]); 58 | 59 | $container->setDefinition('flagception.activator.database_activator', $definition); 60 | 61 | // Set caching 62 | if ($config['cache']['enable'] === true) { 63 | $cacheDefinition = new Definition(CacheActivator::class); 64 | $cacheDefinition->setDecoratedService('flagception.activator.database_activator'); 65 | $cacheDefinition->addArgument(new Reference('flagception.activator.database_activator.cache.inner')); 66 | $cacheDefinition->addArgument(new Reference($config['cache']['pool'])); 67 | $cacheDefinition->addArgument($config['cache']['lifetime']); 68 | $container->setDefinition('flagception.activator.database_activator.cache', $cacheDefinition); 69 | } 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function addConfiguration(ArrayNodeDefinition $node) 76 | { 77 | $node 78 | ->addDefaultsIfNotSet(['enable' => false]) 79 | ->validate() 80 | ->ifTrue(function ($config) { 81 | return !isset($config['url']) 82 | && !isset($config['dbal']) 83 | && !isset($config['credentials']); 84 | }) 85 | ->thenInvalid('You must either set the url, dbal or credentials field.') 86 | ->end() 87 | ->children() 88 | ->booleanNode('enable') 89 | ->beforeNormalization() 90 | ->ifString() 91 | ->then(function ($value) { 92 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 93 | }) 94 | ->end() 95 | ->defaultFalse() 96 | ->end() 97 | ->integerNode('priority') 98 | ->defaultValue(220) 99 | ->end() 100 | ->scalarNode('url') 101 | ->info('Connection string for the database') 102 | ->end() 103 | ->scalarNode('dbal') 104 | ->info('Service with dbal instance') 105 | ->end() 106 | ->arrayNode('credentials') 107 | ->children() 108 | ->scalarNode('dbname') 109 | ->isRequired() 110 | ->cannotBeEmpty() 111 | ->end() 112 | ->scalarNode('user') 113 | ->isRequired() 114 | ->cannotBeEmpty() 115 | ->end() 116 | ->scalarNode('password') 117 | ->isRequired() 118 | ->cannotBeEmpty() 119 | ->end() 120 | ->scalarNode('host') 121 | ->isRequired() 122 | ->cannotBeEmpty() 123 | ->end() 124 | ->scalarNode('driver') 125 | ->isRequired() 126 | ->cannotBeEmpty() 127 | ->end() 128 | ->end() 129 | ->end() 130 | ->arrayNode('options') 131 | ->addDefaultsIfNotSet() 132 | ->children() 133 | ->scalarNode('db_table') 134 | ->defaultValue('flagception_features') 135 | ->cannotBeEmpty() 136 | ->end() 137 | ->scalarNode('db_column_feature') 138 | ->defaultValue('feature') 139 | ->cannotBeEmpty() 140 | ->end() 141 | ->scalarNode('db_column_state') 142 | ->defaultValue('state') 143 | ->cannotBeEmpty() 144 | ->end() 145 | ->end() 146 | ->end() 147 | ->arrayNode('cache') 148 | ->addDefaultsIfNotSet() 149 | ->children() 150 | ->booleanNode('enable') 151 | ->beforeNormalization() 152 | ->ifString() 153 | ->then(function ($value) { 154 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 155 | }) 156 | ->end() 157 | ->defaultFalse() 158 | ->end() 159 | ->scalarNode('pool') 160 | ->defaultValue('cache.app') 161 | ->cannotBeEmpty() 162 | ->end() 163 | ->integerNode('lifetime') 164 | ->defaultValue(3600) 165 | ->end() 166 | ->end() 167 | ->end() 168 | ->end(); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/Profiler/FeatureDataCollectorTest.php: -------------------------------------------------------------------------------- 1 | 20 | * @package Flagception\Tests\FlagceptionBundle\Profiler 21 | */ 22 | class FeatureDataCollectorTest extends TestCase 23 | { 24 | /** 25 | * Test get name 26 | * 27 | * @return void 28 | */ 29 | public function testGetName() 30 | { 31 | $collector = new FeatureDataCollector(new TraceableChainActivator(), new ChainDecorator()); 32 | static::assertEquals('flagception', $collector->getName()); 33 | } 34 | 35 | /** 36 | * Test the reset method 37 | * 38 | * @return void 39 | */ 40 | public function testReset() 41 | { 42 | $collector = new FeatureDataCollector(new TraceableChainActivator(), new ChainDecorator()); 43 | $collector->reset(); 44 | static::assertEquals([], $collector->getActivators()); 45 | } 46 | 47 | /** 48 | * Test complete collect handling 49 | * 50 | * @return void 51 | */ 52 | public function testCollect() 53 | { 54 | $collector = new FeatureDataCollector( 55 | $chainActivator = $this->createMock(TraceableChainActivator::class), 56 | $chainDecorator = new ChainDecorator() 57 | ); 58 | 59 | $chainDecorator->add(new ArrayDecorator()); 60 | 61 | $chainActivator 62 | ->expects(static::once()) 63 | ->method('getActivators') 64 | ->willReturn([new ArrayActivator(), new CookieActivator([])]); 65 | 66 | $chainActivator 67 | ->expects(static::exactly(2)) 68 | ->method('getTrace') 69 | ->willReturn([ 70 | [ 71 | 'feature' => 'abc', 72 | 'context' => new Context(), 73 | 'result' => true, 74 | 'stack' => [ 75 | 'array' => false, 76 | 'cookie' => true 77 | ] 78 | ], 79 | [ 80 | 'feature' => 'abc', 81 | 'context' => new Context(), 82 | 'result' => true, 83 | 'stack' => [ 84 | 'array' => false, 85 | 'cookie' => true 86 | ] 87 | ], 88 | [ 89 | 'feature' => 'def', 90 | 'context' => new Context(), 91 | 'result' => false, 92 | 'stack' => [ 93 | 'array' => false, 94 | 'cookie' => false 95 | ] 96 | ], 97 | [ 98 | 'feature' => 'ywz', 99 | 'context' => new Context(), 100 | 'result' => true, 101 | 'stack' => [ 102 | 'array' => true 103 | ] 104 | ], 105 | [ 106 | 'feature' => 'corrupt', 107 | 'context' => new Context(), 108 | 'result' => true, 109 | 'stack' => [ 110 | 'array' => false, 111 | 'cookie' => true 112 | ] 113 | ], 114 | [ 115 | 'feature' => 'corrupt', 116 | 'context' => new Context(), 117 | 'result' => false, 118 | 'stack' => [ 119 | 'array' => false, 120 | 'cookie' => false 121 | ] 122 | ] 123 | ]); 124 | 125 | $collector->collect(new Request(), new Response()); 126 | 127 | static::assertEquals([ 128 | 'features' => 4, 129 | 'activeFeatures' => 2, 130 | 'inactiveFeatures' => 1, 131 | 'corruptFeatures' => 1 132 | ], $collector->getSummary()); 133 | 134 | static::assertEquals([ 135 | 'array' => [ 136 | 'priority' => 1, 137 | 'name' => 'array' 138 | ] 139 | ], $collector->getDecorators()); 140 | 141 | static::assertEquals([ 142 | 'array' => [ 143 | 'priority' => 1, 144 | 'name' => 'array', 145 | 'requests' => 6, 146 | 'activeRequests' => 1, 147 | 'inactiveRequests' => 5, 148 | ], 149 | 'cookie' => [ 150 | 'priority' => 2, 151 | 'name' => 'cookie', 152 | 'requests' => 5, 153 | 'activeRequests' => 3, 154 | 'inactiveRequests' => 2, 155 | ] 156 | ], $collector->getActivators()); 157 | 158 | static::assertEquals([ 159 | [ 160 | 'feature' => 'abc', 161 | 'context' => new Context(), 162 | 'result' => true, 163 | 'stack' => [ 164 | 'array' => false, 165 | 'cookie' => true 166 | ] 167 | ], 168 | [ 169 | 'feature' => 'abc', 170 | 'context' => new Context(), 171 | 'result' => true, 172 | 'stack' => [ 173 | 'array' => false, 174 | 'cookie' => true 175 | ] 176 | ], 177 | [ 178 | 'feature' => 'def', 179 | 'context' => new Context(), 180 | 'result' => false, 181 | 'stack' => [ 182 | 'array' => false, 183 | 'cookie' => false 184 | ] 185 | ], 186 | [ 187 | 'feature' => 'ywz', 188 | 'context' => new Context(), 189 | 'result' => true, 190 | 'stack' => [ 191 | 'array' => true 192 | ] 193 | ], 194 | [ 195 | 'feature' => 'corrupt', 196 | 'context' => new Context(), 197 | 'result' => true, 198 | 'stack' => [ 199 | 'array' => false, 200 | 'cookie' => true 201 | ] 202 | ], 203 | [ 204 | 'feature' => 'corrupt', 205 | 'context' => new Context(), 206 | 'result' => false, 207 | 'stack' => [ 208 | 'array' => false, 209 | 'cookie' => false 210 | ] 211 | ] 212 | ], $collector->getTrace()); 213 | 214 | static::assertEquals([ 215 | 'abc' => [ 216 | 'requests' => 2, 217 | 'activeRequests' => 2, 218 | 'inactiveRequests' => 0, 219 | 'activators' => [ 220 | 'cookie' 221 | ] 222 | ], 223 | 'def' => [ 224 | 'requests' => 1, 225 | 'activeRequests' => 0, 226 | 'inactiveRequests' => 1, 227 | 'activators' => [] 228 | ], 229 | 'ywz' => [ 230 | 'requests' => 1, 231 | 'activeRequests' => 1, 232 | 'inactiveRequests' => 0, 233 | 'activators' => [ 234 | 'array' 235 | ] 236 | ], 237 | 'corrupt' => [ 238 | 'requests' => 2, 239 | 'activeRequests' => 1, 240 | 'inactiveRequests' => 1, 241 | 'activators' => [ 242 | 'cookie' 243 | ] 244 | ], 245 | ], $collector->getRequests()); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/Resources/views/profiler/layout.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@WebProfiler/Profiler/layout.html.twig' %} 2 | 3 | {% block toolbar %} 4 | {% if collector.summary.features|length > 0 %} 5 | {% set icon %} 6 | {{ include('@Flagception/collector/icon.svg') }} 7 | {{ collector.summary.features }} features 8 | {% endset %} 9 | 10 | {% set text %} 11 |
12 | Features 13 | {{ collector.summary.features }} 14 |
15 |
16 | Active features 17 | {{ collector.summary.activeFeatures }} 18 |
19 |
20 | Inactive features 21 | {{ collector.summary.inactiveFeatures }} 22 |
23 | {% if collector.summary.corruptFeatures > 0 %} 24 |
25 | Corrupt features 26 | {{ collector.summary.corruptFeatures }} 27 |
28 | {% endif %} 29 | {% endset %} 30 | 31 | {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: true }) }} 32 | {% endif %} 33 | {% endblock %} 34 | 35 | {% block menu %} 36 | 37 | 38 | {{ include('@Flagception/collector/icon.svg') }} 39 | 40 | Features 41 | {% if collector.summary.features > 0 %} 42 | 43 | {{ collector.summary.features }} 44 | 45 | {% endif %} 46 | 47 | {% endblock %} 48 | 49 | {% block panel %} 50 |

Metrics

51 |
52 |
53 | {{ collector.summary.features }} 54 | Features 55 |
56 | 57 |
58 | {{ collector.summary.activeFeatures }} 59 | Active features 60 |
61 | 62 |
63 | {{ collector.summary.inactiveFeatures }} 64 | Inactive features 65 |
66 | 67 |
68 | {{ collector.summary.corruptFeatures }} 69 | Corrupt features 70 |
71 |
72 | 73 |

States

74 |
75 |
76 |

States {{ collector.summary.features }}

77 | 78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | {% for name, request in collector.requests %} 91 | 92 | 93 | 102 | 103 | 104 | 105 | 106 | 107 | {% endfor %} 108 |
Feature nameStateAmount requestsAmount activeAmount inactiveActivators
{{ name }} 94 | {% if request.activeRequests > 0 and request.inactiveRequests == 0 %} 95 | ACTIVE 96 | {% elseif request.inactiveRequests > 0 and request.activeRequests == 0 %} 97 | INACTIVE 98 | {% else %} 99 | CORRUPT 100 | {% endif %} 101 | {{ request.requests }}{{ request.activeRequests }}{{ request.inactiveRequests }}{{ request.activators|join(', ') }}
109 |
110 |
111 |
112 |

Activators {{ collector.activators|length }}

113 | 114 |
115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | {% for activator in collector.activators %} 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | {% else %} 134 | 135 | 136 | 137 | {% endfor %} 138 |
PriorityNameAmount requestsAmount activeAmount inactive
{{ activator.priority }}{{ activator.name }}{{ activator.requests }}{{ activator.activeRequests }}{{ activator.inactiveRequests }}
No activators defined
139 |
140 |
141 |
142 |

Decorators {{ collector.decorators|length }}

143 | 144 |
145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | {% for decorator in collector.decorators %} 153 | 154 | 155 | 156 | 157 | {% else %} 158 | 159 | 160 | 161 | {% endfor %} 162 |
PriorityName
{{ decorator.priority }}{{ decorator.name }}
No decorators defined
163 |
164 |
165 |
166 |

Log

167 | 168 |
169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | {% for trace in collector.trace %} 179 | {% for activator, result in trace.stack %} 180 | 181 | 182 | 189 | 190 | 191 | 192 | {% endfor %} 193 | {% endfor %} 194 |
Feature nameStateActivatorContext
{{ trace.feature }} 183 | {% if result %} 184 | ACTIVE 185 | {% else %} 186 | INACTIVE 187 | {% endif %} 188 | {{ activator }}{{ dump(trace.context.all) }}
195 |
196 |
197 |
198 | {% endblock %} 199 | -------------------------------------------------------------------------------- /tests/DependencyInjection/Configurator/CookieConfiguratorTest.php: -------------------------------------------------------------------------------- 1 | 17 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\Configurator 18 | */ 19 | class CookieConfiguratorTest extends TestCase 20 | { 21 | /** 22 | * The container 23 | * 24 | * @var ContainerBuilder 25 | */ 26 | private $container; 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | protected function setUp(): void 32 | { 33 | $container = new ContainerBuilder(); 34 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../../../src/Resources/config')); 35 | $loader->load('configurators.yml'); 36 | 37 | $this->container = $container; 38 | } 39 | 40 | /** 41 | * Test key 42 | * 43 | * @return void 44 | */ 45 | public function testKey() 46 | { 47 | static::assertEquals('cookie', (new CookieConfigurator())->getKey()); 48 | } 49 | 50 | /** 51 | * Test activator default state 52 | * 53 | * @return void 54 | */ 55 | public function testActivatorDefaultState() 56 | { 57 | $config = []; 58 | $extension = new FlagceptionExtension(); 59 | $extension->load($config, $this->container); 60 | 61 | static::assertFalse($this->container->hasDefinition('flagception.activator.cookie_activator')); 62 | } 63 | 64 | /** 65 | * Test activator default state 66 | * 67 | * @return void 68 | */ 69 | public function testActivatorDefaultPriority() 70 | { 71 | $config = [ 72 | [ 73 | 'activators' => [ 74 | 'cookie' => [ 75 | 'enable' => true 76 | ] 77 | ] 78 | ] 79 | ]; 80 | $extension = new FlagceptionExtension(); 81 | $extension->load($config, $this->container); 82 | 83 | $definition = $this->container->getDefinition('flagception.activator.cookie_activator'); 84 | static::assertEquals(200, $definition->getTag('flagception.activator')[0]['priority']); 85 | } 86 | 87 | /** 88 | * Test activator can be disabled 89 | * 90 | * @return void 91 | */ 92 | public function testActivatorCanByDisabled() 93 | { 94 | $config = [ 95 | [ 96 | 'activators' => [ 97 | 'cookie' => [ 98 | 'enable' => false 99 | ] 100 | ] 101 | ] 102 | ]; 103 | $extension = new FlagceptionExtension(); 104 | $extension->load($config, $this->container); 105 | 106 | static::assertFalse($this->container->hasDefinition('flagception.activator.cookie_activator')); 107 | } 108 | 109 | /** 110 | * Test activator can be disabled by string 111 | * 112 | * @return void 113 | */ 114 | public function testActivatorCanByDisabledByString() 115 | { 116 | $config = [ 117 | [ 118 | 'activators' => [ 119 | 'cookie' => [ 120 | 'enable' => 'false' 121 | ] 122 | ] 123 | ] 124 | ]; 125 | $extension = new FlagceptionExtension(); 126 | $extension->load($config, $this->container); 127 | 128 | static::assertFalse($this->container->hasDefinition('flagception.activator.cookie_activator')); 129 | } 130 | 131 | /** 132 | * Test activator can be enabled 133 | * 134 | * @return void 135 | */ 136 | public function testActivatorCanByEnabled() 137 | { 138 | $config = [ 139 | [ 140 | 'activators' => [ 141 | 'cookie' => [ 142 | 'enable' => true 143 | ] 144 | ] 145 | ] 146 | ]; 147 | $extension = new FlagceptionExtension(); 148 | $extension->load($config, $this->container); 149 | 150 | static::assertTrue($this->container->hasDefinition('flagception.activator.cookie_activator')); 151 | } 152 | 153 | /** 154 | * Test activator can be enabled by string 155 | * 156 | * @return void 157 | */ 158 | public function testActivatorCanByEnabledByString() 159 | { 160 | $config = [ 161 | [ 162 | 'activators' => [ 163 | 'cookie' => [ 164 | 'enable' => 'true' 165 | ] 166 | ] 167 | ] 168 | ]; 169 | $extension = new FlagceptionExtension(); 170 | $extension->load($config, $this->container); 171 | 172 | static::assertTrue($this->container->hasDefinition('flagception.activator.cookie_activator')); 173 | } 174 | 175 | /** 176 | * Test set activator priority 177 | * 178 | * @return void 179 | */ 180 | public function testActivatorSetPriority() 181 | { 182 | $config = [ 183 | [ 184 | 'activators' => [ 185 | 'cookie' => [ 186 | 'enable' => true, 187 | 'priority' => 10 188 | ] 189 | ] 190 | ] 191 | ]; 192 | $extension = new FlagceptionExtension(); 193 | $extension->load($config, $this->container); 194 | 195 | $definition = $this->container->getDefinition('flagception.activator.cookie_activator'); 196 | static::assertEquals(10, $definition->getTag('flagception.activator')[0]['priority']); 197 | } 198 | 199 | /** 200 | * Test add features 201 | * 202 | * @return void 203 | */ 204 | public function testAddFeatures() 205 | { 206 | $config = [ 207 | [ 208 | 'features' => [ 209 | 'feature_foo' => [ 210 | 'cookie' => true 211 | ], 212 | 'feature_bar' => [ 213 | 'cookie' => false 214 | ], 215 | 'feature_bazz' => [ 216 | 'cookie' => 'true' 217 | ], 218 | 'feature_foobazz' => [] 219 | ], 220 | 'activators' => [ 221 | 'cookie' => [ 222 | 'enable' => true 223 | ] 224 | ] 225 | ] 226 | ]; 227 | 228 | $extension = new FlagceptionExtension(); 229 | $extension->load($config, $this->container); 230 | 231 | static::assertEquals( 232 | [ 233 | [ 234 | 'feature_foo', 235 | 'feature_bazz', 236 | 'feature_foobazz' 237 | ], 238 | 'flagception', 239 | ',', 240 | CookieActivator::WHITELIST 241 | ], 242 | $this->container->getDefinition('flagception.activator.cookie_activator')->getArguments() 243 | ); 244 | } 245 | 246 | /** 247 | * Test add features by blacklist 248 | * 249 | * @return void 250 | */ 251 | public function testAddFeaturesByBlacklist() 252 | { 253 | $config = [ 254 | [ 255 | 'features' => [ 256 | 'feature_foo' => [ 257 | 'cookie' => true 258 | ], 259 | 'feature_bar' => [ 260 | 'cookie' => false 261 | ], 262 | 'feature_bazz' => [ 263 | 'cookie' => 'true' 264 | ], 265 | 'feature_foobazz' => [], 266 | 'feature_wyz' => [ 267 | 'cookie' => 'false' 268 | ], 269 | ], 270 | 'activators' => [ 271 | 'cookie' => [ 272 | 'enable' => true, 273 | 'mode' => CookieActivator::BLACKLIST 274 | ] 275 | ] 276 | ] 277 | ]; 278 | 279 | $extension = new FlagceptionExtension(); 280 | $extension->load($config, $this->container); 281 | 282 | static::assertEquals( 283 | [ 284 | [ 285 | 'feature_bar', 286 | 'feature_wyz' 287 | ], 288 | 'flagception', 289 | ',', 290 | CookieActivator::BLACKLIST 291 | ], 292 | $this->container->getDefinition('flagception.activator.cookie_activator')->getArguments() 293 | ); 294 | } 295 | 296 | /** 297 | * Test full config 298 | * 299 | * @return void 300 | */ 301 | public function testFullConfig() 302 | { 303 | $config = [ 304 | [ 305 | 'activators' => [ 306 | 'cookie' => [ 307 | 'enable' => true, 308 | 'name' => $name = uniqid(), 309 | 'separator' => $separator = uniqid(), 310 | 'mode' => CookieActivator::BLACKLIST 311 | ] 312 | ] 313 | ] 314 | ]; 315 | 316 | $extension = new FlagceptionExtension(); 317 | $extension->load($config, $this->container); 318 | 319 | static::assertEquals( 320 | [ 321 | [], 322 | $name, 323 | $separator, 324 | CookieActivator::BLACKLIST 325 | ], 326 | $this->container->getDefinition('flagception.activator.cookie_activator')->getArguments() 327 | ); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /tests/DependencyInjection/Configurator/DatabaseConfiguratorTest.php: -------------------------------------------------------------------------------- 1 | 20 | * @package Flagception\Tests\FlagceptionBundle\DependencyInjection\Configurator 21 | */ 22 | class DatabaseConfiguratorTest extends TestCase 23 | { 24 | /** 25 | * The container 26 | * 27 | * @var ContainerBuilder 28 | */ 29 | private $container; 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | protected function setUp(): void 35 | { 36 | $container = new ContainerBuilder(); 37 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../../../src/Resources/config')); 38 | $loader->load('configurators.yml'); 39 | 40 | $this->container = $container; 41 | } 42 | 43 | /** 44 | * Test key 45 | * 46 | * @return void 47 | */ 48 | public function testKey() 49 | { 50 | static::assertEquals('database', (new DatabaseConfigurator())->getKey()); 51 | } 52 | 53 | /** 54 | * Test activator default state 55 | * 56 | * @return void 57 | */ 58 | public function testActivatorDefaultState() 59 | { 60 | $config = []; 61 | $extension = new FlagceptionExtension(); 62 | $extension->load($config, $this->container); 63 | 64 | static::assertFalse($this->container->hasDefinition('flagception.activator.database_activator')); 65 | } 66 | 67 | /** 68 | * Test activator default state 69 | * 70 | * @return void 71 | */ 72 | public function testActivatorDefaultPriority() 73 | { 74 | $config = [ 75 | [ 76 | 'activators' => [ 77 | 'database' => [ 78 | 'enable' => true, 79 | 'url' => 'mysql://foo' 80 | ] 81 | ] 82 | ] 83 | ]; 84 | $extension = new FlagceptionExtension(); 85 | $extension->load($config, $this->container); 86 | 87 | $definition = $this->container->getDefinition('flagception.activator.database_activator'); 88 | static::assertEquals(220, $definition->getTag('flagception.activator')[0]['priority']); 89 | } 90 | 91 | /** 92 | * Test activator can be enabled 93 | * 94 | * @return void 95 | */ 96 | public function testActivatorCanByEnabled() 97 | { 98 | $config = [ 99 | [ 100 | 'activators' => [ 101 | 'database' => [ 102 | 'enable' => true, 103 | 'url' => 'pdo-sqlite://:memory:' 104 | ] 105 | ] 106 | ] 107 | ]; 108 | $extension = new FlagceptionExtension(); 109 | $extension->load($config, $this->container); 110 | 111 | static::assertTrue($this->container->hasDefinition('flagception.activator.database_activator')); 112 | 113 | /** @var DatabaseActivator $activator */ 114 | $activator = $this->container->get('flagception.activator.database_activator'); 115 | 116 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 117 | } 118 | 119 | /** 120 | * Test activator can be enabled by string 121 | * 122 | * @return void 123 | */ 124 | public function testActivatorCanByEnabledByString() 125 | { 126 | $config = [ 127 | [ 128 | 'activators' => [ 129 | 'database' => [ 130 | 'enable' => 'true', 131 | 'url' => 'pdo-sqlite://:memory:' 132 | ] 133 | ] 134 | ] 135 | ]; 136 | $extension = new FlagceptionExtension(); 137 | $extension->load($config, $this->container); 138 | 139 | static::assertTrue($this->container->hasDefinition('flagception.activator.database_activator')); 140 | 141 | /** @var DatabaseActivator $activator */ 142 | $activator = $this->container->get('flagception.activator.database_activator'); 143 | 144 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 145 | } 146 | 147 | /** 148 | * Test set activator priority 149 | * 150 | * @return void 151 | */ 152 | public function testActivatorSetPriority() 153 | { 154 | $config = [ 155 | [ 156 | 'activators' => [ 157 | 'database' => [ 158 | 'enable' => true, 159 | 'priority' => 10, 160 | 'url' => 'pdo-sqlite://:memory:' 161 | ] 162 | ] 163 | ] 164 | ]; 165 | $extension = new FlagceptionExtension(); 166 | $extension->load($config, $this->container); 167 | 168 | $definition = $this->container->getDefinition('flagception.activator.database_activator'); 169 | static::assertEquals(10, $definition->getTag('flagception.activator')[0]['priority']); 170 | 171 | /** @var DatabaseActivator $activator */ 172 | $activator = $this->container->get('flagception.activator.database_activator'); 173 | 174 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 175 | } 176 | 177 | /** 178 | * Test set activator by url 179 | * 180 | * @return void 181 | */ 182 | public function testActivatorByUrl() 183 | { 184 | $config = [ 185 | [ 186 | 'activators' => [ 187 | 'database' => [ 188 | 'enable' => true, 189 | 'url' => 'pdo-sqlite://:memory:' 190 | ] 191 | ] 192 | ] 193 | ]; 194 | $extension = new FlagceptionExtension(); 195 | $extension->load($config, $this->container); 196 | 197 | static::assertTrue($this->container->hasDefinition('flagception.activator.database_activator')); 198 | 199 | /** @var DatabaseActivator $activator */ 200 | $activator = $this->container->get('flagception.activator.database_activator'); 201 | 202 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 203 | } 204 | 205 | /** 206 | * Test set activator by dbal 207 | * 208 | * @return void 209 | */ 210 | public function testActivatorByDbal() 211 | { 212 | $this->container->set('my.dbal.service', DriverManager::getConnection([ 213 | 'dbname' => 'mydb', 214 | 'user' => 'user', 215 | 'password' => 'secret', 216 | 'host' => 'localhost', 217 | 'driver' => 'pdo_mysql' 218 | ])); 219 | 220 | $config = [ 221 | [ 222 | 'activators' => [ 223 | 'database' => [ 224 | 'enable' => true, 225 | 'dbal' => 'my.dbal.service' 226 | ] 227 | ] 228 | ] 229 | ]; 230 | 231 | $extension = new FlagceptionExtension(); 232 | $extension->load($config, $this->container); 233 | 234 | static::assertTrue($this->container->hasDefinition('flagception.activator.database_activator')); 235 | 236 | /** @var DatabaseActivator $activator */ 237 | $activator = $this->container->get('flagception.activator.database_activator'); 238 | 239 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 240 | } 241 | 242 | /** 243 | * Test set activator by credentials 244 | * 245 | * @return void 246 | */ 247 | public function testActivatorByCredentials() 248 | { 249 | $config = [ 250 | [ 251 | 'activators' => [ 252 | 'database' => [ 253 | 'enable' => true, 254 | 'credentials' => [ 255 | 'dbname' => 'mydb', 256 | 'user' => 'user', 257 | 'password' => 'secret', 258 | 'host' => 'localhost', 259 | 'driver' => 'pdo_mysql' 260 | ] 261 | ] 262 | ] 263 | ] 264 | ]; 265 | $extension = new FlagceptionExtension(); 266 | $extension->load($config, $this->container); 267 | 268 | static::assertTrue($this->container->hasDefinition('flagception.activator.database_activator')); 269 | 270 | /** @var DatabaseActivator $activator */ 271 | $activator = $this->container->get('flagception.activator.database_activator'); 272 | 273 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 274 | } 275 | 276 | /** 277 | * Test set activator by invalid credentials 278 | * 279 | * @return void 280 | */ 281 | public function testActivatorByInvalidCredentials() 282 | { 283 | $this->expectException(InvalidConfigurationException::class); 284 | 285 | $config = [ 286 | [ 287 | 'activators' => [ 288 | 'database' => [ 289 | 'enable' => true, 290 | 'credentials' => [ 291 | 'dbname' => 'mydb', 292 | 'driver' => 'pdo_mysql' 293 | ] 294 | ] 295 | ] 296 | ] 297 | ]; 298 | $extension = new FlagceptionExtension(); 299 | $extension->load($config, $this->container); 300 | 301 | $this->container->hasDefinition('flagception.activator.database_activator'); 302 | 303 | /** @var DatabaseActivator $activator */ 304 | $activator = $this->container->get('flagception.activator.database_activator'); 305 | 306 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 307 | } 308 | 309 | /** 310 | * Test set activator with missing connection field 311 | * 312 | * @return void 313 | */ 314 | public function testActivatorByMissingConnectionField() 315 | { 316 | $this->expectException(InvalidConfigurationException::class); 317 | 318 | $config = [ 319 | [ 320 | 'activators' => [ 321 | 'database' => [ 322 | 'enable' => true 323 | ] 324 | ] 325 | ] 326 | ]; 327 | $extension = new FlagceptionExtension(); 328 | $extension->load($config, $this->container); 329 | 330 | $this->container->hasDefinition('flagception.activator.database_activator'); 331 | } 332 | 333 | /** 334 | * Test set activator cache is disabled by default 335 | * 336 | * @return void 337 | */ 338 | public function testActivatorCacheIsDisabled() 339 | { 340 | $config = [ 341 | [ 342 | 'activators' => [ 343 | 'database' => [ 344 | 'enable' => true, 345 | 'url' => 'pdo-sqlite://:memory:' 346 | ] 347 | ] 348 | ] 349 | ]; 350 | $extension = new FlagceptionExtension(); 351 | $extension->load($config, $this->container); 352 | 353 | static::assertFalse($this->container->hasDefinition('flagception.activator.database_activator.cache')); 354 | } 355 | 356 | /** 357 | * Test set activator with cache 358 | * 359 | * @return void 360 | */ 361 | public function testActivatorWithCache() 362 | { 363 | $config = [ 364 | [ 365 | 'activators' => [ 366 | 'database' => [ 367 | 'enable' => true, 368 | 'url' => 'pdo-sqlite://:memory:', 369 | 'cache' => [ 370 | 'enable' => true 371 | ] 372 | ] 373 | ] 374 | ] 375 | ]; 376 | $extension = new FlagceptionExtension(); 377 | $extension->load($config, $this->container); 378 | 379 | static::assertTrue($this->container->hasDefinition('flagception.activator.database_activator.cache')); 380 | 381 | $definition = $this->container->getDefinition('flagception.activator.database_activator.cache'); 382 | static::assertEquals( 383 | 'flagception.activator.database_activator', 384 | $definition->getDecoratedService()[0] 385 | ); 386 | 387 | /** @var DatabaseActivator $activator */ 388 | $activator = $this->container->get('flagception.activator.database_activator'); 389 | 390 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 391 | } 392 | 393 | /** 394 | * Test set activator with cache by string 395 | * 396 | * @return void 397 | */ 398 | public function testActivatorWithCacheByString() 399 | { 400 | $config = [ 401 | [ 402 | 'activators' => [ 403 | 'database' => [ 404 | 'enable' => true, 405 | 'url' => 'pdo-sqlite://:memory:', 406 | 'cache' => [ 407 | 'enable' => 'true' 408 | ] 409 | ] 410 | ] 411 | ] 412 | ]; 413 | $extension = new FlagceptionExtension(); 414 | $extension->load($config, $this->container); 415 | 416 | static::assertTrue($this->container->hasDefinition('flagception.activator.database_activator.cache')); 417 | 418 | /** @var DatabaseActivator $activator */ 419 | $activator = $this->container->get('flagception.activator.database_activator'); 420 | 421 | static::assertInstanceOf(Connection::class, $activator->getConnection()); 422 | } 423 | } 424 | --------------------------------------------------------------------------------