diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a7c44dd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..02419b4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.php_cs.dist export-ignore +/.github export-ignore +/psalm.xml export-ignore + diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml new file mode 100644 index 0000000..4cf285f --- /dev/null +++ b/.github/workflows/php-cs-fixer.yml @@ -0,0 +1,23 @@ +name: Check & fix styling + +on: [push] + +jobs: + php-cs-fixer: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Run PHP CS Fixer + uses: docker://oskarstark/php-cs-fixer-ga + with: + args: --config=.php_cs.dist --allow-risky=yes + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Fix styling diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml new file mode 100644 index 0000000..ec1935b --- /dev/null +++ b/.github/workflows/psalm.yml @@ -0,0 +1,33 @@ +name: Psalm + +on: + push: + paths: + - '**.php' + - 'psalm.xml.dist' + +jobs: + psalm: + name: psalm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Cache composer dependencies + uses: actions/cache@v2 + with: + path: vendor + key: composer-${{ hashFiles('composer.lock') }} + + - name: Run composer install + run: composer install -n --prefer-dist + + - name: Run psalm + run: ./vendor/bin/psalm diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..cdf1660 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,44 @@ +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [7.4] + laravel: [8.*] + dependency-version: [prefer-lowest, prefer-stable] + include: + - laravel: 8.* + testbench: 6.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ~/.composer/cache/files + key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0296b57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +.php_cs +.php_cs.cache +.phpunit.result.cache +build +composer.lock +coverage +docs +phpunit.xml +psalm.xml +vendor diff --git a/.php_cs.dist b/.php_cs.dist new file mode 100644 index 0000000..c7d380c --- /dev/null +++ b/.php_cs.dist @@ -0,0 +1,43 @@ +notPath('bootstrap/*') + ->notPath('storage/*') + ->notPath('resources/view/mail/*') + ->in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->notName('*.blade.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return PhpCsFixer\Config::create() + ->setRules([ + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sortAlgorithm' => 'alpha'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline_array' => true, + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'method', + ], + ], + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'single_trait_insert_per_statement' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..662ad4e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `Webhooks` will be documented in this file + +## 1.0.0 - 202X-XX-XX + +- initial release diff --git a/README.md b/README.md new file mode 100644 index 0000000..39195b9 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Webhooks for Laravel + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/viezel/webhooks.svg?style=flat-square)](https://packagist.org/packages/viezel/webhooks) +[![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/viezel/webhooks/run-tests?label=tests)](https://github.com/viezel/webhooks/actions?query=workflow%3Arun-tests+branch%3Amaster) + + +Simple and clear implementation of Webhooks. + + +## Installation + +You can install the package via composer: + +```bash +composer require viezel/webhooks +``` + +You can publish and run the migrations with: + +```bash +php artisan vendor:publish --provider="Viezel\Webhooks\WebhooksServiceProvider" --tag="migrations" +php artisan migrate +``` + +Add routes to your application. Below is a typical route configuration with auth, api prefix and naming. + +```php +Route::middleware('auth:api')->prefix('api')->as('webhooks.api.')->group(function() { + Route::get('hooks', Viezel\Webhooks\Controllers\API\ListWebhooks::class)->name('list'); + Route::get('hooks/events', Viezel\Webhooks\Controllers\API\ListWebhookEvents::class)->name('events'); + Route::post('hooks', Viezel\Webhooks\Controllers\API\CreateWebhook::class)->name('create'); + Route::post('hooks/{id}', Viezel\Webhooks\Controllers\API\DeleteWebhook::class)->name('delete'); +}); +``` + + +## Usage + +First, register Events in your application that should be exposed as Webhooks. +To do so, your Events should implement the `ShouldDeliverWebhooks` interface. + +The interface has two methods, `getWebhookName` for giving the webhook a unique name, +and `getWebhookPayload` to define the data send with the webhook. + +The following example shows how a Post Updated Event and its implementation: + +```php +use App\Models\Post; +use Viezel\Webhooks\Contracts\ShouldDeliverWebhooks; + +class PostUpdatedEvent implements ShouldDeliverWebhooks +{ + public function __construct(Post $post) + { + $this->post = $post; + } + + public function getWebhookName(): string + { + return 'post:updated'; + } + + public function getWebhookPayload(): array + { + return [ + 'post' => $this->post->toArray(), + 'extra' => [ + 'foo' => 'bar' + ] + ]; + } +} +``` + +## Testing + +```bash +composer test +``` + +## Credits + +- [Mads Møller](https://github.com/viezel) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..df618a7 --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "viezel/webhooks", + "description": "Webhooks for Laravel", + "keywords": ["webhooks"], + "homepage": "https://github.com/viezel/webhooks", + "license": "MIT", + "require": { + "php": "^7.4", + "illuminate/contracts": "^8.0", + "illuminate/queue": "^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "orchestra/testbench": "^6.0", + "phpunit/phpunit": "^9.3", + "vimeo/psalm": "^3.11" + }, + "autoload": { + "psr-4": { + "Viezel\\Webhooks\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Viezel\\Webhooks\\Tests\\": "tests" + } + }, + "scripts": { + "psalm": "vendor/bin/psalm", + "test": "vendor/bin/phpunit --colors=always", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage", + "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "Viezel\\Webhooks\\WebhooksServiceProvider" + ], + "aliases": { + "Webhooks": "Viezel\\Webhooks\\WebhooksFacade" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/database/migrations/create_webhooks_table.php.stub b/database/migrations/create_webhooks_table.php.stub new file mode 100644 index 0000000..2305c73 --- /dev/null +++ b/database/migrations/create_webhooks_table.php.stub @@ -0,0 +1,27 @@ +string('id')->primary(); + $table->text('description')->nullable(); + $table->string('url'); + $table->boolean('verify_ssl')->default(false); + $table->json('events'); + $table->json('headers')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('webhooks'); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..342d629 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + tests + + + + + ./src + + + + + + + + + + + diff --git a/psalm.xml.dist b/psalm.xml.dist new file mode 100644 index 0000000..c6df33e --- /dev/null +++ b/psalm.xml.dist @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/src/Contracts/ShouldDeliverWebhooks.php b/src/Contracts/ShouldDeliverWebhooks.php new file mode 100644 index 0000000..7ced9ce --- /dev/null +++ b/src/Contracts/ShouldDeliverWebhooks.php @@ -0,0 +1,11 @@ +validated()); + + return Response::json($hook->toArray(), 201); + } +} diff --git a/src/Controllers/API/DeleteWebhook.php b/src/Controllers/API/DeleteWebhook.php new file mode 100644 index 0000000..87f38e2 --- /dev/null +++ b/src/Controllers/API/DeleteWebhook.php @@ -0,0 +1,25 @@ +delete(); + + return Response::json([], 204); + } +} diff --git a/src/Controllers/API/ListWebhookEvents.php b/src/Controllers/API/ListWebhookEvents.php new file mode 100644 index 0000000..25c4fe0 --- /dev/null +++ b/src/Controllers/API/ListWebhookEvents.php @@ -0,0 +1,23 @@ +paginate(); + } +} diff --git a/src/Jobs/WebhookCall.php b/src/Jobs/WebhookCall.php new file mode 100644 index 0000000..61fb054 --- /dev/null +++ b/src/Jobs/WebhookCall.php @@ -0,0 +1,44 @@ +webhook = $webhook; + $this->payload = $payload; + } + + public function handle() + { + Http::timeout(10) + //->retry(3, 60) + ->withHeaders($this->webhook->headers ?? []) + ->withOptions([ + 'verify' => $this->webhook->verify_ssl, + ]) + ->asJson() + ->post($this->webhook->url, $this->payload); + } +} diff --git a/src/Listener/WebhookListener.php b/src/Listener/WebhookListener.php new file mode 100644 index 0000000..9e456f0 --- /dev/null +++ b/src/Listener/WebhookListener.php @@ -0,0 +1,15 @@ +getWebhookName(), $event->getWebhookPayload()); + } +} diff --git a/src/Models/Webhook.php b/src/Models/Webhook.php new file mode 100644 index 0000000..0834917 --- /dev/null +++ b/src/Models/Webhook.php @@ -0,0 +1,45 @@ + 'json', + 'headers' => 'json', + 'verify_ssl' => 'boolean', + ]; + + public static function trigger(string $event, array $payload): void + { + $hooks = self::query()->whereJsonContains('events', $event)->get(); + + if ($hooks->isEmpty()) { + return; + } + + foreach ($hooks as $hook) { + WebhookCall::dispatch($hook, $payload); + } + } +} diff --git a/src/Requests/CreateWebhookRequest.php b/src/Requests/CreateWebhookRequest.php new file mode 100644 index 0000000..75ce683 --- /dev/null +++ b/src/Requests/CreateWebhookRequest.php @@ -0,0 +1,28 @@ + ['required', 'array'], + 'events.*' => ['string', 'distinct', Rule::in(WebhookRegistry::allEvents())], + 'headers' => ['nullable', 'array'], + 'url' => ['required', 'string'], + 'description' => ['nullable', 'string'], + 'verify_ssl' => ['nullable', 'boolean'], + ]; + } +} diff --git a/src/Support/GeneratesIds.php b/src/Support/GeneratesIds.php new file mode 100644 index 0000000..dff51f1 --- /dev/null +++ b/src/Support/GeneratesIds.php @@ -0,0 +1,33 @@ +getKey()) { + $model->setAttribute($model->getKeyName(), (string) Str::orderedUuid()); + } + }); + } + + public function getIncrementing() + { + return false; + } + + public function getKeyType() + { + return 'string'; + } + + public function getIdAttribute($value) + { + return (string) $value; + } +} diff --git a/src/WebhookRegistry.php b/src/WebhookRegistry.php new file mode 100644 index 0000000..b6a7589 --- /dev/null +++ b/src/WebhookRegistry.php @@ -0,0 +1,38 @@ +newInstanceWithoutConstructor() + ->getWebhookName(); + } +} diff --git a/src/WebhooksServiceProvider.php b/src/WebhooksServiceProvider.php new file mode 100644 index 0000000..b90dbf2 --- /dev/null +++ b/src/WebhooksServiceProvider.php @@ -0,0 +1,33 @@ +app->runningInConsole()) { + $migrationFileName = 'create_webhooks_table.php'; + if (! $this->migrationFileExists($migrationFileName)) { + $this->publishes([ + __DIR__ . "/../database/migrations/{$migrationFileName}.stub" => database_path('migrations/' . date('Y_m_d_His', time()) . '_' . $migrationFileName), + ], 'migrations'); + } + } + } + + public static function migrationFileExists(string $migrationFileName): bool + { + $len = strlen($migrationFileName); + foreach (glob(database_path("migrations/*.php")) as $filename) { + if ((substr($filename, -$len) === $migrationFileName)) { + return true; + } + } + + return false; + } +} diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php new file mode 100644 index 0000000..0d5266e --- /dev/null +++ b/tests/ExampleTest.php @@ -0,0 +1,12 @@ +assertTrue(true); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..4cfd6e6 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,36 @@ +set('database.default', 'sqlite'); + $app['config']->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + /* + include_once __DIR__.'/../database/migrations/create_webhooks_table.php.stub'; + (new \CreatePackageTable())->up(); + */ + } +}