From 2af7a2d9eab3e4290a8c789631fa1bbb69ebcd74 Mon Sep 17 00:00:00 2001 From: Maksim Kotlyar Date: Thu, 6 Dec 2018 08:45:25 +0200 Subject: [PATCH 1/9] Amazon SNS transport. --- composer.json | 3 +- docker-compose.yml | 4 +- phpunit.xml.dist | 4 + pkg/sns/.gitattributes | 5 + pkg/sns/.gitignore | 6 + pkg/sns/.travis.yml | 21 ++ pkg/sns/LICENSE | 20 ++ pkg/sns/README.md | 28 +++ pkg/sns/SnsClient.php | 128 ++++++++++ pkg/sns/SnsConnectionFactory.php | 149 ++++++++++++ pkg/sns/SnsContext.php | 148 +++++++++++ pkg/sns/SnsDestination.php | 91 +++++++ pkg/sns/SnsMessage.php | 33 +++ pkg/sns/SnsProducer.php | 119 +++++++++ pkg/sns/SnsSubscribe.php | 39 +++ pkg/sns/Tests/SnsClientTest.php | 229 ++++++++++++++++++ .../Tests/SnsConnectionFactoryConfigTest.php | 152 ++++++++++++ pkg/sns/Tests/SnsConnectionFactoryTest.php | 79 ++++++ pkg/sns/Tests/SnsDestinationTest.php | 52 ++++ pkg/sns/Tests/SnsProducerTest.php | 144 +++++++++++ .../Tests/Spec/SnsConnectionFactoryTest.php | 18 ++ pkg/sns/Tests/Spec/SnsContextTest.php | 25 ++ pkg/sns/Tests/Spec/SnsMessageTest.php | 14 ++ pkg/sns/Tests/Spec/SnsProducerTest.php | 15 ++ pkg/sns/Tests/Spec/SnsQueueTest.php | 14 ++ pkg/sns/Tests/Spec/SnsTopicTest.php | 14 ++ pkg/sns/composer.json | 38 +++ pkg/sns/examples/consume.php | 33 +++ pkg/sns/examples/produce.php | 34 +++ pkg/sns/phpunit.xml.dist | 30 +++ pkg/sqs/README.md | 4 +- pkg/sqs/SqsClient.php | 7 +- pkg/sqs/SqsContext.php | 29 +++ pkg/sqs/Tests/SqsClientTest.php | 14 ++ pkg/sqs/Tests/SqsContextTest.php | 30 +++ pkg/test/SnsExtension.php | 18 ++ 36 files changed, 1786 insertions(+), 5 deletions(-) create mode 100644 pkg/sns/.gitattributes create mode 100644 pkg/sns/.gitignore create mode 100644 pkg/sns/.travis.yml create mode 100644 pkg/sns/LICENSE create mode 100644 pkg/sns/README.md create mode 100644 pkg/sns/SnsClient.php create mode 100644 pkg/sns/SnsConnectionFactory.php create mode 100644 pkg/sns/SnsContext.php create mode 100644 pkg/sns/SnsDestination.php create mode 100644 pkg/sns/SnsMessage.php create mode 100644 pkg/sns/SnsProducer.php create mode 100644 pkg/sns/SnsSubscribe.php create mode 100644 pkg/sns/Tests/SnsClientTest.php create mode 100644 pkg/sns/Tests/SnsConnectionFactoryConfigTest.php create mode 100644 pkg/sns/Tests/SnsConnectionFactoryTest.php create mode 100644 pkg/sns/Tests/SnsDestinationTest.php create mode 100644 pkg/sns/Tests/SnsProducerTest.php create mode 100644 pkg/sns/Tests/Spec/SnsConnectionFactoryTest.php create mode 100644 pkg/sns/Tests/Spec/SnsContextTest.php create mode 100644 pkg/sns/Tests/Spec/SnsMessageTest.php create mode 100644 pkg/sns/Tests/Spec/SnsProducerTest.php create mode 100644 pkg/sns/Tests/Spec/SnsQueueTest.php create mode 100644 pkg/sns/Tests/Spec/SnsTopicTest.php create mode 100644 pkg/sns/composer.json create mode 100644 pkg/sns/examples/consume.php create mode 100644 pkg/sns/examples/produce.php create mode 100644 pkg/sns/phpunit.xml.dist create mode 100644 pkg/test/SnsExtension.php diff --git a/composer.json b/composer.json index ff9db427d..500ff2d06 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "doctrine/orm": "~2.4", "mongodb/mongodb": "^1.2", "pda/pheanstalk": "^3", - "aws/aws-sdk-php": "~3.26", + "aws/aws-sdk-php": "^3.26", "stomp-php/stomp-php": "^4", "php-http/guzzle6-adapter": "^1.1", "php-http/client-common": "^1.7@dev", @@ -75,6 +75,7 @@ "Enqueue\\Redis\\": "pkg/redis/", "Enqueue\\SimpleClient\\": "pkg/simple-client/", "Enqueue\\Sqs\\": "pkg/sqs/", + "Enqueue\\Sns\\": "pkg/sns/", "Enqueue\\Stomp\\": "pkg/stomp/", "Enqueue\\Test\\": "pkg/test/", "Enqueue\\Dsn\\": "pkg/dsn/", diff --git a/docker-compose.yml b/docker-compose.yml index cc8451fbc..3d50a6940 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: - PHPREDIS_DSN=redis+phpredis://redis - GPS_DSN=gps:?projectId=mqdev&emulatorHost=http://google-pubsub:8085 - SQS_DSN=sqs:?key=key&secret=secret®ion=us-east-1&endpoint=http://localstack:4576&version=latest + - SNS_DSN=sns:?key=key&secret=secret®ion=us-east-1&endpoint=http://localstack:4575&version=latest - WAMP_DSN=wamp://thruway:9090 - REDIS_HOST=redis - REDIS_PORT=6379 @@ -121,9 +122,10 @@ services: image: 'localstack/localstack:latest' ports: - '4576:4576' + - '4575:4575' environment: HOSTNAME_EXTERNAL: 'localstack' - SERVICES: 'sqs' + SERVICES: 'sqs,sns' influxdb: image: 'influxdb:latest' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f1c8f205f..5c5501702 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -61,6 +61,10 @@ pkg/sqs/Tests + + pkg/sns/Tests + + pkg/pheanstalk/Tests diff --git a/pkg/sns/.gitattributes b/pkg/sns/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/sns/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/sns/.gitignore b/pkg/sns/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/sns/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/sns/.travis.yml b/pkg/sns/.travis.yml new file mode 100644 index 000000000..9ed4fa123 --- /dev/null +++ b/pkg/sns/.travis.yml @@ -0,0 +1,21 @@ +sudo: false + +git: + depth: 10 + +language: php + +php: + - '7.1' + - '7.2' + +cache: + directories: + - $HOME/.composer/cache + +install: + - composer self-update + - composer install --prefer-source + +script: + - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/sns/LICENSE b/pkg/sns/LICENSE new file mode 100644 index 000000000..20211e5fd --- /dev/null +++ b/pkg/sns/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2018 Max Kotliar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/sns/README.md b/pkg/sns/README.md new file mode 100644 index 000000000..bcec08c1e --- /dev/null +++ b/pkg/sns/README.md @@ -0,0 +1,28 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Amazon SNS Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://travis-ci.org/php-enqueue/sns.png?branch=master)](https://travis-ci.org/php-enqueue/sns) +[![Total Downloads](https://poser.pugx.org/enqueue/sns/d/total.png)](https://packagist.org/packages/enqueue/sns) +[![Latest Stable Version](https://poser.pugx.org/enqueue/sns/version.png)](https://packagist.org/packages/enqueue/sns) + +This is an implementation of Queue Interop specification. It allows you to send and consume message using [Amazon SNS](https://aws.amazon.com/sns/) service. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/transport/sns.md) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## License + +It is released under the [MIT License](LICENSE). \ No newline at end of file diff --git a/pkg/sns/SnsClient.php b/pkg/sns/SnsClient.php new file mode 100644 index 000000000..6b5f6a337 --- /dev/null +++ b/pkg/sns/SnsClient.php @@ -0,0 +1,128 @@ +inputClient = $inputClient; + } + + public function createTopic(array $args): Result + { + return $this->callApi('createTopic', $args); + } + + public function publish(array $args): Result + { + return $this->callApi('publish', $args); + } + + public function subscribe(array $args): Result + { + return $this->callApi('subscribe', $args); + } + + public function getAWSClient(): AwsSnsClient + { + $this->resolveClient(); + + if ($this->singleClient) { + return $this->singleClient; + } + + if ($this->multiClient) { + $mr = new \ReflectionMethod($this->multiClient, 'getClientFromPool'); + $mr->setAccessible(true); + $singleClient = $mr->invoke($this->multiClient, $this->multiClient->getRegion()); + $mr->setAccessible(false); + + return $singleClient; + } + + throw new \LogicException('The multi or single client must be set'); + } + + private function callApi(string $name, array $args): Result + { + $this->resolveClient(); + + if ($this->singleClient) { + if (false == empty($args['@region'])) { + throw new \LogicException('Cannot send message to another region because transport is configured with single aws client'); + } + + unset($args['@region']); + + return call_user_func([$this->singleClient, $name], $args); + } + + if ($this->multiClient) { + return call_user_func([$this->multiClient, $name], $args); + } + + throw new \LogicException('The multi or single client must be set'); + } + + private function resolveClient(): void + { + if ($this->singleClient || $this->multiClient) { + return; + } + + $client = $this->inputClient; + if ($client instanceof MultiRegionClient) { + $this->multiClient = $client; + + return; + } elseif ($client instanceof AwsSnsClient) { + $this->singleClient = $client; + + return; + } elseif (is_callable($client)) { + $client = call_user_func($client); + if ($client instanceof MultiRegionClient) { + $this->multiClient = $client; + + return; + } + if ($client instanceof AwsSnsClient) { + $this->singleClient = $client; + + return; + } + } + + throw new \LogicException(sprintf( + 'The input client must be an instance of "%s" or "%s" or a callable that returns one of those. Got "%s"', + AwsSnsClient::class, + MultiRegionClient::class, + is_object($client) ? get_class($client) : gettype($client) + )); + } +} diff --git a/pkg/sns/SnsConnectionFactory.php b/pkg/sns/SnsConnectionFactory.php new file mode 100644 index 000000000..ffef39803 --- /dev/null +++ b/pkg/sns/SnsConnectionFactory.php @@ -0,0 +1,149 @@ + null AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'secret' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'token' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'region' => null, (string, required) Region to connect to. See http://docs.aws.amazon.com/general/latest/gr/rande.html for a list of available regions. + * 'version' => '2012-11-05', (string, required) The version of the webservice to utilize + * 'lazy' => true, Enable lazy connection (boolean) + * 'endpoint' => null (string, default=null) The full URI of the webservice. This is only required when connecting to a custom endpoint e.g. localstack + * ]. + * + * or + * + * sns: + * sns::?key=aKey&secret=aSecret&token=aToken + * + * @param array|string|SnsClient|null $config + */ + public function __construct($config = 'sns:') + { + if ($config instanceof AwsSnsClient) { + $this->client = new SnsClient($config); + $this->config = ['lazy' => false] + $this->defaultConfig(); + + return; + } + + if (empty($config)) { + $config = []; + } elseif (is_string($config)) { + $config = $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $config = array_replace_recursive($config, $this->parseDsn($config['dsn'])); + + unset($config['dsn']); + } + } else { + throw new \LogicException(sprintf('The config must be either an array of options, a DSN string, null or instance of %s', AwsSnsClient::class)); + } + + $this->config = array_replace($this->defaultConfig(), $config); + } + + /** + * @return SnsContext + */ + public function createContext(): Context + { + return new SnsContext($this->establishConnection(), $this->config); + } + + private function establishConnection(): SnsClient + { + if ($this->client) { + return $this->client; + } + + $config = [ + 'version' => $this->config['version'], + 'region' => $this->config['region'], + ]; + + if (isset($this->config['endpoint'])) { + $config['endpoint'] = $this->config['endpoint']; + } + + if ($this->config['key'] && $this->config['secret']) { + $config['credentials'] = [ + 'key' => $this->config['key'], + 'secret' => $this->config['secret'], + ]; + + if ($this->config['token']) { + $config['credentials']['token'] = $this->config['token']; + } + } + + $establishConnection = function () use ($config) { + return (new Sdk(['Sns' => $config]))->createMultiRegionSns(); + }; + + $this->client = $this->config['lazy'] ? + new SnsClient($establishConnection) : + new SnsClient($establishConnection()) + ; + + return $this->client; + } + + private function parseDsn(string $dsn): array + { + $dsn = Dsn::parseFirst($dsn); + + if ('sns' !== $dsn->getSchemeProtocol()) { + throw new \LogicException(sprintf( + 'The given scheme protocol "%s" is not supported. It must be "sns"', + $dsn->getSchemeProtocol() + )); + } + + return array_filter(array_replace($dsn->getQuery(), [ + 'key' => $dsn->getString('key'), + 'secret' => $dsn->getString('secret'), + 'token' => $dsn->getString('token'), + 'region' => $dsn->getString('region'), + 'version' => $dsn->getString('version'), + 'lazy' => $dsn->getBool('lazy'), + 'endpoint' => $dsn->getString('endpoint'), + ]), function ($value) { return null !== $value; }); + } + + private function defaultConfig(): array + { + return [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + ]; + } +} diff --git a/pkg/sns/SnsContext.php b/pkg/sns/SnsContext.php new file mode 100644 index 000000000..818eda4a7 --- /dev/null +++ b/pkg/sns/SnsContext.php @@ -0,0 +1,148 @@ +client = $client; + $this->config = $config; + + $this->topicArns = []; + } + + /** + * @return SnsMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new SnsMessage($body, $properties, $headers); + } + + /** + * @return SnsDestination + */ + public function createTopic(string $topicName): Topic + { + return new SnsDestination($topicName); + } + + /** + * @return SnsDestination + */ + public function createQueue(string $queueName): Queue + { + return new SnsDestination($queueName); + } + + public function declareTopic(SnsDestination $destination): void + { + $result = $this->client->createTopic([ + 'Attributes' => $destination->getAttributes(), + 'Name' => $destination->getQueueName(), + ]); + + if (false == $result->hasKey('TopicArn')) { + throw new \RuntimeException(sprintf('Cannot create topic. topicName: "%s"', $destination->getTopicName())); + } + + $this->topicArns[$destination->getTopicName()] = (string) $result->get('TopicArn'); + } + + public function subscribe(SnsSubscribe $subscribe): void + { + $this->client->subscribe([ + 'Attributes' => $subscribe->getAttributes(), + 'Endpoint' => $subscribe->getEndpoint(), + //'Protocol' => '', // REQUIRED +//'ReturnSubscriptionArn' => true || false, +//'TopicArn' => '', // REQUIRED +//]); + ]); + } + + public function getTopicArn(SnsDestination $destination): string + { + if (false == array_key_exists($destination->getTopicName(), $this->topicArns)) { + $this->declareTopic($destination); + } + + return $this->topicArns[$destination->getTopicName()]; + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + /** + * @return SnsProducer + */ + public function createProducer(): Producer + { + return new SnsProducer($this); + } + + /** + * @param SnsDestination $destination + */ + public function createConsumer(Destination $destination): Consumer + { + throw new \LogicException('SNS transport does not support consumption. You should consider using SQS instead.'); + } + + public function close(): void + { + } + + /** + * @param SnsDestination $queue + */ + public function purgeQueue(Queue $queue): void + { + PurgeQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function getAwsSnsClient(): AwsSnsClient + { + return $this->client->getAWSClient(); + } + + public function getSnsClient(): SnsClient + { + return $this->client; + } +} diff --git a/pkg/sns/SnsDestination.php b/pkg/sns/SnsDestination.php new file mode 100644 index 000000000..0db9d914e --- /dev/null +++ b/pkg/sns/SnsDestination.php @@ -0,0 +1,91 @@ +name = $name; + $this->attributes = []; + } + + public function getQueueName(): string + { + return $this->name; + } + + public function getTopicName(): string + { + return $this->name; + } + + /** + * The policy that defines who can access your topic. By default, only the topic owner can publish or subscribe to the topic. + */ + public function setPolicy(string $policy = null): void + { + $this->setAttribute('Policy', $policy); + } + + public function getPolicy(): ?string + { + return $this->getAttribute('Policy'); + } + + /** + * The display name to use for a topic with SMS subscriptions. + */ + public function setDisplayName(string $displayName = null): void + { + $this->setAttribute('DisplayName', $displayName); + } + + public function getDisplayName(): ?string + { + return $this->getAttribute('DisplayName'); + } + + /** + * The display name to use for a topic with SMS subscriptions. + */ + public function setDeliveryPolicy(int $deliveryPolicy = null): void + { + $this->setAttribute('DeliveryPolicy', $deliveryPolicy); + } + + public function getDeliveryPolicy(): ?int + { + return $this->getAttribute('DeliveryPolicy'); + } + + public function getAttributes(): array + { + return $this->attributes; + } + + private function getAttribute(string $name, $default = null) + { + return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default; + } + + private function setAttribute(string $name, $value): void + { + if (null == $value) { + unset($this->attributes[$name]); + } else { + $this->attributes[$name] = $value; + } + } +} diff --git a/pkg/sns/SnsMessage.php b/pkg/sns/SnsMessage.php new file mode 100644 index 000000000..7eb3c0d83 --- /dev/null +++ b/pkg/sns/SnsMessage.php @@ -0,0 +1,33 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } + + public function getSnsMessageId(): ?string + { + return $this->snsMessageId; + } + + public function setSnsMessageId(?string $snsMessageId): void + { + $this->snsMessageId = $snsMessageId; + } +} diff --git a/pkg/sns/SnsProducer.php b/pkg/sns/SnsProducer.php new file mode 100644 index 000000000..b702d10c3 --- /dev/null +++ b/pkg/sns/SnsProducer.php @@ -0,0 +1,119 @@ +context = $context; + } + + /** + * @param SnsDestination $destination + * @param SnsMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, SnsDestination::class); + InvalidMessageException::assertMessageInstanceOf($message, SnsMessage::class); + + $body = $message->getBody(); + if (empty($body)) { + throw new InvalidMessageException('The message body must be a non-empty string.'); + } + + $topicArn = $this->context->getTopicArn($destination); + + $arguments = [ + 'Message' => $message->getBody(), + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => json_encode([$message->getHeaders(), $message->getProperties()]), + ], + ], + 'TopicArn' => $topicArn, + ]; + + $result = $this->context->getSnsClient()->publish($arguments); + + if (false == $result->hasKey('MessageId')) { + throw new \RuntimeException('Message was not sent'); + } + + $message->setSnsMessageId((string) $result->get('MessageId')); + } + + /** + * @return SnsProducer + */ + public function setDeliveryDelay(int $deliveryDelay = null): Producer + { + if (null === $deliveryDelay) { + return $this; + } + + DeliveryDelayNotSupportedException::providerDoestNotSupportIt(); + } + + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * @return SnsProducer + */ + public function setPriority(int $priority = null): Producer + { + if (null === $priority) { + return $this; + } + + throw PriorityNotSupportedException::providerDoestNotSupportIt(); + } + + public function getPriority(): ?int + { + return null; + } + + /** + * @return SnsProducer + */ + public function setTimeToLive(int $timeToLive = null): Producer + { + if (null === $timeToLive) { + return $this; + } + + throw TimeToLiveNotSupportedException::providerDoestNotSupportIt(); + } + + public function getTimeToLive(): ?int + { + return null; + } +} diff --git a/pkg/sns/SnsSubscribe.php b/pkg/sns/SnsSubscribe.php new file mode 100644 index 000000000..94d457d55 --- /dev/null +++ b/pkg/sns/SnsSubscribe.php @@ -0,0 +1,39 @@ +destination = $destination; + $this->endpoint = $endpoint; + $this->protocol = $protocol; + $this->attributes = $arguments; + } + + public function getDestination(): SnsDestination + { + return $this->destination; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/pkg/sns/Tests/SnsClientTest.php b/pkg/sns/Tests/SnsClientTest.php new file mode 100644 index 000000000..e56a18790 --- /dev/null +++ b/pkg/sns/Tests/SnsClientTest.php @@ -0,0 +1,229 @@ + [ + 'key' => '', + 'secret' => '', + 'token' => '', + 'region' => '', + 'version' => '2010-03-31', + 'endpoint' => 'http://localhost', + ]]))->createSns(); + + $client = new SnsClient($awsClient); + + $this->assertSame($awsClient, $client->getAWSClient()); + } + + public function testShouldAllowGetAwsClientIfMultipleClientProvided() + { + $awsClient = (new Sdk(['Sns' => [ + 'key' => '', + 'secret' => '', + 'token' => '', + 'region' => '', + 'version' => '2010-03-31', + 'endpoint' => 'http://localhost', + ]]))->createMultiRegionSns(); + + $client = new SnsClient($awsClient); + + $this->assertInstanceOf(AwsSnsClient::class, $client->getAWSClient()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SnsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testLazyApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SnsClient(function () use ($awsClient) { + return $awsClient; + }); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testThrowIfInvalidInputClientApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $client = new SnsClient(new \stdClass()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The input client must be an instance of "Aws\Sns\SnsClient" or "Aws\MultiRegionClient" or a callable that returns one of those. Got "stdClass"'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsSingleClient + * @dataProvider provideApiCallsMultipleClient + */ + public function testThrowIfInvalidLazyInputClientApiCall(string $method, array $args, array $result, string $awsClientClass) + { + $client = new SnsClient(function () { return new \stdClass(); }); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The input client must be an instance of "Aws\Sns\SnsClient" or "Aws\MultiRegionClient" or a callable that returns one of those. Got "stdClass"'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsMultipleClient + */ + public function testApiCallWithMultiClientAndCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $args['@region'] = 'theRegion'; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($args)) + ->willReturn(new Result($result)); + + $client = new SnsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + /** + * @dataProvider provideApiCallsSingleClient + */ + public function testApiCallWithSingleClientAndCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $args['@region'] = 'theRegion'; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->never()) + ->method($method) + ; + + $client = new SnsClient($awsClient); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot send message to another region because transport is configured with single aws client'); + $client->{$method}($args); + } + + /** + * @dataProvider provideApiCallsSingleClient + */ + public function testApiCallWithMultiClientAndEmptyCustomRegion(string $method, array $args, array $result, string $awsClientClass) + { + $expectedArgs = $args; + $args['@region'] = ''; + + $awsClient = $this->getMockBuilder($awsClientClass) + ->disableOriginalConstructor() + ->setMethods([$method]) + ->getMock(); + $awsClient + ->expects($this->once()) + ->method($method) + ->with($this->identicalTo($expectedArgs)) + ->willReturn(new Result($result)); + + $client = new SnsClient($awsClient); + + $actualResult = $client->{$method}($args); + + $this->assertInstanceOf(Result::class, $actualResult); + $this->assertSame($result, $actualResult->toArray()); + } + + public function provideApiCallsSingleClient() + { + yield [ + 'createTopic', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSnsClient::class, + ]; + + yield [ + 'publish', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSnsClient::class, + ]; + } + + public function provideApiCallsMultipleClient() + { + yield [ + 'createTopic', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + + yield [ + 'publish', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + } +} diff --git a/pkg/sns/Tests/SnsConnectionFactoryConfigTest.php b/pkg/sns/Tests/SnsConnectionFactoryConfigTest.php new file mode 100644 index 000000000..d9cbff390 --- /dev/null +++ b/pkg/sns/Tests/SnsConnectionFactoryConfigTest.php @@ -0,0 +1,152 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('The config must be either an array of options, a DSN string, null or instance of Aws\Sns\SnsClient'); + + new SnsConnectionFactory(new \stdClass()); + } + + public function testThrowIfSchemeIsNotAmqp() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The given scheme protocol "http" is not supported. It must be "sns"'); + + new SnsConnectionFactory('http://example.com'); + } + + public function testThrowIfDsnCouldNotBeParsed() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The DSN is invalid.'); + + new SnsConnectionFactory('foo'); + } + + /** + * @dataProvider provideConfigs + * + * @param mixed $config + * @param mixed $expectedConfig + */ + public function testShouldParseConfigurationAsExpected($config, $expectedConfig) + { + $factory = new SnsConnectionFactory($config); + + $this->assertAttributeEquals($expectedConfig, 'config', $factory); + } + + public static function provideConfigs() + { + yield [ + null, + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + ], + ]; + + yield [ + 'sns:', + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + ], + ]; + + yield [ + [], + [ + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => true, + 'endpoint' => null, + ], + ]; + + yield [ + 'sns:?key=theKey&secret=theSecret&token=theToken&lazy=0', + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => null, + ], + ]; + + yield [ + ['dsn' => 'sns:?key=theKey&secret=theSecret&token=theToken&lazy=0'], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => null, + ], + ]; + + yield [ + ['key' => 'theKey', 'secret' => 'theSecret', 'token' => 'theToken', 'lazy' => false], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => null, + ], + ]; + + yield [ + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'lazy' => false, + 'endpoint' => 'http://localstack:1111', + ], + [ + 'key' => 'theKey', + 'secret' => 'theSecret', + 'token' => 'theToken', + 'region' => null, + 'version' => '2010-03-31', + 'lazy' => false, + 'endpoint' => 'http://localstack:1111', + ], + ]; + } +} diff --git a/pkg/sns/Tests/SnsConnectionFactoryTest.php b/pkg/sns/Tests/SnsConnectionFactoryTest.php new file mode 100644 index 000000000..104c4dc18 --- /dev/null +++ b/pkg/sns/Tests/SnsConnectionFactoryTest.php @@ -0,0 +1,79 @@ +assertClassImplements(ConnectionFactory::class, SnsConnectionFactory::class); + } + + public function testCouldBeConstructedWithEmptyConfiguration() + { + $factory = new SnsConnectionFactory([]); + + $this->assertAttributeEquals([ + 'lazy' => true, + 'key' => null, + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'endpoint' => null, + ], 'config', $factory); + } + + public function testCouldBeConstructedWithCustomConfiguration() + { + $factory = new SnsConnectionFactory(['key' => 'theKey']); + + $this->assertAttributeEquals([ + 'lazy' => true, + 'key' => 'theKey', + 'secret' => null, + 'token' => null, + 'region' => null, + 'version' => '2010-03-31', + 'endpoint' => null, + ], 'config', $factory); + } + + public function testCouldBeConstructedWithClient() + { + $awsClient = $this->createMock(AwsSnsClient::class); + + $factory = new SnsConnectionFactory($awsClient); + + $context = $factory->createContext(); + + $this->assertInstanceOf(SnsContext::class, $context); + + $client = $this->readAttribute($context, 'client'); + $this->assertInstanceOf(SnsClient::class, $client); + $this->assertAttributeSame($awsClient, 'inputClient', $client); + } + + public function testShouldCreateLazyContext() + { + $factory = new SnsConnectionFactory(['lazy' => true]); + + $context = $factory->createContext(); + + $this->assertInstanceOf(SnsContext::class, $context); + + $client = $this->readAttribute($context, 'client'); + $this->assertInstanceOf(SnsClient::class, $client); + $this->assertAttributeInstanceOf(\Closure::class, 'inputClient', $client); + } +} diff --git a/pkg/sns/Tests/SnsDestinationTest.php b/pkg/sns/Tests/SnsDestinationTest.php new file mode 100644 index 000000000..c9f9669e7 --- /dev/null +++ b/pkg/sns/Tests/SnsDestinationTest.php @@ -0,0 +1,52 @@ +assertClassImplements(Topic::class, SnsDestination::class); + $this->assertClassImplements(Queue::class, SnsDestination::class); + } + + public function testShouldReturnNameSetInConstructor() + { + $destination = new SnsDestination('aDestinationName'); + + $this->assertSame('aDestinationName', $destination->getQueueName()); + $this->assertSame('aDestinationName', $destination->getTopicName()); + } + + public function testCouldSetPolicyAttribute() + { + $destination = new SnsDestination('aDestinationName'); + $destination->setPolicy('thePolicy'); + + $this->assertSame(['Policy' => 'thePolicy'], $destination->getAttributes()); + } + + public function testCouldSetDisplayNameAttribute() + { + $destination = new SnsDestination('aDestinationName'); + $destination->setDisplayName('theDisplayName'); + + $this->assertSame(['DisplayName' => 'theDisplayName'], $destination->getAttributes()); + } + + public function testCouldSetDeliveryPolicyAttribute() + { + $destination = new SnsDestination('aDestinationName'); + $destination->setDeliveryPolicy(123); + + $this->assertSame(['DeliveryPolicy' => 123], $destination->getAttributes()); + } +} diff --git a/pkg/sns/Tests/SnsProducerTest.php b/pkg/sns/Tests/SnsProducerTest.php new file mode 100644 index 000000000..f5a039702 --- /dev/null +++ b/pkg/sns/Tests/SnsProducerTest.php @@ -0,0 +1,144 @@ +assertClassImplements(Producer::class, SnsProducer::class); + } + + public function testCouldBeConstructedWithRequiredArguments() + { + new SnsProducer($this->createSnsContextMock()); + } + + public function testShouldThrowIfBodyOfInvalidType() + { + $this->expectException(InvalidMessageException::class); + $this->expectExceptionMessage('The message body must be a non-empty string.'); + + $producer = new SnsProducer($this->createSnsContextMock()); + + $message = new SnsMessage(''); + + $producer->send(new SnsDestination(''), $message); + } + + public function testShouldThrowIfDestinationOfInvalidType() + { + $this->expectException(InvalidDestinationException::class); + $this->expectExceptionMessage('The destination must be an instance of Enqueue\Sns\SnsDestination but got Mock_Destinat'); + + $producer = new SnsProducer($this->createSnsContextMock()); + + $producer->send($this->createMock(Destination::class), new SnsMessage()); + } + + public function testShouldThrowIfPublishFailed() + { + $destination = new SnsDestination('queue-name'); + + $client = $this->createSnsClientMock(); + $client + ->expects($this->once()) + ->method('publish') + ->willReturn(new Result()) + ; + + $context = $this->createSnsContextMock(); + $context + ->expects($this->once()) + ->method('getTopicArn') + ->with($this->identicalTo($destination)) + ->willReturn('theTopicArn') + ; + $context + ->expects($this->once()) + ->method('getSnsClient') + ->will($this->returnValue($client)) + ; + + $message = new SnsMessage('foo'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Message was not sent'); + + $producer = new SnsProducer($context); + $producer->send($destination, $message); + } + + public function testShouldPublish() + { + $destination = new SnsDestination('queue-name'); + + $expectedArguments = [ + 'Message' => 'theBody', + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => '[{"hkey":"hvaleu"},{"key":"value"}]', + ], + ], + 'TopicArn' => 'theTopicArn', + ]; + + $client = $this->createSnsClientMock(); + $client + ->expects($this->once()) + ->method('publish') + ->with($this->identicalTo($expectedArguments)) + ->willReturn(new Result(['MessageId' => 'theMessageId'])) + ; + + $context = $this->createSnsContextMock(); + $context + ->expects($this->once()) + ->method('getTopicArn') + ->with($this->identicalTo($destination)) + ->willReturn('theTopicArn') + ; + $context + ->expects($this->once()) + ->method('getSnsClient') + ->will($this->returnValue($client)) + ; + + $message = new SnsMessage('theBody', ['key' => 'value'], ['hkey' => 'hvaleu']); + + $producer = new SnsProducer($context); + $producer->send($destination, $message); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|SnsContext + */ + private function createSnsContextMock(): SnsContext + { + return $this->createMock(SnsContext::class); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|SnsClient + */ + private function createSnsClientMock(): SnsClient + { + return $this->createMock(SnsClient::class); + } +} diff --git a/pkg/sns/Tests/Spec/SnsConnectionFactoryTest.php b/pkg/sns/Tests/Spec/SnsConnectionFactoryTest.php new file mode 100644 index 000000000..20008bc04 --- /dev/null +++ b/pkg/sns/Tests/Spec/SnsConnectionFactoryTest.php @@ -0,0 +1,18 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('SNS transport does not support consumption. You should consider using SQS instead.'); + + parent::testShouldCreateConsumerOnCreateConsumerMethodCall(); + } + + protected function createContext() + { + $client = $this->createMock(SnsClient::class); + + return new SnsContext($client, []); + } +} diff --git a/pkg/sns/Tests/Spec/SnsMessageTest.php b/pkg/sns/Tests/Spec/SnsMessageTest.php new file mode 100644 index 000000000..af24344e6 --- /dev/null +++ b/pkg/sns/Tests/Spec/SnsMessageTest.php @@ -0,0 +1,14 @@ +createMock(SnsContext::class)); + } +} diff --git a/pkg/sns/Tests/Spec/SnsQueueTest.php b/pkg/sns/Tests/Spec/SnsQueueTest.php new file mode 100644 index 000000000..39c0e5513 --- /dev/null +++ b/pkg/sns/Tests/Spec/SnsQueueTest.php @@ -0,0 +1,14 @@ +createContext(); + +$queue = $context->createQueue('enqueue'); +$consumer = $context->createConsumer($queue); + +while (true) { + if ($m = $consumer->receive(20000)) { + $consumer->acknowledge($m); + echo 'Received message: '.$m->getBody().PHP_EOL; + } +} + +echo 'Done'."\n"; diff --git a/pkg/sns/examples/produce.php b/pkg/sns/examples/produce.php new file mode 100644 index 000000000..d37fce79f --- /dev/null +++ b/pkg/sns/examples/produce.php @@ -0,0 +1,34 @@ + getenv('ENQUEUE_AWS__SQS__KEY'), + 'secret' => getenv('ENQUEUE_AWS__SQS__SECRET'), + 'region' => getenv('ENQUEUE_AWS__SQS__REGION'), +]); +$context = $factory->createContext(); + +$topic = $context->createTopic('test_enqueue'); +$context->declareTopic($topic); + +$message = $context->createMessage('a_body'); +$message->setProperty('aProp', 'aPropVal'); +$message->setHeader('aHeader', 'aHeaderVal'); + +$context->createProducer()->send($topic, $message); diff --git a/pkg/sns/phpunit.xml.dist b/pkg/sns/phpunit.xml.dist new file mode 100644 index 000000000..3fe611e9c --- /dev/null +++ b/pkg/sns/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/sqs/README.md b/pkg/sqs/README.md index 7c46d801c..1c7d7da08 100644 --- a/pkg/sqs/README.md +++ b/pkg/sqs/README.md @@ -14,12 +14,12 @@ Enqueue is an MIT-licensed open source project with its ongoing development made [![Total Downloads](https://poser.pugx.org/enqueue/sqs/d/total.png)](https://packagist.org/packages/enqueue/sqs) [![Latest Stable Version](https://poser.pugx.org/enqueue/sqs/version.png)](https://packagist.org/packages/enqueue/sqs) -This is an implementation of Queue Interop specification. It allows you to send and consume message through Amazon SQS library. +This is an implementation of Queue Interop specification. It allows you to send and consume message using [Amazon SQS](https://aws.amazon.com/sqs/) service. ## Resources * [Site](https://enqueue.forma-pro.com/) -* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/index.md) +* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/transport/sqs.md) * [Questions](https://gitter.im/php-enqueue/Lobby) * [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) diff --git a/pkg/sqs/SqsClient.php b/pkg/sqs/SqsClient.php index 65cf2fb29..07256d3aa 100644 --- a/pkg/sqs/SqsClient.php +++ b/pkg/sqs/SqsClient.php @@ -26,7 +26,7 @@ class SqsClient private $inputClient; /** - * @param AwsSqsClient|callable $inputClient + * @param AwsSqsClient|MultiRegionClient|callable $inputClient */ public function __construct($inputClient) { @@ -53,6 +53,11 @@ public function getQueueUrl(array $args): Result return $this->callApi('getQueueUrl', $args); } + public function getQueueAttributes(array $args): Result + { + return $this->callApi('getQueueAttributes', $args); + } + public function createQueue(array $args): Result { return $this->callApi('createQueue', $args); diff --git a/pkg/sqs/SqsContext.php b/pkg/sqs/SqsContext.php index 8be6fcddb..4860bbefe 100644 --- a/pkg/sqs/SqsContext.php +++ b/pkg/sqs/SqsContext.php @@ -29,6 +29,11 @@ class SqsContext implements Context */ private $queueUrls; + /** + * @var array + */ + private $queueArns; + /** * @var array */ @@ -38,6 +43,9 @@ public function __construct(SqsClient $client, array $config) { $this->client = $client; $this->config = $config; + + $this->queueUrls = []; + $this->queueArns = []; } /** @@ -157,6 +165,27 @@ public function getQueueUrl(SqsDestination $destination): string return $this->queueUrls[$destination->getQueueName()] = (string) $result->get('QueueUrl'); } + public function getQueueArn(SqsDestination $destination): string + { + if (isset($this->queueArns[$destination->getQueueName()])) { + return $this->queueArns[$destination->getQueueName()]; + } + + $arguments = [ + '@region' => $destination->getRegion(), + 'QueueUrl' => $this->getQueueUrl($destination), + 'AttributeNames' => ['QueueArn'], + ]; + + $result = $this->client->getQueueAttributes($arguments); + + if (false == $result->hasKey('QueueArn')) { + throw new \RuntimeException(sprintf('QueueArn cannot be resolved. queueName: "%s"', $destination->getQueueName())); + } + + return $this->queueArns[$destination->getQueueName()] = (string) $result->get('QueueArn'); + } + public function declareQueue(SqsDestination $dest): void { $result = $this->client->createQueue([ diff --git a/pkg/sqs/Tests/SqsClientTest.php b/pkg/sqs/Tests/SqsClientTest.php index 2a45edc72..08c44a307 100644 --- a/pkg/sqs/Tests/SqsClientTest.php +++ b/pkg/sqs/Tests/SqsClientTest.php @@ -223,6 +223,13 @@ public function provideApiCallsSingleClient() AwsSqsClient::class, ]; + yield [ + 'getQueueAttributes', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + AwsSqsClient::class, + ]; + yield [ 'createQueue', ['fooArg' => 'fooArgVal'], @@ -275,6 +282,13 @@ public function provideApiCallsMultipleClient() MultiRegionClient::class, ]; + yield [ + 'getQueueAttributes', + ['fooArg' => 'fooArgVal'], + ['bar' => 'barVal'], + MultiRegionClient::class, + ]; + yield [ 'createQueue', ['fooArg' => 'fooArgVal'], diff --git a/pkg/sqs/Tests/SqsContextTest.php b/pkg/sqs/Tests/SqsContextTest.php index 53dfc4358..204e5d87a 100644 --- a/pkg/sqs/Tests/SqsContextTest.php +++ b/pkg/sqs/Tests/SqsContextTest.php @@ -308,6 +308,36 @@ public function testShouldAllowGetQueueUrl() $context->getQueueUrl(new SqsDestination('aQueueName')); } + public function testShouldAllowGetQueueArn() + { + $sqsClient = $this->createSqsClientMock(); + $sqsClient + ->expects($this->once()) + ->method('getQueueUrl') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueName' => 'aQueueName', + ])) + ->willReturn(new Result(['QueueUrl' => 'theQueueUrl'])) + ; + $sqsClient + ->expects($this->once()) + ->method('getQueueAttributes') + ->with($this->identicalTo([ + '@region' => 'theRegion', + 'QueueUrl' => 'theQueueUrl', + 'AttributeNames' => ['QueueArn'], + ])) + ->willReturn(new Result(['QueueArn' => 'theQueueArn'])) + ; + + $context = new SqsContext($sqsClient, []); + + $queue = $context->createQueue('aQueueName'); + + $this->assertSame('theQueueArn', $context->getQueueArn($queue)); + } + public function testShouldAllowGetQueueUrlWithCustomRegion() { $sqsClient = $this->createSqsClientMock(); diff --git a/pkg/test/SnsExtension.php b/pkg/test/SnsExtension.php new file mode 100644 index 000000000..6c01abe2f --- /dev/null +++ b/pkg/test/SnsExtension.php @@ -0,0 +1,18 @@ +createContext(); + } +} From a35a32390f77c81389192f5772561052b276a50c Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Mon, 18 Feb 2019 14:24:26 +0200 Subject: [PATCH 2/9] senses --- composer.json | 1 + pkg/sns/SnsClient.php | 10 ++ pkg/sns/SnsContext.php | 53 ++++++- pkg/sns/SnsSubscribe.php | 49 ++++++- pkg/sns/SnsUnsubscribe.php | 48 +++++++ pkg/snsqs/.gitattributes | 5 + pkg/snsqs/.gitignore | 6 + pkg/snsqs/.travis.yml | 21 +++ pkg/snsqs/LICENSE | 20 +++ pkg/snsqs/README.md | 28 ++++ pkg/snsqs/SnsQsConnectionFactory.php | 97 +++++++++++++ pkg/snsqs/SnsQsConsumer.php | 102 +++++++++++++ pkg/snsqs/SnsQsContext.php | 208 +++++++++++++++++++++++++++ pkg/snsqs/SnsQsMessage.php | 37 +++++ pkg/snsqs/SnsQsProducer.php | 90 ++++++++++++ pkg/snsqs/SnsQsQueue.php | 11 ++ pkg/snsqs/SnsQsTopic.php | 11 ++ pkg/snsqs/composer.json | 39 +++++ pkg/snsqs/examples/consumer.php | 40 ++++++ pkg/snsqs/examples/produce.php | 40 ++++++ pkg/snsqs/phpunit.xml.dist | 30 ++++ pkg/sqs/SqsContext.php | 4 +- 22 files changed, 937 insertions(+), 13 deletions(-) create mode 100644 pkg/sns/SnsUnsubscribe.php create mode 100644 pkg/snsqs/.gitattributes create mode 100644 pkg/snsqs/.gitignore create mode 100644 pkg/snsqs/.travis.yml create mode 100644 pkg/snsqs/LICENSE create mode 100644 pkg/snsqs/README.md create mode 100644 pkg/snsqs/SnsQsConnectionFactory.php create mode 100644 pkg/snsqs/SnsQsConsumer.php create mode 100644 pkg/snsqs/SnsQsContext.php create mode 100644 pkg/snsqs/SnsQsMessage.php create mode 100644 pkg/snsqs/SnsQsProducer.php create mode 100644 pkg/snsqs/SnsQsQueue.php create mode 100644 pkg/snsqs/SnsQsTopic.php create mode 100644 pkg/snsqs/composer.json create mode 100644 pkg/snsqs/examples/consumer.php create mode 100644 pkg/snsqs/examples/produce.php create mode 100644 pkg/snsqs/phpunit.xml.dist diff --git a/composer.json b/composer.json index 500ff2d06..b04c52d23 100644 --- a/composer.json +++ b/composer.json @@ -76,6 +76,7 @@ "Enqueue\\SimpleClient\\": "pkg/simple-client/", "Enqueue\\Sqs\\": "pkg/sqs/", "Enqueue\\Sns\\": "pkg/sns/", + "Enqueue\\SnsQs\\": "pkg/snsqs/", "Enqueue\\Stomp\\": "pkg/stomp/", "Enqueue\\Test\\": "pkg/test/", "Enqueue\\Dsn\\": "pkg/dsn/", diff --git a/pkg/sns/SnsClient.php b/pkg/sns/SnsClient.php index 6b5f6a337..133f43fb9 100644 --- a/pkg/sns/SnsClient.php +++ b/pkg/sns/SnsClient.php @@ -48,6 +48,16 @@ public function subscribe(array $args): Result return $this->callApi('subscribe', $args); } + public function unsubscribe(array $args): Result + { + return $this->callApi('unsubscribe', $args); + } + + public function listSubscriptionsByTopic(array $args): Result + { + return $this->callApi('ListSubscriptionsByTopic', $args); + } + public function getAWSClient(): AwsSnsClient { $this->resolveClient(); diff --git a/pkg/sns/SnsContext.php b/pkg/sns/SnsContext.php index 818eda4a7..02bcfd173 100644 --- a/pkg/sns/SnsContext.php +++ b/pkg/sns/SnsContext.php @@ -79,16 +79,61 @@ public function declareTopic(SnsDestination $destination): void public function subscribe(SnsSubscribe $subscribe): void { + foreach ($this->getSubscriptions($subscribe->getTopic()) as $subscription) { + if ($subscription['Protocol'] === $subscribe->getProtocol() + && $subscription['Endpoint'] === $subscribe->getEndpoint()) { + return; + } + } + $this->client->subscribe([ 'Attributes' => $subscribe->getAttributes(), 'Endpoint' => $subscribe->getEndpoint(), - //'Protocol' => '', // REQUIRED -//'ReturnSubscriptionArn' => true || false, -//'TopicArn' => '', // REQUIRED -//]); + 'Protocol' => $subscribe->getProtocol(), + 'ReturnSubscriptionArn' => $subscribe->isReturnSubscriptionArn(), + 'TopicArn' => $this->getTopicArn($subscribe->getTopic()), ]); } + public function unsubscibe(SnsUnsubscribe $unsubscribe): void + { + foreach ($this->getSubscriptions($unsubscribe->getTopic()) as $subscription) { + if ($subscription['Protocol'] != $unsubscribe->getProtocol()) { + continue; + } + + if ($subscription['Endpoint'] != $unsubscribe->getEndpoint()) { + continue; + } + + $this->client->unsubscribe([ + 'SubscriptionArn' => $subscription['SubscriptionArn'], + ]); + } + } + + public function getSubscriptions(SnsDestination $destination): array + { + $args = [ + 'TopicArn' => $this->getTopicArn($destination), + ]; + + $subscriptions = []; + while (true) { + $result = $this->client->listSubscriptionsByTopic($args); + + $subscriptions = array_merge($subscriptions, $result->get('Subscriptions')); + + if (false == $result->hasKey('NextToken')) { + break; + } + + $args['NextToken'] = $result->get('NextToken'); + } + + return $subscriptions; + } + public function getTopicArn(SnsDestination $destination): string { if (false == array_key_exists($destination->getTopicName(), $this->topicArns)) { diff --git a/pkg/sns/SnsSubscribe.php b/pkg/sns/SnsSubscribe.php index 94d457d55..70addb7e0 100644 --- a/pkg/sns/SnsSubscribe.php +++ b/pkg/sns/SnsSubscribe.php @@ -6,25 +6,50 @@ class SnsSubscribe { - private $destination; + const PROTOCOL_SQS = 'sqs'; + /** + * @var SnsDestination + */ + private $topic; + + /** + * @var string + */ private $endpoint; + /** + * @var string + */ private $protocol; + /** + * @var + */ + private $returnSubscriptionArn; + + /** + * @var + */ private $attributes; - public function __construct(SnsDestination $destination, string $endpoint, string $protocol, array $arguments) - { - $this->destination = $destination; + public function __construct( + SnsDestination $topic, + string $endpoint, + string $protocol, + bool $returnSubscriptionArn = false, + array $attributes = [] + ) { + $this->topic = $topic; $this->endpoint = $endpoint; $this->protocol = $protocol; - $this->attributes = $arguments; + $this->returnSubscriptionArn = $returnSubscriptionArn; + $this->attributes = $attributes; } - public function getDestination(): SnsDestination + public function getTopic(): SnsDestination { - return $this->destination; + return $this->topic; } public function getEndpoint(): string @@ -32,6 +57,16 @@ public function getEndpoint(): string return $this->endpoint; } + public function getProtocol(): string + { + return $this->protocol; + } + + public function isReturnSubscriptionArn(): bool + { + return $this->returnSubscriptionArn; + } + public function getAttributes(): array { return $this->attributes; diff --git a/pkg/sns/SnsUnsubscribe.php b/pkg/sns/SnsUnsubscribe.php new file mode 100644 index 000000000..cd5733651 --- /dev/null +++ b/pkg/sns/SnsUnsubscribe.php @@ -0,0 +1,48 @@ +topic = $topic; + $this->endpoint = $endpoint; + $this->protocol = $protocol; + } + + public function getTopic(): SnsDestination + { + return $this->topic; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function getProtocol(): string + { + return $this->protocol; + } +} diff --git a/pkg/snsqs/.gitattributes b/pkg/snsqs/.gitattributes new file mode 100644 index 000000000..bdf2dcb14 --- /dev/null +++ b/pkg/snsqs/.gitattributes @@ -0,0 +1,5 @@ +/Tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpunit.xml.dist export-ignore diff --git a/pkg/snsqs/.gitignore b/pkg/snsqs/.gitignore new file mode 100644 index 000000000..a770439e5 --- /dev/null +++ b/pkg/snsqs/.gitignore @@ -0,0 +1,6 @@ +*~ +/composer.lock +/composer.phar +/phpunit.xml +/vendor/ +/.idea/ diff --git a/pkg/snsqs/.travis.yml b/pkg/snsqs/.travis.yml new file mode 100644 index 000000000..9ed4fa123 --- /dev/null +++ b/pkg/snsqs/.travis.yml @@ -0,0 +1,21 @@ +sudo: false + +git: + depth: 10 + +language: php + +php: + - '7.1' + - '7.2' + +cache: + directories: + - $HOME/.composer/cache + +install: + - composer self-update + - composer install --prefer-source + +script: + - vendor/bin/phpunit --exclude-group=functional diff --git a/pkg/snsqs/LICENSE b/pkg/snsqs/LICENSE new file mode 100644 index 000000000..20211e5fd --- /dev/null +++ b/pkg/snsqs/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2018 Max Kotliar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/snsqs/README.md b/pkg/snsqs/README.md new file mode 100644 index 000000000..b944e6c7e --- /dev/null +++ b/pkg/snsqs/README.md @@ -0,0 +1,28 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Amazon SNS-SQS Transport + +[![Gitter](https://badges.gitter.im/php-enqueue/Lobby.svg)](https://gitter.im/php-enqueue/Lobby) +[![Build Status](https://travis-ci.org/php-enqueue/snsqs.png?branch=master)](https://travis-ci.org/php-enqueue/snsqs) +[![Total Downloads](https://poser.pugx.org/enqueue/snsqs/d/total.png)](https://packagist.org/packages/enqueue/snsqs) +[![Latest Stable Version](https://poser.pugx.org/enqueue/snsqs/version.png)](https://packagist.org/packages/enqueue/snsqs) + +This is an implementation of Queue Interop specification. It allows you to send and consume message using [Amazon SNS-SQS](https://aws.amazon.com/snsqs/) service. + +## Resources + +* [Site](https://enqueue.forma-pro.com/) +* [Documentation](https://github.com/php-enqueue/enqueue-dev/blob/master/docs/transport/snsqs.md) +* [Questions](https://gitter.im/php-enqueue/Lobby) +* [Issue Tracker](https://github.com/php-enqueue/enqueue-dev/issues) + +## License + +It is released under the [MIT License](LICENSE). \ No newline at end of file diff --git a/pkg/snsqs/SnsQsConnectionFactory.php b/pkg/snsqs/SnsQsConnectionFactory.php new file mode 100644 index 000000000..9c272d82f --- /dev/null +++ b/pkg/snsqs/SnsQsConnectionFactory.php @@ -0,0 +1,97 @@ + null AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'secret' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'token' => null, AWS credentials. If no credentials are provided, the SDK will attempt to load them from the environment. + * 'region' => null, (string, required) Region to connect to. See http://docs.aws.amazon.com/general/latest/gr/rande.html for a list of available regions. + * 'version' => '2012-11-05', (string, required) The version of the webservice to utilize + * 'lazy' => true, Enable lazy connection (boolean) + * 'endpoint' => null (string, default=null) The full URI of the webservice. This is only required when connecting to a custom endpoint e.g. localstack + * ]. + * + * or + * + * snsqs: + * snsqs:?key=aKey&secret=aSecret&token=aToken + * + * @param array|string|null $config + */ + public function __construct($config = 'snsqs:') + { + if (empty($config)) { + $this->snsConfig = []; + $this->sqsConfig = []; + } elseif (is_string($config)) { + $this->parseDsn($config); + } elseif (is_array($config)) { + if (array_key_exists('dsn', $config)) { + $this->parseDsn($config['dsn']); + } else { + if (array_key_exists('sns', $config)) { + $this->snsConfig = $config['sns']; + } else { + $this->snsConfig = $config; + } + + if (array_key_exists('sqs', $config)) { + $this->sqsConfig = $config['sqs']; + } else { + $this->sqsConfig = $config; + } + } + } else { + throw new \LogicException(sprintf('The config must be either an array of options, a DSN string, null or instance of %s', AwsSnsClient::class)); + } + } + + /** + * @return SnsQsContext + */ + public function createContext(): Context + { + return new SnsQsContext(function() { + return (new SnsConnectionFactory($this->snsConfig))->createContext(); + }, function() { + return (new SqsConnectionFactory($this->sqsConfig))->createContext(); + }); + } + + private function parseDsn(string $dsn): void + { + $dsn = Dsn::parseFirst($dsn); + + if ('snsqs' !== $dsn->getSchemeProtocol()) { + throw new \LogicException(sprintf( + 'The given scheme protocol "%s" is not supported. It must be "snsqs"', + $dsn->getSchemeProtocol() + )); + } + + $this->snsConfig = 'sns:?'.$dsn->getQueryString(); + $this->sqsConfig = 'sqs:?'.$dsn->getQueryString(); + } +} diff --git a/pkg/snsqs/SnsQsConsumer.php b/pkg/snsqs/SnsQsConsumer.php new file mode 100644 index 000000000..362905370 --- /dev/null +++ b/pkg/snsqs/SnsQsConsumer.php @@ -0,0 +1,102 @@ +context = $context; + $this->consumer = $consumer; + $this->queue = $queue; + } + + public function getQueue(): Queue + { + return $this->queue; + } + + public function receive(int $timeout = 0): ?Message + { + if ($sqsMessage = $this->consumer->receive($timeout)) { + return $this->convertMessage($sqsMessage); + } + + return null; + } + + public function receiveNoWait(): ?Message + { + if ($sqsMessage = $this->consumer->receiveNoWait()) { + return $this->convertMessage($sqsMessage); + } + + return null; + } + + /** + * @param SnsQsMessage $message + */ + public function acknowledge(Message $message): void + { + InvalidMessageException::assertMessageInstanceOf($message, SnsQsMessage::class); + + $this->consumer->acknowledge($message->getSqsMessage()); + } + + /** + * @param SnsQsMessage $message + */ + public function reject(Message $message, bool $requeue = false): void + { + InvalidMessageException::assertMessageInstanceOf($message, SnsQsMessage::class); + + $this->consumer->reject($message->getSqsMessage(), $requeue); + } + + private function convertMessage(SqsMessage $sqsMessage): SnsQsMessage + { + $message = $this->context->createMessage(); + $message->setRedelivered($sqsMessage->isRedelivered()); + $message->setSqsMessage($sqsMessage); + + $data = json_decode($sqsMessage->getBody(), true); + + if (isset($data['Message'])) { + $message->setBody((string) $data['Message']); + } + + if (isset($data['MessageAttributes']['Headers'])) { + $headersData = json_decode($data['MessageAttributes']['Headers']['Value'], true); + + $message->setHeaders($headersData[0]); + $message->setProperties($headersData[1]); + } + + return $message; + } +} diff --git a/pkg/snsqs/SnsQsContext.php b/pkg/snsqs/SnsQsContext.php new file mode 100644 index 000000000..a878add1d --- /dev/null +++ b/pkg/snsqs/SnsQsContext.php @@ -0,0 +1,208 @@ +snsContext = $snsContext; + } elseif (is_callable($snsContext)) { + $this->snsContextFactory = $snsContext; + } else { + throw new \InvalidArgumentException(sprintf( + 'The $snsContext argument must be either %s or callable that returns %s once called.', + SnsContext::class, + SnsContext::class + )); + } + + if ($sqsContext instanceof SqsContext) { + $this->sqsContext = $sqsContext; + } elseif (is_callable($sqsContext)) { + $this->sqsContextFactory = $sqsContext; + } else { + throw new \InvalidArgumentException(sprintf( + 'The $sqsContext argument must be either %s or callable that returns %s once called.', + SqsContext::class, + SqsContext::class + )); + } + } + + /** + * @return SnsQsMessage + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new SnsQsMessage($body, $properties, $headers); + } + + /** + * @return SnsQsTopic + */ + public function createTopic(string $topicName): Topic + { + return new SnsQsTopic($topicName); + } + + /** + * @return SnsQsQueue + */ + public function createQueue(string $queueName): Queue + { + return new SnsQsQueue($queueName); + } + + public function createTemporaryQueue(): Queue + { + throw TemporaryQueueNotSupportedException::providerDoestNotSupportIt(); + } + + public function createProducer(): Producer + { + return new SnsQsProducer($this->getSnsContext()); + } + + /** + * @param SnsQsQueue $destination + */ + public function createConsumer(Destination $destination): Consumer + { + InvalidDestinationException::assertDestinationInstanceOf($destination, SnsQsQueue::class); + + return new SnsQsConsumer($this, $this->getSqsContext()->createConsumer($destination), $destination); + } + + /** + * @param SnsQsQueue $queue + */ + public function purgeQueue(Queue $queue): void + { + InvalidDestinationException::assertDestinationInstanceOf($queue, SnsQsQueue::class); + + $this->getSqsContext()->purgeQueue($queue); + } + + public function createSubscriptionConsumer(): SubscriptionConsumer + { + throw SubscriptionConsumerNotSupportedException::providerDoestNotSupportIt(); + } + + public function declareTopic(SnsQsTopic $topic): void + { + InvalidDestinationException::assertDestinationInstanceOf($topic, SnsQsTopic::class); + + $this->getSnsContext()->declareTopic($topic); + } + + public function declareQueue(SnsQsQueue $queue): void + { + InvalidDestinationException::assertDestinationInstanceOf($queue, SnsQsQueue::class); + + $this->getSqsContext()->declareQueue($queue); + } + + public function bind(SnsQsTopic $topic, SnsQsQueue $queue): void + { + $this->getSnsContext()->subscribe(new SnsSubscribe( + $topic, + $this->getSqsContext()->getQueueArn($queue), + SnsSubscribe::PROTOCOL_SQS + )); + } + + public function unbind(SnsQsTopic $topic, SnsQsQueue $queue): void + { + $this->getSnsContext()->unsubscibe(new SnsUnsubscribe( + $topic, + $this->getSqsContext()->getQueueArn($queue), + SnsSubscribe::PROTOCOL_SQS + )); + } + + public function close(): void + { + $this->getSnsContext()->close(); + $this->getSqsContext()->close(); + } + + private function getSnsContext(): SnsContext + { + if (null === $this->snsContext) { + $context = call_user_func($this->snsContextFactory); + if (false == $context instanceof SnsContext) { + throw new \LogicException(sprintf( + 'The factory must return instance of %s. It returned %s', + SnsContext::class, + is_object($context) ? get_class($context) : gettype($context) + )); + } + + $this->snsContext = $context; + } + + return $this->snsContext; + } + + private function getSqsContext(): SqsContext + { + if (null === $this->sqsContext) { + $context = call_user_func($this->sqsContextFactory); + if (false == $context instanceof SqsContext) { + throw new \LogicException(sprintf( + 'The factory must return instance of %s. It returned %s', + SqsContext::class, + is_object($context) ? get_class($context) : gettype($context) + )); + } + + $this->sqsContext = $context; + } + + return $this->sqsContext; + } +} diff --git a/pkg/snsqs/SnsQsMessage.php b/pkg/snsqs/SnsQsMessage.php new file mode 100644 index 000000000..e34a103ff --- /dev/null +++ b/pkg/snsqs/SnsQsMessage.php @@ -0,0 +1,37 @@ +body = $body; + $this->properties = $properties; + $this->headers = $headers; + $this->redelivered = false; + } + + public function setSqsMessage(SqsMessage $message): void + { + $this->sqsMessage = $message; + } + + public function getSqsMessage(): SqsMessage + { + return $this->sqsMessage; + } +} diff --git a/pkg/snsqs/SnsQsProducer.php b/pkg/snsqs/SnsQsProducer.php new file mode 100644 index 000000000..bde0cde1b --- /dev/null +++ b/pkg/snsqs/SnsQsProducer.php @@ -0,0 +1,90 @@ +context = $context; + } + + /** + * @param SnsQsTopic $destination + * @param SnsQsMessage $message + */ + public function send(Destination $destination, Message $message): void + { + InvalidDestinationException::assertDestinationInstanceOf($destination, SnsQsTopic::class); + InvalidMessageException::assertMessageInstanceOf($message, SnsQsMessage::class); + + $snsMessage = $this->context->createMessage($message->getBody(), $message->getProperties(), $message->getHeaders()); + + $this->getProducer()->send($destination, $snsMessage); + } + + public function setDeliveryDelay(int $deliveryDelay = null): Producer + { + $this->getProducer()->setDeliveryDelay($deliveryDelay); + + return $this; + } + + public function getDeliveryDelay(): ?int + { + return $this->getProducer()->getDeliveryDelay(); + } + + public function setPriority(int $priority = null): Producer + { + $this->getProducer()->setPriority($priority); + + return $this; + } + + public function getPriority(): ?int + { + return $this->getProducer()->getPriority(); + } + + public function setTimeToLive(int $timeToLive = null): Producer + { + $this->getProducer()->setTimeToLive($timeToLive); + + return $this; + } + + public function getTimeToLive(): ?int + { + return $this->getProducer()->getTimeToLive(); + } + + private function getProducer(): SnsProducer + { + if (null === $this->producer) { + $this->producer = $this->context->createProducer(); + } + + return $this->producer; + } +} diff --git a/pkg/snsqs/SnsQsQueue.php b/pkg/snsqs/SnsQsQueue.php new file mode 100644 index 000000000..92c3a542b --- /dev/null +++ b/pkg/snsqs/SnsQsQueue.php @@ -0,0 +1,11 @@ + getenv('SNS_DSN'), + 'sqs' => getenv('SQS_DSN'), +]))->createContext(); + +$topic = $context->createTopic('topic'); +$queue = $context->createQueue('queue'); + +$context->declareTopic($topic); +$context->declareQueue($queue); +$context->bind($topic, $queue); + +$consumer = $context->createConsumer($queue); + +while (true) { + if ($m = $consumer->receive(20000)) { + $consumer->acknowledge($m); + echo 'Received message: '.$m->getBody().' '.json_encode($m->getHeaders()).' '.json_encode($m->getProperties()).PHP_EOL; + } +} +echo 'Done'."\n"; diff --git a/pkg/snsqs/examples/produce.php b/pkg/snsqs/examples/produce.php new file mode 100644 index 000000000..0396d9cd0 --- /dev/null +++ b/pkg/snsqs/examples/produce.php @@ -0,0 +1,40 @@ + getenv('SNS_DSN'), + 'sqs' => getenv('SQS_DSN'), +]))->createContext(); + +$topic = $context->createTopic('topic'); +$queue = $context->createQueue('queue'); + +$context->declareTopic($topic); +$context->declareQueue($queue); +$context->bind($topic, $queue); + +$message = $context->createMessage('Hello Bar!', ['key' => 'value'], ['key2' => 'value2']); + +while (true) { + $context->createProducer()->send($topic, $message); + echo 'Sent message: '.$message->getBody().PHP_EOL; + sleep(1); +} + +echo 'Done'."\n"; diff --git a/pkg/snsqs/phpunit.xml.dist b/pkg/snsqs/phpunit.xml.dist new file mode 100644 index 000000000..e9b3b30e8 --- /dev/null +++ b/pkg/snsqs/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + ./Tests + + + + + + . + + ./vendor + ./Tests + + + + diff --git a/pkg/sqs/SqsContext.php b/pkg/sqs/SqsContext.php index 4860bbefe..60906f72f 100644 --- a/pkg/sqs/SqsContext.php +++ b/pkg/sqs/SqsContext.php @@ -179,11 +179,11 @@ public function getQueueArn(SqsDestination $destination): string $result = $this->client->getQueueAttributes($arguments); - if (false == $result->hasKey('QueueArn')) { + if (false == $arn = $result->search('Attributes.QueueArn')) { throw new \RuntimeException(sprintf('QueueArn cannot be resolved. queueName: "%s"', $destination->getQueueName())); } - return $this->queueArns[$destination->getQueueName()] = (string) $result->get('QueueArn'); + return $this->queueArns[$destination->getQueueName()] = (string) $arn; } public function declareQueue(SqsDestination $dest): void From 159de1982c01193817deb90788e0629108314d3e Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Tue, 19 Feb 2019 13:24:41 +0200 Subject: [PATCH 3/9] snsqs tests --- bin/subtree-split | 2 + pkg/sns/SnsClient.php | 7 ++ pkg/sns/SnsContext.php | 7 ++ pkg/snsqs/SnsQsConsumer.php | 28 +++++-- pkg/snsqs/SnsQsContext.php | 16 ++-- pkg/snsqs/SnsQsProducer.php | 80 +++++++++++++++---- .../Tests/Spec/SnsQsConnectionFactoryTest.php | 18 +++++ pkg/snsqs/Tests/Spec/SnsQsContextTest.php | 27 +++++++ pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php | 68 ++++++++++++++++ pkg/snsqs/Tests/Spec/SnsQsMessageTest.php | 14 ++++ pkg/snsqs/Tests/Spec/SnsQsProducerTest.php | 19 +++++ pkg/snsqs/Tests/Spec/SnsQsQueueTest.php | 14 ++++ .../SnsQsSendToAndReceiveFromQueueTest.php | 34 ++++++++ ...sQsSendToAndReceiveNoWaitFromQueueTest.php | 34 ++++++++ ...nsQsSendToTopicAndReceiveFromQueueSpec.php | 39 +++++++++ ...ndToTopicAndReceiveNoWaitFromQueueTest.php | 39 +++++++++ pkg/snsqs/Tests/Spec/SnsQsTopicTest.php | 14 ++++ pkg/test/SnsQsExtension.php | 21 +++++ 18 files changed, 451 insertions(+), 30 deletions(-) create mode 100644 pkg/snsqs/Tests/Spec/SnsQsConnectionFactoryTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsContextTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsMessageTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsProducerTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsQueueTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveFromQueueTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveFromQueueSpec.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveNoWaitFromQueueTest.php create mode 100644 pkg/snsqs/Tests/Spec/SnsQsTopicTest.php create mode 100644 pkg/test/SnsQsExtension.php diff --git a/bin/subtree-split b/bin/subtree-split index c52f15a8e..1ccc74383 100755 --- a/bin/subtree-split +++ b/bin/subtree-split @@ -84,6 +84,8 @@ split 'pkg/redis' redis split 'pkg/dbal' dbal split 'pkg/null' null split 'pkg/sqs' sqs +split 'pkg/sns' sns +split 'pkg/snsqs' snsqs split 'pkg/gps' gps split 'pkg/enqueue-bundle' enqueue-bundle split 'pkg/job-queue' job-queue diff --git a/pkg/sns/SnsClient.php b/pkg/sns/SnsClient.php index 133f43fb9..2d33faf25 100644 --- a/pkg/sns/SnsClient.php +++ b/pkg/sns/SnsClient.php @@ -38,6 +38,13 @@ public function createTopic(array $args): Result return $this->callApi('createTopic', $args); } + public function deleteTopic(string $topicArn): Result + { + return $this->callApi('DeleteTopic', [ + 'TopicArn' => $topicArn, + ]); + } + public function publish(array $args): Result { return $this->callApi('publish', $args); diff --git a/pkg/sns/SnsContext.php b/pkg/sns/SnsContext.php index 02bcfd173..b489f8d57 100644 --- a/pkg/sns/SnsContext.php +++ b/pkg/sns/SnsContext.php @@ -77,6 +77,13 @@ public function declareTopic(SnsDestination $destination): void $this->topicArns[$destination->getTopicName()] = (string) $result->get('TopicArn'); } + public function deleteTopic(SnsDestination $destination): void + { + $this->client->deleteTopic($this->getTopicArn($destination)); + + unset($this->topicArns[$destination->getTopicName()]); + } + public function subscribe(SnsSubscribe $subscribe): void { foreach ($this->getSubscriptions($subscribe->getTopic()) as $subscription) { diff --git a/pkg/snsqs/SnsQsConsumer.php b/pkg/snsqs/SnsQsConsumer.php index 362905370..77051d364 100644 --- a/pkg/snsqs/SnsQsConsumer.php +++ b/pkg/snsqs/SnsQsConsumer.php @@ -84,19 +84,31 @@ private function convertMessage(SqsMessage $sqsMessage): SnsQsMessage $message->setRedelivered($sqsMessage->isRedelivered()); $message->setSqsMessage($sqsMessage); - $data = json_decode($sqsMessage->getBody(), true); + $body = $sqsMessage->getBody(); - if (isset($data['Message'])) { - $message->setBody((string) $data['Message']); - } + if (isset($body[0]) && $body[0] === '{') { + $data = json_decode($sqsMessage->getBody(), true); + + if (isset($data['TopicArn']) && isset($data['Type']) && $data['Type'] === 'Notification') { + // SNS message conversion + if (isset($data['Message'])) { + $message->setBody((string) $data['Message']); + } - if (isset($data['MessageAttributes']['Headers'])) { - $headersData = json_decode($data['MessageAttributes']['Headers']['Value'], true); + if (isset($data['MessageAttributes']['Headers'])) { + $headersData = json_decode($data['MessageAttributes']['Headers']['Value'], true); - $message->setHeaders($headersData[0]); - $message->setProperties($headersData[1]); + $message->setHeaders($headersData[0]); + $message->setProperties($headersData[1]); + } + + return $message; + } } + $message->setBody($sqsMessage->getBody()); + $message->setProperties($sqsMessage->getProperties()); + return $message; } } diff --git a/pkg/snsqs/SnsQsContext.php b/pkg/snsqs/SnsQsContext.php index a878add1d..d520cdb10 100644 --- a/pkg/snsqs/SnsQsContext.php +++ b/pkg/snsqs/SnsQsContext.php @@ -104,7 +104,7 @@ public function createTemporaryQueue(): Queue public function createProducer(): Producer { - return new SnsQsProducer($this->getSnsContext()); + return new SnsQsProducer($this->getSnsContext(), $this->getSqsContext()); } /** @@ -134,18 +134,24 @@ public function createSubscriptionConsumer(): SubscriptionConsumer public function declareTopic(SnsQsTopic $topic): void { - InvalidDestinationException::assertDestinationInstanceOf($topic, SnsQsTopic::class); - $this->getSnsContext()->declareTopic($topic); } - public function declareQueue(SnsQsQueue $queue): void + public function deleteTopic(SnsQsTopic $topic): void { - InvalidDestinationException::assertDestinationInstanceOf($queue, SnsQsQueue::class); + $this->getSnsContext()->deleteTopic($topic); + } + public function declareQueue(SnsQsQueue $queue): void + { $this->getSqsContext()->declareQueue($queue); } + public function deleteQueue(SnsQsQueue $queue): void + { + $this->getSqsContext()->deleteQueue($queue); + } + public function bind(SnsQsTopic $topic, SnsQsQueue $queue): void { $this->getSnsContext()->subscribe(new SnsSubscribe( diff --git a/pkg/snsqs/SnsQsProducer.php b/pkg/snsqs/SnsQsProducer.php index bde0cde1b..f77b861e6 100644 --- a/pkg/snsqs/SnsQsProducer.php +++ b/pkg/snsqs/SnsQsProducer.php @@ -6,6 +6,8 @@ use Enqueue\Sns\SnsContext; use Enqueue\Sns\SnsProducer; +use Enqueue\Sqs\SqsContext; +use Enqueue\Sqs\SqsProducer; use Interop\Queue\Destination; use Interop\Queue\Exception\InvalidDestinationException; use Interop\Queue\Exception\InvalidMessageException; @@ -17,16 +19,27 @@ class SnsQsProducer implements Producer /** * @var SnsContext */ - private $context; + private $snsContext; /** * @var SnsProducer */ - private $producer; + private $snsProducer; - public function __construct(SnsContext $context) + /** + * @var SqsContext + */ + private $sqsContext; + + /** + * @var SqsProducer + */ + private $sqsProducer; + + public function __construct(SnsContext $snsContext, SqsContext $sqsContext) { - $this->context = $context; + $this->snsContext = $snsContext; + $this->sqsContext = $sqsContext; } /** @@ -35,56 +48,89 @@ public function __construct(SnsContext $context) */ public function send(Destination $destination, Message $message): void { - InvalidDestinationException::assertDestinationInstanceOf($destination, SnsQsTopic::class); InvalidMessageException::assertMessageInstanceOf($message, SnsQsMessage::class); - $snsMessage = $this->context->createMessage($message->getBody(), $message->getProperties(), $message->getHeaders()); + if (false == $destination instanceof SnsQsTopic && false == $destination instanceof SnsQsQueue) { + throw new InvalidDestinationException(sprintf( + 'The destination must be an instance of [%s|%s] but got %s.', + SnsQsTopic::class, SnsQsQueue::class, + is_object($destination) ? get_class($destination) : gettype($destination) + )); + } - $this->getProducer()->send($destination, $snsMessage); + if ($destination instanceof SnsQsTopic) { + $snsMessage = $this->snsContext->createMessage( + $message->getBody(), + $message->getProperties(), + $message->getHeaders() + ); + + $this->getSnsProducer()->send($destination, $snsMessage); + } else { + $sqsMessage = $this->sqsContext->createMessage( + $message->getBody(), + $message->getProperties(), + $message->getHeaders() + ); + + $this->getSqsProducer()->send($destination, $sqsMessage); + } } public function setDeliveryDelay(int $deliveryDelay = null): Producer { - $this->getProducer()->setDeliveryDelay($deliveryDelay); + $this->getSnsProducer()->setDeliveryDelay($deliveryDelay); + $this->getSqsProducer()->setDeliveryDelay($deliveryDelay); return $this; } public function getDeliveryDelay(): ?int { - return $this->getProducer()->getDeliveryDelay(); + return $this->getSnsProducer()->getDeliveryDelay(); } public function setPriority(int $priority = null): Producer { - $this->getProducer()->setPriority($priority); + $this->getSnsProducer()->setPriority($priority); + $this->getSqsProducer()->setPriority($priority); return $this; } public function getPriority(): ?int { - return $this->getProducer()->getPriority(); + return $this->getSnsProducer()->getPriority(); } public function setTimeToLive(int $timeToLive = null): Producer { - $this->getProducer()->setTimeToLive($timeToLive); + $this->getSnsProducer()->setTimeToLive($timeToLive); + $this->getSqsProducer()->setTimeToLive($timeToLive); return $this; } public function getTimeToLive(): ?int { - return $this->getProducer()->getTimeToLive(); + return $this->getSnsProducer()->getTimeToLive(); + } + + private function getSnsProducer(): SnsProducer + { + if (null === $this->snsProducer) { + $this->snsProducer = $this->snsContext->createProducer(); + } + + return $this->snsProducer; } - private function getProducer(): SnsProducer + private function getSqsProducer(): SqsProducer { - if (null === $this->producer) { - $this->producer = $this->context->createProducer(); + if (null === $this->sqsProducer) { + $this->sqsProducer = $this->sqsContext->createProducer(); } - return $this->producer; + return $this->sqsProducer; } } diff --git a/pkg/snsqs/Tests/Spec/SnsQsConnectionFactoryTest.php b/pkg/snsqs/Tests/Spec/SnsQsConnectionFactoryTest.php new file mode 100644 index 000000000..f00c350da --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsConnectionFactoryTest.php @@ -0,0 +1,18 @@ +createMock(SqsContext::class); + $sqsContext + ->expects($this->any()) + ->method('createConsumer') + ->willReturn($this->createMock(SqsConsumer::class)) + ; + + return new SnsQsContext( + $this->createMock(SnsContext::class), + $sqsContext + ); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php b/pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php new file mode 100644 index 000000000..efc4a7046 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsFactoryTrait.php @@ -0,0 +1,68 @@ +snsQsContext = $this->buildSnsQsContext(); + } + + protected function createSnsQsQueue(string $queueName): SnsQsQueue + { + $queueName = $queueName.time(); + + $this->snsQsQueue = $this->snsQsContext->createQueue($queueName); + $this->snsQsContext->declareQueue($this->snsQsQueue); + + if ($this->snsQsTopic) { + $this->snsQsContext->bind($this->snsQsTopic, $this->snsQsQueue); + } + + return $this->snsQsQueue; + } + + protected function createSnsQsTopic(string $topicName): SnsQsTopic + { + $topicName = $topicName.time(); + + $this->snsQsTopic = $this->snsQsContext->createTopic($topicName); + $this->snsQsContext->declareTopic($this->snsQsTopic); + + return $this->snsQsTopic; + } + + protected function cleanUpSnsQs(): void + { + if ($this->snsQsTopic) { + $this->snsQsContext->deleteTopic($this->snsQsTopic); + } + + if ($this->snsQsQueue) { + $this->snsQsContext->deleteQueue($this->snsQsQueue); + } + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsMessageTest.php b/pkg/snsqs/Tests/Spec/SnsQsMessageTest.php new file mode 100644 index 000000000..a2815cde5 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsMessageTest.php @@ -0,0 +1,14 @@ +createMock(SnsContext::class), + $this->createMock(SqsContext::class) + ); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsQueueTest.php new file mode 100644 index 000000000..6a6bd4dfd --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsQueueTest.php @@ -0,0 +1,14 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..4ec916bd1 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,34 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveFromQueueSpec.php b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveFromQueueSpec.php new file mode 100644 index 000000000..f32e37a94 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveFromQueueSpec.php @@ -0,0 +1,39 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createTopic(Context $context, $topicName) + { + return $this->createSnsQsTopic($topicName); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveNoWaitFromQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveNoWaitFromQueueTest.php new file mode 100644 index 000000000..5d404e4a3 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToTopicAndReceiveNoWaitFromQueueTest.php @@ -0,0 +1,39 @@ +cleanUpSnsQs(); + } + + protected function createContext() + { + return $this->createSnsQsContext(); + } + + protected function createTopic(Context $context, $topicName) + { + return $this->createSnsQsTopic($topicName); + } + + protected function createQueue(Context $context, $queueName) + { + return $this->createSnsQsQueue($queueName); + } +} diff --git a/pkg/snsqs/Tests/Spec/SnsQsTopicTest.php b/pkg/snsqs/Tests/Spec/SnsQsTopicTest.php new file mode 100644 index 000000000..94a455987 --- /dev/null +++ b/pkg/snsqs/Tests/Spec/SnsQsTopicTest.php @@ -0,0 +1,14 @@ + $snsDsn, 'sqs' => $sqsDsn]))->createContext(); + } +} From 8c0e3c62d2a0602b0f4b229c3bb289e97f821c5e Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Tue, 19 Feb 2019 15:35:48 +0200 Subject: [PATCH 4/9] snsqs client driver --- phpunit.xml.dist | 4 + pkg/enqueue/Client/Driver/SnsQsDriver.php | 90 +++++++++++++++++++++++ pkg/enqueue/Client/Resources.php | 7 ++ pkg/enqueue/Resources.php | 4 + 4 files changed, 105 insertions(+) create mode 100644 pkg/enqueue/Client/Driver/SnsQsDriver.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5c5501702..37d8ec56b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -65,6 +65,10 @@ pkg/sns/Tests + + pkg/snsqs/Tests + + pkg/pheanstalk/Tests diff --git a/pkg/enqueue/Client/Driver/SnsQsDriver.php b/pkg/enqueue/Client/Driver/SnsQsDriver.php new file mode 100644 index 000000000..0720699cb --- /dev/null +++ b/pkg/enqueue/Client/Driver/SnsQsDriver.php @@ -0,0 +1,90 @@ +debug(sprintf('[SqsQsDriver] '.$text, ...$args)); + }; + + // setup router + $routerTopic = $this->createRouterTopic(); + $log('Declare router topic: %s', $routerTopic->getTopicName()); + $this->getContext()->declareTopic($routerTopic); + + $routerQueue = $this->createQueue($this->getConfig()->getRouterQueue()); + $log('Declare router queue: %s', $routerQueue->getQueueName()); + $this->getContext()->declareQueue($routerQueue); + + $log('Bind router queue to topic: %s -> %s', $routerQueue->getQueueName(), $routerTopic->getTopicName()); + $this->getContext()->bind($routerTopic, $routerQueue); + + // setup queues + $declaredQueues = []; + $declaredTopics = []; + foreach ($this->getRouteCollection()->all() as $route) { + $queue = $this->createRouteQueue($route); + if (false === array_key_exists($queue->getQueueName(), $declaredQueues)) { + $log('Declare processor queue: %s', $queue->getQueueName()); + $this->getContext()->declareQueue($queue); + + $declaredQueues[$queue->getQueueName()] = true; + } + + if ($route->isCommand()) { + continue; + } + + $topic = $this->doCreateTopic($this->createTransportQueueName($route->getSource(), true)); + if (false === array_key_exists($topic->getTopicName(), $declaredTopics)) { + $log('Declare processor topic: %s', $topic->getTopicName()); + $this->getContext()->declareTopic($topic); + + $declaredTopics[$topic->getTopicName()] = true; + } + + $log('Bind processor queue to topic: %s -> %s', $queue->getQueueName(), $topic->getTopicName()); + $this->getContext()->bind($topic, $queue); + } + } + + protected function createRouterTopic(): Destination + { + return $this->doCreateTopic( + $this->createTransportRouterTopicName($this->getConfig()->getRouterTopic(), true) + ); + } + + protected function createTransportRouterTopicName(string $name, bool $prefix): string + { + $name = parent::createTransportRouterTopicName($name, $prefix); + + return str_replace('.', '_dot_', $name); + } + + protected function createTransportQueueName(string $name, bool $prefix): string + { + $name = parent::createTransportQueueName($name, $prefix); + + return str_replace('.', '_dot_', $name); + } +} diff --git a/pkg/enqueue/Client/Resources.php b/pkg/enqueue/Client/Resources.php index b88b8227b..1207b64f8 100644 --- a/pkg/enqueue/Client/Resources.php +++ b/pkg/enqueue/Client/Resources.php @@ -12,6 +12,7 @@ use Enqueue\Client\Driver\RabbitMqStompDriver; use Enqueue\Client\Driver\RdKafkaDriver; use Enqueue\Client\Driver\RedisDriver; +use Enqueue\Client\Driver\SnsQsDriver; use Enqueue\Client\Driver\SqsDriver; use Enqueue\Client\Driver\StompDriver; @@ -92,6 +93,12 @@ public static function getKnownDrivers(): array 'requiredSchemeExtensions' => [], 'packages' => ['enqueue/enqueue', 'enqueue/sqs'], ]; + $map[] = [ + 'schemes' => ['snsqs'], + 'driverClass' => SnsQsDriver::class, + 'requiredSchemeExtensions' => [], + 'packages' => ['enqueue/enqueue', 'enqueue/sqs', 'enqueue/sns', 'enqueue/snsqs'], + ]; $map[] = [ 'schemes' => ['stomp'], 'driverClass' => StompDriver::class, diff --git a/pkg/enqueue/Resources.php b/pkg/enqueue/Resources.php index a3d94da66..0c804d917 100644 --- a/pkg/enqueue/Resources.php +++ b/pkg/enqueue/Resources.php @@ -155,6 +155,10 @@ public static function getKnownConnections(): array 'schemes' => ['sqs'], 'supportedSchemeExtensions' => [], 'package' => 'enqueue/sqs', ]; + $map[SqsConnectionFactory::class] = [ + 'schemes' => ['snsqs'], + 'supportedSchemeExtensions' => [], + 'package' => 'enqueue/snsqs', ]; $map[GpsConnectionFactory::class] = [ 'schemes' => ['gps'], 'supportedSchemeExtensions' => [], From d9fa5f4de2e33a27f75f67d261262ba16e4b5637 Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Tue, 19 Feb 2019 18:15:00 +0200 Subject: [PATCH 5/9] snsqs client driver --- docker-compose.yml | 1 + .../Tests/Functional/UseCasesTest.php | 9 ++++ pkg/enqueue/Resources.php | 3 +- pkg/snsqs/SnsQsConnectionFactory.php | 48 +++++++++++++------ pkg/test/SnsQsExtension.php | 7 +-- 5 files changed, 48 insertions(+), 20 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3d50a6940..c46bfc472 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - GPS_DSN=gps:?projectId=mqdev&emulatorHost=http://google-pubsub:8085 - SQS_DSN=sqs:?key=key&secret=secret®ion=us-east-1&endpoint=http://localstack:4576&version=latest - SNS_DSN=sns:?key=key&secret=secret®ion=us-east-1&endpoint=http://localstack:4575&version=latest + - SNSQS_DSN=snsqs:?key=key&secret=secret®ion=us-east-1&sns_endpoint=http://localstack:4575&sqs_endpoint=http://localstack:4576&version=latest - WAMP_DSN=wamp://thruway:9090 - REDIS_HOST=redis - REDIS_PORT=6379 diff --git a/pkg/enqueue-bundle/Tests/Functional/UseCasesTest.php b/pkg/enqueue-bundle/Tests/Functional/UseCasesTest.php index 474cb8710..7b9045b75 100644 --- a/pkg/enqueue-bundle/Tests/Functional/UseCasesTest.php +++ b/pkg/enqueue-bundle/Tests/Functional/UseCasesTest.php @@ -136,6 +136,15 @@ public function provideEnqueueConfigs() 'transport' => getenv('MONGO_DSN'), ], ]]; + + yield 'snsqs' => [[ + 'default' => [ + 'transport' => [ + 'dsn' => getenv('SNSQS_DSN'), + ], + ], + ]]; + // // yield 'gps' => [[ // 'transport' => [ diff --git a/pkg/enqueue/Resources.php b/pkg/enqueue/Resources.php index 0c804d917..75884bab6 100644 --- a/pkg/enqueue/Resources.php +++ b/pkg/enqueue/Resources.php @@ -14,6 +14,7 @@ use Enqueue\Pheanstalk\PheanstalkConnectionFactory; use Enqueue\RdKafka\RdKafkaConnectionFactory; use Enqueue\Redis\RedisConnectionFactory; +use Enqueue\SnsQs\SnsQsConnectionFactory; use Enqueue\Sqs\SqsConnectionFactory; use Enqueue\Stomp\StompConnectionFactory; use Enqueue\Wamp\WampConnectionFactory; @@ -155,7 +156,7 @@ public static function getKnownConnections(): array 'schemes' => ['sqs'], 'supportedSchemeExtensions' => [], 'package' => 'enqueue/sqs', ]; - $map[SqsConnectionFactory::class] = [ + $map[SnsQsConnectionFactory::class] = [ 'schemes' => ['snsqs'], 'supportedSchemeExtensions' => [], 'package' => 'enqueue/snsqs', ]; diff --git a/pkg/snsqs/SnsQsConnectionFactory.php b/pkg/snsqs/SnsQsConnectionFactory.php index 9c272d82f..42e200d2f 100644 --- a/pkg/snsqs/SnsQsConnectionFactory.php +++ b/pkg/snsqs/SnsQsConnectionFactory.php @@ -35,8 +35,16 @@ class SnsQsConnectionFactory implements ConnectionFactory * * or * + * $config = [ + * 'sns_key' => null, SNS option + * 'sqs_secret' => null, SQS option + * 'token' Option for both SNS and SQS + * ]. + * + * or + * * snsqs: - * snsqs:?key=aKey&secret=aSecret&token=aToken + * snsqs:?key=aKey&secret=aSecret&sns_token=aSnsToken&sqs_token=aSqsToken * * @param array|string|null $config */ @@ -51,17 +59,7 @@ public function __construct($config = 'snsqs:') if (array_key_exists('dsn', $config)) { $this->parseDsn($config['dsn']); } else { - if (array_key_exists('sns', $config)) { - $this->snsConfig = $config['sns']; - } else { - $this->snsConfig = $config; - } - - if (array_key_exists('sqs', $config)) { - $this->sqsConfig = $config['sqs']; - } else { - $this->sqsConfig = $config; - } + $this->parseOptions($config); } } else { throw new \LogicException(sprintf('The config must be either an array of options, a DSN string, null or instance of %s', AwsSnsClient::class)); @@ -91,7 +89,29 @@ private function parseDsn(string $dsn): void )); } - $this->snsConfig = 'sns:?'.$dsn->getQueryString(); - $this->sqsConfig = 'sqs:?'.$dsn->getQueryString(); + $this->parseOptions($dsn->getQuery()); + } + + private function parseOptions(array $options): void + { + // set default options + foreach ($options as $key => $value) { + if (false === in_array(substr($key, 0, 4), ['sns_', 'sqs_'])) { + $this->snsConfig[$key] = $value; + $this->sqsConfig[$key] = $value; + } + } + + // set transport specific options + foreach ($options as $key => $value) { + switch (substr($key, 0, 4)) { + case 'sns_': + $this->snsConfig[substr($key, 4)] = $value; + break; + case 'sqs_': + $this->sqsConfig[substr($key, 4)] = $value; + break; + } + } } } diff --git a/pkg/test/SnsQsExtension.php b/pkg/test/SnsQsExtension.php index 8bbc2d7e7..719f5ce58 100644 --- a/pkg/test/SnsQsExtension.php +++ b/pkg/test/SnsQsExtension.php @@ -9,13 +9,10 @@ trait SnsQsExtension { private function buildSnsQsContext(): SnsQsContext { - $snsDsn = getenv('SNS_DSN'); - $sqsDsn = getenv('SQS_DSN'); - - if (false == $snsDsn || false == $sqsDsn) { + if (false == $dsn = getenv('SNSQS_DSN')) { throw new \PHPUnit_Framework_SkippedTestError('Functional tests are not allowed in this environment'); } - return (new SnsQsConnectionFactory(['sns' => $snsDsn, 'sqs' => $sqsDsn]))->createContext(); + return (new SnsQsConnectionFactory($dsn))->createContext(); } } From 7e0767ece390677879dd01f81251e7a673222e9e Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Wed, 20 Feb 2019 09:48:49 +0200 Subject: [PATCH 6/9] snsqs client driver --- pkg/snsqs/SnsQsConnectionFactory.php | 2 +- .../Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/snsqs/SnsQsConnectionFactory.php b/pkg/snsqs/SnsQsConnectionFactory.php index 42e200d2f..e5bd59a4b 100644 --- a/pkg/snsqs/SnsQsConnectionFactory.php +++ b/pkg/snsqs/SnsQsConnectionFactory.php @@ -62,7 +62,7 @@ public function __construct($config = 'snsqs:') $this->parseOptions($config); } } else { - throw new \LogicException(sprintf('The config must be either an array of options, a DSN string, null or instance of %s', AwsSnsClient::class)); + throw new \LogicException('The config must be either an array of options, a DSN string or null'); } } diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php index 4ec916bd1..3738af03e 100644 --- a/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php @@ -10,7 +10,7 @@ * @group functional * @retry 5 */ -class SqsSendToAndReceiveNoWaitFromQueueTest extends SendToAndReceiveNoWaitFromQueueSpec +class SqsQsSendToAndReceiveNoWaitFromQueueTest extends SendToAndReceiveNoWaitFromQueueSpec { use RetryTrait; use SnsQsFactoryTrait; From b0e3da4a413530cef453bf04911ccc462d4f60af Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Wed, 20 Feb 2019 09:56:06 +0200 Subject: [PATCH 7/9] snsqs fix typo --- pkg/enqueue/Client/Driver/SnsQsDriver.php | 2 +- pkg/snsqs/SnsQsConnectionFactory.php | 6 +++--- pkg/snsqs/SnsQsConsumer.php | 4 ++-- pkg/snsqs/SnsQsProducer.php | 2 +- .../Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/enqueue/Client/Driver/SnsQsDriver.php b/pkg/enqueue/Client/Driver/SnsQsDriver.php index 0720699cb..2b1d4f233 100644 --- a/pkg/enqueue/Client/Driver/SnsQsDriver.php +++ b/pkg/enqueue/Client/Driver/SnsQsDriver.php @@ -10,7 +10,7 @@ /** * @method SnsQsContext getContext() - * @method SnsQsTopic createRouterTopic() + * @method SnsQsTopic createRouterTopic() */ class SnsQsDriver extends GenericDriver { diff --git a/pkg/snsqs/SnsQsConnectionFactory.php b/pkg/snsqs/SnsQsConnectionFactory.php index e5bd59a4b..abb154a9b 100644 --- a/pkg/snsqs/SnsQsConnectionFactory.php +++ b/pkg/snsqs/SnsQsConnectionFactory.php @@ -71,9 +71,9 @@ public function __construct($config = 'snsqs:') */ public function createContext(): Context { - return new SnsQsContext(function() { + return new SnsQsContext(function () { return (new SnsConnectionFactory($this->snsConfig))->createContext(); - }, function() { + }, function () { return (new SqsConnectionFactory($this->sqsConfig))->createContext(); }); } @@ -96,7 +96,7 @@ private function parseOptions(array $options): void { // set default options foreach ($options as $key => $value) { - if (false === in_array(substr($key, 0, 4), ['sns_', 'sqs_'])) { + if (false === in_array(substr($key, 0, 4), ['sns_', 'sqs_'], true)) { $this->snsConfig[$key] = $value; $this->sqsConfig[$key] = $value; } diff --git a/pkg/snsqs/SnsQsConsumer.php b/pkg/snsqs/SnsQsConsumer.php index 77051d364..82209896e 100644 --- a/pkg/snsqs/SnsQsConsumer.php +++ b/pkg/snsqs/SnsQsConsumer.php @@ -86,10 +86,10 @@ private function convertMessage(SqsMessage $sqsMessage): SnsQsMessage $body = $sqsMessage->getBody(); - if (isset($body[0]) && $body[0] === '{') { + if (isset($body[0]) && '{' === $body[0]) { $data = json_decode($sqsMessage->getBody(), true); - if (isset($data['TopicArn']) && isset($data['Type']) && $data['Type'] === 'Notification') { + if (isset($data['TopicArn']) && isset($data['Type']) && 'Notification' === $data['Type']) { // SNS message conversion if (isset($data['Message'])) { $message->setBody((string) $data['Message']); diff --git a/pkg/snsqs/SnsQsProducer.php b/pkg/snsqs/SnsQsProducer.php index f77b861e6..1baeef307 100644 --- a/pkg/snsqs/SnsQsProducer.php +++ b/pkg/snsqs/SnsQsProducer.php @@ -43,7 +43,7 @@ public function __construct(SnsContext $snsContext, SqsContext $sqsContext) } /** - * @param SnsQsTopic $destination + * @param SnsQsTopic $destination * @param SnsQsMessage $message */ public function send(Destination $destination, Message $message): void diff --git a/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php index 3738af03e..72e29eeae 100644 --- a/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php +++ b/pkg/snsqs/Tests/Spec/SnsQsSendToAndReceiveNoWaitFromQueueTest.php @@ -10,7 +10,7 @@ * @group functional * @retry 5 */ -class SqsQsSendToAndReceiveNoWaitFromQueueTest extends SendToAndReceiveNoWaitFromQueueSpec +class SnsQsSendToAndReceiveNoWaitFromQueueTest extends SendToAndReceiveNoWaitFromQueueSpec { use RetryTrait; use SnsQsFactoryTrait; From 3f043adffc43774e26de0e4c407383750bcd6f43 Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Wed, 20 Feb 2019 10:09:20 +0200 Subject: [PATCH 8/9] snsqs fix tests --- pkg/sqs/Tests/SqsContextTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/sqs/Tests/SqsContextTest.php b/pkg/sqs/Tests/SqsContextTest.php index 204e5d87a..1f5fcf239 100644 --- a/pkg/sqs/Tests/SqsContextTest.php +++ b/pkg/sqs/Tests/SqsContextTest.php @@ -328,12 +328,17 @@ public function testShouldAllowGetQueueArn() 'QueueUrl' => 'theQueueUrl', 'AttributeNames' => ['QueueArn'], ])) - ->willReturn(new Result(['QueueArn' => 'theQueueArn'])) + ->willReturn(new Result([ + 'Attributes' => [ + 'QueueArn' => 'theQueueArn', + ], + ])) ; $context = new SqsContext($sqsClient, []); $queue = $context->createQueue('aQueueName'); + $queue->setRegion('theRegion'); $this->assertSame('theQueueArn', $context->getQueueArn($queue)); } From 905c471b81830ad202a795a9faf2b637257bca2f Mon Sep 17 00:00:00 2001 From: Alexander Kozienko Date: Wed, 20 Feb 2019 11:38:49 +0200 Subject: [PATCH 9/9] snsqs docs --- bin/subtree-split | 2 + docs/index.md | 1 + docs/transport/snsqs.md | 182 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 docs/transport/snsqs.md diff --git a/bin/subtree-split b/bin/subtree-split index 1ccc74383..e8aa1ed4c 100755 --- a/bin/subtree-split +++ b/bin/subtree-split @@ -58,6 +58,8 @@ remote rdkafka git@github.com:php-enqueue/rdkafka.git remote dbal git@github.com:php-enqueue/dbal.git remote null git@github.com:php-enqueue/null.git remote sqs git@github.com:php-enqueue/sqs.git +remote sns git@github.com:php-enqueue/sns.git +remote snsqs git@github.com:php-enqueue/snsqs.git remote gps git@github.com:php-enqueue/gps.git remote enqueue-bundle git@github.com:php-enqueue/enqueue-bundle.git remote job-queue git@github.com:php-enqueue/job-queue.git diff --git a/docs/index.md b/docs/index.md index c369d6b9f..521e642fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ Enqueue is an MIT-licensed open source project with its ongoing development made * [Quick tour](quick_tour.md) * [Transports](#transports) - Amqp based on [the ext](transport/amqp.md), [bunny](transport/amqp_bunny.md), [the lib](transport/amqp_lib.md) + - [Amazon SNS-SQS](transport/snsqs.md) - [Amazon SQS](transport/sqs.md) - [Google PubSub](transport/gps.md) - [Beanstalk (Pheanstalk)](transport/pheanstalk.md) diff --git a/docs/transport/snsqs.md b/docs/transport/snsqs.md new file mode 100644 index 000000000..330f091a1 --- /dev/null +++ b/docs/transport/snsqs.md @@ -0,0 +1,182 @@ +

