From 5dd85bffb2a8de416d034840f5d5f4afc9093dda Mon Sep 17 00:00:00 2001 From: Jonathan Louw Date: Tue, 29 Mar 2022 16:16:09 +1000 Subject: [PATCH 1/4] various improvements - added enums package - removed support for php 7.4 - renamed restricted to dynamic - added readme instructions --- .php-cs-fixer.dist.php | 11 + README.md | 158 +++- composer.json | 12 +- composer.lock | 901 ++++++++++++++++++--- config/feature-flags.php | 5 +- database/factories/FeatureFactory.php | 7 +- src/Casts/FeatureStateCast.php | 27 + src/Enums/FeatureState.php | 14 + src/Events/FeatureOffEvent.php | 13 - src/Events/FeatureRestrictedEvent.php | 13 - src/Events/FeatureUpdatedEvent.php | 8 +- src/Exceptions/MissingFeatureException.php | 10 + src/Facades/FeatureFlag.php | 27 + src/Facades/Features.php | 16 - src/FeatureFlags.php | 120 ++- src/FeatureFlagsServiceProvider.php | 17 + src/Models/Feature.php | 7 + src/Traits/HasFeatureStates.php | 8 + tests/FeaturesTest.php | 117 ++- tests/Pest.php | 2 +- 20 files changed, 1280 insertions(+), 213 deletions(-) create mode 100644 .php-cs-fixer.dist.php create mode 100644 src/Casts/FeatureStateCast.php create mode 100644 src/Enums/FeatureState.php delete mode 100644 src/Events/FeatureOffEvent.php delete mode 100644 src/Events/FeatureRestrictedEvent.php create mode 100644 src/Exceptions/MissingFeatureException.php create mode 100644 src/Facades/FeatureFlag.php delete mode 100644 src/Facades/Features.php create mode 100644 src/Traits/HasFeatureStates.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..4857215 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,11 @@ +in([ + __DIR__.'/src', + __DIR__.'/config', + __DIR__.'/database', + __DIR__.'/tests', + ]); + +return CodingLabs\styles($finder); \ No newline at end of file diff --git a/README.md b/README.md index 5acbb05..2a3518c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ - -[](https://supportukrainenow.org) - # Dynamic feature flags for laravel. [![Latest Version on Packagist](https://img.shields.io/packagist/v/codinglabsau/laravel-feature-flags.svg?style=flat-square)](https://packagist.org/packages/codinglabsau/laravel-feature-flags) @@ -8,16 +5,20 @@ [![GitHub Code Style Action Status](https://img.shields.io/github/workflow/status/codinglabsau/laravel-feature-flags/Check%20&%20fix%20styling?label=code%20style)](https://github.com/codinglabsau/laravel-feature-flags/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amain) [![Total Downloads](https://img.shields.io/packagist/dt/codinglabsau/laravel-feature-flags.svg?style=flat-square)](https://packagist.org/packages/codinglabsau/laravel-feature-flags) -This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. - -## Support us - -[](https://spatie.be/github-ad-click/laravel-feature-flags) - -We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). - -We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). +This package offers the ability to implement feature flags throughout your codebase allowing you to easily toggle parts of your application. Features are database driven which will allow you to easily configure them via a command or build a front end to manage their states. Here's an example of how they could be used: +```php +@feature('search-v2') + // new search goes here +@else + // legacy search here +@endfeature +``` +And in your codebase: +```php +FeatureFlag::isEnabled('search-v2') // true +``` +___ ## Installation You can install the package via composer: @@ -43,21 +44,144 @@ This is the contents of the published config file: ```php return [ + + /* + |-------------------------------------------------------------------------- + | Cache + |-------------------------------------------------------------------------- + | + | Configure the cache store that will be used to cache the state of a + | feature. You can also configure a prefix for all keys in the cache. + */ + + 'cache_store' => env('FEATURES_CACHE_STORE'), + 'cache_prefix' => 'features', + + /* + |-------------------------------------------------------------------------- + | Models + |-------------------------------------------------------------------------- + | + | If you need to customise any models used then you can swap them out by + | replacing the default models defined here. + */ + + 'feature_model' => \Codinglabs\FeatureFlags\Models\Feature::class, + ]; ``` -Optionally, you can publish the views using +## Usage + +### Basic Setup +###Migrations +Make sure you have published the migrations as the `features` table is required: ```bash -php artisan vendor:publish --tag="laravel-feature-flags-views" +php artisan vendor:publish --tag="laravel-feature-flags-migrations" +php artisan migrate ``` -## Usage +### Configuring Cache +Each features state will be cached on access which means it won't be calling the database every time a feature is being checked. You can configure the cache store by publishing the config: +``` +php artisan vendor:publish --tag="laravel-feature-flags-config" +``` +Then update your .env: +```php +FEATURES_CACHE_STORE=redis +``` +Note that this package uses the `rememberForever()` method and that if you are using the `Memcached` driver, items that are stored "forever" may be removed when the cache reaches its size limit. + +Create a new feature in the database and give it a default state: +```php +Feature::create([ + 'name' => 'search-v2', + 'state' => Codinglabs\FeatureFlags\Enums\FeatureState::on() +]); +``` +There are three states a feature can be in: +```php +use Codinglabs\FeatureFlags\Enums\FeatureState; + +FeatureState::on() +FeatureState::off() +FeatureState::dynamic() +``` +### Check If A Feature Is Enabled + +#### Blade View +```php +@feature('search-v2') + // new search goes here +@else + // legacy search here +@endfeature +``` +#### Code +```php +use Codinglabs\FeatureFlags\Facades\FeatureFlag; + +if (FeatureFlag::isEnabled('search-v2')) { + // new feature code +} else { + // old code +} +``` + +### Updating A Features State + +To change a features state you can call the following methods: +```php +use Codinglabs\FeatureFlags\Facades\FeatureFlag; + +FeatureFlag::turnOn('search-v2'); +FeatureFlag::turnOff('search-v2'); +FeatureFlag::makeDynamic('search-v2'); +``` +Alternatively you can set the state directly by passing a feature state enum: +```php +FeatureFlag::updateFeatureState('search-v2', FeatureState::on()) +``` +It is recommended that you only update a features state using the above methods as it will take care of updating the cache. + +### Dynamic Features + +When a features state is in the dynamic state it will look for a dynamic handler to determine whether that feature is enabled or not. A dynamic handler can be defined in the `boot()` method of your `AppServiceProvider`: +```php +use Codinglabs\FeatureFlags\Facades\FeatureFlag; + +FeatureFlag::registerDynamicHandler('search-v2', function ($feature, $request) { + return $request->user() && $request->user()->canAccessFeature($feature); +}); +``` + Each handler is given the feature name and the current request as arguments and must return a bool. + +#### Default Handler For Dynamic Features +You may also define a default dynamic handler which will be the catch-all dynamic handler for features that don't have an explicit handler defined for them: +```php +FeatureFlag::registerDefaultDynamicHandler(function ($feature, $request) { + return $request->user() && $request->user()->hasRole('Tester'); +}); +``` +When a feature is in the dynamic state it will look for an explicit handler for that feature first. If it can't find a handler and a default handler has been defined it will use that instead. If it can't find any handlers the feature will resolve to `off` by default. + +### Events +#### Updated +After a feature has been updated an event will be dispatched: +```php +\Codinglabs\FeatureFlags\Events\FeatureUpdatedEvent::class +``` +This can be used to create a listener that could for example handle clearing any custom cache data created by dynamic handlers. +### Handle Missing Features +Features must exist in the database otherwise a `MissingFeatureException` will be thrown. This behaviour can be turned off by explicitly handling cases where a feature doesn't exist: ```php -$features = new Codinglabs\FeatureFlags(); -echo $features->echoPhrase('Hello, Codinglabs!'); +FeatureFlag::handleMissingFeaturesWith(function ($feature) { + // log or report this somewhere... +}) ``` +If a handler for missing features has been defined then an exception will **not** be thrown and the feature will resolve to `off`. ## Testing diff --git a/composer.json b/composer.json index eefd5e5..9be2605 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,11 @@ } ], "require": { - "php": "^7.4|^8.0", - "spatie/laravel-package-tools": "^1.9.2", - "illuminate/contracts": "^7.0|^8.0|^9.0" + "php": "^8.0", + "codinglabsau/php-styles": "dev-main", + "illuminate/contracts": "^7.0|^8.0|^9.0", + "spatie/enum": "^3.12", + "spatie/laravel-package-tools": "^1.9.2" }, "require-dev": { "nunomaduro/collision": "^6.0", @@ -55,10 +57,10 @@ "Codinglabs\\FeatureFlags\\FeatureFlagsServiceProvider" ], "aliases": { - "Features": "Codinglabs\\FeatureFlags\\Facades\\Features" + "FeatureFlag": "Codinglabs\\FeatureFlags\\Facades\\FeatureFlags" } } }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index c7540d8..e25a37c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b44063227042c725f2a891ca48f2c81", + "content-hash": "9b3268844b656b649cbfb2ac4f22ff5d", "packages": [ { "name": "brick/math", @@ -66,6 +66,259 @@ ], "time": "2021-08-15T20:50:18+00:00" }, + { + "name": "codinglabsau/php-styles", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/codinglabsau/php-styles.git", + "reference": "9c540a3e3563451fff5c7d45b13708ce692925cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/codinglabsau/php-styles/zipball/9c540a3e3563451fff5c7d45b13708ce692925cf", + "reference": "9c540a3e3563451fff5c7d45b13708ce692925cf", + "shasum": "" + }, + "require": { + "friendsofphp/php-cs-fixer": "^3.7" + }, + "default-branch": true, + "type": "library", + "autoload": { + "files": [ + "./src/helpers.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Coding Labs styles for PHP-CS-Fixer", + "support": { + "issues": "https://github.com/codinglabsau/php-styles/issues", + "source": "https://github.com/codinglabsau/php-styles/tree/main" + }, + "time": "2022-03-15T00:08:17+00:00" + }, + { + "name": "composer/pcre", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "e300eb6c535192decd27a85bc72a9290f0d6b3bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/e300eb6c535192decd27a85bc72a9290f0d6b3bd", + "reference": "e300eb6c535192decd27a85bc72a9290f0d6b3bd", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.3", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.0.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-02-25T20:21:48+00:00" + }, + { + "name": "composer/semver", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "5d8e574bb0e69188786b8ef77d43341222a41a71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/5d8e574bb0e69188786b8ef77d43341222a41a71", + "reference": "5d8e574bb0e69188786b8ef77d43341222a41a71", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.3.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-03-16T11:22:07+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "ced299686f41dce890debac69273b47ffe98a40c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/ced299686f41dce890debac69273b47ffe98a40c", + "reference": "ced299686f41dce890debac69273b47ffe98a40c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-02-25T21:32:43+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.1", @@ -141,6 +394,78 @@ }, "time": "2021-08-13T13:06:58+00:00" }, + { + "name": "doctrine/annotations", + "version": "1.13.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine/annotations.git", + "reference": "5b668aef16090008790395c02c893b1ba13f7e08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/5b668aef16090008790395c02c893b1ba13f7e08", + "reference": "5b668aef16090008790395c02c893b1ba13f7e08", + "shasum": "" + }, + "require": { + "doctrine/lexer": "1.*", + "ext-tokenizer": "*", + "php": "^7.1 || ^8.0", + "psr/cache": "^1 || ^2 || ^3" + }, + "require-dev": { + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/coding-standard": "^6.0 || ^8.1", + "phpstan/phpstan": "^0.12.20", + "phpunit/phpunit": "^7.5 || ^8.0 || ^9.1.5", + "symfony/cache": "^4.4 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Docblock Annotations Parser", + "homepage": "https://www.doctrine-project.org/projects/annotations.html", + "keywords": [ + "annotations", + "docblock", + "parser" + ], + "support": { + "issues": "https://github.com/doctrine/annotations/issues", + "source": "https://github.com/doctrine/annotations/tree/1.13.2" + }, + "time": "2021-08-05T19:00:23+00:00" + }, { "name": "doctrine/inflector", "version": "2.0.4", @@ -437,6 +762,95 @@ ], "time": "2021-10-11T09:18:27+00:00" }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.8.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", + "reference": "cbad1115aac4b5c3c5540e7210d3c9fba2f81fa3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/cbad1115aac4b5c3c5540e7210d3c9fba2f81fa3", + "reference": "cbad1115aac4b5c3c5540e7210d3c9fba2f81fa3", + "shasum": "" + }, + "require": { + "composer/semver": "^3.2", + "composer/xdebug-handler": "^3.0.3", + "doctrine/annotations": "^1.13", + "ext-json": "*", + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0", + "php-cs-fixer/diff": "^2.0", + "symfony/console": "^5.4 || ^6.0", + "symfony/event-dispatcher": "^5.4 || ^6.0", + "symfony/filesystem": "^5.4 || ^6.0", + "symfony/finder": "^5.4 || ^6.0", + "symfony/options-resolver": "^5.4 || ^6.0", + "symfony/polyfill-mbstring": "^1.23", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php81": "^1.25", + "symfony/process": "^5.4 || ^6.0", + "symfony/stopwatch": "^5.4 || ^6.0" + }, + "require-dev": { + "justinrainbow/json-schema": "^5.2", + "keradus/cli-executor": "^1.5", + "mikey179/vfsstream": "^1.6.10", + "php-coveralls/php-coveralls": "^2.5.2", + "php-cs-fixer/accessible-object": "^1.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1", + "phpspec/prophecy": "^1.15", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "phpunitgoodpractices/polyfill": "^1.5", + "phpunitgoodpractices/traits": "^1.9.1", + "symfony/phpunit-bridge": "^6.0", + "symfony/yaml": "^5.4 || ^6.0" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz RumiƄski", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "support": { + "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues", + "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v3.8.0" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2022-03-18T17:20:59+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.2.0", @@ -1482,6 +1896,58 @@ }, "time": "2022-01-24T11:29:14+00:00" }, + { + "name": "php-cs-fixer/diff", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/diff.git", + "reference": "29dc0d507e838c4580d018bd8b5cb412474f7ec3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/diff/zipball/29dc0d507e838c4580d018bd8b5cb412474f7ec3", + "reference": "29dc0d507e838c4580d018bd8b5cb412474f7ec3", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.7.23 || ^6.4.3 || ^7.0", + "symfony/process": "^3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "sebastian/diff v3 backport support for PHP 5.6+", + "homepage": "https://github.com/PHP-CS-Fixer", + "keywords": [ + "diff" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/diff/issues", + "source": "https://github.com/PHP-CS-Fixer/diff/tree/v2.0.2" + }, + "time": "2020-10-14T08:32:19+00:00" + }, { "name": "phpoption/phpoption", "version": "1.8.1", @@ -1553,6 +2019,55 @@ ], "time": "2021-12-04T23:24:31+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -1838,25 +2353,24 @@ }, { "name": "ramsey/uuid", - "version": "4.2.3", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df" + "reference": "8505afd4fea63b81a85d3b7b53ac3cb8dc347c28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df", - "reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8505afd4fea63b81a85d3b7b53ac3cb8dc347c28", + "reference": "8505afd4fea63b81a85d3b7b53ac3cb8dc347c28", "shasum": "" }, "require": { "brick/math": "^0.8 || ^0.9", + "ext-ctype": "*", "ext-json": "*", - "php": "^7.2 || ^8.0", - "ramsey/collection": "^1.0", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-php80": "^1.14" + "php": "^8.0", + "ramsey/collection": "^1.0" }, "replace": { "rhumsaa/uuid": "self.version" @@ -1893,9 +2407,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "4.x-dev" - }, "captainhook": { "force-install": true } @@ -1907,32 +2418,108 @@ "psr-4": { "Ramsey\\Uuid\\": "src/" } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.3.1" + }, + "funding": [ + { + "url": "https://github.com/ramsey", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", + "type": "tidelift" + } + ], + "time": "2022-03-27T21:42:02+00:00" + }, + { + "name": "spatie/enum", + "version": "3.12.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/enum.git", + "reference": "c0180f1de7c5d06e4b84efbc751ea19167140173" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/enum/zipball/c0180f1de7c5d06e4b84efbc751ea19167140173", + "reference": "c0180f1de7c5d06e4b84efbc751ea19167140173", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "fakerphp/faker": "^1.9.1", + "larapack/dd": "^1.1", + "phpunit/phpunit": "^9.0", + "vimeo/psalm": "^4.3" + }, + "suggest": { + "fakerphp/faker": "To use the enum faker provider", + "phpunit/phpunit": "To use the enum assertions" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Enum\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brent Roose", + "email": "brent@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev@gummibeer.de", + "homepage": "https://gummibeer.de", + "role": "Developer" + } ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "description": "PHP Enums", + "homepage": "https://github.com/spatie/enum", "keywords": [ - "guid", - "identifier", - "uuid" + "enum", + "enumerable", + "spatie" ], "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.2.3" + "docs": "https://docs.spatie.be/enum", + "issues": "https://github.com/spatie/enum/issues", + "source": "https://github.com/spatie/enum" }, "funding": [ { - "url": "https://github.com/ramsey", - "type": "github" + "url": "https://spatie.be/open-source/support-us", + "type": "custom" }, { - "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", - "type": "tidelift" + "url": "https://github.com/spatie", + "type": "github" } ], - "time": "2021-09-25T23:10:38+00:00" + "time": "2022-02-05T09:44:52+00:00" }, { "name": "spatie/laravel-package-tools", @@ -2453,6 +3040,69 @@ ], "time": "2021-07-15T12:33:35+00:00" }, + { + "name": "symfony/filesystem", + "version": "v6.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "52b888523545b0b4049ab9ce48766802484d7046" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/52b888523545b0b4049ab9ce48766802484d7046", + "reference": "52b888523545b0b4049ab9ce48766802484d7046", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-03-02T12:58:14+00:00" + }, { "name": "symfony/finder", "version": "v6.0.3", @@ -2850,6 +3500,73 @@ ], "time": "2022-01-02T09:55:41+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v6.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "51f7006670febe4cbcbae177cbffe93ff833250d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/51f7006670febe4cbcbae177cbffe93ff833250d", + "reference": "51f7006670febe4cbcbae177cbffe93ff833250d", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/deprecation-contracts": "^2.1|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v6.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:55:41+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.25.0", @@ -3736,6 +4453,68 @@ ], "time": "2021-11-04T17:53:12+00:00" }, + { + "name": "symfony/stopwatch", + "version": "v6.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "f2c1780607ec6502f2121d9729fd8150a655d337" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/f2c1780607ec6502f2121d9729fd8150a655d337", + "reference": "f2c1780607ec6502f2121d9729fd8150a655d337", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/service-contracts": "^1|^2|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v6.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-02-21T17:15:17+00:00" + }, { "name": "symfony/string", "version": "v6.0.3", @@ -7748,68 +8527,6 @@ ], "time": "2022-01-04T09:04:05+00:00" }, - { - "name": "symfony/stopwatch", - "version": "v6.0.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "f2c1780607ec6502f2121d9729fd8150a655d337" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/f2c1780607ec6502f2121d9729fd8150a655d337", - "reference": "f2c1780607ec6502f2121d9729fd8150a655d337", - "shasum": "" - }, - "require": { - "php": ">=8.0.2", - "symfony/service-contracts": "^1|^2|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Stopwatch\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a way to profile code", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/stopwatch/tree/v6.0.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-02-21T17:15:17+00:00" - }, { "name": "symfony/yaml", "version": "v6.0.3", @@ -8138,11 +8855,13 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": { + "codinglabsau/php-styles": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^7.4|^8.0" + "php": "^8.0" }, "platform-dev": [], "plugin-api-version": "2.1.0" diff --git a/config/feature-flags.php b/config/feature-flags.php index c803fc2..e0dab8f 100644 --- a/config/feature-flags.php +++ b/config/feature-flags.php @@ -7,7 +7,7 @@ | Cache |-------------------------------------------------------------------------- | - | Configure the cache driver that will be used to cache the state of a + | Configure the cache store that will be used to cache the state of a | feature. You can also configure a prefix for all keys in the cache. */ @@ -20,8 +20,7 @@ |-------------------------------------------------------------------------- | | If you need to customise any models used then you can swap them out by - | replacing the default models defined here. Make sure you extend the - | feature model if you do choose to create a custom model. + | replacing the default models defined here. */ 'feature_model' => \Codinglabs\FeatureFlags\Models\Feature::class, diff --git a/database/factories/FeatureFactory.php b/database/factories/FeatureFactory.php index 32aa8a3..df714c5 100644 --- a/database/factories/FeatureFactory.php +++ b/database/factories/FeatureFactory.php @@ -3,6 +3,7 @@ namespace Codinglabs\FeatureFlags\Database\Factories; use Codinglabs\FeatureFlags\Models\Feature; +use Codinglabs\FeatureFlags\Enums\FeatureState; use Illuminate\Database\Eloquent\Factories\Factory; class FeatureFactory extends Factory @@ -13,7 +14,11 @@ public function definition() { return [ 'name' => $this->faker->unique()->slug(2), - 'state' => $this->faker->randomElement(['on', 'off', 'restricted']) + 'state' => $this->faker->randomElement([ + FeatureState::on()->value, + FeatureState::off()->value, + FeatureState::dynamic()->value, + ]) ]; } } diff --git a/src/Casts/FeatureStateCast.php b/src/Casts/FeatureStateCast.php new file mode 100644 index 0000000..1b7e535 --- /dev/null +++ b/src/Casts/FeatureStateCast.php @@ -0,0 +1,27 @@ + $value->value + ]; + } +} \ No newline at end of file diff --git a/src/Enums/FeatureState.php b/src/Enums/FeatureState.php new file mode 100644 index 0000000..e663eb4 --- /dev/null +++ b/src/Enums/FeatureState.php @@ -0,0 +1,14 @@ +feature = config('features.feature_model')::where('name', $feature)->first(); - } -} \ No newline at end of file diff --git a/src/Events/FeatureRestrictedEvent.php b/src/Events/FeatureRestrictedEvent.php deleted file mode 100644 index 17118e0..0000000 --- a/src/Events/FeatureRestrictedEvent.php +++ /dev/null @@ -1,13 +0,0 @@ -feature = config('features.feature_model')::where('name', $feature)->first(); - } -} \ No newline at end of file diff --git a/src/Events/FeatureUpdatedEvent.php b/src/Events/FeatureUpdatedEvent.php index 79b573a..a78af78 100644 --- a/src/Events/FeatureUpdatedEvent.php +++ b/src/Events/FeatureUpdatedEvent.php @@ -2,12 +2,14 @@ namespace Codinglabs\FeatureFlags\Events; +use Illuminate\Database\Eloquent\Model; + class FeatureUpdatedEvent extends Event { - public $feature; + public Model $feature; - public function __construct(string $feature) + public function __construct(Model $feature) { - $this->feature = config('feature-flags.feature_model')::where('name', $feature)->first(); + $this->feature = $feature; } } \ No newline at end of file diff --git a/src/Exceptions/MissingFeatureException.php b/src/Exceptions/MissingFeatureException.php new file mode 100644 index 0000000..f659658 --- /dev/null +++ b/src/Exceptions/MissingFeatureException.php @@ -0,0 +1,10 @@ +join('.'); + $parts = [config('feature-flags.cache_prefix'), $feature]; + + return implode('.' , array_filter($parts, 'strlen')); } - public static function isEnabled(string $feature): bool + private static function getFeatureModel(string $feature): ?Model { + if ($featureModel = config('feature-flags.feature_model')::firstWhere('name', $feature)) { + return $featureModel; + } + + if (is_callable(self::$handleMissingFeatureClosure)) { + call_user_func(self::$handleMissingFeatureClosure, $feature); + } else { + throw new MissingFeatureException("Missing feature: {$feature}"); + } + + return null; + } + + private static function getState(string $feature): FeatureState { - $featureKey = self::getFeatureKey($feature); + $featureKey = self::getFeatureCacheKey($feature); - $state = cache()->store(config('feature-flags.cache_store'))->rememberForever($featureKey, function () use ($feature) { - if ($featureModel = config('feature-flags.feature_model')::where('name', $feature)->first()) { - return $featureModel->state; + $state = self::cache()->rememberForever($featureKey, function () use ($feature) { + if ($featureModel = self::getFeatureModel($feature)) { + return $featureModel->state->value; } return null; }); if ($state === null) { - cache()->store(config('feature-flags.cache_store'))->forget($featureKey); + self::cache()->forget($featureKey); - return false; + return FeatureState::off(); } + return FeatureState::from($state); + } + + public static function handleMissingFeatureWith(Closure $closure): void + { + self::$handleMissingFeatureClosure = $closure; + } + + public static function isEnabled(string $feature): bool + { + $state = self::getState($feature); + switch ($state) { - case 'on': + case FeatureState::on(): return true; - case 'off': + case FeatureState::off(): return false; - case 'restricted': + case FeatureState::dynamic(): { - if (array_key_exists($feature, self::$restricted)) { - return self::$restricted[$feature]($feature, request()) === true; + if (array_key_exists($feature, self::$dynamicHandlers)) { + return self::$dynamicHandlers[$feature]($feature, request()) === true; + } elseif (is_callable(self::$defaultDynamicHandler)) { + return call_user_func(self::$defaultDynamicHandler, $feature, request()) === true; } return false; @@ -56,14 +96,46 @@ public static function isEnabled(string $feature): bool return false; } - public static function updateFeatureState(string $feature, string $state) + public static function reset(): void { - $featureModel = config('feature-flags.feature_model')::where('name', $feature)->first(); + self::$dynamicHandlers = []; + self::$defaultDynamicHandler = null; + self::$handleMissingFeatureClosure = null; + } - $featureModel->update(['state' => $state]); + public static function makeDynamic(string $feature): void + { + self::updateFeatureState($feature, FeatureState::dynamic()); + } - cache()->store(config('feature-flags.cache_store'))->forget(static::getFeatureKey($feature)); + public static function registerDynamicHandler(string $feature, callable $closure): void + { + self::$dynamicHandlers[$feature] = $closure; + } - event(new FeatureUpdatedEvent($feature)); + public static function registerDefaultDynamicHandler(Closure $closure): void + { + self::$defaultDynamicHandler = $closure; + } + + public static function turnOn(string $feature): void + { + self::updateFeatureState($feature, FeatureState::on()); + } + + public static function turnOff(string $feature): void + { + self::updateFeatureState($feature, FeatureState::off()); + } + + public static function updateFeatureState(string $feature, FeatureState $state): void + { + if ($featureModel = self::getFeatureModel($feature)) { + $featureModel->update(['state' => $state]); + + self::cache()->forget(static::getFeatureCacheKey($feature)); + + event(new FeatureUpdatedEvent($featureModel)); + } } } diff --git a/src/FeatureFlagsServiceProvider.php b/src/FeatureFlagsServiceProvider.php index 4520a83..44545a4 100644 --- a/src/FeatureFlagsServiceProvider.php +++ b/src/FeatureFlagsServiceProvider.php @@ -2,7 +2,10 @@ namespace Codinglabs\FeatureFlags; +use Illuminate\Support\Facades\Blade; use Spatie\LaravelPackageTools\Package; +use Codinglabs\FeatureFlags\Models\Feature; +use Codinglabs\FeatureFlags\Facades\FeatureFlag; use Spatie\LaravelPackageTools\PackageServiceProvider; use Codinglabs\FeatureFlags\Commands\FeaturesCommand; @@ -20,4 +23,18 @@ public function configurePackage(Package $package): void ->hasConfigFile() ->hasMigration('create_features_table'); } + + public function packageRegistered() + { + $this->app->singleton('features', function () { + return new FeatureFlags(); + }); + } + + public function packageBooted() + { + Blade::if('feature', function ($value) { + return FeatureFlag::isEnabled($value); + }); + } } diff --git a/src/Models/Feature.php b/src/Models/Feature.php index ece3606..18e903c 100644 --- a/src/Models/Feature.php +++ b/src/Models/Feature.php @@ -2,9 +2,16 @@ namespace Codinglabs\FeatureFlags\Models; +use Codinglabs\FeatureFlags\Casts\FeatureStateCast; use Illuminate\Database\Eloquent\Factories\HasFactory; class Feature extends \Illuminate\Database\Eloquent\Model { use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'state' => FeatureStateCast::class + ]; } \ No newline at end of file diff --git a/src/Traits/HasFeatureStates.php b/src/Traits/HasFeatureStates.php new file mode 100644 index 0000000..734531f --- /dev/null +++ b/src/Traits/HasFeatureStates.php @@ -0,0 +1,8 @@ +flush(); - +beforeEach(function () { config([ - 'feature-flags.cache_prefix' => 'testing', 'feature-flags.cache_store' => 'array', + 'feature-flags.cache_prefix' => 'testing', ]); + + cache()->store('array')->clear(); +}); + +afterEach(function () { + FeatureFlag::reset(); +}); + +it('throws an exception if calling isEnabled for a feature that does not exist', function () { + $this->expectException(MissingFeatureException::class); + + FeatureFlag::isEnabled('some-feature'); + expect(cache()->store('array')->get('testing.some-feature'))->toBeNull(); }); it('generates the correct cache key', function () { - expect(FeatureFlags::getFeatureKey('some-feature'))->toBe('testing.some-feature'); + config(['feature-flags.cache_prefix' => 'some-prefix']); + + expect(FeatureFlag::getFeatureCacheKey('some-feature'))->toBe('some-prefix.some-feature'); }); -it('feature is not enabled if it does not exist', function () { - expect(FeatureFlags::isEnabled('some-feature'))->toBeFalse(); - expect(cache()->driver(config('feature-flags.cache_store'))->get('testing.some-feature'))->toBeNull(); +it('handles a missing feature exception when a global handler has been defined', function () { + FeatureFlag::handleMissingFeatureWith(function ($feature) { + // handling... + }); + + expect(FeatureFlag::isEnabled('some-feature'))->toBeFalse(); + expect(cache()->store('array')->get('testing.some-feature'))->toBeNull(); }); -it('feature is not enabled if state is off', function () { +it('resolves isEnabled to false when the features state is "off"', function () { Feature::factory()->create([ 'name' => 'some-feature', - 'state' => 'off' + 'state' => FeatureState::off() ]); - expect(FeatureFlags::isEnabled('some-feature'))->toBeFalse(); - expect(cache()->driver(config('feature-flags.cache_store'))->get('testing.some-feature'))->toBe('off'); + expect(FeatureFlag::isEnabled('some-feature'))->toBeFalse(); + expect(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::off()->value); }); -it('feature is enabled if restricted and closure returns true', function () { +it('resolves isEnabled to true when the features state is "on"', function () { Feature::factory()->create([ 'name' => 'some-feature', - 'state' => 'restricted' + 'state' => FeatureState::on() ]); - FeatureFlags::restrictFeatureWith('some-feature', function($feature) { - expect($feature)->toBe('some-feature'); + expect(FeatureFlag::isEnabled('some-feature'))->toBeTrue(); + expect(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::on()->value); +}); + +it('resolves isEnabled to true when feature state is "restricted" and the closure returns true', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::dynamic(), + ]); + FeatureFlag::registerDynamicHandler('some-feature', function($feature) { return true; }); - expect(FeatureFlags::isEnabled('some-feature'))->toBeTrue(); - expect(cache()->driver(config('feature-flags.cache_store'))->get('testing.some-feature'))->toBe('restricted'); + expect(FeatureFlag::isEnabled('some-feature'))->toBeTrue(); + expect(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::dynamic()->value); }); -it('feature is not enabled if restricted and closure returns false', function () { +it('resolves isEnabled to false when feature state is "restricted" and the closure returns false', function () { Feature::factory()->create([ 'name' => 'some-feature', - 'state' => 'restricted' + 'state' => FeatureState::dynamic(), ]); - FeatureFlags::restrictFeatureWith('some-feature', function($feature) { - expect($feature)->toBe('some-feature'); - + FeatureFlag::registerDynamicHandler('some-feature', function($feature) { return false; }); - expect(FeatureFlags::isEnabled('some-feature'))->toBeFalse(); - expect(cache()->driver(config('feature-flags.cache_store'))->get('testing.some-feature'))->toBe('restricted'); + expect(FeatureFlag::isEnabled('some-feature'))->toBeFalse(); + expect(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::dynamic()->value); +}); + +it('uses the default restricted closure if no feature specific closure has been defined', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::dynamic(), + ]); + + FeatureFlag::registerDefaultDynamicHandler(function() { + return true; + }); + + expect(FeatureFlag::isEnabled('some-feature'))->toBeTrue(); + expect(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::dynamic()->value); +}); + +it('resolves isEnabled to false when feature state is "restricted" and no restricted closure has been defined', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::dynamic(), + ]); + + expect(FeatureFlag::isEnabled('some-feature'))->toBeFalse(); +}); + +it ('can update a features state', function () { + Event::fake(); + + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::off() + ]); + + cache()->store('array')->set('testing.some-feature', 'off'); + + FeatureFlag::updateFeatureState('some-feature', FeatureState::on()); + + Event::assertDispatched(\Codinglabs\FeatureFlags\Events\FeatureUpdatedEvent::class); + expect(FeatureFlag::isEnabled('some-feature'))->toBeTrue(); + expect(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::on()->value); }); \ No newline at end of file diff --git a/tests/Pest.php b/tests/Pest.php index 3414335..78e1bf0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,4 +2,4 @@ use Codinglabs\FeatureFlags\Tests\TestCase; -uses(TestCase::class)->in(__DIR__); +uses(TestCase::class)->in(__DIR__); \ No newline at end of file From 50d58f17f4d9dd38a75b8fa8e95ba57867f614eb Mon Sep 17 00:00:00 2001 From: Jonathan Louw Date: Tue, 29 Mar 2022 16:16:45 +1000 Subject: [PATCH 2/4] cs style fixes --- src/Casts/FeatureStateCast.php | 3 +-- src/Enums/FeatureState.php | 2 +- src/Events/FeatureUpdatedEvent.php | 2 +- src/Exceptions/MissingFeatureException.php | 3 +-- src/FeatureFlags.php | 5 +++-- src/FeatureFlagsServiceProvider.php | 2 +- src/Models/Feature.php | 2 +- src/Traits/HasFeatureStates.php | 3 +-- tests/FeaturesTest.php | 10 +++++----- tests/Pest.php | 2 +- tests/TestCase.php | 6 +++--- 11 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/Casts/FeatureStateCast.php b/src/Casts/FeatureStateCast.php index 1b7e535..3a47947 100644 --- a/src/Casts/FeatureStateCast.php +++ b/src/Casts/FeatureStateCast.php @@ -8,7 +8,6 @@ class FeatureStateCast implements CastsAttributes { - public function get($model, string $key, $value, array $attributes) { return FeatureState::from($attributes['state']); @@ -24,4 +23,4 @@ public function set($model, string $key, $value, array $attributes) 'state' => $value->value ]; } -} \ No newline at end of file +} diff --git a/src/Enums/FeatureState.php b/src/Enums/FeatureState.php index e663eb4..3b50c2e 100644 --- a/src/Enums/FeatureState.php +++ b/src/Enums/FeatureState.php @@ -11,4 +11,4 @@ */ class FeatureState extends Enum { -} \ No newline at end of file +} diff --git a/src/Events/FeatureUpdatedEvent.php b/src/Events/FeatureUpdatedEvent.php index a78af78..2790222 100644 --- a/src/Events/FeatureUpdatedEvent.php +++ b/src/Events/FeatureUpdatedEvent.php @@ -12,4 +12,4 @@ public function __construct(Model $feature) { $this->feature = $feature; } -} \ No newline at end of file +} diff --git a/src/Exceptions/MissingFeatureException.php b/src/Exceptions/MissingFeatureException.php index f659658..487f41f 100644 --- a/src/Exceptions/MissingFeatureException.php +++ b/src/Exceptions/MissingFeatureException.php @@ -6,5 +6,4 @@ class MissingFeatureException extends Exception { - -} \ No newline at end of file +} diff --git a/src/FeatureFlags.php b/src/FeatureFlags.php index 7ed4f52..8c05b99 100755 --- a/src/FeatureFlags.php +++ b/src/FeatureFlags.php @@ -29,10 +29,11 @@ public static function getFeatureCacheKey(string $feature): string { $parts = [config('feature-flags.cache_prefix'), $feature]; - return implode('.' , array_filter($parts, 'strlen')); + return implode('.', array_filter($parts, 'strlen')); } - private static function getFeatureModel(string $feature): ?Model { + private static function getFeatureModel(string $feature): ?Model + { if ($featureModel = config('feature-flags.feature_model')::firstWhere('name', $feature)) { return $featureModel; } diff --git a/src/FeatureFlagsServiceProvider.php b/src/FeatureFlagsServiceProvider.php index 44545a4..f98002f 100644 --- a/src/FeatureFlagsServiceProvider.php +++ b/src/FeatureFlagsServiceProvider.php @@ -6,8 +6,8 @@ use Spatie\LaravelPackageTools\Package; use Codinglabs\FeatureFlags\Models\Feature; use Codinglabs\FeatureFlags\Facades\FeatureFlag; -use Spatie\LaravelPackageTools\PackageServiceProvider; use Codinglabs\FeatureFlags\Commands\FeaturesCommand; +use Spatie\LaravelPackageTools\PackageServiceProvider; class FeatureFlagsServiceProvider extends PackageServiceProvider { diff --git a/src/Models/Feature.php b/src/Models/Feature.php index 18e903c..67b10e8 100644 --- a/src/Models/Feature.php +++ b/src/Models/Feature.php @@ -14,4 +14,4 @@ class Feature extends \Illuminate\Database\Eloquent\Model protected $casts = [ 'state' => FeatureStateCast::class ]; -} \ No newline at end of file +} diff --git a/src/Traits/HasFeatureStates.php b/src/Traits/HasFeatureStates.php index 734531f..e207797 100644 --- a/src/Traits/HasFeatureStates.php +++ b/src/Traits/HasFeatureStates.php @@ -4,5 +4,4 @@ trait HasFeatureStates { - -} \ No newline at end of file +} diff --git a/tests/FeaturesTest.php b/tests/FeaturesTest.php index 6ffc275..10b78d4 100644 --- a/tests/FeaturesTest.php +++ b/tests/FeaturesTest.php @@ -66,7 +66,7 @@ 'state' => FeatureState::dynamic(), ]); - FeatureFlag::registerDynamicHandler('some-feature', function($feature) { + FeatureFlag::registerDynamicHandler('some-feature', function ($feature) { return true; }); @@ -80,7 +80,7 @@ 'state' => FeatureState::dynamic(), ]); - FeatureFlag::registerDynamicHandler('some-feature', function($feature) { + FeatureFlag::registerDynamicHandler('some-feature', function ($feature) { return false; }); @@ -94,7 +94,7 @@ 'state' => FeatureState::dynamic(), ]); - FeatureFlag::registerDefaultDynamicHandler(function() { + FeatureFlag::registerDefaultDynamicHandler(function () { return true; }); @@ -111,7 +111,7 @@ expect(FeatureFlag::isEnabled('some-feature'))->toBeFalse(); }); -it ('can update a features state', function () { +it('can update a features state', function () { Event::fake(); Feature::factory()->create([ @@ -126,4 +126,4 @@ Event::assertDispatched(\Codinglabs\FeatureFlags\Events\FeatureUpdatedEvent::class); expect(FeatureFlag::isEnabled('some-feature'))->toBeTrue(); expect(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::on()->value); -}); \ No newline at end of file +}); diff --git a/tests/Pest.php b/tests/Pest.php index 78e1bf0..3414335 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,4 +2,4 @@ use Codinglabs\FeatureFlags\Tests\TestCase; -uses(TestCase::class)->in(__DIR__); \ No newline at end of file +uses(TestCase::class)->in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 2da5372..e7a66f6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,8 +2,8 @@ namespace Codinglabs\FeatureFlags\Tests; -use Illuminate\Database\Eloquent\Factories\Factory; use Orchestra\Testbench\TestCase as Orchestra; +use Illuminate\Database\Eloquent\Factories\Factory; use Codinglabs\FeatureFlags\FeatureFlagsServiceProvider; class TestCase extends Orchestra @@ -13,7 +13,7 @@ protected function setUp(): void parent::setUp(); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Codinglabs\\FeatureFlags\\Database\\Factories\\'.class_basename($modelName).'Factory' + fn (string $modelName) => 'Codinglabs\\FeatureFlags\\Database\\Factories\\' . class_basename($modelName) . 'Factory' ); } @@ -29,7 +29,7 @@ public function getEnvironmentSetUp($app) config()->set('database.default', 'testing'); - $migration = include __DIR__.'/../database/migrations/create_features_table.php.stub'; + $migration = include __DIR__ . '/../database/migrations/create_features_table.php.stub'; $migration->up(); } } From e4860beadc553216a3da80efa1463a8cf2b172f2 Mon Sep 17 00:00:00 2001 From: Jonathan Louw Date: Wed, 30 Mar 2022 15:25:27 +1000 Subject: [PATCH 3/4] clean up docs, add github action, --- .github/workflows/run-tests.yml | 34 ++++++ CHANGELOG.md | 7 -- LICENSE.md | 2 +- README.md | 183 +++++++++++++++------------- composer.json | 4 +- src/FeatureFlagsServiceProvider.php | 2 - 6 files changed, 134 insertions(+), 98 deletions(-) create mode 100644 .github/workflows/run-tests.yml delete mode 100644 CHANGELOG.md diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..deeaab3 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,34 @@ +name: Test + +on: [push] + +jobs: + test: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + extensions: zip, sqlite3 + coverage: none + + - name: Restore caches + uses: actions/cache@v2 + with: + path: ~/.composer/cache/files + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install composer dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Code sniff + run: vendor/bin/php-cs-fixer fix --dry-run + + - name: Execute tests + run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 676753e..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,7 +0,0 @@ -# Changelog - -All notable changes to `laravel-feature-flags` will be documented in this file. - -## 1.0.0 - 202X-XX-XX - -- initial release diff --git a/LICENSE.md b/LICENSE.md index 9a8cb14..bbd779b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) Codinglabs +Copyright (c) Coding Labs Pty Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2a3518c..f8c09ac 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,63 @@ # Dynamic feature flags for laravel. [![Latest Version on Packagist](https://img.shields.io/packagist/v/codinglabsau/laravel-feature-flags.svg?style=flat-square)](https://packagist.org/packages/codinglabsau/laravel-feature-flags) -[![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/codinglabsau/laravel-feature-flags/run-tests?label=tests)](https://github.com/codinglabsau/laravel-feature-flags/actions?query=workflow%3Arun-tests+branch%3Amain) -[![GitHub Code Style Action Status](https://img.shields.io/github/workflow/status/codinglabsau/laravel-feature-flags/Check%20&%20fix%20styling?label=code%20style)](https://github.com/codinglabsau/laravel-feature-flags/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amain) +[![Test](https://github.com/codinglabsau/laravel-feature-flags/actions/workflows/run-tests.yml/badge.svg)](https://github.com/codinglabsau/laravel-feature-flags/actions/workflows/run-tests.yml) [![Total Downloads](https://img.shields.io/packagist/dt/codinglabsau/laravel-feature-flags.svg?style=flat-square)](https://packagist.org/packages/codinglabsau/laravel-feature-flags) -This package offers the ability to implement feature flags throughout your codebase allowing you to easily toggle parts of your application. Features are database driven which will allow you to easily configure them via a command or build a front end to manage their states. Here's an example of how they could be used: - -```php -@feature('search-v2') - // new search goes here -@else - // legacy search here -@endfeature -``` -And in your codebase: -```php -FeatureFlag::isEnabled('search-v2') // true -``` +This package offers the ability to implement feature flags in your application which can be easily toggled on or off. You can also set a feature to a dynamic state where you can define custom rules around whether that feature is enabled or not. ___ ## Installation -You can install the package via composer: +### Install With Composer: ```bash composer require codinglabsau/laravel-feature-flags ``` -You can publish and run the migrations with: +### Database Migrations ```bash -php artisan vendor:publish --tag="laravel-feature-flags-migrations" +php artisan vendor:publish --tag="feature-flags-migrations" php artisan migrate ``` -You can publish the config file with: +### Publish Configuration: ```bash -php artisan vendor:publish --tag="laravel-feature-flags-config" +php artisan vendor:publish --tag="feature-flags-config" ``` -This is the contents of the published config file: +### Cache Store +update your `.env`: ```php -return [ - - /* - |-------------------------------------------------------------------------- - | Cache - |-------------------------------------------------------------------------- - | - | Configure the cache store that will be used to cache the state of a - | feature. You can also configure a prefix for all keys in the cache. - */ - - 'cache_store' => env('FEATURES_CACHE_STORE'), - 'cache_prefix' => 'features', - - /* - |-------------------------------------------------------------------------- - | Models - |-------------------------------------------------------------------------- - | - | If you need to customise any models used then you can swap them out by - | replacing the default models defined here. - */ - - 'feature_model' => \Codinglabs\FeatureFlags\Models\Feature::class, - -]; +FEATURES_CACHE_STORE=file ``` +Note that under the hood this package uses the `rememberForever()` method for caching and that if you are using the `Memcached` driver, items that are stored "forever" may be removed when the cache reaches its size limit. -## Usage +### Use Your Own Model -### Basic Setup +To use your own model, update the config and replace the existing reference with your own model: -###Migrations -Make sure you have published the migrations as the `features` table is required: -```bash -php artisan vendor:publish --tag="laravel-feature-flags-migrations" -php artisan migrate -``` +```php +// app/config/feature-flags.php -### Configuring Cache -Each features state will be cached on access which means it won't be calling the database every time a feature is being checked. You can configure the cache store by publishing the config: -``` -php artisan vendor:publish --tag="laravel-feature-flags-config" +'feature_model' => \App\Models\Feature::class, ``` -Then update your .env: + +Make sure to also cast the state column to a feature state enum using the `FeatureStateCast`: + ```php -FEATURES_CACHE_STORE=redis +// app/Models/Feature.php + +use Codinglabs\FeatureFlags\Casts\FeatureStateCast; + +protected $casts = [ + 'state' => FeatureStateCast::class +]; ``` -Note that this package uses the `rememberForever()` method and that if you are using the `Memcached` driver, items that are stored "forever" may be removed when the cache reaches its size limit. + +## Usage Create a new feature in the database and give it a default state: ```php @@ -100,7 +66,10 @@ Feature::create([ 'state' => Codinglabs\FeatureFlags\Enums\FeatureState::on() ]); ``` -There are three states a feature can be in: + +Its recommended that you seed the features to your database before a new deployment or as soon as possible after a deployment. + +A feature can be in one of three states: ```php use Codinglabs\FeatureFlags\Enums\FeatureState; @@ -118,7 +87,8 @@ FeatureState::dynamic() // legacy search here @endfeature ``` -#### Code + +#### In Your Code ```php use Codinglabs\FeatureFlags\Facades\FeatureFlag; @@ -129,6 +99,47 @@ if (FeatureFlag::isEnabled('search-v2')) { } ``` +#### Sharing features with UI (Inertiajs example) +```php +// config/app.php + +'features' => [ + [ + 'name' => 'search-v2', + 'state' => \Codinglabs\FeatureFlags\Enums\FeatureState::dynamic() + ] +], +``` + +```php +// app/Middleware/HandleInertiaRequest.php + +Inertia::share([ + 'features' => function () { + return collect(config('app.features')) + ->filter(fn ($feature) => FeatureFlag::isEnabled($feature['name'])) + ->pluck('name'); + } +]); +``` + +```javascript +// app.js + +Vue.mixin({ + methods: { + hasFeature: function(feature) { + return this.$page.features.includes(feature) + } + } +}) +``` +```html + + +
Some cool new feature
+``` + ### Updating A Features State To change a features state you can call the following methods: @@ -143,44 +154,52 @@ Alternatively you can set the state directly by passing a feature state enum: ```php FeatureFlag::updateFeatureState('search-v2', FeatureState::on()) ``` -It is recommended that you only update a features state using the above methods as it will take care of updating the cache. +It is recommended that you only update a features state using the above methods as it will take care of updating the cache and dispatching the feature updated event: + +```php +\Codinglabs\FeatureFlags\Events\FeatureUpdatedEvent::class +``` +An example use case of the feature updated event would be if you were caching the result of a dynamic handler and need to clear that cache when a feature is updated. + +___ +## Advanced Usage ### Dynamic Features -When a features state is in the dynamic state it will look for a dynamic handler to determine whether that feature is enabled or not. A dynamic handler can be defined in the `boot()` method of your `AppServiceProvider`: +A dynamic handler can be defined in the `boot()` method of your `AppServiceProvider`: ```php use Codinglabs\FeatureFlags\Facades\FeatureFlag; FeatureFlag::registerDynamicHandler('search-v2', function ($feature, $request) { - return $request->user() && $request->user()->canAccessFeature($feature); + return $request->user() && $request->user()->hasRole('Tester') }); ``` - Each handler is given the feature name and the current request as arguments and must return a bool. +Dynamic handlers will only be called when a feature is in the `dynamic` state. This will allow you to define custom rules around whether that feature is enabled like in the example above where the user can only access the feature if they have a tester role. + +Each handler is provided with the features name and current request as arguments and must return a bool value. + +### Default Handler For Dynamic Features + +You may also define a default handler which will be the catch-all handler for features that don't have an explicit handler defined for them: -#### Default Handler For Dynamic Features -You may also define a default dynamic handler which will be the catch-all dynamic handler for features that don't have an explicit handler defined for them: ```php FeatureFlag::registerDefaultDynamicHandler(function ($feature, $request) { return $request->user() && $request->user()->hasRole('Tester'); }); ``` -When a feature is in the dynamic state it will look for an explicit handler for that feature first. If it can't find a handler and a default handler has been defined it will use that instead. If it can't find any handlers the feature will resolve to `off` by default. -### Events -#### Updated -After a feature has been updated an event will be dispatched: -```php -\Codinglabs\FeatureFlags\Events\FeatureUpdatedEvent::class -``` -This can be used to create a listener that could for example handle clearing any custom cache data created by dynamic handlers. +An explicit handler defined using `registerDynamicHandler()` will take precedence over the default handler. If neither a default nor explicit handler has been defined then the feature will resolve to `off` by default. ### Handle Missing Features + Features must exist in the database otherwise a `MissingFeatureException` will be thrown. This behaviour can be turned off by explicitly handling cases where a feature doesn't exist: + ```php FeatureFlag::handleMissingFeaturesWith(function ($feature) { // log or report this somewhere... }) ``` + If a handler for missing features has been defined then an exception will **not** be thrown and the feature will resolve to `off`. ## Testing @@ -189,14 +208,6 @@ If a handler for missing features has been defined then an exception will **not* composer test ``` -## Changelog - -Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. - -## Contributing - -Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. - ## Security Vulnerabilities Please review [our security policy](../../security/policy) on how to report security vulnerabilities. diff --git a/composer.json b/composer.json index 9be2605..5705bb4 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,8 @@ "license": "MIT", "authors": [ { - "name": "Steve Thomas", - "email": "steve@codinglabs.com.au", + "name": "Jonathan Louw", + "email": "JonathanLouw@users.noreply.github.com", "role": "Developer" } ], diff --git a/src/FeatureFlagsServiceProvider.php b/src/FeatureFlagsServiceProvider.php index f98002f..2150648 100644 --- a/src/FeatureFlagsServiceProvider.php +++ b/src/FeatureFlagsServiceProvider.php @@ -4,9 +4,7 @@ use Illuminate\Support\Facades\Blade; use Spatie\LaravelPackageTools\Package; -use Codinglabs\FeatureFlags\Models\Feature; use Codinglabs\FeatureFlags\Facades\FeatureFlag; -use Codinglabs\FeatureFlags\Commands\FeaturesCommand; use Spatie\LaravelPackageTools\PackageServiceProvider; class FeatureFlagsServiceProvider extends PackageServiceProvider From f422396f8546abe55246a68b0d5b02c065798e39 Mon Sep 17 00:00:00 2001 From: Jonathan Louw Date: Wed, 30 Mar 2022 15:32:07 +1000 Subject: [PATCH 4/4] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8c09ac..d2ff7ce 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ Please review [our security policy](../../security/policy) on how to report secu ## Credits -- [Steve Thomas](https://github.com/codinglabsau) +- [Jonathan Louw](https://github.com/JonathanLouw) - [All Contributors](../../contributors) ## License