diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6f313c6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..02167a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - '*' + tags: + - '*' + pull_request: + branches: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.0', '7.1', '7.2', '7.3', '7.4'] + name: PHP ${{ matrix.php }} + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + name: Cache dependencies + with: + path: ~/.composer/cache/files + key: composer-php-${{ matrix.php }}-${{ hashFiles('composer.json') }} + - name: Install dependencies + run: | + composer install --no-interaction --prefer-source + - name: Run tests + run: | + phpunit --coverage-text --coverage-clover=coverage.xml + - uses: codecov/codecov-action@v1 + with: + fail_ci_if_error: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 138d207..0000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: php - -php: - - 7.2 - - 7.3 - - 7.4 - -env: - matrix: - - COMPOSER_OPTIONS="" - -before_install: - - sudo apt-get update - - travis_retry composer self-update - -install: - - travis_retry composer update ${COMPOSER_OPTIONS} --prefer-source - -script: - - phpunit --coverage-text --coverage-clover=coverage.xml - -after_success: - - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE diff --git a/readme.md b/README.md similarity index 64% rename from readme.md rename to README.md index 87dfc03..e76ad46 100644 --- a/readme.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Build Status](https://travis-ci.org/rennokki/laravel-sns-events.svg?branch=master)](https://travis-ci.org/rennokki/laravel-sns-events) -[![codecov](https://codecov.io/gh/rennokki/laravel-sns-events/branch/master/graph/badge.svg)](https://codecov.io/gh/rennokki/laravel-sns-events/branch/master) +![CI](https://github.com/renoki-co/laravel-sns-events/workflows/CI/badge.svg?branch=master) +[![codecov](https://codecov.io/gh/renoki-co/laravel-sns-events/branch/master/graph/badge.svg)](https://codecov.io/gh/renoki-co/laravel-sns-events/branch/master) [![StyleCI](https://github.styleci.io/repos/189254977/shield?branch=master)](https://github.styleci.io/repos/189254977) [![Latest Stable Version](https://poser.pugx.org/rennokki/laravel-sns-events/v/stable)](https://packagist.org/packages/rennokki/laravel-sns-events) [![Total Downloads](https://poser.pugx.org/rennokki/laravel-sns-events/downloads)](https://packagist.org/packages/rennokki/laravel-sns-events) @@ -7,9 +7,9 @@ [![License](https://poser.pugx.org/rennokki/laravel-sns-events/license)](https://packagist.org/packages/rennokki/laravel-sns-events) # Laravel SNS Events -Laravel SNS Events allow you to listen to SNS webhooks via Laravel Events. It implements a controller that is made to properly listen to SNS HTTP(s) webhooks and trigger events on which you can listen to in Laravel. +Laravel SNS Events allow you to listen to SNS webhooks via Laravel Events. It leverages a controller that is made to properly listen to SNS HTTP(s) webhooks and trigger events on which you can listen to in Laravel. -If you are not familiar with Laravel Events & Listeners, make sure you check the [documentation section on laravel.com](https://laravel.com/docs/5.8/events) because this package will need you to understand this concept. +If you are not familiar with Laravel Events & Listeners, make sure you check the [documentation section on Laravel Documentation](https://laravel.com/docs/master/events) because this package will need you to understand this concept. ## Install ```bash @@ -17,11 +17,13 @@ $ composer require rennokki/laravel-sns-events ``` ## Usage -The package comes with two event classes: +There are two classes that get triggered, depending on the request sent by AWS: + * `Rennokki\LaravelSnsEvents\Events\SnsEvent` - triggered on each SNS message * `Rennokki\LaravelSnsEvents\Events\SnsSubscriptionConfirmation` - triggered when the subscription is confirmed -This package comes with a controller that will handle the response for you. Register the route in your `web.php`: +A controller that will handle the response for you should be registered in your routes: + ```php ... @@ -29,7 +31,8 @@ This package comes with a controller that will handle the response for you. Regi Route::any('/aws/sns', 'Rennokki\LaravelSnsEvents\Http\Controllers\SnsController@handle'); ``` -SNS sends data through POST, so you will need to whitelist your route in your `VerifyCsrfToken.php`: +SNS sends data as raw json, so you will need to whitelist your route in your `VerifyCsrfToken.php`: + ```php protected $except = [ ... @@ -39,9 +42,12 @@ protected $except = [ You will need an AWS account and register a SNS Topic and set up a subscription for HTTP(s) protocol that will point out to the route you just registered. +Make sure to enable RAW JSON format for your SNS Subscription. + If you have registered the route and created a SNS Topic, you should register the URL and click the confirmation button from the AWS Dashboard. In a short while, if you implemented the route well, you'll be seeing that your endpoint is registered. To process the events, you should add the events in your `app/Providers/EventServiceProvider.php`: + ```php use Rennokki\LaravelSnsEvents\Events\SnsEvent; use Rennokki\LaravelSnsEvents\Events\SnsSubscriptionConfirmation; @@ -60,6 +66,7 @@ protected $listen = [ ``` You will be able to access the SNS message from your listeners like this: + ```php class MyListener { @@ -67,30 +74,18 @@ class MyListener public function handle($event) { - // $event->message is an array + // $event->message is an array containing the payload sent + // $event->headers is an array containing the headers sent + + $content = json_decode($event->message['Message'], true); + + // ... } } ``` -For example, synthetizing an AWS Polly Text-To-Speech would return in `$event->message` an array like this: -``` -[ - 'taskId' => '...', - 'taskStatus' => 'FAILED', - 'taskStatusReason' => 'Error occurred while trying to upload file to S3. Please verify that the bucket existsin this region and you have permission to write objects to the specified bucket.', - 'outputUri' => 's3://...', - 'creationTime' => '2019-05-29T15:27:31.231Z', - 'requestCharacters' => 58, - 'snsTopicArn' => '...', - 'outputFormat' => 'Mp3', - 'sampleRate' => '22050', - 'speechMarkTypes' => [], - 'textType' => 'Text', - 'voiceId' => 'Joanna', -] -``` - ## Testing + Run the tests with: ``` bash diff --git a/composer.json b/composer.json index 1944d1e..55ba336 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "authors": [ { "name": "Alex Renoki", + "email": "alex@renoki.org", "homepage": "https://github.com/rennokki", "role": "Developer" } diff --git a/src/Events/SnsEvent.php b/src/Events/SnsEvent.php index 66282e9..171e2f9 100644 --- a/src/Events/SnsEvent.php +++ b/src/Events/SnsEvent.php @@ -17,14 +17,23 @@ class SnsEvent */ public $message; + /** + * The headers sent through the SNS request. + * + * @var array + */ + public $headers = []; + /** * Create a new event instance. * * @param array $message + * @param array $headers * @return void */ - public function __construct($message) + public function __construct($message, $headers = []) { $this->message = $message; + $this->headers = $headers; } } diff --git a/src/Events/SnsSubscriptionConfirmation.php b/src/Events/SnsSubscriptionConfirmation.php index 762d80a..410ee26 100644 --- a/src/Events/SnsSubscriptionConfirmation.php +++ b/src/Events/SnsSubscriptionConfirmation.php @@ -10,13 +10,21 @@ class SnsSubscriptionConfirmation { use Dispatchable, InteractsWithSockets, SerializesModels; + /** + * The headers sent through the SNS request. + * + * @var array + */ + public $headers = []; + /** * Create a new event instance. * + * @param array $headers * @return void */ - public function __construct() + public function __construct($headers = []) { - // + $this->headers = $headers; } } diff --git a/src/Http/Controllers/SnsController.php b/src/Http/Controllers/SnsController.php index 8dce9dc..74afa7f 100644 --- a/src/Http/Controllers/SnsController.php +++ b/src/Http/Controllers/SnsController.php @@ -2,6 +2,7 @@ namespace Rennokki\LaravelSnsEvents\Http\Controllers; +use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Rennokki\LaravelSnsEvents\Events\SnsEvent; use Rennokki\LaravelSnsEvents\Events\SnsSubscriptionConfirmation; @@ -11,22 +12,40 @@ class SnsController extends Controller /** * Handle the incoming SNS event. * + * @param \Illuminate\Http\Request $request * @return mixed */ - public function handle() + public function handle(Request $request) { - $message = json_decode(file_get_contents('php://input'), true); - - if (isset($message['Type']) && $message['Type'] === 'SubscriptionConfirmation') { - file_get_contents($message['SubscribeURL']); - - event(new SnsSubscriptionConfirmation); - - return response('OK', 200); + $message = json_decode($this->getContent($request), true); + + if (isset($message['Type'])) { + if ($message['Type'] === 'SubscriptionConfirmation') { + file_get_contents($message['SubscribeURL']); + + event(new SnsSubscriptionConfirmation( + $request->headers->all() + )); + } + + if ($message['Type'] === 'Notification') { + event(new SnsEvent( + $message, $request->headers->all() + )); + } } - event(new SnsEvent($message)); - return response('OK', 200); } + + /** + * Get the payload content from the request. + * + * @param \Illuminate\Http\Request $request + * @return null|string + */ + protected function getContent(Request $request) + { + return $request->getContent() ?: file_get_contents('php://input'); + } } diff --git a/tests/EventTest.php b/tests/EventTest.php new file mode 100644 index 0000000..b466ae4 --- /dev/null +++ b/tests/EventTest.php @@ -0,0 +1,76 @@ +json('GET', route('sns')) + ->assertSee('OK'); + + Event::assertNotDispatched(SnsEvent::class); + Event::assertNotDispatched(SnsSubscriptionConfirmation::class); + } + + public function test_subscription_confirmation() + { + Event::fake(); + + $this + ->withHeaders([ + 'x-test-header' => 1, + ]) + ->json('POST', route('sns'), $this->getSubscriptionConfirmationPayload()) + ->assertSee('OK'); + + Event::assertNotDispatched(SnsEvent::class); + + Event::assertDispatched(SnsSubscriptionConfirmation::class, function ($event) { + $this->assertTrue( + isset($event->headers['x-test-header']) + ); + + return true; + }); + } + + public function test_notification_confirmation() + { + Event::fake(); + + $message = json_encode([ + 'test' => 1, + 'sns' => true, + ]); + + $this + ->withHeaders([ + 'x-test-header' => 1, + ]) + ->json('POST', route('sns'), $this->getNotificationPayload($message)) + ->assertSee('OK'); + + Event::assertNotDispatched(SnsSubscriptionConfirmation::class); + + Event::assertDispatched(SnsEvent::class, function ($event) { + $this->assertTrue( + isset($event->headers['x-test-header']) + ); + + $message = json_decode($event->message['Message'], true); + + $this->assertEquals(1, $message['test']); + $this->assertEquals(true, $message['sns']); + + return true; + }); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index f822469..d010542 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,6 +17,7 @@ protected function getPackageProviders($app) { return [ \Rennokki\LaravelSnsEvents\LaravelSnsEventsServiceProvider::class, + TestServiceProvider::class, ]; } @@ -31,4 +32,47 @@ protected function getEnvironmentSetUp($app) { $app['config']->set('app.key', '6rE9Nz59bGRbeMATftriyQjrpF7DcOQm'); } + + /** + * Get an example subscription payload for testing. + * + * @return array + */ + protected function getSubscriptionConfirmationPayload(): array + { + return [ + 'Type' => 'SubscriptionConfirmation', + 'MessageId' => '165545c9-2a5c-472c-8df2-7ff2be2b3b1b', + 'Token' => '2336412f37...', + 'TopicArn' => 'arn:aws:sns:us-west-2:123456789012:MyTopic', + 'Message' => 'You have chosen to subscribe to the topic arn:aws:sns:us-west-2:123456789012:MyTopic.\nTo confirm the subscription, visit the SubscribeURL included in this message.', + 'SubscribeURL' => 'https://example.com', + 'Timestamp' => '2012-04-26T20:45:04.751Z', + 'SignatureVersion' => '1', + 'Signature' => 'EXAMPLEpH+DcEwjAPg8O9mY8dReBSwksfg2S7WKQcikcNKWLQjwu6A4VbeS0QHVCkhRS7fUQvi2egU3N858fiTDN6bkkOxYDVrY0Ad8L10Hs3zH81mtnPk5uvvolIC1CXGu43obcgFxeL3khZl8IKvO61GWB6jI9b5+gLPoBc1Q=', + 'SigningCertURL' => 'https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem', + ]; + } + + /** + * Get an example notification payload for testing. + * + * @param string $message + * @return array + */ + protected function getNotificationPayload($message = ''): array + { + return [ + 'Type' => 'Notification', + 'MessageId' => '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324', + 'TopicArn' => 'arn:aws:sns:us-west-2:123456789012:MyTopic', + 'Subject' => 'My First Message', + 'Message' => "{$message}", + 'Timestamp' => '2012-05-02T00:54:06.655Z', + 'SignatureVersion' => '1', + 'Signature' => 'EXAMPLEw6JRN...', + 'SigningCertURL' => 'https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem', + 'UnsubscribeURL' => 'https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96', + ]; + } } diff --git a/tests/TestServiceProvider.php b/tests/TestServiceProvider.php new file mode 100644 index 0000000..7aec577 --- /dev/null +++ b/tests/TestServiceProvider.php @@ -0,0 +1,28 @@ +loadRoutesFrom(__DIR__.'/routes/web.php'); + } +} diff --git a/tests/routes/web.php b/tests/routes/web.php new file mode 100644 index 0000000..4d5bd94 --- /dev/null +++ b/tests/routes/web.php @@ -0,0 +1,6 @@ +name('sns');