Supporting Enqueue

+ +Enqueue is an MIT-licensed open source project with its ongoing development made possible entirely by the support of community and our customers. If you'd like to join them, please consider: + +- [Become a sponsor](https://www.patreon.com/makasim) +- [Become our client](http://forma-pro.com/) + +--- + +# Amazon SNS-SQS transport + +Utilize two Amazon services [SNS-SQS](https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html) to +implement [Publish-Subscribe](https://www.enterpriseintegrationpatterns.com/patterns/messaging/PublishSubscribeChannel.html) +enterprise integration pattern. As opposed to single SQS transport this adds ability to use [MessageBus](https://www.enterpriseintegrationpatterns.com/patterns/messaging/MessageBus.html) +with enqueue. + +A transport for [Amazon SQS](https://aws.amazon.com/sqs/) broker. +It uses internally official [aws sdk library](https://packagist.org/packages/aws/aws-sdk-php) + +* [Installation](#installation) +* [Create context](#create-context) +* [Declare topic, queue and bind them together](#declare-topic-queue-and-bind-them-together) +* [Send message to topic](#send-message-to-topic) +* [Send message to queue](#send-message-to-queue) +* [Consume message](#consume-message) +* [Purge queue messages](#purge-queue-messages) +* [Queue from another AWS account](#queue-from-another-aws-account) + +## Installation + +```bash +$ composer require enqueue/sqs +``` + +## Create context + +```php + 'aKey', + 'secret' => 'aSecret', + 'region' => 'aRegion', + + // or you can segregate options using prefixes "sns_", "sqs_" + 'key' => 'aKey', // common option for both SNS and SQS + 'sns_region' => 'aSnsRegion', // SNS transport option + 'sqs_region' => 'aSqsRegion', // SQS transport option +]); + +// same as above but given as DSN string. You may need to url encode secret if it contains special char (like +) +$factory = new SnsQsConnectionFactory('snsqs:?key=aKey&secret=aSecret®ion=aRegion'); + +$context = $factory->createContext(); + +// if you have enqueue/enqueue library installed you can use a factory to build context from DSN +$context = (new \Enqueue\ConnectionFactoryFactory())->create('snsqs:')->createContext(); +``` + +## Declare topic, queue and bind them together + +Declare topic, queue operation creates a topic, queue on a broker side. +Bind creates connection between topic and queue. You publish message to +the topic and topic sends message to each queue connected to the topic. + + +```php +createTopic('in'); +$context->declareTopic($inTopic); + +$out1Queue = $context->createQueue('out1'); +$context->declareQueue($out1Queue); + +$out2Queue = $context->createQueue('out2'); +$context->declareQueue($out2Queue); + +$context->bind($inTopic, $out1Queue); +$context->bind($inTopic, $out2Queue); + +// to remove topic/queue use deleteTopic/deleteQueue method +//$context->deleteTopic($inTopic); +//$context->deleteQueue($out1Queue); +//$context->unbind(inTopic, $out1Queue); +``` + +## Send message to topic + +```php +createTopic('in'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($inTopic, $message); +``` + +## Send message to queue + +You can bypass topic and publish message directly to the queue + +```php +createQueue('foo'); +$message = $context->createMessage('Hello world!'); + +$context->createProducer()->send($fooQueue, $message); +``` + + +## Consume message: + +```php +createQueue('out1'); +$consumer = $context->createConsumer($out1Queue); + +$message = $consumer->receive(); + +// process a message + +$consumer->acknowledge($message); +// $consumer->reject($message); +``` + +## Purge queue messages: + +```php +createQueue('foo'); + +$context->purgeQueue($fooQueue); +``` + +## Queue from another AWS account + +SQS allows to use queues from another account. You could set it globally for all queues via option `queue_owner_aws_account_id` or +per queue using `SnsQsQueue::setQueueOwnerAWSAccountId` method. + +```php +createContext(); + +// per queue. +$queue = $context->createQueue('foo'); +$queue->setQueueOwnerAWSAccountId('awsAccountId'); +``` + +## Multi region examples + +Enqueue SNSQS provides a generic multi-region support. This enables users to specify which AWS Region to send a command to by setting region on SnsQsQueue. +If not specified the default region is used. + +```php +createContext(); + +$queue = $context->createQueue('foo'); +$queue->setRegion('us-west-2'); + +// the request goes to US West (Oregon) Region +$context->declareQueue($queue); +``` + +[back to index](../index.md)