diff --git a/README.md b/README.md index 227834c..e6d17e1 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ return [ 'status_code' => 404, ], + + 'enable_time_bombs' => false, + + 'time_bomb_environments' => ['production'] ]; ``` @@ -152,6 +156,36 @@ if (auth()->user()->hasFeature('user level feature')) { } ``` +## Timebombs for Features + +A common use case for Feature Flags is to allow developers to add new functionality without breaking existing code. + +This process is great when paired with a solid CI/CD pipeline. But the biggest drawback to this is residual technical debt that can +occur when developers forget about removing implemented flags across a code base. + +To handle this, users of this package can utilise Timebombs! Timebombs are used to cause Feature Flags to throw an exception +when a flag should have been removed from the code base. + +To use Timebombs, you will need to explicitly enable them within the config ('enable_time_bombs' => true). +And define which environments you do not want exceptions to be thrown. (This is particularly useful with CI/CD, as you will want to throw exceptions locally, in CI and on staging environments but NOT on production). + +### Defining when a timebomb should throw an exception + +Once Timebombs are enabled, when creating a new Flag, you will be asked when you want your flag to expire (This is number of days). +When the current time surpasses that expiration date, then your feature flag will throw an exception. + +To extend a flag, you can use the handy command + +```php +php artisan feature-flags:extend-feature +``` + +Where you will be prompted to define how many more days are required before the flag should throw an exception again. + +### Further reading + +To learn more on Feature flags and Timebombs, there is a great article by Martin Fowler [Here](https://martinfowler.com/articles/feature-toggles.html). + ## Template Usage There are some Blade Directives to help control access to features in your UI: diff --git a/composer.json b/composer.json index b6d0ee3..af742b1 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "illuminate/support": "^9.0.1" }, "require-dev": { + "doctrine/dbal": "^3.3", "nunomaduro/collision": "^6.0", "orchestra/testbench": "^7.0.0", "pestphp/pest": "^1.21.1", @@ -44,7 +45,10 @@ }, "config": { "sort-packages": true, - "optimize-autoloader": true + "optimize-autoloader": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } }, "extra": { "laravel": { diff --git a/config/feature-flags.php b/config/feature-flags.php index aacedeb..b0b1020 100644 --- a/config/feature-flags.php +++ b/config/feature-flags.php @@ -36,4 +36,28 @@ */ 'status_code' => 404, ], + + /* + |-------------------------------------------------------------------------- + | Enabling Time bombs for Features + |-------------------------------------------------------------------------- + | + | This option controls whether an exception will be thrown if a feature + | has expired. See Martin Fowler's blog post on this: + | https://martinfowler.com/articles/feature-toggles.html#WorkingWithFeature-flaggedSystems + | + */ + 'enable_time_bombs' => false, + + /* + |-------------------------------------------------------------------------- + | Environments that will NOT trigger Time Bombs + |-------------------------------------------------------------------------- + | + | This option controls which environment settings will prevent time bomb + | exceptions from being thrown. To trigger in all environments, leave + | the array as empty. + | + */ + 'time_bomb_environments' => ['production'] ]; diff --git a/database/migrations/2022_04_27_090000_add_feature_flag_expiry.php b/database/migrations/2022_04_27_090000_add_feature_flag_expiry.php new file mode 100644 index 0000000..7beda9f --- /dev/null +++ b/database/migrations/2022_04_27_090000_add_feature_flag_expiry.php @@ -0,0 +1,26 @@ +datetime('expires_at')->nullable(); + }); + } + } + + public function down() + { + if (Schema::hasTable('features')) { + Schema::table('features', function (Blueprint $table) { + $table->dropColumn('expires_at'); + }); + } + } +}; diff --git a/src/Concerns/HasFeatures.php b/src/Concerns/HasFeatures.php index 9d05430..af857ec 100644 --- a/src/Concerns/HasFeatures.php +++ b/src/Concerns/HasFeatures.php @@ -112,9 +112,6 @@ public function joinGroup(...$groups): self groups: Arr::flatten($groups) ); - // TODO? Fix groups? Maybe? - // dd($groups); - if (is_null($groups)) { return $this; } @@ -160,7 +157,7 @@ protected function featureExists(string $featureName): bool { $exists = Feature::name($featureName)->first(); - return (is_null($exists)) ? false : true; + return !is_null($exists); } public function features(): BelongsToMany diff --git a/src/Console/ActivateFeature.php b/src/Console/ActivateFeature.php index 77beb2c..aa734b2 100644 --- a/src/Console/ActivateFeature.php +++ b/src/Console/ActivateFeature.php @@ -13,7 +13,7 @@ class ActivateFeature extends Command protected $description = 'Activates a feature'; - public function handle() + public function handle(): int { $featureName = $this->ask('Feature name to activate'); $feature = Feature::name($featureName)->first(); diff --git a/src/Console/ActivateFeatureGroup.php b/src/Console/ActivateFeatureGroup.php index b04240b..c7ae093 100644 --- a/src/Console/ActivateFeatureGroup.php +++ b/src/Console/ActivateFeatureGroup.php @@ -13,7 +13,7 @@ class ActivateFeatureGroup extends Command protected $description = 'Activates a feature group'; - public function handle() + public function handle(): int { $groupName = $this->ask('Group name to activate'); $group = FeatureGroup::name($groupName)->first(); diff --git a/src/Console/AddFeature.php b/src/Console/AddFeature.php index fcbf073..3416712 100644 --- a/src/Console/AddFeature.php +++ b/src/Console/AddFeature.php @@ -13,7 +13,7 @@ class AddFeature extends Command protected $description = 'Add a new feature'; - public function handle() + public function handle(): int { $featureName = $this->ask('Feature Name'); $existingFeature = Feature::name($featureName)->first(); @@ -27,10 +27,15 @@ public function handle() $description = $this->ask('Feature Description'); $active = $this->choice('Is the feature active', ['no', 'yes'], 'yes'); + if(config('feature-flags.enable_time_bombs')) { + $expires_at = $this->ask('When do you want your feature to expire? (Number of Days)', 0); + } + Feature::create([ 'name' => $featureName, 'description' => $description, 'active' => $active == 'yes', + 'expires_at' => isset($expires_at) ? \Carbon\Carbon::now()->addDays($expires_at) : null ]); $this->info("Created '{$featureName}' feature"); diff --git a/src/Console/AddFeatureGroup.php b/src/Console/AddFeatureGroup.php index 4ff6688..07d37a3 100644 --- a/src/Console/AddFeatureGroup.php +++ b/src/Console/AddFeatureGroup.php @@ -13,7 +13,7 @@ class AddFeatureGroup extends Command protected $description = 'Add a new feature group'; - public function handle() + public function handle(): int { $groupName = $this->ask('Group Name'); $existingGroup = FeatureGroup::name($groupName)->first(); diff --git a/src/Console/AddFeatureToGroup.php b/src/Console/AddFeatureToGroup.php index 1514b39..9e40a4f 100644 --- a/src/Console/AddFeatureToGroup.php +++ b/src/Console/AddFeatureToGroup.php @@ -14,7 +14,7 @@ class AddFeatureToGroup extends Command protected $description = 'Add a feature to a group'; - public function handle() + public function handle(): int { $featureName = $this->ask('Feature Name'); $feature = Feature::name($featureName)->first(); diff --git a/src/Console/DeactivateFeature.php b/src/Console/DeactivateFeature.php index 5201a7d..2521161 100644 --- a/src/Console/DeactivateFeature.php +++ b/src/Console/DeactivateFeature.php @@ -13,7 +13,7 @@ class DeactivateFeature extends Command protected $description = 'Deactivates a feature'; - public function handle() + public function handle(): int { $featureName = $this->ask('Feature name to deactivate'); $feature = Feature::name($featureName)->first(); diff --git a/src/Console/DeactivateFeatureGroup.php b/src/Console/DeactivateFeatureGroup.php index d05700d..83742dc 100644 --- a/src/Console/DeactivateFeatureGroup.php +++ b/src/Console/DeactivateFeatureGroup.php @@ -13,7 +13,7 @@ class DeactivateFeatureGroup extends Command protected $description = 'Deactivates a feature group'; - public function handle() + public function handle(): int { $groupName = $this->ask('Group name to deactivate'); $group = FeatureGroup::name($groupName)->first(); diff --git a/src/Console/ExtendFeature.php b/src/Console/ExtendFeature.php new file mode 100644 index 0000000..cd01bbd --- /dev/null +++ b/src/Console/ExtendFeature.php @@ -0,0 +1,32 @@ +info("Time bombs are not enabled!"); + + $featureName = $this->ask('Feature Name to Extend'); + $feature = Feature::name($featureName)->first(); + + $extendBy = $this->ask('When do you want your feature to expire? (Number of Days)', 0); + + $feature->expires_at = $feature->expires_at->addDays($extendBy); + $feature->save(); + + $this->info("Updated '{$featureName}' feature expiry date"); + + return 0; + } +} diff --git a/src/Console/ViewFeatureGroups.php b/src/Console/ViewFeatureGroups.php index 0d38369..1cbff09 100644 --- a/src/Console/ViewFeatureGroups.php +++ b/src/Console/ViewFeatureGroups.php @@ -13,7 +13,7 @@ class ViewFeatureGroups extends Command protected $description = 'View feature groups'; - public function handle() + public function handle(): void { $groups = FeatureGroup::all(['name', 'description', 'active'])->toArray(); diff --git a/src/Console/ViewFeatures.php b/src/Console/ViewFeatures.php index d91022b..bf9ec20 100644 --- a/src/Console/ViewFeatures.php +++ b/src/Console/ViewFeatures.php @@ -13,11 +13,13 @@ class ViewFeatures extends Command protected $description = 'View features'; - public function handle() + public function handle(): void { - $features = Feature::all(['name', 'description', 'active'])->toArray(); + $features = Feature::withoutEvents(function() { + return Feature::all(['name', 'description', 'active', 'expires_at'])->toArray(); + }); - $headers = ['Name', 'Description', 'Active']; + $headers = ['Name', 'Description', 'Active', 'Expires At']; $this->table($headers, $features); } diff --git a/src/Console/ViewGroupsWithFeatures.php b/src/Console/ViewGroupsWithFeatures.php index cd0aa42..17b9997 100644 --- a/src/Console/ViewGroupsWithFeatures.php +++ b/src/Console/ViewGroupsWithFeatures.php @@ -13,7 +13,7 @@ class ViewGroupsWithFeatures extends Command protected $description = 'View groups with features'; - public function handle() + public function handle(): void { $groups = FeatureGroup::all(); $headers = ['Group', 'Features']; diff --git a/src/FeatureFlagsServiceProvider.php b/src/FeatureFlagsServiceProvider.php index a244bca..4a4b483 100644 --- a/src/FeatureFlagsServiceProvider.php +++ b/src/FeatureFlagsServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; use JustSteveKing\Laravel\FeatureFlags\Console\AddFeature; +use JustSteveKing\Laravel\FeatureFlags\Console\ExtendFeature; use JustSteveKing\Laravel\FeatureFlags\Console\ViewFeatures; use JustSteveKing\Laravel\FeatureFlags\Console\ActivateFeature; use JustSteveKing\Laravel\FeatureFlags\Console\AddFeatureGroup; @@ -59,6 +60,7 @@ public function boot() ViewFeatures::class, ActivateFeature::class, AddFeatureGroup::class, + ExtendFeature::class, ViewFeatureGroups::class, AddFeatureToGroup::class, DeactivateFeature::class, diff --git a/src/Models/Feature.php b/src/Models/Feature.php index cdbc27c..9185bc6 100644 --- a/src/Models/Feature.php +++ b/src/Models/Feature.php @@ -4,25 +4,48 @@ namespace JustSteveKing\Laravel\FeatureFlags\Models; +use Carbon\Carbon; +use Exception; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Support\Facades\App; use JustSteveKing\Laravel\FeatureFlags\Models\Concerns\NormaliseName; use JustSteveKing\Laravel\FeatureFlags\Models\Builders\FeatureBuilder; class Feature extends Model { use NormaliseName; - + protected $fillable = [ 'name', 'description', 'active', + 'expires_at' ]; protected $casts = [ 'active' => 'boolean', + 'expires_at' => 'datetime' ]; + public static function booted(): void + { + static::retrieved(function(Feature $feature) { + $timeBombsAreEnabled = config('feature-flags.enable_time_bombs'); + $environmentAllowsTimeBombs = ! App::environment(config('feature-flags.time_bomb_environments')); + + if($timeBombsAreEnabled && $environmentAllowsTimeBombs) { + $featureHasExpired = Carbon::now()->isAfter($feature->expires_at); + + if ($featureHasExpired) { + throw new Exception(sprintf('The Feature has expired - %s', $feature->name)); + } + return true; + } + return true; + }); + } + public function groups(): BelongsToMany { return $this->belongsToMany( diff --git a/tests/Stubs/User.php b/tests/Stubs/User.php index 40e94fc..db24ecc 100644 --- a/tests/Stubs/User.php +++ b/tests/Stubs/User.php @@ -10,7 +10,7 @@ class User extends AuthUser { use HasFeatures; - + public $guarded = []; public $table = 'users'; diff --git a/tests/Unit/FeatureArtisanTest.php b/tests/Unit/FeatureArtisanTest.php index 2b7da0e..c00f94f 100644 --- a/tests/Unit/FeatureArtisanTest.php +++ b/tests/Unit/FeatureArtisanTest.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Testing\Concerns\InteractsWithConsole; use JustSteveKing\Laravel\FeatureFlags\Models\Feature; +use Illuminate\Support\Facades\Config; uses(InteractsWithConsole::class); @@ -118,7 +119,7 @@ it('can display a table of empty features', function(): void { $this->artisan('feature-flags:view-features') - ->expectsTable(['Name', 'Description', 'Active'], []); + ->expectsTable(['Name', 'Description', 'Active', 'Expires At'], []); }); it('can display a table of all features', function(): void { @@ -136,8 +137,76 @@ 'active' => false, ]); - $expected_rows = Feature::all(['name', 'description', 'active'])->toArray(); + $expected_rows = Feature::all(['name', 'description', 'active', 'expires_at'])->toArray(); $this->artisan('feature-flags:view-features') - ->expectsTable(['Name', 'Description', 'Active'], $expected_rows); + ->expectsTable(['Name', 'Description', 'Active', 'Expires At'], $expected_rows); +}); + +it('can display a table of all features with Expire At', function(): void { + Feature::create([ + 'name' => 'first feature', + ]); + + Feature::create([ + 'name' => 'second feature', + 'description' => 'A description' + ]); + + Feature::create([ + 'name' => 'third feature', + 'active' => false, + 'expires_at' => \Carbon\Carbon::now()->addDays(7) + ]); + + $expected_rows = Feature::all(['name', 'description', 'active', 'expires_at'])->toArray(); + + $this->artisan('feature-flags:view-features') + ->expectsTable(['Name', 'Description', 'Active', 'Expires At'], $expected_rows); +}); + +it('can display a table of all features with Expire At when TimeBombs are enabled', function(): void { + Config::set('feature-flags.enable_time_bombs', true); + + Feature::create([ + 'name' => 'first feature', + ]); + + Feature::create([ + 'name' => 'second feature', + 'description' => 'A description' + ]); + + Feature::create([ + 'name' => 'third feature', + 'active' => false, + 'expires_at' => \Carbon\Carbon::now()->subDays(7) + ]); + + $expected_rows = Feature::withoutEvents(function() { + return Feature::all(['name', 'description', 'active', 'expires_at'])->toArray(); + }); + + $this->artisan('feature-flags:view-features') + ->expectsTable(['Name', 'Description', 'Active', 'Expires At'], $expected_rows); +}); + +it('can update an expiry date', function(): void { + \Carbon\Carbon::setTestNow(); + + Feature::create([ + 'name' => 'test feature', + 'active' => true, + 'expires_at' => \Carbon\Carbon::now() + ]); + + $feature = Feature::first()->expires_at; + + $this->artisan('feature-flags:extend-feature') + ->expectsQuestion('Feature Name to Extend', 'test feature') + ->expectsQuestion('When do you want your feature to expire? (Number of Days)', 7) + ->expectsOutput("Updated 'test feature' feature expiry date") + ->assertExitCode(0); + + expect(Feature::first()->expires_at)->toEqual($feature->addDays(7)); }); diff --git a/tests/Unit/FeatureTimeBombTest.php b/tests/Unit/FeatureTimeBombTest.php new file mode 100644 index 0000000..97e6230 --- /dev/null +++ b/tests/Unit/FeatureTimeBombTest.php @@ -0,0 +1,104 @@ +assertNull($exception, 'Unexpected Exception was thrown'); +} + +it('can create a feature with an expiry date', function(): void { + Feature::create([ + 'name' => 'Expiring Feature', + 'expires_at' => Carbon::now()->addDay() + ]); + + expect(Feature::all()) + ->toHaveCount(1); +}); + +it('Does not throw an Exception when time bombs are disabled', function(): void { + Config::set('feature-flags.enable_time_bombs', false); + + Feature::create([ + 'name' => 'Expired Feature', + 'expires_at' => Carbon::now()->subDay() + ]); + + test()->tryException(); +}); + +it('throws an Exception when time bombs are enabled', function(): void { + Feature::create([ + 'name' => 'Expired Feature', + 'expires_at' => Carbon::now()->subDay() + ]); + + Feature::all(); +})->throws(Exception::class, 'The Feature has expired - expired feature'); + +it('casts the Expiry date to Carbon', function(): void { + Feature::create([ + 'name' => 'Expired Feature', + 'expires_at' => Carbon::now()->addDay() + ]); + + expect(Feature::first()->expires_at)->toBeInstanceOf(Carbon::class); +}); + +it('throws an Exception when 1 second past an expiry date', function(): void { + Feature::create([ + 'name' => 'Expired Feature', + 'expires_at' => Carbon::now()->subSecond() + ]); + + Feature::all(); +})->throws(Exception::class, 'The Feature has expired - expired feature'); + +it('Does not throw an Exception when an expiry date is 1 second in the future', function(): void { + Feature::create([ + 'name' => 'Expired Feature', + 'expires_at' => Carbon::now()->addSecond() + ]); + + test()->tryException(); +}); + +it('Does not throw an Exception when Expiry date is Null', function(): void { + Feature::create([ + 'name' => 'Null Expiry Feature', + 'expires_at' => null + ]); + + test()->tryException(); +}); + +it('Throws an exception when the environments array is empty', function() : void { + Config::set('feature-flags.time_bomb_environments', []); + + Feature::create([ + 'name' => 'Expired Feature', + 'expires_at' => Carbon::now()->subSecond() + ]); + + Feature::all(); +})->throws(Exception::class, 'The Feature has expired - expired feature');