├── .editorconfig ├── composer.json ├── src ├── Queue │ ├── Connectors │ │ └── SqsSnsConnector.php │ ├── Jobs │ │ └── SqsSnsJob.php │ └── SqsSnsQueue.php └── SqsSnsServiceProvider.php └── tests ├── SqsSnsConnectorTest.php ├── SqsSnsJobTest.php ├── SqsSnsQueueTest.php └── SqsSnsServiceProviderTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editorconfig for consistent coding styles 2 | # http://editorconfig.org 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | [*.php] 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.{json,yml}] 14 | charset = utf-8 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joblocal/laravel-sqs-sns-subscription-queue", 3 | "description": "A simple Laravel service provider which adds a new queue connector to handle SNS subscription queues.", 4 | "license": "MIT", 5 | "keywords": ["laravel", "lumen", "queue", "sns subscription", "aws", "sqs", "sns"], 6 | "authors": [ 7 | { 8 | "name": "Johannes Hofmann", 9 | "email": "johannes.hofmann@joblocal.de", 10 | "role": "Developer" 11 | }, 12 | { 13 | "name": "Julius Liebert", 14 | "email": "julius.liebert@joblocal.de", 15 | "role": "Developer" 16 | }, 17 | { 18 | "name": "Bastian Hofmann", 19 | "email": "bastian.hofmann@joblocal.de", 20 | "role": "Developer" 21 | } 22 | ], 23 | "require": { 24 | "php": ">=7.1.3", 25 | "illuminate/queue": "^5.6|^6.0|^7.0|^8.0|^9.0|^10.0", 26 | "illuminate/support": "^5.6|^6.0|^7.0|^8.0|^9.0|^10.0", 27 | "aws/aws-sdk-php": "^3.62" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^8|^9", 31 | "squizlabs/php_codesniffer": "^3", 32 | "orchestra/testbench": "~3.8|^4.0|^5.0|^6.0|^7.0|^8.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Joblocal\\LaravelSqsSnsSubscriptionQueue\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Joblocal\\LaravelSqsSnsSubscriptionQueue\\Tests\\": "tests/" 42 | } 43 | }, 44 | "scripts": { 45 | "lint": [ 46 | "phpcs --standard=phpcs.xml --colors -p ." 47 | ], 48 | "test": [ 49 | "phpunit -c phpunit.xml --order-by random" 50 | ] 51 | }, 52 | "minimum-stability": "stable" 53 | } 54 | -------------------------------------------------------------------------------- /src/Queue/Connectors/SqsSnsConnector.php: -------------------------------------------------------------------------------- 1 | getDefaultConfiguration($config); 22 | 23 | if ($config['key'] && $config['secret']) { 24 | $config['credentials'] = Arr::only($config, ['key', 'secret']); 25 | } 26 | 27 | return new SqsSnsQueue( 28 | new SqsClient($config), 29 | $config['queue'], 30 | Arr::get($config, 'prefix', ''), 31 | Arr::get($config, 'routes', []) 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Queue/Jobs/SqsSnsJob.php: -------------------------------------------------------------------------------- 1 | job = $this->resolveSnsSubscription($this->job, $routes); 34 | } 35 | 36 | /** 37 | * Resolves SNS queue messages 38 | * 39 | * @param array $job 40 | * @param array $routes 41 | * @return array 42 | */ 43 | protected function resolveSnsSubscription(array $job, array $routes) 44 | { 45 | $body = json_decode($job['Body'], true); 46 | 47 | $commandName = null; 48 | 49 | // available parameters to route your jobs by 50 | $possibleRouteParams = ['Subject', 'TopicArn']; 51 | 52 | foreach ($possibleRouteParams as $param) { 53 | if (isset($body[$param]) && array_key_exists($body[$param], $routes)) { 54 | // Find name of command in queue routes using the param field 55 | $commandName = $routes[$body[$param]]; 56 | break; 57 | } 58 | } 59 | 60 | if ($commandName !== null) { 61 | // If there is a command available, we will resolve the job instance for it from 62 | // the service container, passing in the subject and the payload of the 63 | // notification. 64 | 65 | $command = $this->makeCommand($commandName, $body); 66 | 67 | // The instance for the job will then be serialized and the body of 68 | // the job is reconstructed. 69 | 70 | $job['Body'] = json_encode([ 71 | 'uuid' => $body['MessageId'], 72 | 'displayName' => $commandName, 73 | 'job' => CallQueuedHandler::class . '@call', 74 | 'data' => compact('commandName', 'command'), 75 | ]); 76 | } 77 | 78 | return $job; 79 | } 80 | 81 | /** 82 | * Make the serialized command. 83 | * 84 | * @param string $commandName 85 | * @param array $body 86 | * @return string 87 | */ 88 | protected function makeCommand($commandName, $body) 89 | { 90 | $payload = json_decode($body['Message'], true); 91 | 92 | $data = [ 93 | 'subject' => (isset($body['Subject'])) ? $body['Subject'] : '', 94 | 'payload' => $payload 95 | ]; 96 | 97 | $instance = $this->container->make($commandName, $data); 98 | 99 | return serialize($instance); 100 | } 101 | 102 | 103 | 104 | /** 105 | * Get the underlying raw SQS job. 106 | * 107 | * @return array 108 | */ 109 | public function getSqsSnsJob() 110 | { 111 | return $this->job; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Queue/SqsSnsQueue.php: -------------------------------------------------------------------------------- 1 | routes = $routes; 32 | } 33 | 34 | /** 35 | * Pop the next job off of the queue. 36 | * 37 | * @param string $queue 38 | * @return \Joblocal\LaravelSqsSnsSubscriptionQueue\Queue\Jobs\SqsSnsJob 39 | */ 40 | public function pop($queue = null) 41 | { 42 | $queue = $this->getQueue($queue); 43 | 44 | $response = $this->sqs->receiveMessage([ 45 | 'QueueUrl' => $queue, 46 | 'AttributeNames' => ['ApproximateReceiveCount'], 47 | ]); 48 | 49 | if (is_array($response['Messages']) && count($response['Messages']) > 0) { 50 | if ($this->routeExists($response['Messages'][0]) || $this->classExists($response['Messages'][0])) { 51 | return new SqsSnsJob( 52 | $this->container, 53 | $this->sqs, 54 | $response['Messages'][0], 55 | $this->connectionName, 56 | $queue, 57 | $this->routes 58 | ); 59 | } else { 60 | // remove unwanted messages from topics with multiple messages 61 | $this->sqs->deleteMessage([ 62 | 'QueueUrl' => $queue, // REQUIRED 63 | 'ReceiptHandle' => $response['Messages'][0]['ReceiptHandle'] // REQUIRED 64 | ]); 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * Check if subject exist within the routes. 71 | * This skips creating a job for messages from 72 | * topics that publish multiple different messages. 73 | * 74 | * @param array $message 75 | * @return bool 76 | */ 77 | protected function routeExists(array $message) 78 | { 79 | $body = json_decode($message['Body'], true); 80 | 81 | $possibleRouteParams = ['Subject', 'TopicArn']; 82 | 83 | foreach ($possibleRouteParams as $param) { 84 | if (isset($body[$param]) && array_key_exists($body[$param], $this->routes)) { 85 | return true; 86 | } 87 | } 88 | 89 | return false; 90 | } 91 | 92 | /** 93 | * Check if the job class 94 | * you're trying to trigger exists. 95 | * 96 | * @param array $message 97 | * @return bool 98 | */ 99 | protected function classExists(array $message) 100 | { 101 | $body = json_decode($message['Body'], true); 102 | 103 | return isset($body['data']['commandName']) && class_exists($body['data']['commandName']); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/SqsSnsServiceProvider.php: -------------------------------------------------------------------------------- 1 | app['queue']->extend('sqs-sns', function () { 29 | return new SqsSnsConnector; 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/SqsSnsConnectorTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(SqsSnsConnector::class, $connector); 15 | } 16 | 17 | public function testCanConnectToQueue() 18 | { 19 | $connector = new SqsSnsConnector(); 20 | $queue = $connector->connect([ 21 | 'key' => 'dummy_key', 22 | 'secret' => 'dummy_secret', 23 | 'region' => 'us-west-2', 24 | 'queue' => '', 25 | ]); 26 | 27 | $this->assertInstanceOf(SqsSnsQueue::class, $queue); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/SqsSnsJobTest.php: -------------------------------------------------------------------------------- 1 | sqsClient = $this->getMockBuilder(SqsClient::class) 19 | ->disableOriginalConstructor() 20 | ->getMock(); 21 | 22 | $this->container = new Container; 23 | } 24 | 25 | private function createSqsSnsJob($routes = []) 26 | { 27 | $body = [ 28 | 'MessageId' => '4f4749d6-b004-478a-bc38-d934124914b2', 29 | 'TopicArn' => 'TopicArn:123456', 30 | 'Subject' => 'Subject#action', 31 | 'Message' => 'The Message', 32 | ]; 33 | $payload = [ 34 | 'Body' => json_encode($body), 35 | ]; 36 | 37 | return new SqsSnsJob( 38 | $this->container, 39 | $this->sqsClient, 40 | $payload, 41 | 'connection_name', 42 | 'default_queue', 43 | $routes 44 | ); 45 | } 46 | 47 | private function getSqsSnsJobSubjectRoute() 48 | { 49 | return $this->createSqsSnsJob([ 50 | 'Subject#action' => '\\stdClass', 51 | ]); 52 | } 53 | 54 | private function getSqsSnsJobTopicRoute() 55 | { 56 | return $this->createSqsSnsJob([ 57 | 'TopicArn:123456' => '\\stdClass', 58 | ]); 59 | } 60 | 61 | 62 | public function testWillResolveSqsSubscriptionJob() 63 | { 64 | $jobPayload = $this->getSqsSnsJobSubjectRoute()->payload(); 65 | 66 | $this->assertEquals('Illuminate\\Queue\\CallQueuedHandler@call', $jobPayload['job']); 67 | } 68 | 69 | public function testWillResolveSqsSubscriptionCommandName() 70 | { 71 | $jobPayload = $this->getSqsSnsJobSubjectRoute()->payload(); 72 | 73 | $this->assertEquals('\\stdClass', $jobPayload['data']['commandName']); 74 | } 75 | 76 | public function testWillResolveSqsSubscriptionCommand() 77 | { 78 | $jobPayload = $this->getSqsSnsJobSubjectRoute()->payload(); 79 | $expectedCommand = serialize(new \stdClass); 80 | 81 | $this->assertEquals($expectedCommand, $jobPayload['data']['command']); 82 | } 83 | 84 | 85 | public function testWillResolveSqsSubscriptionJobTopicRoute() 86 | { 87 | $jobPayload = $this->getSqsSnsJobTopicRoute()->payload(); 88 | 89 | $this->assertEquals('Illuminate\\Queue\\CallQueuedHandler@call', $jobPayload['job']); 90 | } 91 | 92 | public function testWillResolveSqsSubscriptionCommandNameTopicRoute() 93 | { 94 | $jobPayload = $this->getSqsSnsJobTopicRoute()->payload(); 95 | 96 | $this->assertEquals('\\stdClass', $jobPayload['data']['commandName']); 97 | } 98 | 99 | public function testWillResolveSqsSubscriptionCommandTopicRoute() 100 | { 101 | $jobPayload = $this->getSqsSnsJobTopicRoute()->payload(); 102 | $expectedCommand = serialize(new \stdClass); 103 | 104 | $this->assertEquals($expectedCommand, $jobPayload['data']['command']); 105 | } 106 | 107 | 108 | public function testWillLeaveDefaultSqsJobUntouched() 109 | { 110 | $body = [ 111 | 'Message' => 'The Message', 112 | ]; 113 | 114 | $defaultSqsJob = new SqsSnsJob( 115 | $this->container, 116 | $this->sqsClient, 117 | [ 118 | 'Body' => json_encode($body), 119 | ], 120 | 'connection_name', 121 | 'default_queue', 122 | [] 123 | ); 124 | 125 | $jobPayload = $defaultSqsJob->payload(); 126 | 127 | $this->assertEquals($body, $jobPayload); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/SqsSnsQueueTest.php: -------------------------------------------------------------------------------- 1 | sqsClient = $this->getMockBuilder(SqsClient::class) 19 | ->disableOriginalConstructor() 20 | ->setMethods(['receiveMessage']) 21 | ->getMock(); 22 | } 23 | 24 | public function testCanInstantiateQueue() 25 | { 26 | $queue = new SqsSnsQueue($this->sqsClient, 'default_queue'); 27 | 28 | $this->assertInstanceOf(SqsSnsQueue::class, $queue); 29 | } 30 | 31 | public function testWillSetRoutes() 32 | { 33 | $queue = new SqsSnsQueue($this->sqsClient, 'default_queue', '', [ 34 | "Subject#action" => '\\Job', 35 | ]); 36 | 37 | $queueReflection = new \ReflectionClass($queue); 38 | $routeReflectionProperty = $queueReflection->getProperty('routes'); 39 | $routeReflectionProperty->setAccessible(true); 40 | 41 | $this->assertEquals([ 42 | "Subject#action" => '\\Job', 43 | ], $routeReflectionProperty->getValue($queue)); 44 | } 45 | 46 | public function testWillCallReceiveMessage() 47 | { 48 | $this->sqsClient->expects($this->once()) 49 | ->method('receiveMessage') 50 | ->willReturn([ 51 | 'Messages' => [], 52 | ]); 53 | 54 | $queue = new SqsSnsQueue($this->sqsClient, 'default_queue'); 55 | $queue->setContainer($this->createMock(Container::class)); 56 | 57 | $queue->pop(); 58 | } 59 | 60 | public function testWillPopMessageOffQueue() 61 | { 62 | $body = json_encode( 63 | [ 64 | 'MessageId' => 'bc065409-fe1b-59c2-b17c-0e056cd19d5d', 65 | 'TopicArn' => 'arn:aws:sns', 66 | 'Subject' => 'Subject#action', 67 | 'Message' => '', 68 | ] 69 | ); 70 | 71 | $message = [ 72 | 'Body' => $body, 73 | ]; 74 | 75 | $this->sqsClient->method('receiveMessage')->willReturn([ 76 | 'Messages' => [ 77 | $message, 78 | ], 79 | ]); 80 | 81 | $queue = new SqsSnsQueue($this->sqsClient, 'default_queue', '', [ 82 | "Subject#action" => '\\Job', 83 | ]); 84 | 85 | $queue->setContainer($this->createMock(\Illuminate\Container\Container::class)); 86 | 87 | $job = $queue->pop(); 88 | $expectedRawBody = [ 89 | 'uuid' => 'bc065409-fe1b-59c2-b17c-0e056cd19d5d', 90 | 'displayName' => '\\Job', 91 | 'job' => 'Illuminate\Queue\CallQueuedHandler@call', 92 | 'data' => [ 93 | 'commandName' => '\\Job', 94 | 'command' => 'N;', 95 | ], 96 | ]; 97 | 98 | $this->assertInstanceOf(SqsSnsJob::class, $job); 99 | $this->assertEquals(json_encode($expectedRawBody), $job->getRawBody()); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/SqsSnsServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | set('queue.connections.sqs-sns', [ 23 | 'driver' => 'sqs-sns', 24 | 'key' => env('AWS_ACCESS_KEY', 'your-public-key'), 25 | 'secret' => env('AWS_SECRET_ACCESS_KEY', 'your-secret-key'), 26 | 'queue' => env('QUEUE_URL', 'your-queue-url'), 27 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 28 | 'routes' => [], 29 | ]); 30 | $app['config']->set('queue.default', 'sqs-sns'); 31 | } 32 | 33 | public function testWillRegisterSqsSnsQueueConnector() 34 | { 35 | $reflectionQueueManager = new \ReflectionClass($this->app['queue']); 36 | $reflectionQueueManagerGetConnectorMethod = $reflectionQueueManager->getMethod('getConnector'); 37 | $reflectionQueueManagerGetConnectorMethod->setAccessible(true); 38 | 39 | $connector = $reflectionQueueManagerGetConnectorMethod->invoke( 40 | $this->app['queue'], 41 | 'sqs-sns' 42 | ); 43 | 44 | $this->assertInstanceOf(SqsSnsConnector::class, $connector); 45 | } 46 | } 47 | --------------------------------------------------------------------------------