From c133d16067f8a1260d38eec62b1c64adf7e566a2 Mon Sep 17 00:00:00 2001 From: Srisha Haridas Date: Wed, 18 Dec 2019 19:15:52 +0530 Subject: [PATCH] XOL-5020 Update uecode/qpush-bundle --- LICENSE | 177 +++++ README.md | 162 ++++- composer.json | 46 ++ docs/Makefile | 153 +++++ docs/aws-provider.rst | 118 ++++ docs/conf.py | 242 +++++++ docs/configuration.rst | 144 ++++ docs/console-commands.rst | 43 ++ docs/custom-provider.rst | 23 + docs/file-provider.rst | 23 + docs/index.rst | 24 + docs/installation.rst | 31 + docs/iron-mq-provider.rst | 130 ++++ docs/sync-provider.rst | 24 + docs/usage.rst | 182 +++++ phpunit.xml.dist | 29 + src/Command/QueueBuildCommand.php | 103 +++ src/Command/QueueDestroyCommand.php | 138 ++++ src/Command/QueuePublishCommand.php | 100 +++ src/Command/QueueReceiveCommand.php | 113 ++++ src/DependencyInjection/Configuration.php | 224 +++++++ .../UecodeQPushExtension.php | 247 +++++++ src/Event/Events.php | 61 ++ src/Event/MessageEvent.php | 78 +++ src/Event/NotificationEvent.php | 112 ++++ src/EventListener/RequestListener.php | 177 +++++ src/Message/Message.php | 104 +++ src/Message/Notification.php | 104 +++ src/Provider/AbstractProvider.php | 166 +++++ src/Provider/AwsProvider.php | 620 ++++++++++++++++++ src/Provider/CustomProvider.php | 125 ++++ src/Provider/FileProvider.php | 168 +++++ src/Provider/IronMqProvider.php | 367 +++++++++++ src/Provider/ProviderInterface.php | 156 +++++ src/Provider/ProviderRegistry.php | 94 +++ src/Provider/SyncProvider.php | 78 +++ src/Resources/config/config.yml | 27 + src/Resources/config/parameters.yml | 8 + src/Resources/config/services.yml | 20 + src/UecodeQPushBundle.php | 61 ++ .../UecodeQPushExtensionTest.php | 84 +++ tests/Event/EventsTest.php | 51 ++ tests/Event/MessageEventTest.php | 73 +++ tests/Event/NotificationEventTest.php | 107 +++ tests/EventListener/RequestListenerTest.php | 172 +++++ tests/Fixtures/config_test.yml | 90 +++ tests/Message/BaseMessageTest.php | 70 ++ tests/Message/MessageTest.php | 55 ++ tests/Message/NotificationTest.php | 58 ++ tests/MockClient/AwsMockClient.php | 46 ++ tests/MockClient/CustomMockClient.php | 72 ++ tests/MockClient/IronMqMockClient.php | 112 ++++ tests/MockClient/SnsMockClient.php | 98 +++ tests/MockClient/SqsMockClient.php | 94 +++ tests/Provider/AbstractProviderTest.php | 180 +++++ tests/Provider/AwsProviderTest.php | 297 +++++++++ tests/Provider/CustomProviderTest.php | 106 +++ tests/Provider/FileProviderTest.php | 172 +++++ tests/Provider/IronMqProviderTest.php | 211 ++++++ tests/Provider/ProviderRegisteryTest.php | 48 ++ tests/Provider/SyncProviderTest.php | 123 ++++ tests/Provider/TestProvider.php | 81 +++ tests/bootstrap.php | 34 + 63 files changed, 7435 insertions(+), 1 deletion(-) create mode 100644 LICENSE create mode 100755 composer.json create mode 100644 docs/Makefile create mode 100644 docs/aws-provider.rst create mode 100644 docs/conf.py create mode 100644 docs/configuration.rst create mode 100644 docs/console-commands.rst create mode 100644 docs/custom-provider.rst create mode 100644 docs/file-provider.rst create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/iron-mq-provider.rst create mode 100644 docs/sync-provider.rst create mode 100644 docs/usage.rst create mode 100644 phpunit.xml.dist create mode 100755 src/Command/QueueBuildCommand.php create mode 100755 src/Command/QueueDestroyCommand.php create mode 100755 src/Command/QueuePublishCommand.php create mode 100755 src/Command/QueueReceiveCommand.php create mode 100755 src/DependencyInjection/Configuration.php create mode 100755 src/DependencyInjection/UecodeQPushExtension.php create mode 100755 src/Event/Events.php create mode 100755 src/Event/MessageEvent.php create mode 100755 src/Event/NotificationEvent.php create mode 100755 src/EventListener/RequestListener.php create mode 100755 src/Message/Message.php create mode 100755 src/Message/Notification.php create mode 100755 src/Provider/AbstractProvider.php create mode 100755 src/Provider/AwsProvider.php create mode 100755 src/Provider/CustomProvider.php create mode 100644 src/Provider/FileProvider.php create mode 100755 src/Provider/IronMqProvider.php create mode 100755 src/Provider/ProviderInterface.php create mode 100755 src/Provider/ProviderRegistry.php create mode 100644 src/Provider/SyncProvider.php create mode 100755 src/Resources/config/config.yml create mode 100755 src/Resources/config/parameters.yml create mode 100755 src/Resources/config/services.yml create mode 100755 src/UecodeQPushBundle.php create mode 100644 tests/DependencyInjection/UecodeQPushExtensionTest.php create mode 100644 tests/Event/EventsTest.php create mode 100644 tests/Event/MessageEventTest.php create mode 100644 tests/Event/NotificationEventTest.php create mode 100644 tests/EventListener/RequestListenerTest.php create mode 100644 tests/Fixtures/config_test.yml create mode 100644 tests/Message/BaseMessageTest.php create mode 100644 tests/Message/MessageTest.php create mode 100644 tests/Message/NotificationTest.php create mode 100644 tests/MockClient/AwsMockClient.php create mode 100644 tests/MockClient/CustomMockClient.php create mode 100644 tests/MockClient/IronMqMockClient.php create mode 100644 tests/MockClient/SnsMockClient.php create mode 100644 tests/MockClient/SqsMockClient.php create mode 100644 tests/Provider/AbstractProviderTest.php create mode 100755 tests/Provider/AwsProviderTest.php create mode 100644 tests/Provider/CustomProviderTest.php create mode 100644 tests/Provider/FileProviderTest.php create mode 100755 tests/Provider/IronMqProviderTest.php create mode 100644 tests/Provider/ProviderRegisteryTest.php create mode 100644 tests/Provider/SyncProviderTest.php create mode 100755 tests/Provider/TestProvider.php create mode 100644 tests/bootstrap.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index b1d61d1..69e15e0 100644 --- a/README.md +++ b/README.md @@ -1 +1,161 @@ -# qpush-bundle +QPush - Symfony2 Push Queue Bundle +================================== + +[![Build Status](https://img.shields.io/travis/uecode/qpush-bundle/master.svg?style=flat-square)](https://travis-ci.org/uecode/qpush-bundle) +[![Quality Score](https://img.shields.io/scrutinizer/g/uecode/qpush-bundle.svg?style=flat-square)](https://scrutinizer-ci.com/g/uecode/qpush-bundle/) +[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/uecode/qpush-bundle.svg?style=flat-square)](https://scrutinizer-ci.com/g/uecode/qpush-bundle/) +[![Total Downloads](http://img.shields.io/packagist/dt/uecode/qpush-bundle.svg?style=flat-square)](https://packagist.org/packages/uecode/qpush-bundle) + +##Overview +This bundle allows you to easily consume messages from Push Queues by simply +tagging your services and relying on Symfony's event dispatcher - without +needing to run a daemon or background process to continuously poll your queue. + +**Full Documentation:** [qpush-bundle.readthedocs.org](http://qpush-bundle.rtfd.org) + +##Installation + +The bundle should be installed through composer. + +####Add the bundle to your composer.json file + +```json +{ + "require": { + "uecode/qpush-bundle": "~2.2.0", + } +} +``` + +####Update AppKernel.php of your Symfony Application + +Add the `UecodeQPushBundle` to your kernel bootstrap sequence, in the `$bundles` +array. + +```php +public function registerBundles() +{ + $bundles = array( + // ... + new Uecode\Bundle\QPushBundle\UecodeQPushBundle(), + ); + + return $bundles; +} +``` + +##Basic Configuration: + +Here is a basic configuration that would create a push queue called +`my_queue_name` using AWS or IronMQ. You can read about the supported providers +and provider options in the [full documentation](http://qpush-bundle.rtfd.org). + +######Example + +```yaml +#app/config.yml + +uecode_qpush: + providers: + ironmq: + token: YOUR_IRON_MQ_TOKEN_HERE + project_id: YOUR_IRON_MQ_PROJECT_ID_HERE + aws: + key: YOUR_AWS_KEY_HERE + secret: YOUR_AWS_SECRET_HERE + region: YOUR_AWS_REGION_HERE + queues: + my_queue_key: + provider: ironmq #or aws + options: + queue_name: my_queue_name #optional. the queue name used on the provider + push_notifications: true + subscribers: + - { endpoint: http://example.com/qpush, protocol: http } +``` + +You may exclude aws key and secret to default to IAM role on the EC2 machine. + +##Publishing messages to your Queue + +Publishing messages is simple - fetch the registered Provider service from the +container and call the `publish` method on the respective queue. + +This bundle stores your messages as a json object and the publish method expects +an array, typically associative. + +######Example + +```php +// src/My/Bundle/ExampleBundle/Controller/MyController.php + +public function publishAction() +{ + $message = ['foo' => 'bar']; + + // fetch your provider service from the container + $this->get('uecode_qpush')->get('my_queue_key')->publish($message); + + // you can also fetch it directly + $this->get('uecode_qpush.my_queue_key')->publish($message); +} + +``` + +##Working with messages from your Queue + +When a message hits your application, this bundle will dispatch a `MessageEvent` +which can be handled by your services. You need to tag your services to handle +these events. + +######Example +```yaml +services: + my_example_service: + class: My\Bundle\ExampleBundle\Service\ExampleService + tags: + - { name: uecode_qpush.event_listener, event: my_queue_key.message_received, method: onMessageReceived } +``` + +######Example +```php +// src/My/Bundle/ExampleBundle/Service/ExampleService.php + +use Uecode\Bundle\QPushBundle\Event\MessageEvent; + +public function onMessageReceived(MessageEvent $event) +{ + $queue_name = $event->getQueueName(); + $message = $event->getMessage(); + + // do some processing +} +``` + +The `Message` objects contain the provider specific message id, a message body, +and a collection of provider specific metadata. + +These properties are accessible through simple getters from the message object. + +######Example +```php +// src/My/Bundle/ExampleBundle/Service/ExampleService.php + +use Uecode\Bundle\QPushBundle\Event\MessageEvent; +use Uecode\Bundle\QPushBundle\Message\Message; + +public function onMessageReceived(MessageEvent $event) +{ + $id = $event->getMessage()->getId(); + $body = $event->getMessage()->getBody(); + $metadata = $event->getMessage()->getMetadata(); + + // do some processing +} +``` + +###Cleaning up the Queue + +Once all other Event Listeners have been invoked on a `MessageEvent`, the Bundle +will automatically attempt to remove the Message from your Queue for you. + diff --git a/composer.json b/composer.json new file mode 100755 index 0000000..413570b --- /dev/null +++ b/composer.json @@ -0,0 +1,46 @@ +{ + "name": "uecode/qpush-bundle", + "type": "library", + "description": "Asynchronous processing for Symfony using Push Queues", + "keywords": ["qpush", "pub-sub", "aws", "iron mq", "asynch", "push", "symfony", "bundle" ], + "homepage": "https://github.com/uecode/qpush-bundle", + "license": "Apache 2.0", + "authors": [ + { + "name": "Keith Kirk", + "email": "kkirk@undergroundelephant.com", + "role": "developer", + "homepage": "http://undergroundelephant.com" + } + ], + "support": { + "email": "kkirk@undergroundelephant.com" + }, + "require": { + "php": ">=5.4.0", + "doctrine/common": "~2.4", + "symfony/dependency-injection": "~2.3|^3.0", + "symfony/config": "~2.3|^3.0", + "symfony/http-kernel": "~2.3|^3.0", + "symfony/console": "~2.3|^3.0", + "symfony/monolog-bundle": "~2.3|^3.0" + }, + "require-dev": { + "phpunit/phpunit": "~3.7", + "aws/aws-sdk-php": "~2.5", + "iron-io/iron_mq": "^4.0", + "symfony/finder": "~2.3|^3.0", + "symfony/filesystem": "~2.3|^3.0" + }, + "suggest": { + "aws/aws-sdk-php": "Required to use AWS as a Queue Provider", + "iron-io/iron_mq": "Required to use IronMQ as a Queue Provider", + "symfony/finder": "Required to use File as a Queue Provider", + "symfony/filesystem": "Required to use File as a Queue Provider" + }, + "autoload": { + "psr-4": { + "Uecode\\Bundle\\QPushBundle\\": "src/" + } + } +} diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..307e6e6 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/QPushBundle.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/QPushBundle.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/QPushBundle" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/QPushBundle" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/aws-provider.rst b/docs/aws-provider.rst new file mode 100644 index 0000000..287d1c0 --- /dev/null +++ b/docs/aws-provider.rst @@ -0,0 +1,118 @@ +AWS Provider +------------ + +The AWS Provider uses SQS & SNS to create a Push Queue model. SNS is optional with +this provider and its possible to use just SQS by utilizing the provided Console +Command (``uecode:qpush:receive``) to poll the queue. + +Configuration +^^^^^^^^^^^^^ + +This provider relies on the `AWS SDK PHP v2 `_ library, which +needs to be required in your ``composer.json`` file. + +.. code-block:: js + + { + require: { + "aws/aws-sdk-php": : "2.*" + } + } + +From there, the rest of the configuration is simple. You need to provide your +credentials in your configuration. + +.. code-block:: yaml + + #app/config.yml + + uecode_qpush: + providers: + my_provider: + driver: aws + key: + secret: + region: us-east-1 + queues: + my_queue_name: + provider: my_provider + options: + push_notifications: true + subscribers: + - { endpoint: http://example.com/qpush, protocol: http } + +You may exclude the aws key and secret if you are using IAM role in EC2. + +Using SNS +^^^^^^^^^ + +If you set ``push_notifications`` to ``true`` in your queue config, this provider +will automatically create the SNS Topic, subscribe your SQS queue to it, as well +as loop over your list of ``subscribers``, adding them to your Topic. + +This provider automatically handles Subscription Confirmations sent from SNS, as +long as the HTTP endpoint you've listed is externally accessible and has the QPush Bundle +properly installed and configured. + +Overriding Queue Options +^^^^^^^^^^^^^^^^^^^^^^^^ + +It's possible to override the default queue options that are set in your config file +when sending or receiving messages. + +**Publishing** + +The ``publish()`` method takes an array as a second argument. For the AWS Provider +you are able to change the options listed below per publish. + +If you disable ``push_notifications`` for a message, it will skip using SNS and +only write the message to SQS. You will need to manually poll the SQS queue to +fetch those messages. + ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| Option | Description | Default Value | ++==========================+===========================================================================================+===============+ +| ``push_notifications`` | Whether or not to POST notifications to subscribers of a Queue | ``false`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``message_delay`` | Time in seconds before a published Message is available to be read in a Queue | ``0`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ + +.. code-block:: php + + $message = ['foo' => 'bar']; + + // Optional config to override default options + $options = [ + 'push_notifications' => 0, + 'message_delay' => 1 + ]; + + $this->get('uecode_qpush.my_queue_name')->publish($message, $options); + + +**Receiving** + +The ``receive()`` method takes an array as a second argument. For the AWS Provider +you are able to change the options listed below per attempt to receive messages. + ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| Option | Description | Default Value | ++==========================+===========================================================================================+===============+ +| ``messages_to_receive`` | Maximum amount of messages that can be received when polling the queue | ``1`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``receive_wait_time`` | If supported, time in seconds to leave the polling request open - for long polling | ``3`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ + +.. code-block:: php + + // Optional config to override default options + $options = [ + 'messages_to_receive' => 3, + 'receive_wait_time' => 10 + ]; + + $messages = $this->get('uecode_qpush.my_queue_name')->receive($options); + + foreach ($messages as $message) { + echo $message->getBody(); + } diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..68d3758 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# +# QPush Bundle documentation build configuration file, created by +# sphinx-quickstart on Sat Feb 22 19:40:44 2014. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'QPush Bundle' +copyright = u'2014, Keith Kirk' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.1.3' +# The full version, including alpha/beta/rc tags. +release = '1.1.3' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'QPushBundledoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'QPushBundle.tex', u'QPush Bundle Documentation', + u'Keith Kirk', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'qpushbundle', u'QPush Bundle Documentation', + [u'Keith Kirk'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'QPushBundle', u'QPush Bundle Documentation', + u'Keith Kirk', 'QPushBundle', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..f933b73 --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,144 @@ +Configure the Bundle +==================== + +The bundle allows you to specify different Message Queue providers - however, +Amazon AWS and IronMQ are the only ones currently supported. Blocking, synchronous queues +are also supported through the ``sync`` driver to aid development and debugging. + +We are actively looking to add more and would be more than happy to accept contributions. + +Providers +--------- + +This bundle allows you to configure and use multiple supported providers with in the same +application. Each queue that you create is attached to one of your registered providers +and can have its own configuration options. + +Providers may have their own dependencies that should be added to your ``composer.json`` file. + +For specific instructions on how to configure each provider, please view their documents. + +.. toctree:: + :maxdepth: 2 + + aws-provider + iron-mq-provider + sync-provider + file-provider + custom-provider + +Caching +------- + +Providers can leverage a caching layer to limit the amount of calls to the Message Queue +for basic lookup functionality - this is important for things like AWS's ARN values, etc. + +By default the library will attempt to use file cache, however you can pass your +own cache service, as long as its an instance of ``Doctrine\Common\Cache\Cache``. + +The configuration parameter ``cache_service`` expects the container service id of a registered +Cache service. See below. + +.. code-block:: yaml + + #app/config.yml + + services: + my_cache_service: + class: My\Caching\CacheService + + uecode_qpush: + cache_service: my_cache_service + +**Note:** *Though the Queue Providers will attempt to create queues if they do not exist when publishing or receiving messages, +it is highly recommended that you run the included console command to build queues and warm cache from the CLI beforehand.* + +Queue Options +------------- + +Each queue can have their own options that determine how messages are published or received. +The options and their descriptions are listed below. + ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| Option | Description | Default Value | ++==========================+===========================================================================================+===============+ +| ``queue_name`` | The name used to describe the queue on the Provider's side | ``null`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``push_notifications`` | Whether or not to POST notifications to subscribers of a Queue | ``false`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``notification_retries`` | How many attempts notifications are resent in case of errors - if supported | ``3`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``message_delay`` | Time in seconds before a published Message is available to be read in a Queue | ``0`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``message_timeout`` | Time in seconds a worker has to delete a Message before it is available to other workers | ``30`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``message_expiration`` | Time in seconds that Messages may remain in the Queue before being removed | ``604800`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``messages_to_receive`` | Maximum amount of messages that can be received when polling the queue | ``1`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``receive_wait_time`` | If supported, time in seconds to leave the polling request open - for long polling | ``3`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``subscribers`` | An array of Subscribers, containing an ``endpoint`` and ``protocol`` | ``empty`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ + +Symfony Application as a Subscriber +----------------------------------- + +The QPush Bundle uses a Request Listener which will capture and dispatch notifications from your queue providers for you. The specific route you use does not matter. + +In most cases, it is recommended to just list the host or domain for your Symfony application as the ``endpoint`` of your subscriber. You do not need to create a new action for QPush to receive messages. + +Logging with Monolog +-------------------- + +By default, logging is enabled in the Qpush Bundle and uses Monolog, configured +via the MonologBundle. You can toggle the logging behavior by setting +``logging_enabled`` to ``false``. + +Logs will output to your default Symfony environment logs using the 'qpush' channel. + +Example Configuration +--------------------- + +A working configuration would look like the following + +.. code-block:: yaml + + uecode_qpush: + cache_service: null + logging_enabled: true + providers: + aws: + driver: aws #optional for providers named 'aws' or 'ironmq' + key: YOUR_AWS_KEY_HERE + secret: YOUR_AWS_SECRET_HERE + region: YOUR_AWS_REGION_HERE + another_aws_provider: + driver: aws #required for named providers + key: YOUR_AWS_KEY_HERE + secret: YOUR_AWS_SECRET_HERE + region: YOUR_AWS_REGION_HERE + ironmq: + driver: aws #optional for providers named 'aws' or 'ironmq' + token: YOUR_IRONMQ_TOKEN_HERE + project_id: YOUR_IRONMQ_PROJECT_ID_HERE + in_band: + driver: sync + custom_provider: + driver: custom + service: YOUR_CUSTOM_SERVICE_ID + queues: + my_queue_key: + provider: ironmq #or aws or in_band or another_aws_provider + options: + queue_name: my_actual_queue_name + push_notifications: true + notification_retries: 3 + message_delay: 0 + message_timeout: 30 + message_expiration: 604800 + messages_to_receive: 1 + receive_wait_time: 3 + subscribers: + - { endpoint: http://example1.com/, protocol: http } + - { endpoint: http://example2.com/, protocol: http } diff --git a/docs/console-commands.rst b/docs/console-commands.rst new file mode 100644 index 0000000..c769bee --- /dev/null +++ b/docs/console-commands.rst @@ -0,0 +1,43 @@ +Console Commands +================ + +This bundle includes some Console Commands which can be used for building, destroying and polling your queues +as well as sending simple messages. + +Build Command +------------- + +You can use the ``uecode:qpush:build`` command to create the queues on your providers. You can specify the name of a queue +as an argument to build a single queue. This command will also warm cache which avoids the need to query the provider's API +to ensure that the queue exists. Most queue providers create commands are idempotent, so running this multiple times is not an issue.:: + + $ php app/console uecode:qpush:build my_queue_name + +**Note:** *By default, this bundle uses File Cache. If you clear cache, it is highly recommended you re-run the build command to warm the cache!* + +Destroy Command +--------------- + +You can use the ``uecode:qpush:destroy`` command to completely remove queues. You can specify the name of a queue as an argument to destroy +a single queue. If you do not specify an argument, this will destroy all queues after confirmation.:: + + $ php app/console uecode:qpush:destroy my_queue_name + +**Note:** *This will remove queues, even if there are still unreceived messages in the queue!* + +Receive Command +--------------- + +You can use the ``uecode:qpush:receive`` command to poll the specified queue. This command takes the name of a queue as an argument. +Messages received from this command are dispatched through the ``EventDispatcher`` and can be handled by your tagged services the same +as Push Notifications would be.:: + + $ php app/console uecode:qpush:receive my_queue_name + +Publish Command +--------------- + +You can use the ``uecode:qpush:publish`` command to send messages to your queue from the CLI. This command takes two arguments, the name of +the queue and the message to publish. The message needs to be a json encoded string.:: + + $ php app/console uecode:qpush:publish my_queue_name '{"foo": "bar"}' diff --git a/docs/custom-provider.rst b/docs/custom-provider.rst new file mode 100644 index 0000000..aba3c1a --- /dev/null +++ b/docs/custom-provider.rst @@ -0,0 +1,23 @@ +Custom Provider +------------- + +The custom provider allows you to use your own provider. When using this provider, your implementation must implement +``Uecode\Bundle\QPushBundle\Provider\ProviderInterface`` + +Configuration +^^^^^^^^^^^^^ + +To designate a queue as custom, set the ``driver`` of its provider to ``custom``, and the ``service`` to your service id. + +.. code-block:: yaml + + #app/config_dev.yml + + uecode_qpush: + providers: + custom_provider: + driver: custom + service: YOUR_CUSTOM_SERVICE_ID + queues: + my_queue_name: + provider: custom_provider \ No newline at end of file diff --git a/docs/file-provider.rst b/docs/file-provider.rst new file mode 100644 index 0000000..23dae24 --- /dev/null +++ b/docs/file-provider.rst @@ -0,0 +1,23 @@ +File Provider +------------- + +The file provider uses the filesystem to dispatch and resolve queued messages. + +Configuration +^^^^^^^^^^^^^ + +To designate a queue as file, set the ``driver`` of its provider to ``file``. You will +need to configure a readable and writable path to store the messages. + +.. code-block:: yaml + + #app/config_dev.yml + + uecode_qpush: + providers: + file_based: + driver: file + path: [Path to store messages] + queues: + my_queue_name: + provider: file_based \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..b4ae69f --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,24 @@ +Overview +======== + +The QPush Bundle relies on the Push Queue model of Message Queues to provide asynchronous +processing in your Symfony application. This allows you to remove blocking processes from the +immediate flow of your application and delegate them to another part of your application or, say, a +cluster of workers. + +This bundle allows you to easily consume and process messages by simply tagging your service or +services and relying on Symfony's event dispatcher - without needing to run a daemon or background +process to continuously poll your queue. + +Content +======== + +.. toctree:: + :maxdepth: 4 + + installation + configuration + usage + console-commands + + diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..c0c047b --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,31 @@ +Installation +============ + +The bundle should be installed through composer. + +**Add the bundle to composer** + +.. code-block:: js + + { + "require": { + "uecode/qpush-bundle": "~2.2.0", + } + } + +**Update AppKernel.php of your Symfony Application** + +Add the ``UecodeQPushBundle`` to your kernel bootstrap sequence, in the ``$bundles`` array + +.. code-block:: php + + public function registerBundles() + { + $bundles = array( + // ... + new Uecode\Bundle\QPushBundle\UecodeQPushBundle(), + ); + + return $bundles; + } + diff --git a/docs/iron-mq-provider.rst b/docs/iron-mq-provider.rst new file mode 100644 index 0000000..cae2ad7 --- /dev/null +++ b/docs/iron-mq-provider.rst @@ -0,0 +1,130 @@ +IronMQ Provider +--------------- + +The IronMQ Provider uses its Push Queues to notify subscribers of new queued +messages without needing to continually poll the queue. + +Using a Push Queue is optional with this provider and its possible to use simple +Pull queues by utilizing the provided Console Command (``uecode:qpush::receive``) +to poll the queue. + +Configuration +^^^^^^^^^^^^^ + +This provider relies on the `Iron MQ `_ classes +and needs to have the library included in your ``composer.json`` file. + +.. code-block:: js + + { + require: { + "iron-io/iron_mq": "^4.0" + } + } + + +Configuring the provider is very easy. It requires that you have already created +an account and have a project id. + +`Iron.io `_ provides free accounts for Development, which makes +testing and using this service extremely easy. + +Just include your OAuth `token` and `project_id` in the configuration and set your +queue to use a provider using the `ironmq` driver. + +.. code-block:: yaml + + #app/config.yml + + uecode_qpush: + providers: + my_provider: + driver: ironmq + token: YOUR_TOKEN_HERE + project_id: YOUR_PROJECT_ID_HERE + host: YOUR_OPTIONAL_HOST_HERE + port: YOUR_OPTIONAL_PORT_HERE + version_id: YOUR_OPTIONAL_VERSION_HERE + queues: + my_queue_name: + provider: my_provider + options: + push_notifications: true + subscribers: + - { endpoint: http://example.com/qpush, protocol: http } + +IronMQ Push Queues +^^^^^^^^^^^^^^^^^^ + +If you set ``push_notifications`` to ``true`` in your queue config, this provider +will automatically create your Queue as a Push Queue and loop over your list of ``subscribers``, +adding them to your Queue. + +This provider only supports ``http`` and ``https`` subscribers. This provider also uses the +``multicast`` setting for its Push Queues, meaning that all ``subscribers`` are notified of +the same new messages. + +You can chose to have your IronMQ queues work as a Pull Queue by setting ``push_notifications`` to ``false``. +This would require you to use the ``uecode:qpush:receive`` Console Command to poll the queue. + +Overriding Queue Options +^^^^^^^^^^^^^^^^^^^^^^^^ + +It's possible to override the default queue options that are set in your config file +when sending or receiving messages. + +**Publishing** + +The ``publish()`` method takes an array as a second argument. For the IronMQ +Provider you are able to change the options listed below per publish. + ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| Option | Description | Default Value | ++==========================+===========================================================================================+===============+ +| ``message_delay`` | Time in seconds before a published Message is available to be read in a Queue | ``0`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``message_timeout`` | Time in seconds a worker has to delete a Message before it is available to other workers | ``30`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``message_expiration`` | Time in seconds that Messages may remain in the Queue before being removed | ``604800`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ + +.. code-block:: php + + $message = ['foo' => 'bar']; + + // Optional config to override default options + $options = [ + 'message_delay' => 1, + 'message_timeout' => 1, + 'message_expiration' => 60 + ]; + + $this->get('uecode_qpush.my_queue_name')->publish($message, $options); + + +**Receiving** + +The ``receive()`` method takes an array as a second argument. For the AWS Provider +you are able to change the options listed below per attempt to receive messages. + ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| Option | Description | Default Value | ++==========================+===========================================================================================+===============+ +| ``messages_to_receive`` | Maximum amount of messages that can be received when polling the queue | ``1`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ +| ``message_timeout`` | Time in seconds a worker has to delete a Message before it is available to other workers | ``30`` | ++--------------------------+-------------------------------------------------------------------------------------------+---------------+ + +.. code-block:: php + + // Optional config to override default options + $options = [ + 'messages_to_receive' => 3, + 'message_timeout' => 10 + ]; + + $messages = $this->get('uecode_qpush.my_queue_name')->receive($options); + + foreach ($messages as $message) { + echo $message->getBody(); + } diff --git a/docs/sync-provider.rst b/docs/sync-provider.rst new file mode 100644 index 0000000..85e0046 --- /dev/null +++ b/docs/sync-provider.rst @@ -0,0 +1,24 @@ +Sync Provider +------------- + +The sync provider immediately dispatches and resolves queued events. It is not intended +for production use but instead to support local development, debugging and testing +of queue-based code paths. + +Configuration +^^^^^^^^^^^^^ + +To designate a queue as synchronous, set the ``driver`` of its provider to ``sync``. No further +configuration is necessary. + +.. code-block:: yaml + + #app/config_dev.yml + + uecode_qpush: + providers: + in_band: + driver: sync + queues: + my_queue_name: + provider: in_band \ No newline at end of file diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..d5d9df0 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,182 @@ +Usage +===== + +Once configured, you can create messages and publish them to the queue. You may also +create services that will automatically be fired as messages are pushed to your application. + +For your convenience, a custom ``Provider`` service will be created and registered +in the Container for each of your defined Queues. The container queue service id will be +in the format of ``uecode_qpush.{your queue name}``. + +Publishing messages to your Queue +--------------------------------- + +Publishing messages is simple - fetch your ``Provider`` service from the container and +call the ``publish`` method on the respective queue, which accepts an array. + +.. code-block:: php + + #src/My/Bundle/ExampleBundle/Controller/MyController.php + + public function publishAction() + { + $message = [ + 'messages should be an array', + 'they can be flat arrays' => [ + 'or multidimensional' + ] + ]; + + $this->get('uecode_qpush.my_queue_name')->publish($message); + } + +Working with messages from your Queue +------------------------------------- + +Messages are either automatically received by your application and events dispatched +(setting ``push_notification`` to ``true``), or can be picked up by Cron jobs through an included +command if you are not using a Message Queue provider that supports Push notifications. + +When the notifications or messages are Pushed to your application, the QPush Bundle automatically +catches the request and dispatches an event which can be easily hooked into. + +MessageEvents +^^^^^^^^^^^^^ + +Once a message is received via POST from your Message Queue, a ``MessageEvent`` is dispatched +which can be handled by your services. Each ``MessageEvent`` contains the name of the queue +and a ``Uecode\Bundle\QPushBundle\Message\Message`` object, accessible through getters. + +.. code-block:: php + + #src/My/Bundle/ExampleBundle/Service/ExampleService.php + + use Uecode\Bundle\QPushBundle\Event\MessageEvent + + public function onMessageReceived(MessageEvent $event) + { + $queue_name = $event->getQueueName(); + $message = $event->getMessage(); + } + +The ``Message`` objects contain the provider specific message id, a message body, +and a collection of provider specific metadata. + +These properties are accessible through simple getters. + +The message ``body`` is an array matching your original message. The ``metadata`` property is an +``ArrayCollection`` of varying fields sent with your message from your Queue Provider. + +.. code-block:: php + + #src/My/Bundle/ExampleBundle/Service/ExampleService.php + + use Uecode\Bundle\QPushBundle\Event\MessageEvent; + use Uecode\Bundle\QPushBundle\Message\Message; + + public function onMessageReceived(MessageEvent $event) + { + $id = $event->getMessage()->getId(); + $body = $event->getMessage()->getBody(); + $metadata = $event->getMessage()->getMetadata(); + + // do some processing + } + +Tagging Your Services +^^^^^^^^^^^^^^^^^^^^^ + +For your Services to be called on QPush events, they must be tagged with the name +``uecode_qpush.event_listener``. A complete tag is made up of the following properties: + +============ ================================= ========================================================================================== +Tag Property Example Description +============ ================================= ========================================================================================== +``name`` ``uecode_qpush.event_listener`` The Qpush Event Listener Tag +``event`` ``{queue name}.message_received`` The `message_received` event, prefixed with the Queue name +``method`` ``onMessageReceived`` A publicly accessible method on your service +``priority`` ``100`` Priority, ``1``-``100`` to control order of services. Higher priorities are called earlier +============ ================================= ========================================================================================== + +The ``priority`` is useful to chain services, ensuring that they fire in a certain order - the higher priorities fire earlier. + +Each event fired by the Qpush Bundle is prefixed with the name of your queue, ex: ``my_queue_name.message_received``. + +This allows you to assign services to fire only on certain queues, based on the queue name. +However, you may also have multiple tags on a single service, so that one service can handle +events from multiple queues. + +.. code-block:: yaml + + services: + my_example_service: + class: My\Example\ExampleService + tags: + - { name: uecode_qpush.event_listener, event: my_queue_name.message_received, method: onMessageReceived } + +The method listed in the tag must be publicly available in your service and should +take a single argument, an instance of ``Uecode\Bundle\QPushBundle\Event\MessageEvent``. + +.. code-block:: php + + #src/My/Bundle/ExampleBundle/Service/MyService.php + + use Uecode\Bundle\QPushBundle\Event\MessageEvent; + + // ... + + public function onMessageReceived(MessageEvent $event) + { + $queueName = $event->getQueueName(); + $message = $event->getMessage(); + $metadata = $message()->getMetadata(); + + // Process ... + } + +Cleaning Up the Queue +--------------------- + +Once all other Event Listeners have been invoked on a ``MessageEvent``, the QPush Bundle +will automatically attempt to remove the Message from your Queue for you. + +If an error or exception is thrown, or event propagation is stopped earlier in the chain, +the Message will not be removed automatically and may be picked up by other workers. + +If you would like to remove the message inside your service, you can do so by calling the ``delete`` +method on your provider and passing it the message ``id``. However, you must also stop +the event propagation to avoid other services (including the Provider service) from firing on that +``MessageEvent``. + +.. code-block:: php + + #src/My/Bundle/ExampleBundle/Service/MyService.php + + use Uecode\Bundle\QPushBundle\Event\MessageEvent; + + // ... + + public function onMessageReceived(MessageEvent $event) + { + $id = $event->getMessage()->getId(); + // Removes the message from the queue + $awsProvider->delete($id); + + // Stops the event from propagating + $event->stopPropagation(); + } + +Push Queues in Development +-------------------------- + +It is recommended to use your ``config_dev.yml`` file to disable the +``push_notifications`` settings on your queues. This will make the queue a simple +Pull queue. You can then use the ``uecode:qpush:receive`` Console Command to receive +messages from your Queue. + +If you need to test the Push Queue functionality from a local stack or internal +machine, it's possible to use `ngrok `_ to tunnel to your development +environment, so its reachable by your Queue Provider. + +You would need to update your `config_dev.yml` configuration to use the `ngrok` url for +your subscriber(s). diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8a410cb --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + src/ + + + + + + + + ./tests/ + + + + + + diff --git a/src/Command/QueueBuildCommand.php b/src/Command/QueueBuildCommand.php new file mode 100755 index 0000000..3e6f94c --- /dev/null +++ b/src/Command/QueueBuildCommand.php @@ -0,0 +1,103 @@ + + */ +class QueueBuildCommand extends Command implements ContainerAwareInterface +{ + /** + * @var ContainerInterface + * + * @api + */ + protected $container; + + /** + * Sets the Container associated with this Controller. + * + * @param ContainerInterface $container A ContainerInterface instance + * + * @api + */ + public function setContainer(ContainerInterface $container = null) + { + $this->container = $container; + } + + protected $output; + + protected function configure() + { + $this + ->setName('uecode:qpush:build') + ->setDescription('Builds the configured Queues') + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'Name of a specific queue to build', + null + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->output = $output; + $registry = $this->container->get('uecode_qpush'); + + $name = $input->getArgument('name'); + + if (null !== $name) { + return $this->buildQueue($registry, $name); + } + + foreach ($registry->all() as $queue) { + $this->buildQueue($registry, $queue->getName()); + } + + return 0; + } + + private function buildQueue($registry, $name) + { + if (!$registry->has($name)) { + return $this->output->writeln( + sprintf("The [%s] queue you have specified does not exists!", $name) + ); + } + + $registry->get($name)->create(); + $this->output->writeln(sprintf("The %s queue has been built successfully.", $name)); + + return 0; + } +} diff --git a/src/Command/QueueDestroyCommand.php b/src/Command/QueueDestroyCommand.php new file mode 100755 index 0000000..e0892c4 --- /dev/null +++ b/src/Command/QueueDestroyCommand.php @@ -0,0 +1,138 @@ + + */ +class QueueDestroyCommand extends Command implements ContainerAwareInterface +{ + /** + * @var ContainerInterface + * + * @api + */ + protected $container; + + /** + * Sets the Container associated with this Controller. + * + * @param ContainerInterface $container A ContainerInterface instance + * + * @api + */ + public function setContainer(ContainerInterface $container = null) + { + $this->container = $container; + } + + protected $output; + + protected function configure() + { + $this + ->setName('uecode:qpush:destroy') + ->setDescription('Destroys the configured Queues and cleans Cache') + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'Name of a specific queue to destroy', + null + ) + ->addOption( + 'force', + null, + InputOption::VALUE_NONE, + 'Set this parameter to force this action' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->output = $output; + $registry = $this->container->get('uecode_qpush'); + $dialog = $this->getHelperSet()->get('dialog'); + + $name = $input->getArgument('name'); + + if (null !== $name) { + if (!$input->getOption('force')) { + $response = $dialog->askConfirmation( + $output, + sprintf( + 'This will remove the %s queue, even if it has messages! Are you sure? ', + $name + ), + false + ); + + if (!$response) { + return 0; + } + } + + return $this->destroyQueue($registry, $name); + } + + if (!$input->getOption('force')) { + $response = $dialog->askConfirmation( + $output, + 'This will remove ALL queues, even if they have messages. Are you sure? ', + false + ); + + if (!$response) { + return 0; + } + } + + foreach ($registry->all() as $queue) { + $this->destroyQueue($registry, $queue->getName()); + } + + return 0; + } + + private function destroyQueue($registry, $name) + { + if (!$registry->has($name)) { + return $this->output->writeln( + sprintf("The [%s] queue you have specified does not exists!", $name) + ); + } + + $registry->get($name)->destroy(); + $this->output->writeln(sprintf("The %s queue has been successfully destroyed.", $name)); + + return 0; + } +} diff --git a/src/Command/QueuePublishCommand.php b/src/Command/QueuePublishCommand.php new file mode 100755 index 0000000..9088eff --- /dev/null +++ b/src/Command/QueuePublishCommand.php @@ -0,0 +1,100 @@ + + */ +class QueuePublishCommand extends Command implements ContainerAwareInterface +{ + /** + * @var ContainerInterface + * + * @api + */ + protected $container; + + /** + * Sets the Container associated with this Controller. + * + * @param ContainerInterface $container A ContainerInterface instance + * + * @api + */ + public function setContainer(ContainerInterface $container = null) + { + $this->container = $container; + } + + protected $output; + + protected function configure() + { + $this + ->setName('uecode:qpush:publish') + ->setDescription('Sends a Message to a Queue') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Name of the Queue' + ) + ->addArgument( + 'message', + InputArgument::REQUIRED, + 'A JSON encoded Message to send to the Queue' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->output = $output; + $registry = $this->container->get('uecode_qpush'); + + $name = $input->getArgument('name'); + $message = $input->getArgument('message'); + + return $this->sendMessage($registry, $name, $message); + } + + private function sendMessage($registry, $name, $message) + { + if (!$registry->has($name)) { + return $this->output->writeln( + sprintf("The [%s] queue you have specified does not exists!", $name) + ); + } + + $registry->get($name)->publish(json_decode($message, true)); + $this->output->writeln("The message has been sent."); + + return 0; + } +} diff --git a/src/Command/QueueReceiveCommand.php b/src/Command/QueueReceiveCommand.php new file mode 100755 index 0000000..5fab5de --- /dev/null +++ b/src/Command/QueueReceiveCommand.php @@ -0,0 +1,113 @@ + + */ +class QueueReceiveCommand extends Command implements ContainerAwareInterface +{ + /** + * @var ContainerInterface + * + * @api + */ + protected $container; + + /** + * Sets the Container associated with this Controller. + * + * @param ContainerInterface $container A ContainerInterface instance + * + * @api + */ + public function setContainer(ContainerInterface $container = null) + { + $this->container = $container; + } + + protected $output; + + protected function configure() + { + $this + ->setName('uecode:qpush:receive') + ->setDescription('Polls the configured Queues') + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'Name of a specific queue to poll', + null + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->output = $output; + $registry = $this->container->get('uecode_qpush'); + + $name = $input->getArgument('name'); + + if (null !== $name) { + return $this->pollQueue($registry, $name); + } + + foreach ($registry->all() as $queue) { + $this->pollQueue($registry, $queue->getName()); + } + + return 0; + } + + private function pollQueue($registry, $name) + { + if (!$registry->has($name)) { + return $this->output->writeln( + sprintf("The [%s] queue you have specified does not exists!", $name) + ); + } + + $dispatcher = $this->container->get('event_dispatcher'); + $messages = $registry->get($name)->receive(); + + foreach ($messages as $message) { + $messageEvent = new MessageEvent($name, $message); + $dispatcher->dispatch(Events::Message($name), $messageEvent); + } + + $msg = "Finished polling %s Queue, %d messages fetched."; + $this->output->writeln(sprintf($msg, $name, sizeof($messages))); + + return 0; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100755 index 0000000..5b4522c --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,224 @@ + + */ +class Configuration implements ConfigurationInterface +{ + public function getConfigTreeBuilder() + { + $treeBuilder = new TreeBuilder(); + $rootNode = $treeBuilder->root('uecode_qpush'); + + $rootNode + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('cache_service') + ->defaultNull() + ->end() + ->booleanNode('logging_enabled') + ->defaultTrue() + ->end() + ->append($this->getProvidersNode()) + ->append($this->getQueuesNode()) + ->end() + ; + + return $treeBuilder; + } + + private function getProvidersNode() + { + $treeBuilder = new TreeBuilder(); + $node = $treeBuilder->root('providers'); + $requirements = [ + 'aws' => [], + 'ironmq' => ['token', 'project_id'], + 'sync' => [], + 'custom' => ['service'], + 'file' => ['path'] + ]; + + $node + ->useAttributeAsKey('name') + ->prototype('array') + ->treatNullLike([]) + ->children() + ->enumNode('driver') + ->isRequired() + ->values(array_keys($requirements)) + ->end() + // IronMQ + ->scalarNode('token')->end() + ->scalarNode('project_id')->end() + ->scalarNode('service')->end() + ->enumNode('host') + ->defaultValue('mq-aws-eu-west-1-1') + ->values([ + 'mq-aws-eu-west-1-1', + 'mq-aws-us-east-1-1', + ]) + ->end() + ->scalarNode('port') + ->defaultValue('443') + ->end() + ->scalarNode('api_version') + ->defaultValue(3) + ->end() + // AWS + ->scalarNode('key')->end() + ->scalarNode('secret')->end() + ->scalarNode('region') + ->defaultValue('us-east-1') + ->end() + // File + ->scalarNode('path')->end() + ->end() + + ->validate() + ->always() + ->then(function (array $provider) use ($node, $requirements) { + foreach ($requirements[$provider['driver']] as $requirement) { + if (empty($provider[$requirement])) { + throw new \InvalidArgumentException( + sprintf('%s queue providers must have a %s; none provided', $provider['driver'], $requirement) + ); + } + } + + return $provider; + }) + ->end() + ; + + return $node; + } + + private function getQueuesNode() + { + $treeBuilder = new TreeBuilder(); + $node = $treeBuilder->root('queues'); + + $node + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->prototype('array') + ->children() + ->scalarNode('provider') + ->isRequired() + ->info('The Queue Provider to use') + ->end() + ->arrayNode('options') + ->children() + ->scalarNode('queue_name') + ->defaultNull() + ->info('The actual name of the queue') + ->end() + ->booleanNode('push_notifications') + ->defaultFalse() + ->info('Whether notifications are sent to the subscribers') + ->end() + ->scalarNode('push_type') + ->defaultValue('multicast') + ->info('Whether the push queue is multicast or unicast') + ->example('unicast') + ->end() + ->scalarNode('notification_retries') + ->defaultValue(3) + ->info('How many attempts the Push Notifications are retried if the Subscriber returns an error') + ->example(3) + ->end() + ->scalarNode('notification_retries_delay') + ->defaultValue(60) + ->info('Delay between each Push Notification retry in seconds') + ->example(3) + ->end() + ->scalarNode('message_delay') + ->defaultValue(0) + ->info('How many seconds before messages are inititally visible in the Queue') + ->example(0) + ->end() + ->scalarNode('message_timeout') + ->defaultValue(30) + ->info('How many seconds the Queue hides a message while its being processed') + ->example(30) + ->end() + ->scalarNode('message_expiration') + ->defaultValue(604800) + ->info('How many seconds a message is kept in Queue, the default is 7 days (604800 seconds)') + ->example(604800) + ->end() + ->scalarNode('messages_to_receive') + ->defaultValue(1) + ->info('Max amount of messages to receive at once - an event will be fired for each individually') + ->example(1) + ->end() + ->scalarNode('receive_wait_time') + ->defaultValue(0) + ->info('How many seconds to Long Poll when requesting messages - if supported') + ->example(3) + ->end() + ->scalarNode('rate_limit') + ->defaultValue(-1) + ->info('How many push requests per second will be triggered. -1 for unlimited, 0 disables push') + ->example(1) + ->end() + ->append($this->getSubscribersNode()) + ->end() + ->end() + ->end() + ->end() + ; + + return $node; + } + + private function getSubscribersNode() + { + $treeBuilder = new TreeBuilder(); + $node = $treeBuilder->root('subscribers'); + + $node + ->prototype('array') + ->children() + ->scalarNode('endpoint') + ->info('The url or email address to notify') + ->example('http://foo.bar/qpush/') + ->end() + ->enumNode('protocol') + ->values(['email', 'http', 'https']) + ->info('The endpoint type') + ->example('http') + ->end() + ->end() + ->end() + ; + + return $node; + } +} diff --git a/src/DependencyInjection/UecodeQPushExtension.php b/src/DependencyInjection/UecodeQPushExtension.php new file mode 100755 index 0000000..77aab17 --- /dev/null +++ b/src/DependencyInjection/UecodeQPushExtension.php @@ -0,0 +1,247 @@ + + */ +class UecodeQPushExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container) + { + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $loader = new YamlFileLoader( + $container, + new FileLocator(__DIR__.'/../Resources/config') + ); + + $loader->load('parameters.yml'); + $loader->load('services.yml'); + + $registry = $container->getDefinition('uecode_qpush.registry'); + $cache = $config['cache_service'] ?: 'uecode_qpush.file_cache'; + + foreach ($config['queues'] as $queue => $values) { + + // Adds logging property to queue options + $values['options']['logging_enabled'] = $config['logging_enabled']; + + $provider = $values['provider']; + $class = null; + $client = null; + + switch ($config['providers'][$provider]['driver']) { + case 'aws': + $class = $container->getParameter('uecode_qpush.provider.aws'); + $client = $this->createAwsClient( + $config['providers'][$provider], + $container, + $provider + ); + break; + case 'ironmq': + $class = $container->getParameter('uecode_qpush.provider.ironmq'); + $client = $this->createIronMQClient( + $config['providers'][$provider], + $container, + $provider + ); + break; + case 'sync': + $class = $container->getParameter('uecode_qpush.provider.sync'); + $client = $this->createSyncClient(); + break; + case 'custom': + $class = $container->getParameter('uecode_qpush.provider.custom'); + $client = $this->createCustomClient($config['providers'][$provider]['service']); + break; + case 'file': + $class = $container->getParameter('uecode_qpush.provider.file'); + $values['options']['path'] = $config['providers'][$provider]['path']; + break; + } + + $definition = new Definition( + $class, [$queue, $values['options'], $client, new Reference($cache), new Reference('logger')] + ); + + $name = sprintf('uecode_qpush.%s', $queue); + + $container->setDefinition($name, $definition) + ->addTag('monolog.logger', ['channel' => 'qpush']) + ->addTag( + 'uecode_qpush.event_listener', + [ + 'event' => "{$queue}.on_notification", + 'method' => "onNotification", + 'priority' => 255 + ] + ) + ->addTag( + 'uecode_qpush.event_listener', + [ + 'event' => "{$queue}.message_received", + 'method' => "onMessageReceived", + 'priority' => -255 + ] + ) + ; + + $registry->addMethodCall('addProvider', [$queue, new Reference($name)]); + } + } + + /** + * Creates a definition for the AWS provider + * + * @param array $config A Configuration array for the client + * @param ContainerBuilder $container The container + * @param string $name The provider key + * + * @return Reference + */ + private function createAwsClient($config, ContainerBuilder $container, $name) + { + $service = sprintf('uecode_qpush.provider.%s', $name); + + if (!$container->hasDefinition($service)) { + + $aws2 = class_exists('Aws\Common\Aws'); + $aws3 = class_exists('Aws\Sdk'); + if (!$aws2 && !$aws3) { + throw new \RuntimeException( + 'You must require "aws/aws-sdk-php" to use the AWS provider.' + ); + } + + $awsConfig = [ + 'region' => $config['region'] + ]; + + $aws = new Definition('Aws\Common\Aws'); + $aws->setFactory(['Aws\Common\Aws', 'factory']); + $aws->setArguments([$awsConfig]); + + if ($aws2) { + $aws = new Definition('Aws\Common\Aws'); + $aws->setFactory(['Aws\Common\Aws', 'factory']); + + if (!empty($config['key']) && !empty($config['secret'])) { + $awsConfig['key'] = $config['key']; + $awsConfig['secret'] = $config['secret']; + } + + } else { + $aws = new Definition('Aws\Sdk'); + + if (!empty($config['key']) && !empty($config['secret'])) { + $awsConfig['credentials'] = [ + 'key' => $config['key'], + 'secret' => $config['secret'] + ]; + } + $awsConfig['version'] = 'latest'; + } + + $aws->setArguments([$awsConfig]); + + $container->setDefinition($service, $aws) + ->setPublic(false); + } + + return new Reference($service); + } + + /** + * Creates a definition for the IronMQ provider + * + * @param array $config A Configuration array for the provider + * @param ContainerBuilder $container The container + * @param string $name The provider key + * + * @return Reference + */ + private function createIronMQClient($config, ContainerBuilder $container, $name) + { + $service = sprintf('uecode_qpush.provider.%s', $name); + + if (!$container->hasDefinition($service)) { + + if (!class_exists('IronMQ\IronMQ')) { + throw new \RuntimeException( + 'You must require "iron-io/iron_mq" to use the Iron MQ provider.' + ); + } + + $ironmq = new Definition('IronMQ\IronMQ'); + $ironmq->setArguments([ + [ + 'token' => $config['token'], + 'project_id' => $config['project_id'], + 'host' => sprintf('%s.iron.io', $config['host']), + 'port' => $config['port'], + 'api_version' => $config['api_version'] + ] + ]); + + $container->setDefinition($service, $ironmq) + ->setPublic(false); + } + + return new Reference($service); + } + + private function createSyncClient() + { + return new Reference('event_dispatcher'); + } + + /** + * @param string $serviceId + * + * @return Reference + */ + private function createCustomClient($serviceId) + { + return new Reference($serviceId); + } + + /** + * Returns the Extension Alias + * + * @return string + */ + public function getAlias() + { + return 'uecode_qpush'; + } +} diff --git a/src/Event/Events.php b/src/Event/Events.php new file mode 100755 index 0000000..bfbbed0 --- /dev/null +++ b/src/Event/Events.php @@ -0,0 +1,61 @@ + + */ +abstract class Events +{ + const ON_NOTIFICATION = 'on_notification'; + const ON_MESSAGE = 'message_received'; + + /** + * @codeCoverageIgnore + */ + final private function __construct() { } + + /** + * Returns a QPush Notification Event Name + * + * @param string $name The name of the Queue for this Event + * + * @return string + */ + public static function Notification($name) + { + return sprintf('%s.%s', $name, self::ON_NOTIFICATION); + } + + /** + * Returns a QPush Notification Event Name + * + * @param string $name The name of the Queue for this Event + * + * @return string + */ + public static function Message($name) + { + return sprintf('%s.%s', $name, self::ON_MESSAGE); + } +} diff --git a/src/Event/MessageEvent.php b/src/Event/MessageEvent.php new file mode 100755 index 0000000..4434e4f --- /dev/null +++ b/src/Event/MessageEvent.php @@ -0,0 +1,78 @@ + + */ +class MessageEvent extends Event +{ + /** + * Queue name + * + * @var string + */ + protected $queueName; + + /** + * Message + * + * @var mixed + */ + protected $message; + + /** + * Constructor. + * + * @param string $queueName The queue name + * @param Message $message The Message + */ + public function __construct($queueName, Message $message) + { + $this->queueName = $queueName; + $this->message = $message; + } + + /** + * Return the SQS Queue Name + * + * @return string + */ + public function getQueueName() + { + return $this->queueName; + } + + /** + * Return the Full SQS Message + * + * @return Message + */ + public function getMessage() + { + return $this->message; + } +} diff --git a/src/Event/NotificationEvent.php b/src/Event/NotificationEvent.php new file mode 100755 index 0000000..0db73db --- /dev/null +++ b/src/Event/NotificationEvent.php @@ -0,0 +1,112 @@ + + */ +class NotificationEvent extends Event +{ + /** + * A Subscription Notification Type + */ + const TYPE_SUBSCRIPTION = 'SubscriptionNotification'; + /** + * A Message Notification Type + */ + const TYPE_MESSAGE = 'MessageNotification'; + + /** + * Queue name + * + * @var string + */ + protected $queueName; + + /** + * Notification Type + * + * @var string + */ + protected $type; + + /** + * Notification + * + * @var array + */ + protected $notification; + + /** + * Constructor + * + * @param string $queueName The Queue Name + * @param string $type The Notification Type + * @param Notification $notification The Notification + */ + public function __construct($queueName, $type, Notification $notification) + { + if (!in_array($type, [self::TYPE_SUBSCRIPTION, self::TYPE_MESSAGE])) { + throw new \InvalidArgumentException( + sprintf("Invalid notification type given! (%s)", $type) + ); + } + + $this->queueName = $queueName; + $this->type = $type; + $this->notification = $notification; + } + + /** + * Returns the Queue name + * + * return string + */ + public function getQueueName() + { + return $this->queueName; + } + + /** + * Returns the Notification Type + * + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * Returns the Notification + * + * return array + */ + public function getNotification() + { + return $this->notification; + } +} diff --git a/src/EventListener/RequestListener.php b/src/EventListener/RequestListener.php new file mode 100755 index 0000000..a17881e --- /dev/null +++ b/src/EventListener/RequestListener.php @@ -0,0 +1,177 @@ + + */ +class RequestListener +{ + /** + * Symfony Event Dispatcher + * + * @var EventDispatcherInterface + */ + private $dispatcher; + + /** + * Constructor. + * + * @param EventDispatcherInterface $dispatcher A Symfony Event Dispatcher + */ + public function __construct(EventDispatcherInterface $dispatcher) + { + $this->dispatcher = $dispatcher; + } + + /** + * Kernel Request Event Handler for QPush Notifications + * + * @param GetResponseEvent $event The Kernel Request's GetResponseEvent + */ + public function onKernelRequest(GetResponseEvent $event) + { + if (HttpKernel::MASTER_REQUEST != $event->getRequestType()) { + return; + } + + if ($event->getRequest()->headers->has('x-amz-sns-message-type')) { + $result = $this->handleSnsNotifications($event); + $event->setResponse(new Response($result, 200)); + } + + if ($event->getRequest()->headers->has('iron-message-id')) { + $result = $this->handleIronMqNotifications($event); + $event->setResponse(new Response($result, 200)); + } + } + + /** + * Handles Messages sent from a IronMQ Push Queue + * + * @param GetResponseEvent $event The Kernel Request's GetResponseEvent + * @return string|void + */ + private function handleIronMqNotifications(GetResponseEvent $event) + { + $headers = $event->getRequest()->headers; + $messageId = $headers->get('iron-message-id'); + + // We add the message in an array with Queue as the property name + $message = json_decode($event->getRequest()->getContent(), true); + + if (empty($message['_qpush_queue'])) { + return; + } + + $queue = $message['_qpush_queue']; + $metadata = [ + 'iron-subscriber-message-id' => $headers->get('iron-subscriber-message-id'), + 'iron-subscriber-message-url' => $headers->get('iron-subscriber-message-url') + ]; + + unset($message['_qpush_queue']); + + $notification = new Notification( + $messageId, + $message, + $metadata + ); + + $this->dispatcher->dispatch( + Events::Notification($queue), + new NotificationEvent($queue, NotificationEvent::TYPE_MESSAGE, $notification) + ); + + return "IronMQ Notification Received."; + } + + /** + * Handles Notifications sent from AWS SNS + * + * @param GetResponseEvent $event The Kernel Request's GetResponseEvent + * @return string + */ + private function handleSnsNotifications(GetResponseEvent $event) + { + $notification = json_decode((string)$event->getRequest()->getContent(), true); + + $type = $event->getRequest()->headers->get('x-amz-sns-message-type'); + + $metadata = [ + 'Type' => $notification['Type'], + 'TopicArn' => $notification['TopicArn'], + 'Timestamp' => $notification['Timestamp'], + ]; + + if ($type === 'Notification') { + + // We put the queue name in the Subject field + $queue = $notification['Subject']; + $metadata['Subject'] = $queue; + + $notification = new Notification( + $notification['MessageId'], + $notification['Message'], + $metadata + ); + + $this->dispatcher->dispatch( + Events::Notification($queue), + new NotificationEvent($queue, NotificationEvent::TYPE_MESSAGE, $notification) + ); + + return "SNS Message Notification Received."; + } + + // For subscription notifications, we need to parse the Queue from + // the Topic ARN + $arnParts = explode(':', $notification['TopicArn']); + $last = end($arnParts); + $queue = str_replace('qpush_', '', $last); + + // Get the token for the Subscription Confirmation + $metadata['Token'] = $notification['Token']; + + $notification = new Notification( + $notification['MessageId'], + $notification['Message'], + $metadata + ); + + $this->dispatcher->dispatch( + Events::Notification($queue), + new NotificationEvent($queue, NotificationEvent::TYPE_SUBSCRIPTION, $notification) + ); + + return "SNS Subscription Confirmation Received."; + } +} diff --git a/src/Message/Message.php b/src/Message/Message.php new file mode 100755 index 0000000..a98620f --- /dev/null +++ b/src/Message/Message.php @@ -0,0 +1,104 @@ + + */ +class Message +{ + /** + * Message Id + * + * @var int|string + */ + protected $id; + + /** + * Message Body + * + * @var string|array + */ + protected $body; + + /** + * Message Metadata + * + * @var ArrayCollection + */ + protected $metadata; + + /** + * Constructor. + * + * Sets the Message Id, Message Body, and any Message Metadata + * + * @param int|string $id The Message Id + * @param string|array $body The Message Message + * @param array $metadata The Message Metadata + */ + public function __construct($id, $body, array $metadata) + { + $this->id = $id; + $this->metadata = new ArrayCollection($metadata); + + $message = is_string($body) ? json_decode($body, true) : $body; + if (json_last_error() !== JSON_ERROR_NONE) { + $message = $body; + } + + $this->body = $message; + } + + /** + * Returns the Message Id + * + * @return int|string + */ + public function getId() + { + return $this->id; + } + + /** + * Returns the Message Body + * + * @return string|array + */ + public function getBody() + { + return $this->body; + } + + /** + * Returns the Message Metadata + * + * @return ArrayCollection + */ + public function getMetadata() + { + return $this->metadata; + } +} diff --git a/src/Message/Notification.php b/src/Message/Notification.php new file mode 100755 index 0000000..bec06bd --- /dev/null +++ b/src/Message/Notification.php @@ -0,0 +1,104 @@ + + */ +class Notification +{ + /** + * Notification Id + * + * @var int|string + */ + protected $id; + + /** + * Notification Body + * + * @var string|array + */ + protected $body; + + /** + * Notification Metadata + * + * @var ArrayCollection + */ + protected $metadata; + + /** + * Constructor. + * + * Sets the Notification Id, Notification Body, and any Notification Metadata + * + * @param int|string $id The Notification Id + * @param string|array $body The Notification Message + * @param array $metadata The Notification Metadata + */ + public function __construct($id, $body, array $metadata) + { + $this->id = $id; + $this->metadata = new ArrayCollection($metadata); + + $message = is_string($body) ? json_decode($body, true) : $body; + if (json_last_error() !== JSON_ERROR_NONE) { + $message = $body; + } + + $this->body = $message; + } + + /** + * Returns the Notification Id + * + * @return int|string + */ + public function getId() + { + return $this->id; + } + + /** + * Returns the Notification Body + * + * @return string|array + */ + public function getBody() + { + return $this->body; + } + + /** + * Returns the Notification Metadata + * + * @return ArrayCollection + */ + public function getMetadata() + { + return $this->metadata; + } +} diff --git a/src/Provider/AbstractProvider.php b/src/Provider/AbstractProvider.php new file mode 100755 index 0000000..1279723 --- /dev/null +++ b/src/Provider/AbstractProvider.php @@ -0,0 +1,166 @@ + + */ +abstract class AbstractProvider implements ProviderInterface +{ + /** + * QPush Queue Name + * + * @var string + */ + protected $name; + + /** + * QPush Queue Options + * + * @var array + */ + protected $options; + + /** + * Doctrine APC Cache Driver + * + * @var Cache + */ + protected $cache; + + /** + * Monolog Logger + * + * @var Logger + */ + protected $logger; + + /** + * {@inheritDoc} + */ + public function getName() + { + return $this->name; + } + + /** + * {@inheritDoc} + */ + public function getNameWithPrefix() + { + if (!empty($this->options['queue_name'])) { + return $this->options['queue_name']; + } + + return sprintf("%s_%s", self::QPUSH_PREFIX, $this->name); + } + + /** + * {@inheritDoc} + */ + public function getOptions() + { + return $this->options; + } + + /** + * {@inheritDoc} + */ + public function getCache() + { + return $this->cache; + } + + /** + * {@inheritDoc} + */ + public function getlogger() + { + return $this->logger; + } + + /** + * {@inheritDoc} + */ + public function log($level, $message, array $context = []) + { + if (!$this->options['logging_enabled']) { + return false; + } + + // Add the queue name and provider to the context + $context = array_merge(['queue' => $this->name, 'provider' => $this->getProvider()], $context); + + return $this->logger->addRecord($level, $message, $context); + } + + /** + * @param NotificationEvent $event + * @param string $eventName Name of the event + * @param EventDispatcherInterface $dispatcher + * @return bool + */ + public function onNotification(NotificationEvent $event, $eventName, EventDispatcherInterface $dispatcher) + { + return false; + } + + /** + * @param MessageEvent $event + * @return bool + */ + public function onMessageReceived(MessageEvent $event) + { + return false; + } + + /** + * Merge override options while restricting what keys are allowed + * + * @param array $options An array of options that override the queue defaults + * + * @return array + */ + public function mergeOptions(array $options = []) + { + return array_merge($this->options, array_intersect_key($options, $this->options)); + } + + abstract public function getProvider(); + + abstract public function create(); + + abstract public function publish(array $message, array $options = []); + + abstract public function receive(array $options = []); + + abstract public function delete($id); + + abstract public function destroy(); +} diff --git a/src/Provider/AwsProvider.php b/src/Provider/AwsProvider.php new file mode 100755 index 0000000..e616431 --- /dev/null +++ b/src/Provider/AwsProvider.php @@ -0,0 +1,620 @@ + + */ +class AwsProvider extends AbstractProvider +{ + /** + * Aws SQS Client + * + * @var SqsClient + */ + private $sqs; + + /** + * Aws SNS Client + * + * @var SnsClient + */ + private $sns; + + /** + * SQS Queue URL + * + * @var string + */ + private $queueUrl; + + /** + * SNS Topic ARN + * + * @var string + */ + private $topicArn; + + public function __construct($name, array $options, $client, Cache $cache, Logger $logger) + { + $this->name = $name; + $this->options = $options; + $this->cache = $cache; + $this->logger = $logger; + + // get() method used for sdk v2, create methods for v3 + $useGet = method_exists($client, 'get'); + $this->sqs = $useGet ? $client->get('Sqs') : $client->createSqs(); + $this->sns = $useGet ? $client->get('Sns') : $client->createSns(); + } + + public function getProvider() + { + return "AWS"; + } + + /** + * Builds the configured queues + * + * If a Queue name is passed and configured, this method will build only that + * Queue. + * + * All Create methods are idempotent, if the resource exists, the current ARN + * will be returned + * + */ + public function create() + { + $this->createQueue(); + + if ($this->options['push_notifications']) { + // Create the SNS Topic + $this->createTopic(); + + // Add the SQS Queue as a Subscriber to the SNS Topic + $this->subscribeToTopic( + $this->topicArn, + 'sqs', + $this->sqs->getQueueArn($this->queueUrl) + ); + + // Add configured Subscribers to the SNS Topic + foreach ($this->options['subscribers'] as $subscriber) { + $this->subscribeToTopic( + $this->topicArn, + $subscriber['protocol'], + $subscriber['endpoint'] + ); + } + } + + return true; + } + + /** + * @return Boolean + */ + public function destroy() + { + $key = $this->getNameWithPrefix() . '_url'; + $this->cache->delete($key); + + if ($this->queueExists()) { + // Delete the SQS Queue + $this->sqs->deleteQueue([ + 'QueueUrl' => $this->queueUrl + ]); + + $this->log(200,"SQS Queue removed", ['QueueUrl' => $this->queueUrl]); + } + + $key = $this->getNameWithPrefix() . '_arn'; + $this->cache->delete($key); + + if ($this->topicExists() || !empty($this->queueUrl)) { + // Delete the SNS Topic + $topicArn = !empty($this->topicArn) + ? $this->topicArn + : str_replace('sqs', 'sns', $this->queueUrl) + ; + + $this->sns->deleteTopic([ + 'TopicArn' => $topicArn + ]); + + $this->log(200,"SNS Topic removed", ['TopicArn' => $topicArn]); + } + + return true; + } + + /** + * {@inheritDoc} + * + * This method will either use a SNS Topic to publish a queued message or + * straight to SQS depending on the application configuration. + * + * @return string + */ + public function publish(array $message, array $options = []) + { + $options = $this->mergeOptions($options); + $publishStart = microtime(true); + + // ensures that the SQS Queue and SNS Topic exist + if (!$this->queueExists()) { + $this->create(); + } + + if ($options['push_notifications']) { + + if (!$this->topicExists()) { + $this->create(); + } + + $message = [ + 'default' => $this->getNameWithPrefix(), + 'sqs' => json_encode($message), + 'http' => $this->getNameWithPrefix(), + 'https' => $this->getNameWithPrefix(), + ]; + + $result = $this->sns->publish([ + 'TopicArn' => $this->topicArn, + 'Subject' => $this->getName(), + 'Message' => json_encode($message), + 'MessageStructure' => 'json' + ]); + + $context = [ + 'TopicArn' => $this->topicArn, + 'MessageId' => $result->get('MessageId'), + 'push_notifications' => $options['push_notifications'], + 'publish_time' => microtime(true) - $publishStart + ]; + $this->log(200,"Message published to SNS", $context); + + return $result->get('MessageId'); + } + + $result = $this->sqs->sendMessage([ + 'QueueUrl' => $this->queueUrl, + 'MessageBody' => json_encode($message), + 'DelaySeconds' => $options['message_delay'] + ]); + + $context = [ + 'QueueUrl' => $this->queueUrl, + 'MessageId' => $result->get('MessageId'), + 'push_notifications' => $options['push_notifications'] + ]; + $this->log(200,"Message published to SQS", $context); + + return $result->get('MessageId'); + } + + /** + * {@inheritDoc} + */ + public function receive(array $options = []) + { + $options = $this->mergeOptions($options); + + if (!$this->queueExists()) { + $this->create(); + } + + $result = $this->sqs->receiveMessage([ + 'QueueUrl' => $this->queueUrl, + 'MaxNumberOfMessages' => $options['messages_to_receive'], + 'WaitTimeSeconds' => $options['receive_wait_time'] + ]); + + $messages = $result->get('Messages') ?: []; + + // Convert to Message Class + foreach ($messages as &$message) { + $id = $message['MessageId']; + $metadata = [ + 'ReceiptHandle' => $message['ReceiptHandle'], + 'MD5OfBody' => $message['MD5OfBody'] + ]; + + // When using SNS, the SQS Body is the entire SNS Message + if(is_array($body = json_decode($message['Body'], true)) + && isset($body['Message']) + ) { + $body = json_decode($body['Message'], true); + } + + $message = new Message($id, $body, $metadata); + + $context = ['MessageId' => $id]; + $this->log(200,"Message fetched from SQS Queue", $context); + + } + + return $messages; + } + + /** + * {@inheritDoc} + * + * @return bool + */ + public function delete($id) + { + if (!$this->queueExists()) { + return false; + } + + $this->sqs->deleteMessage([ + 'QueueUrl' => $this->queueUrl, + 'ReceiptHandle' => $id + ]); + + $context = [ + 'QueueUrl' => $this->queueUrl, + 'ReceiptHandle' => $id + ]; + $this->log(200,"Message deleted from SQS Queue", $context); + + return true; + } + + /** + * Return the Queue Url + * + * This method relies on in-memory cache and the Cache provider + * to reduce the need to needlessly call the create method on an existing + * Queue. + * + * @return boolean + */ + public function queueExists() + { + if (isset($this->queueUrl)) { + return true; + } + + $key = $this->getNameWithPrefix() . '_url'; + if ($this->cache->contains($key)) { + $this->queueUrl = $this->cache->fetch($key); + + return true; + } + + try { + $result = $this->sqs->getQueueUrl([ + 'QueueName' => $this->getNameWithPrefix() + ]); + + if ($this->queueUrl = $result->get('QueueUrl')) { + $this->cache->save($key, $this->queueUrl); + + return true; + } + } catch (SqsException $e) {} + + return false; + } + + /** + * Creates an SQS Queue and returns the Queue Url + * + * The create method for SQS Queues is idempotent - if the queue already + * exists, this method will return the Queue Url of the existing Queue. + * + * @return string + */ + public function createQueue() + { + $result = $this->sqs->createQueue([ + 'QueueName' => $this->getNameWithPrefix(), + 'Attributes' => [ + 'VisibilityTimeout' => $this->options['message_timeout'], + 'MessageRetentionPeriod' => $this->options['message_expiration'], + 'ReceiveMessageWaitTimeSeconds' => $this->options['receive_wait_time'] + ] + ]); + + $this->queueUrl = $result->get('QueueUrl'); + + $key = $this->getNameWithPrefix() . '_url'; + $this->cache->save($key, $this->queueUrl); + + $this->log(200, "Created SQS Queue", ['QueueUrl' => $this->queueUrl]); + + if ($this->options['push_notifications']) { + + $policy = $this->createSqsPolicy(); + + $this->sqs->setQueueAttributes([ + 'QueueUrl' => $this->queueUrl, + 'Attributes' => [ + 'Policy' => $policy, + ] + ]); + + $this->log(200, "Created Updated SQS Policy"); + } + } + + /** + * Creates a Policy for SQS that's required to allow SNS SendMessage access + * + * @return string + */ + public function createSqsPolicy() + { + $arn = $this->sqs->getQueueArn($this->queueUrl); + + return json_encode([ + 'Version' => '2008-10-17', + 'Id' => sprintf('%s/SQSDefaultPolicy', $arn), + 'Statement' => [ + [ + 'Sid' => 'SNSPermissions', + 'Effect' => 'Allow', + 'Principal' => ['AWS' => '*'], + 'Action' => 'SQS:SendMessage', + 'Resource' => $arn + ] + ] + ]); + } + + /** + * Checks to see if a Topic exists + * + * This method relies on in-memory cache and the Cache provider + * to reduce the need to needlessly call the create method on an existing + * Topic. + * + * @return boolean + */ + public function topicExists() + { + if (isset($this->topicArn)) { + return true; + } + + $key = $this->getNameWithPrefix() . '_arn'; + if ($this->cache->contains($key)) { + $this->topicArn = $this->cache->fetch($key); + + return true; + } + + if (!empty($this->queueUrl)) { + $queueArn = $this->sqs->getQueueArn($this->queueUrl); + $topicArn = str_replace('sqs', 'sns', $queueArn); + + try { + $this->sns->getTopicAttributes([ + 'TopicArn' => $topicArn + ]); + } catch (NotFoundException $e) { + return false; + } + + $this->topicArn = $topicArn; + $this->cache->save($key, $this->topicArn); + + return true; + } + + return false; + } + + /** + * Creates a SNS Topic and returns the ARN + * + * The create method for the SNS Topics is idempotent - if the topic already + * exists, this method will return the Topic ARN of the existing Topic. + * + * + * @return false|null + */ + public function createTopic() + { + if (!$this->options['push_notifications']) { + return false; + } + + $result = $this->sns->createTopic([ + 'Name' => $this->getNameWithPrefix() + ]); + + $this->topicArn = $result->get('TopicArn'); + + $key = $this->getNameWithPrefix() . '_arn'; + $this->cache->save($key, $this->topicArn); + + $this->log(200, "Created SNS Topic", ['TopicARN' => $this->topicArn]); + } + + /** + * Get a list of Subscriptions for the specified SNS Topic + * + * @param string $topicArn The SNS Topic Arn + * + * @return array + */ + public function getTopicSubscriptions($topicArn) + { + $result = $this->sns->listSubscriptionsByTopic([ + 'TopicArn' => $topicArn + ]); + + return $result->get('Subscriptions'); + } + + /** + * Subscribes an endpoint to a SNS Topic + * + * @param string $topicArn The ARN of the Topic + * @param string $protocol The protocol of the Endpoint + * @param string $endpoint The Endpoint of the Subscriber + * + * @return string + */ + public function subscribeToTopic($topicArn, $protocol, $endpoint) + { + // Check against the current Topic Subscriptions + $subscriptions = $this->getTopicSubscriptions($topicArn); + foreach ($subscriptions as $subscription) { + if ($endpoint === $subscription['Endpoint']) { + return $subscription['SubscriptionArn']; + } + } + + $result = $this->sns->subscribe([ + 'TopicArn' => $topicArn, + 'Protocol' => $protocol, + 'Endpoint' => $endpoint + ]); + + $arn = $result->get('SubscriptionArn'); + + $context = [ + 'Endpoint' => $endpoint, + 'Protocol' => $protocol, + 'SubscriptionArn' => $arn + ]; + $this->log(200, "Endpoint Subscribed to SNS Topic", $context); + + return $arn; + } + + /** + * Unsubscribes an endpoint from a SNS Topic + * + * The method will return TRUE on success, or FALSE if the Endpoint did not + * have a Subscription on the SNS Topic + * + * @param string $topicArn The ARN of the Topic + * @param string $protocol The protocol of the Endpoint + * @param string $endpoint The Endpoint of the Subscriber + * + * @return Boolean + */ + public function unsubscribeFromTopic($topicArn, $protocol, $endpoint) + { + // Check against the current Topic Subscriptions + $subscriptions = $this->getTopicSubscriptions($topicArn); + foreach ($subscriptions as $subscription) { + if ($endpoint === $subscription['Endpoint']) { + $this->sns->unsubscribe([ + 'SubscriptionArn' => $subscription['SubscriptionArn'] + ]); + + $context = [ + 'Endpoint' => $endpoint, + 'Protocol' => $protocol, + 'SubscriptionArn' => $subscription['SubscriptionArn'] + ]; + $this->log(200,"Endpoint unsubscribed from SNS Topic", $context); + + return true; + } + } + + return false; + } + + /** + * Handles SNS Notifications + * + * For Subscription notifications, this method will automatically confirm + * the Subscription request + * + * For Message notifications, this method polls the queue and dispatches + * the `{queue}.message_received` event for each message retrieved + * + * @param NotificationEvent $event The Notification Event + * @param string $eventName Name of the event + * @param EventDispatcherInterface $dispatcher + * @return bool|void + */ + public function onNotification(NotificationEvent $event, $eventName, EventDispatcherInterface $dispatcher) + { + if (NotificationEvent::TYPE_SUBSCRIPTION == $event->getType()) { + $topicArn = $event->getNotification()->getMetadata()->get('TopicArn'); + $token = $event->getNotification()->getMetadata()->get('Token'); + + $this->sns->confirmSubscription([ + 'TopicArn' => $topicArn, + 'Token' => $token + ]); + + $context = ['TopicArn' => $topicArn]; + $this->log(200,"Subscription to SNS Confirmed", $context); + + return; + } + + $messages = $this->receive(); + foreach ($messages as $message) { + + $messageEvent = new MessageEvent($this->name, $message); + $dispatcher->dispatch(Events::Message($this->name), $messageEvent); + } + } + + /** + * Removes the message from queue after all other listeners have fired + * + * If an earlier listener has erred or stopped propagation, this method + * will not fire and the Queued Message should become visible in queue again. + * + * Stops Event Propagation after removing the Message + * + * @param MessageEvent $event The SQS Message Event + * @return bool|void + */ + public function onMessageReceived(MessageEvent $event) + { + $receiptHandle = $event + ->getMessage() + ->getMetadata() + ->get('ReceiptHandle'); + + $this->delete($receiptHandle); + + $event->stopPropagation(); + } +} diff --git a/src/Provider/CustomProvider.php b/src/Provider/CustomProvider.php new file mode 100755 index 0000000..c50a36b --- /dev/null +++ b/src/Provider/CustomProvider.php @@ -0,0 +1,125 @@ + + */ +class CustomProvider extends AbstractProvider +{ + /** + * @type ProviderInterface + */ + private $client; + + /** + * @param string $name + * @param array $options + * @param mixed $client + * @param Cache $cache + * @param Logger $logger + */ + public function __construct($name, array $options, $client, Cache $cache, Logger $logger) + { + $this->name = $name; + $this->options = $options; + $this->cache = $cache; + $this->logger = $logger; + + $this->setClient($client); + } + + /** + * @param ProviderInterface $client + * + * @return CustomProvider + */ + public function setClient(ProviderInterface $client) + { + $this->client = $client; + + return $this; + } + + public function getProvider() + { + return 'Custom'; + } + + /** + * Builds the configured queues + * + * If a Queue name is passed and configured, this method will build only that + * Queue. + * + * All Create methods are idempotent, if the resource exists, the current ARN + * will be returned + * + */ + public function create() + { + return $this->client->create(); + } + + /** + * @return Boolean + */ + public function destroy() + { + return $this->client->destroy(); + } + + /** + * {@inheritDoc} + * + * This method will either use a SNS Topic to publish a queued message or + * straight to SQS depending on the application configuration. + * + * @return string + */ + public function publish(array $message, array $options = []) + { + return $this->client->publish($message, $options); + } + + /** + * {@inheritDoc} + */ + public function receive(array $options = []) + { + return $this->client->receive($options); + } + + /** + * {@inheritDoc} + * + * @return bool + */ + public function delete($id) + { + return $this->client->delete($id); + } +} diff --git a/src/Provider/FileProvider.php b/src/Provider/FileProvider.php new file mode 100644 index 0000000..fed3460 --- /dev/null +++ b/src/Provider/FileProvider.php @@ -0,0 +1,168 @@ +name = $name; + /* md5 only contain numeric and A to F, so it is file system safe */ + $this->queuePath = $options['path'].DIRECTORY_SEPARATOR.str_replace('-', '', hash('md5', $name)); + $this->options = $options; + $this->cache = $cache; + $this->logger = $logger; + } + + public function getProvider() + { + return 'File'; + } + + public function create() + { + $fs = new Filesystem(); + if (!$fs->exists($this->queuePath)) { + $fs->mkdir($this->queuePath); + return $fs->exists($this->queuePath); + } + return true; + } + + public function publish(array $message, array $options = []) + { + $fileName = microtime(false); + $fileName = str_replace(' ', '', $fileName); + $path = substr(hash('md5', $fileName), 0, 3); + + $fs = new Filesystem(); + if (!$fs->exists($this->queuePath.DIRECTORY_SEPARATOR.$path)) { + $fs->mkdir($this->queuePath.DIRECTORY_SEPARATOR.$path); + } + + $fs->dumpFile( + $this->queuePath.DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.$fileName.'.json', + json_encode($message) + ); + return $fileName; + } + + /** + * @param array $options + * @return Message[] + */ + public function receive(array $options = []) + { + $finder = new Finder(); + $finder + ->files() + ->ignoreDotFiles(true) + ->ignoreUnreadableDirs(true) + ->ignoreVCS(true) + ->name('*.json') + ->in($this->queuePath) + ; + if ($this->options['message_delay'] > 0) { + $finder->date( + sprintf('< %d seconds ago', $this->options['message_delay']) + ); + } + $finder + ->date( + sprintf('> %d seconds ago', $this->options['message_expiration']) + ) + ; + $messages = []; + /** @var SplFileInfo $file */ + foreach ($finder as $file) { + $filePointer = fopen($file->getRealPath(), 'r+'); + $id = substr($file->getFilename(), 0, -5); + if (!isset($this->filePointerList[$id]) && flock($filePointer, LOCK_EX | LOCK_NB)) { + $this->filePointerList[$id] = $filePointer; + $messages[] = new Message($id, json_decode($file->getContents(), true), []); + } else { + fclose($filePointer); + } + if (count($messages) === (int) $this->options['messages_to_receive']) { + break; + } + } + return $messages; + } + + public function delete($id) + { + $success = false; + if (isset($this->filePointerList[$id])) { + $fileName = $id; + $path = substr(hash('md5', (string)$fileName), 0, 3); + $fs = new Filesystem(); + $fs->remove( + $this->queuePath . DIRECTORY_SEPARATOR . $path . DIRECTORY_SEPARATOR . $fileName . '.json' + ); + fclose($this->filePointerList[$id]); + unset($this->filePointerList[$id]); + $success = true; + } + if (rand(1,10) === 5) { + $this->cleanUp(); + } + return $success; + } + + public function cleanUp() + { + $finder = new Finder(); + $finder + ->files() + ->in($this->queuePath) + ->ignoreDotFiles(true) + ->ignoreUnreadableDirs(true) + ->ignoreVCS(true) + ->depth('< 2') + ->name('*.json') + ; + $finder->date( + sprintf('> %d seconds ago', $this->options['message_expiration']) + ); + /** @var SplFileInfo $file */ + foreach ($finder as $file) { + @unlink($file->getRealPath()); + } + } + + public function destroy() + { + $fs = new Filesystem(); + $fs->remove($this->queuePath); + $this->filePointerList = []; + return !is_dir($this->queuePath); + } + + /** + * Removes the message from queue after all other listeners have fired + * + * If an earlier listener has erred or stopped propagation, this method + * will not fire and the Queued Message should become visible in queue again. + * + * Stops Event Propagation after removing the Message + * + * @param MessageEvent $event The SQS Message Event + * @return bool|void + */ + public function onMessageReceived(MessageEvent $event) + { + $id = $event->getMessage()->getId(); + $this->delete($id); + $event->stopPropagation(); + } +} \ No newline at end of file diff --git a/src/Provider/IronMqProvider.php b/src/Provider/IronMqProvider.php new file mode 100755 index 0000000..4977259 --- /dev/null +++ b/src/Provider/IronMqProvider.php @@ -0,0 +1,367 @@ + + */ +class IronMqProvider extends AbstractProvider +{ + /** + * IronMQ Client + * + * @var IronMQ + */ + private $ironmq; + + /** + * IronMQ Queue + * + * @var object + */ + private $queue; + + public function __construct($name, array $options, $client, Cache $cache, Logger $logger) + { + $this->name = $name; + $this->options = $options; + $this->ironmq = $client; + $this->cache = $cache; + $this->logger = $logger; + } + + public function getProvider() + { + return "IronMQ"; + } + + /** + * {@inheritDoc} + */ + public function create() + { + if ($this->options['push_notifications']) { + $params = [ + 'type' => $this->options['push_type'], + 'push' => [ + 'rate_limit' => $this->options['rate_limit'], + 'retries' => $this->options['notification_retries'], + 'retries_delay' => $this->options['notification_retries_delay'], + 'subscribers' => [] + ] + ]; + + foreach ($this->options['subscribers'] as $subscriber) { + if ($subscriber['protocol'] == "email") { + throw new \InvalidArgumentException( + 'IronMQ only supports `http` or `https` subscribers!' + ); + } + + $params['push']['subscribers'][] = ['url' => $subscriber['endpoint']]; + } + + } else { + $params = ['push_type' => 'pull']; + } + + $result = $this->ironmq->createQueue($this->getNameWithPrefix(), $params); + $this->queue = $result; + + $key = $this->getNameWithPrefix(); + $this->cache->save($key, json_encode($this->queue)); + + $this->log(200, "Queue has been created.", $params); + + return true; + } + + /** + * {@inheritDoc} + */ + public function destroy() + { + // Catch `queue not found` exceptions, throw the rest. + try { + $this->ironmq->deleteQueue($this->getNameWithPrefix()); + } catch ( \Exception $e) { + if (false !== strpos($e->getMessage(), "Queue not found")) { + $this->log(400, "Queue did not exist"); + } else { + throw $e; + } + } + + $key = $this->getNameWithPrefix(); + $this->cache->delete($key); + + $this->log(200, "Queue has been destroyed."); + + return true; + } + + /** + * {@inheritDoc} + * + * @return int + */ + public function publish(array $message, array $options = []) + { + $options = $this->mergeOptions($options); + $publishStart = microtime(true); + + if (!$this->queueExists()) { + $this->create(); + } + + $result = $this->ironmq->postMessage( + $this->getNameWithPrefix(), + json_encode($message + ['_qpush_queue' => $this->name]), + [ + 'timeout' => $options['message_timeout'], + 'delay' => $options['message_delay'], + 'expires_in' => $options['message_expiration'] + ] + ); + + $context = [ + 'message_id' => $result->id, + 'publish_time' => microtime(true) - $publishStart + ]; + $this->log(200, "Message has been published.", $context); + + return $result->id; + } + + /** + * {@inheritDoc} + */ + public function receive(array $options = []) + { + $options = $this->mergeOptions($options); + + if (!$this->queueExists()) { + $this->create(); + } + + $messages = $this->ironmq->getMessages( + $this->getNameWithPrefix(), + $options['messages_to_receive'], + $options['message_timeout'], + $options['receive_wait_time'] + ); + + if (!is_array($messages)) { + $this->log(200, "No messages found in queue."); + + return []; + } + + // Convert to Message Class + foreach ($messages as &$message) { + $id = $message->id; + $body = json_decode($message->body, true); + $metadata = [ + 'timeout' => $message->timeout, + 'reserved_count' => $message->reserved_count, + 'push_status' => $message->push_status + ]; + + unset($body['_qpush_queue']); + + $message = new Message($id, json_encode($body), $metadata); + + $this->log(200, "Message has been received.", ['message_id' => $id]); + } + + return $messages; + } + + /** + * {@inheritDoc} + */ + public function delete($id) + { + try { + $this->ironmq->deleteMessage($this->getNameWithPrefix(), $id); + $this->log(200, "Message deleted.", ['message_id' => $id]); + } catch ( \Exception $e) { + if (false !== strpos($e->getMessage(), "Queue not found")) { + $this->log(400, "Queue did not exist"); + } else { + throw $e; + } + } + + return true; + } + + /** + * Checks whether or not the Queue exsits + * + * This method relies on in-memory cache and the Cache provider + * to reduce the need to needlessly call the create method on an existing + * Queue. + * + * @return Boolean + */ + public function queueExists() + { + if (isset($this->queue)) { + return true; + } + + $key = $this->getNameWithPrefix(); + if ($this->cache->contains($key)) { + $this->queue = json_decode($this->cache->fetch($key)); + + return true; + } + + return false; + } + + /** + * Polls the Queue on Notification from IronMQ + * + * Dispatches the `{queue}.message_received` event + * + * @param NotificationEvent $event The Notification Event + * @param string $eventName Name of the event + * @param EventDispatcherInterface $dispatcher + * @return void + */ + public function onNotification(NotificationEvent $event, $eventName, EventDispatcherInterface $dispatcher) + { + $message = new Message( + $event->getNotification()->getId(), + $event->getNotification()->getBody(), + $event->getNotification()->getMetadata()->toArray() + ); + + $this->log( + 200, + "Message has been received from Push Notification.", + ['message_id' => $event->getNotification()->getId()] + ); + + $messageEvent = new MessageEvent($this->name, $message); + + $dispatcher->dispatch( + Events::Message($this->name), + $messageEvent + ); + } + + /** + * Removes the message from queue after all other listeners have fired + * + * If an earlier listener has errored or stopped propigation, this method + * will not fire and the Queued Message should become visible in queue again. + * + * Stops Event Propagation after removing the Message + * + * @param MessageEvent $event The SQS Message Event + * @return void + */ + public function onMessageReceived(MessageEvent $event) + { + $metadata = $event->getMessage()->getMetadata(); + + if (!$metadata->containsKey('iron-subscriber-message-id')) { + $id = $event->getMessage()->getId(); + $this->delete($id); + } + + $event->stopPropagation(); + } + + /** + * Get queue info + * + * This allows to get queue size. Allowing to know if processing is finished or not + * + * @return stdObject|null + */ + public function queueInfo() + { + if ($this->queueExists()) { + $key = $this->getNameWithPrefix(); + $this->queue = $this->ironmq->getQueue($key); + + return $this->queue; + } + + return null; + } + + /** + * Publishes multiple message at once + * + * @param array $messages + * @param array $options + * + * @return array + */ + public function publishMessages(array $messages, array $options = []) + { + $options = $this->mergeOptions($options); + $publishStart = microtime(true); + + if (!$this->queueExists()) { + $this->create(); + } + + $encodedMessages = []; + foreach ($messages as $message) { + $encodedMessages[] = json_encode($message + ['_qpush_queue' => $this->name]); + } + + $result = $this->ironmq->postMessages( + $this->getNameWithPrefix(), + $encodedMessages, + [ + 'timeout' => $options['message_timeout'], + 'delay' => $options['message_delay'], + 'expires_in' => $options['message_expiration'] + ] + ); + + $context = [ + 'message_ids' => $result->ids, + 'publish_time' => microtime(true) - $publishStart + ]; + $this->log(200, "Messages have been published.", $context); + + return $result->ids; + } +} diff --git a/src/Provider/ProviderInterface.php b/src/Provider/ProviderInterface.php new file mode 100755 index 0000000..72b455a --- /dev/null +++ b/src/Provider/ProviderInterface.php @@ -0,0 +1,156 @@ + + */ +interface ProviderInterface +{ + /** + * Prefix prepended to the queue names + */ + const QPUSH_PREFIX = 'qpush'; + + /** + * Constructor for Provider classes + * + * @param string $name Name of the Queue the provider is for + * @param array $options An array of configuration options for the Queue + * @param mixed $client A Queue Client for the provider + * @param Cache $cache An instance of Doctrine\Common\Cache\Cache + * @param Logger $logger An instance of Symfony\Bridge\Mongolog\Logger + */ + public function __construct($name, array $options, $client, Cache $cache, Logger $logger); + + /** + * Returns the name of the Queue that this Provider is for + * + * @return string + */ + public function getName(); + + /** + * Returns the Queue Name prefixed with the QPush Prefix + * + * If a Queue name is explicitly set in the configuration, use just that + * name - which is beneficial for reuising existing queues not created by + * qpush. Otherwise, create the queue with the qpush prefix/ + * + * @return string + */ + public function getNameWithPrefix(); + + /** + * Returns the Queue Provider name + * + * @return string + */ + public function getProvider(); + + /** + * Returns the Provider's Configuration Options + * + * @return array + */ + public function getOptions(); + + /** + * Returns the Cache service + * + * @return Cache + */ + public function getCache(); + + /** + * Returns the Logger service + * + * @return Logger + */ + public function getLogger(); + + /** + * Creates the Queue + * + * All Create methods are idempotent, if the resource exists, the current ARN + * will be returned + */ + public function create(); + + /** + * Publishes a message to the Queue + * + * This method should return a string MessageId or Response + * + * @param array $message The message to queue + * @param array $options An array of options that override the queue defaults + * + * @return string + */ + public function publish(array $message, array $options = []); + + /** + * Polls the Queue for Messages + * + * Depending on the Provider, this method may keep the connection open for + * a configurable amount of time, to allow for long polling. In most cases, + * this method is not meant to be used to long poll indefinitely, but should + * return in reasonable amount of time + * + * @param array $options An array of options that override the queue defaults + * + * @return array + */ + public function receive(array $options = []); + + /** + * Deletes the Queue Message + * + * @param mixed $id A message identifier or resource + */ + public function delete($id); + + /** + * Destroys a Queue and clears any Queue related Cache + * + * @return bool + */ + public function destroy(); + + /** + * Logs data from the library + * + * This method wraps the Logger to check if logging is enabled and adds + * the Queue name and Provider automatically to the context + * + * @param int $level The log level + * @param string $message The message to log + * @param array $context The log context + * + * @return bool Whether the record was logged + */ + public function log($level, $message, array $context); +} diff --git a/src/Provider/ProviderRegistry.php b/src/Provider/ProviderRegistry.php new file mode 100755 index 0000000..5232821 --- /dev/null +++ b/src/Provider/ProviderRegistry.php @@ -0,0 +1,94 @@ + + */ +class ProviderRegistry +{ + /** + * All services tagged with `uecode_qpush.receive` + * @var array + */ + private $queues; + + /** + * Constructor. + */ + public function __construct() + { + $this->queues = []; + } + + /** + * Adds a Listener to the chain based on priority + * + * @param string $name The name of the Queue + * @param ProviderInterface $service The QueueProvider + */ + public function addProvider($name, ProviderInterface $service) + { + $this->queues[$name] = $service; + } + + /** + * Returns the Queues + * + * @return array + */ + public function all() + { + return $this->queues; + } + + /** + * Checks whether a Queue Provider exists in the Regisitry + * + * @param string $name The name of the Queue to check for + * + * @return Boolean + */ + public function has($name) + { + return array_key_exists($name, $this->queues); + } + + /** + * Returns a Single QueueProvider by Queue Name + * + * @param string $name + * + * @throws \InvalidArgumentException + * + * @return ProviderInterface + */ + public function get($name) + { + if (!array_key_exists($name, $this->queues)) { + throw new \InvalidArgumentException("The queue does not exist. {$name}"); + } + + return $this->queues[$name]; + } +} diff --git a/src/Provider/SyncProvider.php b/src/Provider/SyncProvider.php new file mode 100644 index 0000000..34af21d --- /dev/null +++ b/src/Provider/SyncProvider.php @@ -0,0 +1,78 @@ +name = $name; + $this->options = $options; + $this->dispatcher = $client; + $this->cache = $cache; + $this->logger = $logger; + } + + public function getProvider() + { + return 'Sync'; + } + + public function publish(array $message, array $options = []) + { + $message = new Message(time(), $message, []); + + $this->dispatcher->dispatch( + Events::Message($this->name), + new MessageEvent($this->name, $message) + ); + + $context = ['MessageId' => $message->getId()]; + $this->log(200, 'Message received and dispatched on Sync Queue', $context); + } + + public function create() {} + + public function destroy() {} + + public function delete($id) {} + + public function receive(array $options = []) {} +} \ No newline at end of file diff --git a/src/Resources/config/config.yml b/src/Resources/config/config.yml new file mode 100755 index 0000000..2e264bb --- /dev/null +++ b/src/Resources/config/config.yml @@ -0,0 +1,27 @@ +#Example Configuration +uecode_qpush: + cache_service: null + logging_enabled: true + providers: + aws: + key: + secret: + region: + ironmq: + token: + project_id: + host: + queues: + default: + provider: aws #or ironmq + options: + push_notifications: true + notification_retries: 3 + message_delay: 0 + message_timeout: 30 + message_expiration: 604800 + messages_to_receive: 1 + receive_wait_time: 3 + subscribers: + - { endpoint: http://example1.com/qpush, protocol: http } + - { endpoint: http://example2.com/qpush, protocol: http } diff --git a/src/Resources/config/parameters.yml b/src/Resources/config/parameters.yml new file mode 100755 index 0000000..c13088e --- /dev/null +++ b/src/Resources/config/parameters.yml @@ -0,0 +1,8 @@ +parameters: + uecode_qpush.request_listener.class: Uecode\Bundle\QPushBundle\EventListener\RequestListener + uecode_qpush.registry.class: Uecode\Bundle\QPushBundle\Provider\ProviderRegistry + uecode_qpush.provider.aws: Uecode\Bundle\QPushBundle\Provider\AwsProvider + uecode_qpush.provider.ironmq: Uecode\Bundle\QPushBundle\Provider\IronMqProvider + uecode_qpush.provider.sync: Uecode\Bundle\QPushBundle\Provider\SyncProvider + uecode_qpush.provider.custom: Uecode\Bundle\QPushBundle\Provider\CustomProvider + uecode_qpush.provider.file: Uecode\Bundle\QPushBundle\Provider\FileProvider diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml new file mode 100755 index 0000000..f0f4b4d --- /dev/null +++ b/src/Resources/config/services.yml @@ -0,0 +1,20 @@ +services: + ### QPush Registry + uecode_qpush.registry: + class: %uecode_qpush.registry.class% + uecode_qpush: + alias: uecode_qpush.registry + + ### QPush Default File Cache + uecode_qpush.file_cache: + class: Doctrine\Common\Cache\PhpFileCache + arguments: [%kernel.cache_dir%/qpush, qpush.php] + public: false + + ### QPush Event Listeners + uecode_qpush.request_listener: + class: %uecode_qpush.request_listener.class% + arguments: + - '@event_dispatcher' + tags: + - { name: kernel.event_listener, event: kernel.request, priority: 254 } diff --git a/src/UecodeQPushBundle.php b/src/UecodeQPushBundle.php new file mode 100755 index 0000000..d78362a --- /dev/null +++ b/src/UecodeQPushBundle.php @@ -0,0 +1,61 @@ + + */ +class UecodeQPushBundle extends Bundle +{ + /** + * {@inlineDoc} + */ + public function __construct() + { + // Setting extension to bypass alias convention check + $this->extension = new UecodeQPushExtension(); + } + + /** + * Adds the Compiler Passes for the QPushBundle + * + * @param ContainerBuilder $container + */ + public function build(ContainerBuilder $container) + { + parent::build($container); + + $container->addCompilerPass( + new RegisterListenersPass( + 'event_dispatcher', + 'uecode_qpush.event_listener', + 'uecode_qpush.event_subscriber' + ) + ); + } +} diff --git a/tests/DependencyInjection/UecodeQPushExtensionTest.php b/tests/DependencyInjection/UecodeQPushExtensionTest.php new file mode 100644 index 0000000..480e599 --- /dev/null +++ b/tests/DependencyInjection/UecodeQPushExtensionTest.php @@ -0,0 +1,84 @@ + + */ +class UecodeQPushExtensionTest extends \PHPUnit_Framework_TestCase +{ + /** + * QPush Extension + * + * @var UecodeQPushExtension + */ + private $extension; + + /** + * Container + * + * @var ContainerBuilder + */ + private $container; + + public function setUp() + { + $this->extension = new UecodeQPushExtension(); + $this->container = new ContainerBuilder(new ParameterBag([ + 'kernel.cache_dir' => '/tmp' + ])); + + $this->container->registerExtension($this->extension); + } + + public function testConfiguration() + { + $loader = new YamlFileLoader($this->container, new FileLocator(__DIR__.'/../Fixtures/')); + $loader->load('config_test.yml'); + + $this->container->compile(); + + $this->assertTrue($this->container->has('uecode_qpush')); + + $this->assertTrue($this->container->has('uecode_qpush.test_aws')); + $this->assertTrue($this->container->has('uecode_qpush.test_file')); + $this->assertTrue($this->container->has('uecode_qpush.test_secondary_aws')); + $this->assertNotSame( + $this->container->get('uecode_qpush.test_aws'), + $this->container->get('uecode_qpush.test_secondary_aws') + ); + + $this->assertTrue($this->container->has('uecode_qpush.test_ironmq')); + $this->assertTrue($this->container->has('uecode_qpush.test_secondary_ironmq')); + $this->assertNotSame( + $this->container->get('uecode_qpush.test_ironmq'), + $this->container->get('uecode_qpush.test_secondary_ironmq') + ); + } +} diff --git a/tests/Event/EventsTest.php b/tests/Event/EventsTest.php new file mode 100644 index 0000000..0250dac --- /dev/null +++ b/tests/Event/EventsTest.php @@ -0,0 +1,51 @@ + + */ +class EventsTest extends \PHPUnit_Framework_TestCase +{ + public function testConstants() + { + $this->assertEquals('message_received', Events::ON_MESSAGE); + $this->assertEquals('on_notification', Events::ON_NOTIFICATION); + } + + public function testMessageEvent() + { + $event = Events::Message('test'); + + $this->assertEquals(sprintf('%s.%s', 'test', Events::ON_MESSAGE), $event); + } + + public function testNotificationEvent() + { + $event = Events::Notification('test'); + + $this->assertEquals(sprintf('%s.%s', 'test', Events::ON_NOTIFICATION), $event); + } +} diff --git a/tests/Event/MessageEventTest.php b/tests/Event/MessageEventTest.php new file mode 100644 index 0000000..f9d7dea --- /dev/null +++ b/tests/Event/MessageEventTest.php @@ -0,0 +1,73 @@ + + */ +class MessageEventTest extends \PHPUnit_Framework_TestCase +{ + protected $event; + + public function setUp() + { + $this->event = new MessageEvent('test', new Message(123, ['foo' => 'bar'], ['bar' => 'baz'])); + } + + public function tearDown() + { + $this->event = null; + } + + public function testMessageEventConstructor() + { + $event = new MessageEvent('test', new Message(123, ['foo' => 'bar'], ['bar' => 'baz'])); + $this->assertInstanceOf('Uecode\Bundle\QPushBundle\Event\MessageEvent', $event); + + if (version_compare(PHP_VERSION, '7.0', '>=')) { + $this->setExpectedException('TypeError'); + } else { + $this->setExpectedException('PHPUnit_Framework_Error'); + } + + $event = new MessageEvent('test', ['bad argument']); + } + + public function testGetQueueName() + { + $name = $this->event->getQueueName(); + + $this->assertEquals('test', $name); + } + + public function testGetMessage() + { + $message = $this->event->getMessage(); + + $this->assertInstanceOf('Uecode\Bundle\QPushBundle\Message\Message', $message); + } +} diff --git a/tests/Event/NotificationEventTest.php b/tests/Event/NotificationEventTest.php new file mode 100644 index 0000000..46063cf --- /dev/null +++ b/tests/Event/NotificationEventTest.php @@ -0,0 +1,107 @@ + + */ +class NotificationEventTest extends \PHPUnit_Framework_TestCase +{ + protected $event; + + public function setUp() + { + $this->event = new NotificationEvent( + 'test', + NotificationEvent::TYPE_SUBSCRIPTION, + new Notification(123, ['foo' => 'bar'], ['bar' => 'baz']) + ); + } + + public function tearDown() + { + $this->event = null; + } + + public function testNotificationEventConstructor() + { + $event = new NotificationEvent( + 'test', + NotificationEvent::TYPE_SUBSCRIPTION, + new Notification(123, ['foo' => 'bar'], ['bar' => 'baz']) + ); + $this->assertInstanceOf('Uecode\Bundle\QPushBundle\Event\NotificationEvent', $event); + + $event = new NotificationEvent( + 'test', + NotificationEvent::TYPE_MESSAGE, + new Notification(123, ['foo' => 'bar'], ['bar' => 'baz']) + ); + $this->assertInstanceOf('Uecode\Bundle\QPushBundle\Event\NotificationEvent', $event); + + $this->setExpectedException('InvalidArgumentException'); + $event = new NotificationEvent( + 'test', + 'InvalidNotificationType', + new Notification(123, ['foo' => 'bar'], ['bar' => 'baz']) + ); + + $this->setExpectedException('PHPUnit_Framework_Error'); + $event = new NotificationEvent( + 'test', + NotificationEvent::TYPE_SUBSCRIPTION, + ['bad argument'] + ); + } + + public function testGetQueueName() + { + $name = $this->event->getQueueName(); + + $this->assertEquals('test', $name); + } + + public function testGetType() + { + $type = $this->event->getType(); + + $this->assertContains( + $type, + [ + NotificationEvent::TYPE_SUBSCRIPTION, + NotificationEvent::TYPE_MESSAGE + ] + ); + } + + public function testGetNotification() + { + $notification = $this->event->getNotification(); + + $this->assertInstanceOf('Uecode\Bundle\QPushBundle\Message\Notification', $notification); + } +} diff --git a/tests/EventListener/RequestListenerTest.php b/tests/EventListener/RequestListenerTest.php new file mode 100644 index 0000000..7ce4f43 --- /dev/null +++ b/tests/EventListener/RequestListenerTest.php @@ -0,0 +1,172 @@ + + */ +class RequestListenerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var EventDispatcher + */ + protected $dispatcher; + + /** + * @var MockInterface + */ + protected $event; + + public function setUp() + { + $this->dispatcher = new EventDispatcher('UTF-8'); + $listener = new RequestListener($this->dispatcher); + + $this->dispatcher->addListener(KernelEvents::REQUEST, [$listener, 'onKernelRequest']); + $this->dispatcher->addListener(QPushEvents::Notification('ironmq-test'), [$this, 'IronMqOnNotificationReceived']); + $this->dispatcher->addListener(QPushEvents::Notification('aws-test'), [$this, 'AwsOnNotificationReceived']); + + $this->kernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface'); + } + + public function testListenerDoesNothingForSubRequests() + { + $event = new GetResponseEvent($this->kernel, new Request(), HttpKernelInterface::SUB_REQUEST); + $this->dispatcher->dispatch(KernelEvents::REQUEST, $event); + + $this->assertFalse($event->hasResponse()); + } + + public function testListenerHandlesIronMQMessageRequests() + { + $message = '{"foo": "bar","_qpush_queue":"ironmq-test"}'; + + $request = new Request([],[],[],[],[],[], $message); + $request->headers->set('iron-message-id', 123); + $request->headers->set('iron-subscriber-message-id', 456); + $request->headers->set('iron-subscriber-message-url', 'http://foo.bar'); + + $event = new GetResponseEvent($this->kernel, $request, HttpKernelInterface::MASTER_REQUEST); + $this->dispatcher->dispatch(KernelEvents::REQUEST, $event); + + $this->assertTrue($event->hasResponse()); + $this->assertEquals("IronMQ Notification Received.", $event->getResponse()->getContent()); + } + + public function IronMqOnNotificationReceived(NotificationEvent $event) + { + $notification = $event->getNotification(); + $this->assertInstanceOf('\Uecode\Bundle\QPushBundle\Message\Notification', $notification); + + $this->assertEquals(123, $notification->getId()); + + $this->assertInternalType('array', $notification->getBody()); + $this->assertEquals($notification->getBody(), ['foo' => 'bar']); + + $this->assertInstanceOf('\Doctrine\Common\Collections\ArrayCollection', $notification->getMetadata()); + $this->assertEquals( + [ + 'iron-subscriber-message-id' => 456, + 'iron-subscriber-message-url' => 'http://foo.bar' + ], + $notification->getMetadata()->toArray() + ); + } + + public function testListenerHandlesAwsNotificationRequests() + { + $message = [ + 'Type' => 'Notification', + 'MessageId' => 123, + 'TopicArn' => 'SomeArn', + 'Subject' => 'aws-test', + 'Message' => '{"foo": "bar"}', + 'Timestamp' => date('Y-m-d H:i:s', 1422040603) + ]; + + $request = new Request([],[],[],[],[],[], json_encode($message)); + $request->headers->set('x-amz-sns-message-type', 'Notification'); + + $event = new GetResponseEvent($this->kernel, $request, HttpKernelInterface::MASTER_REQUEST); + + $this->dispatcher->dispatch(KernelEvents::REQUEST, $event); + + $this->assertTrue($event->hasResponse()); + $this->assertEquals("SNS Message Notification Received.", $event->getResponse()->getContent()); + } + + public function AwsOnNotificationReceived(NotificationEvent $event) + { + $notification = $event->getNotification(); + $this->assertInstanceOf('\Uecode\Bundle\QPushBundle\Message\Notification', $notification); + + $this->assertEquals(123, $notification->getId()); + + $this->assertInternalType('array', $notification->getBody()); + $this->assertEquals($notification->getBody(), ['foo' => 'bar']); + + $this->assertInstanceOf('\Doctrine\Common\Collections\ArrayCollection', $notification->getMetadata()); + $this->assertEquals( + [ + 'Type' => 'Notification', + 'TopicArn' => 'SomeArn', + 'Timestamp' => date('Y-m-d H:i:s', 1422040603), + 'Subject' => 'aws-test' + ], + $notification->getMetadata()->toArray() + ); + } + + public function testListenerHandlesAwsSubscriptionRequests() + { + $message = [ + 'Type' => 'SubscriptionConfirmation', + 'MessageId' => 123, + 'Token' => 456, + 'TopicArn' => 'SomeArn', + 'SubscribeUrl' => 'http://foo.bar', + 'Subject' => 'aws-test', + 'Message' => '{"foo": "bar"}', + 'Timestamp' => date('Y-m-d H:i:s', 1422040603) + ]; + + $request = new Request([],[],[],[],[],[], json_encode($message)); + $request->headers->set('x-amz-sns-message-type', 'SubscriptionConfirmation'); + + $event = new GetResponseEvent($this->kernel, $request, HttpKernelInterface::MASTER_REQUEST); + $this->dispatcher->dispatch(KernelEvents::REQUEST, $event); + + $this->assertTrue($event->hasResponse()); + $this->assertEquals("SNS Subscription Confirmation Received.", $event->getResponse()->getContent()); + } +} diff --git a/tests/Fixtures/config_test.yml b/tests/Fixtures/config_test.yml new file mode 100644 index 0000000..9068687 --- /dev/null +++ b/tests/Fixtures/config_test.yml @@ -0,0 +1,90 @@ +uecode_qpush: + cache_service: null + logging_enabled: true + providers: + aws: + driver: aws + key: 123 + secret: 123 + region: us-east-1 + secondary_aws: + driver: aws + key: 234 + secret: 432 + region: eu-west-1 + ironmq: + driver: ironmq + token: 123 + project_id: 123 + secondary_ironmq: + driver: ironmq + token: 234 + project_id: 234 + file: + driver: file + path: /tmp/my_queue + queues: + test_file: + provider: file + options: + message_delay: 0 + message_timeout: 30 + message_expiration: 604800 + messages_to_receive: 1 + receive_wait_time: 3 + test_aws: + provider: aws + options: + push_notifications: true + notification_retries: 3 + message_delay: 0 + message_timeout: 30 + message_expiration: 604800 + messages_to_receive: 1 + receive_wait_time: 3 + subscribers: + - { endpoint: http://example.com/qpush, protocol: http } + test_secondary_aws: + provider: secondary_aws + options: + push_notifications: true + notification_retries: 3 + message_delay: 0 + message_timeout: 30 + message_expiration: 604800 + messages_to_receive: 1 + receive_wait_time: 3 + subscribers: + - { endpoint: http://example.com/qpush, protocol: http } + test_ironmq: + provider: ironmq + options: + push_notifications: true + notification_retries: 3 + message_delay: 0 + message_timeout: 30 + message_expiration: 604800 + messages_to_receive: 1 + receive_wait_time: 3 + subscribers: + - { endpoint: http://example.com/qpush, protocol: http } + test_secondary_ironmq: + provider: secondary_ironmq + options: + push_notifications: true + notification_retries: 3 + message_delay: 0 + message_timeout: 30 + message_expiration: 604800 + messages_to_receive: 1 + receive_wait_time: 3 + subscribers: + - { endpoint: http://example.com/qpush, protocol: http } + +services: + event_dispatcher: + class: stdClass + logger: + class: Symfony\Bridge\Monolog\Logger + arguments: + - 'test' diff --git a/tests/Message/BaseMessageTest.php b/tests/Message/BaseMessageTest.php new file mode 100644 index 0000000..a0dbd46 --- /dev/null +++ b/tests/Message/BaseMessageTest.php @@ -0,0 +1,70 @@ + + */ +abstract class BaseMessageTest extends \PHPUnit_Framework_TestCase +{ + protected $message; + + /** + * Tests that the Constructor accepts only an array Metadata property + * + * @expectedException PHPUnit_Framework_Error + */ + abstract public function testConstructor(); + + /** + * Test that the Message Id is a String or Integer + */ + public function testGetId() + { + $id = $this->message->getId(); + + $this->assertContains(gettype($id), ['string', 'integer']); + } + + /** + * Test that the Message Body is a String or Array + */ + public function testGetBody() + { + $body = $this->message->getBody(); + + $this->assertContains(gettype($body), ['string', 'array']); + } + + /** + * Test that the Message Metadata is an ArrayCollection + */ + public function testGetMetadata() + { + $metadata = $this->message->getMetadata(); + + $this->assertInstanceOf('Doctrine\\Common\\Collections\\ArrayCollection', $metadata); + } +} diff --git a/tests/Message/MessageTest.php b/tests/Message/MessageTest.php new file mode 100644 index 0000000..d35afd4 --- /dev/null +++ b/tests/Message/MessageTest.php @@ -0,0 +1,55 @@ + + */ +class MessageTest extends BaseMessageTest +{ + public function setUp() + { + $this->message = new Message(123, ['foo' => 'bar'], ['baz' => 'qux']); + } + + public function tearDown() + { + $this->message = null; + } + + public function testConstructor() + { + $message = new Message(123, ['foo' => 'bar'], ['baz' => 'qux']); + $this->assertInstanceOf('Uecode\Bundle\QPushBundle\Message\Message', $message); + + if (version_compare(PHP_VERSION, '7.0', '>=')) { + $this->setExpectedException('TypeError'); + } else { + $this->setExpectedException('PHPUnit_Framework_Error'); + } + + new Message(123, ['foo' => 'bar'], 'invalid argument'); + } +} diff --git a/tests/Message/NotificationTest.php b/tests/Message/NotificationTest.php new file mode 100644 index 0000000..f1c3698 --- /dev/null +++ b/tests/Message/NotificationTest.php @@ -0,0 +1,58 @@ + + */ +class NotificationTest extends BaseMessageTest +{ + public function setUp() + { + $this->message = new Notification(123, ['foo' => 'bar'], ['baz' => 'qux']); + } + + public function tearDown() + { + $this->message = null; + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testConstructor() + { + $notification = new Notification(123, ['foo' => 'bar'], ['baz' => 'qux']); + $this->assertInstanceOf('Uecode\Bundle\QPushBundle\Message\Notification', $notification); + + if (version_compare(PHP_VERSION, '7.0', '>=')) { + $this->setExpectedException('TypeError'); + } else { + $this->setExpectedException('PHPUnit_Framework_Error'); + } + + new Notification(123, ['foo' => 'bar'], 'invalid argument'); + } +} diff --git a/tests/MockClient/AwsMockClient.php b/tests/MockClient/AwsMockClient.php new file mode 100644 index 0000000..fbede38 --- /dev/null +++ b/tests/MockClient/AwsMockClient.php @@ -0,0 +1,46 @@ + + */ +class AwsMockClient extends \Aws\Common\Aws +{ + public function get($name, $throwAway = false) + { + if (!in_array($name, ['Sns', 'Sqs'])) { + throw new \InvalidArgumentException( + sprintf('Only supports Sns and Sqs as options, %s given.', $name) + ); + } + + if ($name == "Sns") { + return new SnsMockClient; + } + + return new SqsMockClient; + } +} diff --git a/tests/MockClient/CustomMockClient.php b/tests/MockClient/CustomMockClient.php new file mode 100644 index 0000000..5e2a68b --- /dev/null +++ b/tests/MockClient/CustomMockClient.php @@ -0,0 +1,72 @@ + + */ +class CustomMockClient extends AbstractProvider +{ + /** + * Constructor for Provider classes + * + * @param string $name Name of the Queue the provider is for + * @param array $options An array of configuration options for the Queue + * @param mixed $client A Queue Client for the provider + * @param Cache $cache An instance of Doctrine\Common\Cache\Cache + * @param Logger $logger An instance of Symfony\Bridge\Mongolog\Logger + */ + public function __construct($name, array $options, $client, Cache $cache, Logger $logger) + { + } + + public function getProvider() + { + } + + public function create() + { + } + + public function publish(array $message, array $options = []) + { + } + + public function receive(array $options = []) + { + } + + public function delete($id) + { + } + + public function destroy() + { + } +} diff --git a/tests/MockClient/IronMqMockClient.php b/tests/MockClient/IronMqMockClient.php new file mode 100644 index 0000000..46160b0 --- /dev/null +++ b/tests/MockClient/IronMqMockClient.php @@ -0,0 +1,112 @@ + + */ +class IronMqMockClient +{ + private $deleteCount = 0; + + public function createQueue($queue, array $params = []) + { + $response = new \stdClass; + $response->id = '530295fe3c94fbcf0c79cffe'; + $response->name = 'test'; + $response->size = 0; + $response->total_messages = 0; + $response->project_id = '52f67d032001c00005000057'; + + return $response; + } + + public function deleteQueue($queue) + { + if ($this->deleteCount == 0) { + $this->deleteCount++; + + return true; + } + + if ($this->deleteCount == 1) { + $this->deleteCount++; + + throw new \Exception('http error: 404 | {"msg":"Queue not found"}'); + } + + throw new \Exception('Random Exception'); + } + + public function postMessage($queue, $message, $options) + { + $response = new \stdClass; + $response->id = 123; + $response->ids = [123]; + $response->msg = "Messages put on queue."; + + return $response; + } + + public function getMessages($queue, $count, $timeout) + { + $response = new \stdClass; + $response->id = 123; + $response->body = '{"foo":"bar","_qpush_queue":"test"}'; + $response->timeout = 60; + $response->reserved_count = 1; + $response->push_status = new \stdClass; + + return [$response]; + } + + public function deleteMessage($queue, $id) + { + $response = new \stdClass; + $response->id = $id; + + if ($id == 456) { + throw new \Exception('http error: 404 | {"msg":"Queue not found"}'); + } + + if ($id == 789) { + throw new \Exception('Random Exception'); + } + + return $response; + } + + public function getQueue($queue) + { + $response = new \stdClass; + $response->id = '530295fe3c94fbcf0c79cffe'; + $response->name = 'test'; + $response->size = 0; + $response->total_messages = 0; + $response->project_id = '52f67d032001c00005000057'; + + return $response; + } +} diff --git a/tests/MockClient/SnsMockClient.php b/tests/MockClient/SnsMockClient.php new file mode 100644 index 0000000..7656aff --- /dev/null +++ b/tests/MockClient/SnsMockClient.php @@ -0,0 +1,98 @@ + + */ +class SnsMockClient +{ + public function deleteTopic(array $args) + { + return true; + } + + public function publish(array $args) + { + return new ArrayCollection([ + 'MessageId' => 123 + ]); + } + + public function createTopic(array $args) + { + return new ArrayCollection([ + 'TopicArn' => 'long_topic_arn_string' + ]); + } + + public function getTopicAttributes(array $args) + { + if ($args['TopicArn'] == null) { + throw new NotFoundException; + } + + return new ArrayCollection([ + 'Attributes' => [ + 'TopicArn' => 'long_topic_arn_string' + ] + ]); + } + + public function listSubscriptionsByTopic(array $args) + { + return new ArrayCollection([ + 'Subscriptions' => [ + [ + 'SubscriptionArn' => 'long_subscription_arn_string', + 'Owner' => 'owner_string', + 'Protocol' => 'http', + 'Endpoint' => 'http://long_url_string.com', + 'TopicArn' => 'long_topic_arn_string' + ] + ] + ]); + } + + public function subscribe(array $args) + { + return new ArrayCollection([ + 'SubscriptionArn' => 'long_subscription_arn_string' + ]); + } + + public function unsubscribe(array $args) + { + return true; + } + + public function confirmSubscription(array $args) + { + return true; + } +} diff --git a/tests/MockClient/SqsMockClient.php b/tests/MockClient/SqsMockClient.php new file mode 100644 index 0000000..09e3e6a --- /dev/null +++ b/tests/MockClient/SqsMockClient.php @@ -0,0 +1,94 @@ + + */ +class SqsMockClient +{ + public function getQueueArn($url) + { + return "long_queue_arn_string"; + } + + public function getQueueUrl($name) + { + return new ArrayCollection([ + 'QueueUrl' => 'long_queue_url_string' + ]); + } + + public function deleteQueue(array $args) + { + return true; + } + + public function sendMessage(array $args) + { + return new ArrayCollection([ + 'MessageId' => 123 + ]); + } + + public function receiveMessage(array $args) + { + return new ArrayCollection([ + 'Messages' => [ + [ + 'MessageId' => 123, + 'ReceiptHandle' => 'long_receipt_handle_string', + 'MD5OfBody' => 'long_md5_hash_string', + 'Body' => json_encode(['foo' => 'bar']) + ], + [ + 'MessageId' => 123, + 'ReceiptHandle' => 'long_receipt_handle_string', + 'MD5OfBody' => 'long_md5_hash_string', + 'Body' => json_encode(['Message' => json_encode(['foo' => 'bar'])]) + ] + ] + ]); + } + + public function deleteMessage(array $args) + { + return true; + } + + public function createQueue(array $args) + { + return new ArrayCollection([ + 'QueueUrl' => 'long_queue_url_string' + ]); + } + + public function setQueueAttributes(array $args) + { + return true; + } +} diff --git a/tests/Provider/AbstractProviderTest.php b/tests/Provider/AbstractProviderTest.php new file mode 100644 index 0000000..1180141 --- /dev/null +++ b/tests/Provider/AbstractProviderTest.php @@ -0,0 +1,180 @@ + + */ +class AbstractProviderTest extends \PHPUnit_Framework_TestCase +{ + protected $provider; + + public function setUp() + { + $this->provider = $this->getTestProvider(); + } + + public function tearDown() + { + $this->provider = null; + + if (file_exists('/tmp/qpush.provider.test.php')) { + unlink('/tmp/qpush.provider.test.php'); + } + } + + private function getTestProvider(array $options = []) + { + + $options = array_merge( + [ + 'logging_enabled' => false, + 'push_notifications' => true, + 'notification_retries' => 3, + 'message_delay' => 0, + 'message_timeout' => 30, + 'message_expiration' => 604800, + 'messages_to_receive' => 1, + 'receive_wait_time' => 3, + 'subscribers' => [ + [ 'protocol' => 'http', 'endpoint' => 'http://fake.com' ] + ] + ], + $options + ); + + return new TestProvider( + 'test', + $options, + new \stdClass, + $this->getMock( + 'Doctrine\Common\Cache\PhpFileCache', + [], + ['/tmp', 'qpush.aws.test.php'] + ), + $this->getMock( + 'Symfony\Bridge\Monolog\Logger', + [], + ['qpush.test'] + ) + ); + } + + public function testGetName() + { + $name = $this->provider->getName(); + + $this->assertEquals($name, 'test'); + } + + public function testGetNameWithPrefix() + { + $name = $this->provider->getNameWithPrefix(); + + $this->assertEquals(sprintf('%s_%s', ProviderInterface::QPUSH_PREFIX, 'test'), $name); + } + + public function testGetNameWithPrefixProvidedName() + { + $provider = $this->getTestProvider(['queue_name' => 'foo']); + $name = $provider->getNameWithPrefix(); + + $this->assertEquals('foo', $name); + } + + public function testGetOptions() + { + $options = $this->provider->getOptions(); + + $this->assertTrue(is_array($options)); + } + + public function testGetCache() + { + $cache = $this->provider->getCache(); + + $this->assertInstanceOf('Doctrine\\Common\\Cache\\Cache', $cache); + } + + public function testGetLogger() + { + $logger = $this->provider->getLogger(); + + $this->assertInstanceOf('Monolog\\Logger', $logger); + } + + public function testLogEnabled() + { + $this->assertFalse($this->provider->log(100, 'test log', [])); + + $provider = $this->getTestProvider(['logging_enabled' => true]); + + $this->assertNull($provider->log(100, 'test log', [])); + } + + public function testGetProvider() + { + $provider = $this->provider->getProvider(); + + $this->assertEquals('TestProvider', $provider); + } + + public function testOnNotification() + { + $dispatcher = $this->getMockForAbstractClass('Symfony\Component\EventDispatcher\EventDispatcherInterface'); + $result = $this->provider->onNotification(new NotificationEvent( + 'test', + NotificationEvent::TYPE_SUBSCRIPTION, + new Notification(123, "test", []) + ), NotificationEvent::TYPE_SUBSCRIPTION, $dispatcher); + + $this->assertFalse($result); + } + + public function testOnMessageReceived() + { + $result = $this->provider->onMessageReceived(new MessageEvent( + 'test', + new Message(123, ['foo' => 'bar'], []) + )); + + $this->assertFalse($result); + } + + public function testMergeOptions() + { + $options = ['message_delay' => 1, 'not_an_option' => false]; + $merged = $this->provider->mergeOptions($options); + + $this->assertTrue($merged['message_delay'] === 1); + $this->assertFalse(isset($merged['not_an_option'])); + } +} diff --git a/tests/Provider/AwsProviderTest.php b/tests/Provider/AwsProviderTest.php new file mode 100755 index 0000000..a092fd3 --- /dev/null +++ b/tests/Provider/AwsProviderTest.php @@ -0,0 +1,297 @@ + + */ +class AwsProviderTest extends \PHPUnit_Framework_TestCase +{ + /** + * Mock Client + * + * @var stdClass + */ + protected $provider; + + public function setUp() + { + $this->provider = $this->getAwsProvider(); + } + + public function tearDown() + { + $this->provider = null; + } + + private function getAwsProvider(array $options = []) + { + $options = array_merge( + [ + 'logging_enabled' => false, + 'push_notifications' => true, + 'notification_retries' => 3, + 'message_delay' => 0, + 'message_timeout' => 30, + 'message_expiration' => 604800, + 'messages_to_receive' => 1, + 'receive_wait_time' => 3, + 'subscribers' => [ + [ 'protocol' => 'http', 'endpoint' => 'http://fake.com' ] + ] + ], + $options + ); + + $client = new AwsMockClient([ + 'key' => '123_this_is_a_key', + 'secret' => '123_this_is_a_secret', + 'region' => 'us-east-1' + ]); + + $cache = $this->getMock( + 'Doctrine\Common\Cache\PhpFileCache', + [], + ['/tmp', 'qpush.aws.test.php'] + ); + + $logger = $this->getMock( + 'Symfony\Bridge\Monolog\Logger', [], ['qpush.test'] + ); + + return new AwsProvider('test', $options, $client, $cache, $logger); + } + + public function testGetProvider() + { + $provider = $this->provider->getProvider(); + + $this->assertEquals('AWS', $provider); + } + + public function testCreate() + { + //$this->assertFalse($this->provider->queueExists()); + + $this->assertTrue($this->provider->create()); + $this->assertTrue($this->provider->queueExists()); + } + + public function testDestroy() + { + $this->assertTrue($this->provider->destroy()); + } + + public function testSqsPublish() + { + $provider = $this->getAwsProvider([ + 'push_notifications' => false + ]); + + $this->assertEquals(123, $provider->publish(['foo' => 'bar'])); + } + + public function testSnsPublish() + { + $this->assertEquals(123, $this->provider->publish(['foo' => 'bar'])); + } + + public function testReceive() + { + $this->assertTrue(is_array($this->provider->receive())); + } + + public function testDelete() + { + $provider = $this->getAwsProvider([ + 'push_notifications' => false + ]); + + $provider->createQueue(); + + $this->assertTrue($provider->delete(123)); + } + + /** + * @covers Uecode\Bundle\QPushBundle\Provider\AwsProvider::createQueue + * @covers Uecode\Bundle\QPushBundle\Provider\AwsProvider::queueExists + */ + public function testCreateQueue() + { + $provider = $this->getAwsProvider([ + 'push_notifications' => false + ]); + + $stub = $provider->getCache(); + $stub->expects($this->once()) + ->method('contains') + ->will($this->returnValue(true)); + + $this->assertTrue($provider->queueExists()); + + $provider->createQueue(); + $this->assertTrue($provider->queueExists()); + + $this->provider->createQueue(); + $this->assertTrue($this->provider->queueExists()); + } + + public function testCreateSqsPolicy() + { + $json_string = json_encode([ + 'Version' => '2008-10-17', + 'Id' => sprintf('%s/SQSDefaultPolicy', "long_queue_arn_string"), + 'Statement' => [ + [ + 'Sid' => 'SNSPermissions', + 'Effect' => 'Allow', + 'Principal' => ['AWS' => '*'], + 'Action' => 'SQS:SendMessage', + 'Resource' => "long_queue_arn_string" + ] + ] + ]); + + $this->assertJsonStringEqualsJsonString( + $json_string, + $this->provider->createSqsPolicy() + ); + } + + /** + * @covers Uecode\Bundle\QPushBundle\Provider\AwsProvider::createTopic + * @covers Uecode\Bundle\QPushBundle\Provider\AwsProvider::topicExists + */ + public function testCreateTopic() + { + $provider = $this->getAwsProvider(); + + $this->assertFalse($provider->topicExists()); + + $stub = $provider->getCache(); + $stub->expects($this->once()) + ->method('contains') + ->will($this->returnValue(true)); + + $this->assertTrue($provider->topicExists()); + + $provider->createTopic(); + $this->assertTrue($provider->topicExists()); + + $provider = $this->getAwsProvider(['push_notifications' => false]); + $this->assertFalse($provider->createTopic()); + } + + public function testGetTopicSubscriptions() + { + $subscriptions = $this->provider->getTopicSubscriptions("long_queue_arn_string"); + $expected = [ + [ + 'SubscriptionArn' => 'long_subscription_arn_string', + 'Owner' => 'owner_string', + 'Protocol' => 'http', + 'Endpoint' => 'http://long_url_string.com', + 'TopicArn' => 'long_topic_arn_string' + ] + ]; + + $this->assertEquals( + $expected, + $subscriptions + ); + } + + public function testSubscribeToTopic() + { + $subscriptionArn = $this->provider->subscribeToTopic( + 'long_topic_arn_string', + 'http', + 'http://long_url_string.com' + ); + + $this->assertEquals('long_subscription_arn_string', $subscriptionArn); + } + + public function testUnsubscribeFromTopic() + { + $this->assertTrue( + $this->provider->unsubscribeFromTopic( + 'long_topic_arn_string', + 'http', + 'http://long_url_string.com' + ) + ); + + $this->assertFalse( + $this->provider->unsubscribeFromTopic( + 'long_topic_arn_string', + 'http', + 'http://bad_long_url_string.com' + ) + ); + } + + public function testOnNotificationSubscriptionEvent() + { + $dispatcher = $this->getMockForAbstractClass('Symfony\Component\EventDispatcher\EventDispatcherInterface'); + $this->provider->onNotification(new NotificationEvent( + 'test', + NotificationEvent::TYPE_SUBSCRIPTION, + new Notification(123, "test", []) + ), NotificationEvent::TYPE_SUBSCRIPTION, $dispatcher); + + } + + public function testOnNotificationMessageEvent() + { + $event = new NotificationEvent( + 'test', + NotificationEvent::TYPE_MESSAGE, + new Notification(123, "test", []) + ); + + $this->provider->onNotification( + $event, + NotificationEvent::TYPE_MESSAGE, + $this->getMock('Symfony\Component\EventDispatcher\EventDispatcherInterface') + ); + } + + public function testOnMessageReceived() + { + $this->provider->onMessageReceived(new MessageEvent( + 'test', + new Message(123, ['foo' => 'bar'], []) + )); + } +} diff --git a/tests/Provider/CustomProviderTest.php b/tests/Provider/CustomProviderTest.php new file mode 100644 index 0000000..5347c3e --- /dev/null +++ b/tests/Provider/CustomProviderTest.php @@ -0,0 +1,106 @@ +provider = $this->getCustomProvider(); + } + + public function testGetProvider() + { + $provider = $this->provider->getProvider(); + + $this->assertEquals('Custom', $provider); + } + + public function testPublish() + { + $this->setNoOpExpectation(); + + $this->provider->publish(['foo' => 'bar']); + } + + public function testCreate() + { + $this->setNoOpExpectation(); + + $this->provider->create(); + } + + public function testDestroy() + { + $this->setNoOpExpectation(); + + $this->provider->destroy(); + } + + public function testDelete() + { + $this->setNoOpExpectation(); + + $this->provider->delete('foo'); + } + + public function testReceive() + { + $this->setNoOpExpectation(); + + $this->provider->receive(); + } + + + protected function getCustomProvider() + { + $options = [ + 'logging_enabled' => false, + 'push_notifications' => true, + 'notification_retries' => 3, + 'message_delay' => 0, + 'message_timeout' => 30, + 'message_expiration' => 604800, + 'messages_to_receive' => 1, + 'receive_wait_time' => 3, + 'subscribers' => [] + ]; + + $cache = $this->getMock( + 'Doctrine\Common\Cache\PhpFileCache', + [], + ['/tmp', 'qpush.custom.test.php'] + ); + + $this->logger = $this->getMock( + 'Symfony\Bridge\Monolog\Logger', [], ['qpush.test'] + ); + + $this->mock = new CustomMockClient('custom', $options, null, $cache, $this->logger); + + return new CustomProvider('test', $options, $this->mock, $cache, $this->logger); + } + + protected function setNoOpExpectation() + { + $this->logger + ->expects($this->never()) + ->method(new \PHPUnit_Framework_Constraint_IsAnything()); + } +} \ No newline at end of file diff --git a/tests/Provider/FileProviderTest.php b/tests/Provider/FileProviderTest.php new file mode 100644 index 0000000..231de4a --- /dev/null +++ b/tests/Provider/FileProviderTest.php @@ -0,0 +1,172 @@ + + */ +class FileProviderTest extends \PHPUnit_Framework_TestCase +{ + /** @var FileProvider */ + protected $provider; + protected $basePath; + protected $queueHash; + protected $umask; + + public function setUp() + { + $this->umask = umask(0); + $this->basePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.time().rand(0, 1000); + mkdir($this->basePath); + $this->provider = $this->getFileProvider(); + } + + public function tearDown() + { + $this->clean($this->basePath); + umask($this->umask); + } + + /** + * @param string $file + */ + protected function clean($file) + { + if (is_dir($file) && !is_link($file)) { + $dir = new \FilesystemIterator($file); + foreach ($dir as $childFile) { + $this->clean($childFile); + } + + rmdir($file); + } else if (is_file($file)) { + unlink($file); + } + } + + private function getFileProvider(array $options = []) + { + $options = array_merge( + [ + 'path' => $this->basePath, + 'logging_enabled' => false, + 'message_delay' => 0, + 'message_timeout' => 30, + 'message_expiration' => 604800, + 'messages_to_receive' => 1, + ], + $options + ); + + $cache = $this->getMock( + 'Doctrine\Common\Cache\PhpFileCache', + [], + ['/tmp', 'qpush.aws.test.php'] + ); + + $logger = $this->getMock( + 'Symfony\Bridge\Monolog\Logger', [], ['qpush.test'] + ); + + $this->queueHash = str_replace('-', '', md5('test')); + + return new FileProvider('test', $options, null, $cache, $logger); + } + + public function testGetProvider() + { + $provider = $this->provider->getProvider(); + + $this->assertEquals('File', $provider); + } + + public function testCreate() + { + $this->assertTrue($this->provider->create()); + $this->assertTrue(is_readable($this->basePath.DIRECTORY_SEPARATOR.$this->queueHash)); + $this->assertTrue(is_writable($this->basePath.DIRECTORY_SEPARATOR.$this->queueHash)); + } + + public function testDestroy() + { + $this->provider->destroy(); + $this->assertFalse(is_dir($this->basePath.DIRECTORY_SEPARATOR.$this->queueHash)); + } + + public function testReceive() + { + $this->provider->create(); + $this->assertTrue(is_array($this->provider->receive())); + } + + public function testDelete() + { + $this->provider->create(); + + $path = substr(hash('md5', '123'), 0, 3); + mkdir($this->basePath.DIRECTORY_SEPARATOR.$this->queueHash.DIRECTORY_SEPARATOR.$path); + touch($this->basePath.DIRECTORY_SEPARATOR.$this->queueHash.DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.'123.json'); + + $messages = $this->provider->receive(); + $this->assertNotEmpty($messages); + $this->assertTrue($this->provider->delete(123)); + } + + public function testPublish() + { + $this->provider->create(); + $content = [ + ['testing'], + ['testing 123'] + ]; + $this->provider->publish($content[0]); + $this->provider->publish($content[1]); + $messagesA = $this->provider->receive(); + $this->assertEquals(1, count($messagesA)); + $this->assertContains($messagesA[0]->getBody(), $content); + $messagesB = $this->provider->receive(); + $this->assertEquals(1, count($messagesB)); + $this->assertContains($messagesB[0]->getBody(), $content); + $this->assertNotEquals($messagesA[0]->getBody(), $messagesB[0]->getBody()); + } + + public function testPublishDelay() { + $this->provider->create(); + $provider = $this->getFileProvider([ + 'message_delay' => 2, + ]); + $provider->publish(['testing']); + $messages = $provider->receive(); + $this->assertEmpty($messages); + } + + public function testOnMessageReceived() + { + $this->provider->create(); + $id = $this->provider->publish(['foo' => 'bar']); + $path = substr(hash('md5', $id), 0, 3); + $this->assertTrue(is_file($this->basePath.DIRECTORY_SEPARATOR.$this->queueHash.DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.$id.'.json')); + $this->provider->onMessageReceived(new MessageEvent( + 'test', + $this->provider->receive()[0] + )); + $this->assertFalse(is_file($this->basePath.DIRECTORY_SEPARATOR.$this->queueHash.DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.$id.'.json')); + } + + public function testCleanUp() + { + $this->provider->create(); + $provider = $this->getFileProvider([ + 'message_expiration' => 1, + ]); + $provider->publish(['testing']); + $provider->publish(['testing 123']); + sleep(1); + $provider->cleanUp(); + $messages = $provider->receive(); + $this->assertEmpty($messages); + } +} \ No newline at end of file diff --git a/tests/Provider/IronMqProviderTest.php b/tests/Provider/IronMqProviderTest.php new file mode 100755 index 0000000..bf67421 --- /dev/null +++ b/tests/Provider/IronMqProviderTest.php @@ -0,0 +1,211 @@ + + */ +class IronMqProviderTest extends \PHPUnit_Framework_TestCase +{ + /** + * Mock Client + * + * @var stdClass + */ + protected $provider; + + public function setUp() + { + $this->provider = $this->getIronMqProvider(); + } + + public function tearDown() + { + $this->provider = null; + } + + private function getIronMqProvider(array $options = []) + { + $options = array_merge( + [ + 'logging_enabled' => false, + 'push_notifications' => true, + 'push_type' => 'multicast', + 'notification_retries' => 3, + 'notification_retries_delay' => 60, + 'message_delay' => 0, + 'message_timeout' => 30, + 'message_expiration' => 604800, + 'messages_to_receive' => 1, + 'rate_limit' => -1, + 'receive_wait_time' => 3, + 'subscribers' => [ + [ 'protocol' => 'http', 'endpoint' => 'http://fake.com' ] + ] + ], + $options + ); + + $client = new IronMqMockClient([ + 'token' => '123_this_is_a_token', + 'project_id' => '123_this_is_a_project_id', + ]); + + return new IronMqProvider( + 'test', + $options, + $client, + $this->getMock( + 'Doctrine\Common\Cache\PhpFileCache', + [], + ['/tmp', 'qpush.ironmq.test.php'] + ), + $this->getMock( + 'Symfony\Bridge\Monolog\Logger', + [], + ['qpush.test'] + ) + ); + } + + public function testGetProvider() + { + $provider = $this->provider->getProvider(); + + $this->assertEquals('IronMQ', $provider); + } + + public function testCreate() + { + $this->assertFalse($this->provider->queueExists()); + + $stub = $this->provider->getCache(); + $stub->expects($this->once()) + ->method('contains') + ->will($this->returnValue(true)); + + $this->assertTrue($this->provider->queueExists()); + + $this->assertTrue($this->provider->create()); + $this->assertTrue($this->provider->queueExists()); + + $provider = $this->getIronMqProvider([ + 'subscribers' => [ + [ 'protocol' => 'email', 'endpoint' => 'test@foo.com' ] + ] + ]); + + $this->setExpectedException('InvalidArgumentException'); + $provider->create(); + } + + public function testDestroy() + { + // First call returns true when the queue exists + $this->assertTrue($this->provider->destroy()); + + // Second call catches exception and returns true when the queue + // does not exists + $this->assertTrue($this->provider->destroy()); + + // Last call throws an exception if there is an exception outside + // of a HTTP 404 + $this->setExpectedException('Exception'); + $this->provider->destroy(); + } + + public function testPublish() + { + $provider = $this->getIronMqProvider([ + 'push_notifications' => false + ]); + + $this->assertEquals(123, $provider->publish(['foo' => 'bar'])); + } + + public function testReceive() + { + $messages = $this->provider->receive(); + $this->assertInternalType('array', $messages); + $this->assertEquals(['foo' => 'bar'], $messages[0]->getBody()); + } + + public function testDelete() + { + // First call returns true when the queue exists + $this->assertTrue($this->provider->delete(123)); + + // Second call catches exception and returns true when the queue + // does not exists + $this->assertTrue($this->provider->delete(456)); + + // Last call throws an exception if there is an exception outside + // of a HTTP 404 + $this->setExpectedException('Exception'); + $this->provider->delete(789); + } + + public function testOnNotification() + { + $event = new NotificationEvent( + 'test', + NotificationEvent::TYPE_MESSAGE, + new Notification(123, "test", []) + ); + + $this->provider->onNotification( + $event, + NotificationEvent::TYPE_MESSAGE, + $this->getMock('Symfony\Component\EventDispatcher\EventDispatcherInterface') + ); + } + + public function testOnMessageReceived() + { + $this->provider->onMessageReceived(new MessageEvent( + 'test', + new Message(123, ['foo' => 'bar'], []) + )); + } + + public function testQueueInfo() + { + $this->assertNull($this->provider->queueInfo()); + + $this->provider->create(); + $queue = $this->provider->queueInfo(); + $this->assertEquals('530295fe3c94fbcf0c79cffe', $queue->id); + $this->assertEquals('test', $queue->name); + $this->assertEquals('52f67d032001c00005000057', $queue->project_id); + } +} diff --git a/tests/Provider/ProviderRegisteryTest.php b/tests/Provider/ProviderRegisteryTest.php new file mode 100644 index 0000000..cc2e324 --- /dev/null +++ b/tests/Provider/ProviderRegisteryTest.php @@ -0,0 +1,48 @@ + + */ +class ProviderRegistryTest extends \PHPUnit_Framework_TestCase +{ + public function testRegistry() + { + $registry = new ProviderRegistry(); + $interface = 'Uecode\Bundle\QPushBundle\Provider\ProviderInterface'; + + $registry->addProvider('test', $this->getMock($interface)); + + $this->assertEquals(['test' => $this->getMock($interface)], $registry->all()); + + $this->assertTrue($registry->has('test')); + + $this->assertEquals($this->getMock($interface), $registry->get('test')); + + $this->setExpectedException('InvalidArgumentException'); + $registry->get('foo'); + } +} diff --git a/tests/Provider/SyncProviderTest.php b/tests/Provider/SyncProviderTest.php new file mode 100644 index 0000000..af41527 --- /dev/null +++ b/tests/Provider/SyncProviderTest.php @@ -0,0 +1,123 @@ +dispatcher = $this->getMock( + 'Symfony\Component\EventDispatcher\EventDispatcherInterface' + ); + + $this->provider = $this->getSyncProvider(); + } + + public function testGetProvider() + { + $provider = $this->provider->getProvider(); + + $this->assertEquals('Sync', $provider); + } + + public function testPublish() + { + $this->dispatcher + ->expects($this->once()) + ->method('dispatch') + ->with( + Events::Message($this->provider->getName()), + new \PHPUnit_Framework_Constraint_IsInstanceOf('Uecode\Bundle\QPushBundle\Event\MessageEvent') + ); + + $this->provider->publish(['foo' => 'bar']); + } + + public function testCreate() + { + $this->setNoOpExpectation(); + + $this->provider->create(); + } + + public function testDestroy() + { + $this->setNoOpExpectation(); + + $this->provider->destroy(); + } + + public function testDelete() + { + $this->setNoOpExpectation(); + + $this->provider->delete('foo'); + } + + public function testReceive() + { + $this->setNoOpExpectation(); + + $this->provider->receive(); + } + + + protected function getSyncProvider() + { + $options = [ + 'logging_enabled' => false, + 'push_notifications' => true, + 'notification_retries' => 3, + 'message_delay' => 0, + 'message_timeout' => 30, + 'message_expiration' => 604800, + 'messages_to_receive' => 1, + 'receive_wait_time' => 3, + 'subscribers' => [ + [ 'protocol' => 'http', 'endpoint' => 'http://fake.com' ] + ] + ]; + + $cache = $this->getMock( + 'Doctrine\Common\Cache\PhpFileCache', + [], + ['/tmp', 'qpush.aws.test.php'] + ); + + $this->logger = $this->getMock( + 'Symfony\Bridge\Monolog\Logger', [], ['qpush.test'] + ); + + return new SyncProvider('test', $options, $this->dispatcher, $cache, $this->logger); + } + + protected function setNoOpExpectation() + { + $this->dispatcher + ->expects($this->never()) + ->method(new \PHPUnit_Framework_Constraint_IsAnything()); + + $this->logger + ->expects($this->never()) + ->method(new \PHPUnit_Framework_Constraint_IsAnything()); + } +} \ No newline at end of file diff --git a/tests/Provider/TestProvider.php b/tests/Provider/TestProvider.php new file mode 100755 index 0000000..4fd83da --- /dev/null +++ b/tests/Provider/TestProvider.php @@ -0,0 +1,81 @@ + + */ +class TestProvider extends AbstractProvider +{ + /** + * Mock Client + * + * @var stdClass + */ + protected $client; + + public function __construct($name, array $options, $client, Cache $cache, Logger $logger) + { + $this->name = $name; + $this->options = $options; + $this->client = $client; + $this->cache = $cache; + $this->logger = $logger; + } + + public function getProvider() + { + return 'TestProvider'; + } + + /** + * @codeCoverageIgnore + */ + public function create() { } + + /** + * @codeCoverageIgnore + */ + public function publish(array $message, array $options = []) { } + + /** + * @codeCoverageIgnore + */ + public function receive(array $options = []) { } + + /** + * @codeCoverageIgnore + */ + public function delete($id) { } + + /** + * @codeCoverageIgnore + */ + public function destroy() { } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8c39408 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,34 @@ +addPsr4('Uecode\\Bundle\\QPushBundle\\Tests\\', __DIR__);