diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9082713 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +composer.lock +/vendor/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..dba7bb9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: php + +php: + - 5.5 + - 5.6 + - 7.0 + +before_script: + - travis_retry composer self-update + - travis_retry composer install --prefer-source --no-interaction + +script: + - vendor/bin/phpunit --coverage-clover=coverage.xml + +before_install: + - pip install --user codecov +after_success: + - codecov \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6144e2c --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Versionable +## Easy to use Model versioning for Laravel 4 and Laravel 5 + +![image](http://img.shields.io/packagist/v/mpociot/versionable.svg?style=flat) +![image](http://img.shields.io/packagist/l/mpociot/versionable.svg?style=flat) +![image](http://img.shields.io/packagist/dt/mpociot/versionable.svg?style=flat) +[![codecov.io](https://codecov.io/github/mpociot/versionable/coverage.svg?branch=master)](https://codecov.io/github/mpociot/versionable?branch=master) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/mpociot/versionable/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/mpociot/versionable/?branch=master) +[![Build Status](https://travis-ci.org/mpociot/versionable.svg?branch=master)](https://travis-ci.org/mpociot/versionable) + +Keep track of all your model changes and revert to previous versions of it. + + +```php +// Restore to the previous change +$content->previousVersion()->revert(); + +// Get model from a version +$oldModel = Version::find(100)->getModel(); +``` + + +## Contents + +- [Installation](#installation) +- [Implementation](#implementation) +- [Usage](#usage) + - [Exclude attributes from versioning](#exclude) + - [Retrieving all versions associated to a model](#retrieve) + - [Getting a diff of two versions](#diff) + - [Revert to a previous version](#revert) +- [FAQ](#faq) +- [License](#license) + + +## Installation + +In order to add Versionable to your project, just add + + "mpociot/versionable": "~2.0" + +to your composer.json. Then run `composer install` or `composer update`. + +Or run `composer require mpociot/versionable ` if you prefere that. + +Run the migrations to create the "versions" table that will hold all version information. + +```bash +php artisan migrate --path=vendor/mpociot/versionable/src/migrations +``` + + +## Usage + +Let the Models you want to set under version control use the `VersionableTrait`. + +```php +class Content extends Model { + + use Mpociot\Versionable\VersionableTrait; + +} +``` +That's it! + +Every time you update your model, a new version containing the previous attributes will be stores in your database. + +All timestamps and the possible soft-delete timestamp will be ignored. + + +### Exclude attributes from versioning + +Sometimes you don't want to create a version *every* time an attribute on your model changes. For example your User model might have a `last_login_at` attribute. +I'm pretty sure you don't want to create a new version of your User model every time that user logs in. + +To exclude specific attributes from versioning, add a new array property to your model named `dontVersionFields`. + +```php +class User extends Model { + + use Mpociot\Versionable\VersionableTrait; + + /** + * @var array + */ + protected $dontVersionFields = [ 'last_login_at' ]; + +} +``` + + +### Retrieving all versions associated to a model + +To retrieve all stored versions use the `versions` attribute on your model. + +This attribute can also be accessed like any other Laravel relation, since it is a `MorphMany` relation. + +```php +$model->versions; +``` + + +### Getting a diff of two versions + +If you want to know, what exactly has changed between two versions, use the version model's `diff` method. + +The diff method takes a version model as an argument. This defines the version to diff against. If no version is provided, it will use the current version. + +```php +/** + * Create a diff against the current version + */ +$diff = $page->previousVersion()->diff(); + + +/** + * Create a diff against a specific version + */ +$diff = $page->currentVersion()->diff( $version ); +``` + +The result will be an associative array containing the attribute name as the key, and the different attribute value. + + +### Revert to a previous version + +Saving versions is pretty cool, but the real benefit will be the ability to revert to a specific version. + +There are multiple ways to do this. + +**Revert to the previous version** + +You can easiliy revert to the version prior to the currently active version using: + +```php +$content->previousVersion()->revert(); +``` + +**Revert to a specific version ID** + +You can also revert to a specific version ID of a model using: + +```php +$revertedModel = Version::find( $version_id )->revert(); +``` + + + + +## License + +Versionable is free software distributed under the terms of the MIT license. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..dd20272 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "mpociot/captainhook", + "license": "MIT", + "description": "Add webhooks to your Laravel app.", + "keywords": ["webhooks", "laravel", "hook", "events"], + "homepage": "http://github.com/mpociot/captainhook", + "authors": [ + { + "name": "Marcel Pociot", + "email": "m.pociot@gmail.com" + } + ], + "support": { + "issues": "https://github.com/mpociot/captainhook/issues", + "source": "https://github.com/mpociot/captainhook" + }, + "require": { + "php": ">=5.3.0", + "illuminate/support": "~5.0" + }, + "require-dev": { + "phpunit/phpunit": "4.7.*", + "mockery/mockery": "dev-master", + "illuminate/database": "~5.0", + "illuminate/events": "~5.0", + "orchestra/testbench": "~3.0", + "guzzlehttp/guzzle": ">=4.0" + }, + "autoload": { + "psr-0": { + "Mpociot\\CaptainHook": "src/" + } + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..bf98672 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests/ + + + + + src/Mpociot/ + + + diff --git a/src/Mpociot/CaptainHook/CaptainHookServiceProvider.php b/src/Mpociot/CaptainHook/CaptainHookServiceProvider.php new file mode 100644 index 0000000..09daa2d --- /dev/null +++ b/src/Mpociot/CaptainHook/CaptainHookServiceProvider.php @@ -0,0 +1,208 @@ +client = new Client(); + $this->cache = app('Illuminate\Contracts\Cache\Repository'); + $this->publishMigration(); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->registerEventListeners(); + $this->registerCommands(); + } + + /** + * Publish migration + */ + protected function publishMigration() + { + $published_migration = glob(database_path('/migrations/*_captain_hook_setup_table.php')); + if (count($published_migration) === 0) { + $this->publishes([ + __DIR__ . '/../../database/2015_10_29_000000_captain_hook_setup_table.php' => database_path('/migrations/' . date('Y_m_d_His') . '_captain_hook_setup_table.php'), + ], 'migrations'); + } + } + + /** + * Register all active event listeners + */ + protected function registerEventListeners() + { + foreach ($this->listeners as $eventName) { + $this->app[ "events" ]->listen($eventName, [$this, "handleEvent"]); + } + } + + /** + * @param array $listeners + */ + public function setListeners($listeners) + { + $this->listeners = $listeners; + + $this->registerEventListeners(); + } + + /** + * @param array $webhooks + */ + public function setWebhooks($webhooks) + { + $this->webhooks = $webhooks; + $this->getCache()->rememberForever(Webhook::CACHE_KEY, function () { + return $this->webhooks; + }); + } + + /** + * @return \Illuminate\Support\Collection + */ + public function getWebhooks() + { + if (!$this->getCache()->has(Webhook::CACHE_KEY)) { + $this->getCache()->rememberForever(Webhook::CACHE_KEY, function () { + return Webhook::all(); + }); + } + return collect($this->getCache()->get(Webhook::CACHE_KEY)); + } + + /** + * @return \Illuminate\Contracts\Cache\Repository + */ + public function getCache() + { + return $this->cache; + } + + /** + * @param \Illuminate\Contracts\Cache\Repository $cache + */ + public function setCache($cache) + { + $this->cache = $cache; + } + + /** + * @param ClientInterface $client + */ + public function setClient($client) + { + $this->client = $client; + } + + /** + * Event listener. + * @param $eventData + */ + public function handleEvent($eventData) + { + $eventName = Event::firing(); + $webhooks = $this->getWebhooks(); + + $this->callWebhooks($webhooks->where("event", $eventName), $eventData); + } + + /** + * Call all webhooks asynchronous + * + * @param array $webhooks + * @param $eventData + */ + private function callWebhooks($webhooks, $eventData) + { + foreach ($webhooks as $webhook) { + $this->client->postAsync($webhook[ "url" ], [ + "body" => json_encode($this->createRequestBody($eventData)), + "verify" => false + ]); + } + } + + /** + * Create the request body for the event data. + * Override this method if necessary to post different data. + * + * @param $eventData + * + * @return array + */ + protected function createRequestBody($eventData) + { + return $eventData; + } + + /** + * Register the artisan commands + */ + protected function registerCommands() + { + $this->app[ 'hook.list' ] = $this->app->share(function ($app) { + return new Commands\ListWebhooks(); + }); + + $this->app[ 'hook.add' ] = $this->app->share(function ($app) { + return new Commands\AddWebhook(); + }); + + $this->app[ 'hook.delete' ] = $this->app->share(function ($app) { + return new Commands\DeleteWebhook(); + }); + + $this->commands( + 'hook.list', + 'hook.add', + 'hook.delete' + ); + } + + +} \ No newline at end of file diff --git a/src/Mpociot/CaptainHook/Commands/AddWebhook.php b/src/Mpociot/CaptainHook/Commands/AddWebhook.php new file mode 100644 index 0000000..485082c --- /dev/null +++ b/src/Mpociot/CaptainHook/Commands/AddWebhook.php @@ -0,0 +1,55 @@ +url = $this->argument("url"); + $hook->event = $this->argument("event"); + try { + $hook->save(); + $this->info("The webhook was saved successfully."); + $this->info("Event: " . $hook->event); + $this->info("URL: " . $hook->url); + } catch (Exception $e) { + $this->error("The webhook couldn't be added to the database " . $e->getMessage()); + } + + + } +} \ No newline at end of file diff --git a/src/Mpociot/CaptainHook/Commands/DeleteWebhook.php b/src/Mpociot/CaptainHook/Commands/DeleteWebhook.php new file mode 100644 index 0000000..98e3adc --- /dev/null +++ b/src/Mpociot/CaptainHook/Commands/DeleteWebhook.php @@ -0,0 +1,48 @@ +argument("id"); + $hook = Webhook::find($id); + if ($hook === null) { + $this->error("Webhook with ID " . $id . " could not be found."); + } else { + $hook->delete(); + $this->info("The webhook was deleted successfully."); + } + } +} \ No newline at end of file diff --git a/src/Mpociot/CaptainHook/Commands/ListWebhooks.php b/src/Mpociot/CaptainHook/Commands/ListWebhooks.php new file mode 100644 index 0000000..fa006a4 --- /dev/null +++ b/src/Mpociot/CaptainHook/Commands/ListWebhooks.php @@ -0,0 +1,41 @@ +get(); + $this->table(['id', 'url', 'event'], $all->toArray()); + } +} \ No newline at end of file diff --git a/src/Mpociot/CaptainHook/Webhook.php b/src/Mpociot/CaptainHook/Webhook.php new file mode 100644 index 0000000..c3663cb --- /dev/null +++ b/src/Mpociot/CaptainHook/Webhook.php @@ -0,0 +1,43 @@ +increments('id'); + $table->string('url'); + $table->string('event'); + $table->timestamps(); + }); + + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + + Schema::drop("webhooks"); + + } +} \ No newline at end of file diff --git a/tests/CaptainHookTest.php b/tests/CaptainHookTest.php new file mode 100644 index 0000000..82287a7 --- /dev/null +++ b/tests/CaptainHookTest.php @@ -0,0 +1,187 @@ +artisan('migrate', [ + '--database' => 'testing', + '--realpath' => realpath(__DIR__.'/../src/database'), + ]); + } + + public function tearDown() + { + \Cache::forget( Webhook::CACHE_KEY ); + } + + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + // Setup default database to use sqlite :memory: + $app['config']->set('database.default', 'testing'); + + \Schema::create('test_models', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function testEloquentEventListenerGetCalled() + { + $provider = $this->app->getProvider("Mpociot\\CaptainHook\\CaptainHookServiceProvider"); + $provider->setWebhooks([ + [ + "event" => "eloquent.saved: TestModel", + "url" => "http://foo.baz/hook" + ], + [ + "event" => "eloquent.saved: TestModel", + "url" => "http://foo.bar/hook" + ], + [ + "event" => "eloquent.deleted: TestModel", + "url" => "http://foo.baz/foo" + ] + ]); + + + + $client = m::mock("GuzzleHttp\\Client"); + + $client->shouldReceive("postAsync") + ->twice(); + + $client->shouldReceive("postAsync") + ->with( "http://foo.baz/hook", m::any() ); + + $client->shouldReceive("postAsync") + ->with( "http://foo.bar/hook", m::any() ); + + $provider->setClient( $client ); + + // Trigger eloquent event + $obj = new TestModel(); + $obj->name = "Test"; + $obj->save(); + } + + public function testCustomEventListener() + { + $provider = $this->app->getProvider("Mpociot\\CaptainHook\\CaptainHookServiceProvider"); + $provider->setListeners([ + "TestEvent" + ]); + $provider->setWebhooks([ + [ + "event" => "TestEvent", + "url" => "http://foo.bar/hook" + ] + ]); + + $model = new TestModel(); + $model->name = "Test"; + + $client = m::mock("GuzzleHttp\\Client"); + + $client->shouldReceive("postAsync") + ->once() + ->with( "http://foo.bar/hook", ['body' => json_encode(["testModel" => $model]), 'verify' => false] ); + + $provider->setClient( $client ); + + // Trigger eloquent event + \Event::fire( new TestEvent( $model ) ); + } + + public function testUsesWebhooksFromCache() + { + $webhook = new Webhook(); + $webhook->url = "http://test.foo/saved"; + $webhook->event = "eloquent.saved: TestModel"; + $webhook->save(); + + $webhook = new Webhook(); + $webhook->url = "http://test.foo/deleted"; + $webhook->event = "eloquent.deleted: TestModel"; + $webhook->save(); + + $provider = $this->app->getProvider("Mpociot\\CaptainHook\\CaptainHookServiceProvider"); + $this->assertCount( 2, $provider->getWebhooks() ); + + $this->assertTrue( Cache::has( Webhook::CACHE_KEY ) ); + $this->assertCount( 2, Cache::get( Webhook::CACHE_KEY ) ); + + } + + public function testUsesWebhooksFromDatabase() + { + $webhook = new Webhook(); + $webhook->url = "http://test.foo/saved"; + $webhook->event = "eloquent.saved: TestModel"; + $webhook->save(); + + $webhook = new Webhook(); + $webhook->url = "http://test.bar/saved"; + $webhook->event = "eloquent.saved: TestModel"; + $webhook->save(); + + $webhook = new Webhook(); + $webhook->url = "http://test.foo/deleted"; + $webhook->event = "eloquent.deleted: TestModel"; + $webhook->save(); + + + $client = m::mock("GuzzleHttp\\Client"); + + $client->shouldReceive("postAsync") + ->twice(); + + $client->shouldReceive("postAsync") + ->with( "http://test.foo/saved", m::any() ); + + $client->shouldReceive("postAsync") + ->with( "http://test.bar/saved", m::any() ); + + $provider = $this->app->getProvider("Mpociot\\CaptainHook\\CaptainHookServiceProvider"); + $provider->setClient( $client ); + + $obj = new TestModel(); + $obj->name = "Test"; + $obj->save(); + + } +} + +class TestModel extends \Illuminate\Database\Eloquent\Model +{ + +} + +class TestEvent extends \Illuminate\Support\Facades\Event +{ + use SerializesModels; + + public function __construct(TestModel $model) + { + $this->testModel = $model; + } +} \ No newline at end of file diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php new file mode 100644 index 0000000..ba5ac9d --- /dev/null +++ b/tests/CommandsTest.php @@ -0,0 +1,109 @@ +artisan('migrate', [ + '--database' => 'testing', + '--realpath' => realpath(__DIR__.'/../src/database'), + ]); + } + + public function testCannotAddWebhookWithoutName() + { + $cmd = m::mock("\\Mpociot\\CaptainHook\\Commands\\AddWebhook[argument,error]"); + + $cmd->shouldReceive("error") + ->once() + ->with( m::type("string") ); + + $cmd->shouldReceive("argument") + ->twice(); + + $cmd->shouldReceive("argument") + ->with("url") + ->andReturn("http://foo.bar"); + + $cmd->handle(); + + $this->assertTrue(true); + } + + public function testCanAddWebhook() + { + $cmd = m::mock("\\Mpociot\\CaptainHook\\Commands\\AddWebhook[argument,info]"); + + $cmd->shouldReceive("argument") + ->with("url") + ->andReturn("http://foo.bar"); + + $cmd->shouldReceive("argument") + ->with("event") + ->andReturn("TestModelTestModel"); + + $cmd->shouldReceive("info") + ->with( m::type("string") ); + + $cmd->handle(); + + + $this->seeInDatabase("webhooks",[ + "event" => "TestModelTestModel", + "url" => "http://foo.bar", + ]); + } + + public function testCannotDeleteWebhookWithWrongID() + { + $webhook = \Mpociot\CaptainHook\Webhook::create([ + "url" => "http://foo.baz", + "event" => "DeleteWebhook", + ]); + $cmd = m::mock("\\Mpociot\\CaptainHook\\Commands\\DeleteWebhook[argument,error]"); + + $cmd->shouldReceive("argument") + ->with("id") + ->andReturn( null ); + + $cmd->shouldReceive("error") + ->with( m::type("string") ); + + $cmd->handle(); + + + $this->seeInDatabase("webhooks",[ + "url" => "http://foo.baz", + "event" => "DeleteWebhook", + ]); + } + + public function testCanDeleteWebhook() + { + $webhook = \Mpociot\CaptainHook\Webhook::create([ + "url" => "http://foo.baz", + "event" => "DeleteWebhook", + ]); + $cmd = m::mock("\\Mpociot\\CaptainHook\\Commands\\DeleteWebhook[argument,info]"); + + $cmd->shouldReceive("argument") + ->with("id") + ->andReturn( $webhook->getKey() ); + + $cmd->shouldReceive("info") + ->with( m::type("string") ); + + $cmd->handle(); + + + $this->notSeeInDatabase("webhooks",[ + "url" => "http://foo.baz", + "event" => "DeleteWebhook", + ]); + } +} \ No newline at end of file