diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba93dc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea +composer.lock +vendor +bin +coverage +coverage.xml \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3936662 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: php + +php: + - 5.6 + - 7.0 + - 7.1 + - hhvm + - nightly + +before_script: + - composer install \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1277155 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM php:7.0-fpm +MAINTAINER Superbalist + +RUN mkdir /opt/php-pubsub +WORKDIR /opt/php-pubsub + +# Packages +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + git \ + zlib1g-dev \ + unzip \ + && rm -r /var/lib/apt/lists/* + +# PHP Extensions +RUN docker-php-ext-install -j$(nproc) zip + +# Composer +ENV COMPOSER_HOME /composer +ENV PATH /composer/vendor/bin:$PATH +ENV COMPOSER_ALLOW_SUPERUSER 1 +RUN curl -o /tmp/composer-setup.php https://getcomposer.org/installer \ + && curl -o /tmp/composer-setup.sig https://composer.github.io/installer.sig \ + && php -r "if (hash('SHA384', file_get_contents('/tmp/composer-setup.php')) !== trim(file_get_contents('/tmp/composer-setup.sig'))) { unlink('/tmp/composer-setup.php'); echo 'Invalid installer' . PHP_EOL; exit(1); }" \ + && php /tmp/composer-setup.php --no-ansi --install-dir=/usr/local/bin --filename=composer --version=1.1.0 && rm -rf /tmp/composer-setup.php + +# Install Composer Application Dependencies +COPY composer.json /opt/php-pubsub/ +RUN composer install --no-autoloader --no-scripts --no-interaction + +COPY src /opt/php/pubsub/ +COPY examples /opt/php/pubsub + +RUN composer dump-autoload --no-interaction + +CMD ["/bin/bash"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9a80d31 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Superbalist.com a division of Takealot Online (Pty) Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..68efc9e --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: tests + +up: + @docker-compose rm -f + @docker-compose pull + @sed -e "s/HOSTIP/$$(docker-machine ip)/g" docker-compose.yml | docker-compose --file - up --build -d + @docker-compose run php-pubsub /bin/bash + +down: + @docker-compose stop -t 1 + +tests: + @./vendor/bin/phpunit --configuration phpunit.xml diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f5f9cf --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# php-pubsub + +A PHP abstraction for the pub-sub pattern + +[![Author](http://img.shields.io/badge/author-@superbalist-blue.svg?style=flat-square)](https://twitter.com/superbalist) +[![Build Status](https://img.shields.io/travis/Superbalist/php-pubsub/master.svg?style=flat-square)](https://travis-ci.org/Superbalist/php-pubsub) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) +[![Packagist Version](https://img.shields.io/packagist/v/superbalist/php-pubsub.svg?style=flat-square)](https://packagist.org/packages/superbalist/php-pubsub) +[![Total Downloads](https://img.shields.io/packagist/dt/superbalist/php-pubsub.svg?style=flat-square)](https://packagist.org/packages/superbalist/php-pubsub) + + +## Installation + +```bash +composer require superbalist/php-pubsub +``` + +## Adapters + +* Local (bundled) +* /dev/null (bundled) +* Redis - link pending +* Kafka - link pending + +## Usage + +```php +$adapter = new \Superbalist\PubSub\Adapters\LocalPubSubAdapter(); + +// consume messages +$adapter->subscribe('my_channel', function ($message) { + var_dump($message); +}); + +// publish messages +$adapter->publish('my_channel', 'Hello World!'); +``` + +## Writing an Adapter + +You can easily write your own custom adapter by implementing the [PubSubAdapterInterface](src/PubSubAdapterInterface.php) interface. + +Your adapter must implement the following methods: + +```php +/** + * Subscribe a handler to a channel. + * + * @param string $channel + * @param callable $handler + */ +public function subscribe($channel, callable $handler); + +/** + * Publish a message to a channel. + * + * @param string $channel + * @param mixed $message + */ +public function publish($channel, $message); +``` + +## Examples + +The library comes with [examples](examples) for all adapters and a [Dockerfile](Dockerfile) for +running the example scripts. + +Run `make up`. + +You will start at a `bash` prompt in the `/opt/php-pubsub` directory. + +If you need another shell to publish a message to a blocking consumer, you can run `docker-compose run php-pubsub /bin/bash` + +To run the examples: +```bash +$ php examples/LocalExample.php +``` diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..e3ec22f --- /dev/null +++ b/changelog.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 - 2016-09-02 + +* Initial release \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4315650 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "superbalist/php-pubsub", + "description": "An adapter based PubSub package for PHP", + "license": "MIT", + "authors": [ + { + "name": "Superbalist.com a division of Takealot Online (Pty) Ltd", + "email": "info@superbalist.com" + } + ], + "require": { + "php": ">=5.6.0" + }, + "autoload": { + "psr-4": { + "Superbalist\\PubSub\\": "src/", + "Tests\\": "tests/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "require-dev": { + "phpunit/phpunit": "^5.5", + "mockery/mockery": "^0.9.5" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf46eba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +version: '2' +services: + php-pubsub: + build: . + volumes: + - ./src:/opt/php-pubsub/src + - ./examples:/opt/php-pubsub/examples \ No newline at end of file diff --git a/examples/LocalExample.php b/examples/LocalExample.php new file mode 100644 index 0000000..cbefdf1 --- /dev/null +++ b/examples/LocalExample.php @@ -0,0 +1,10 @@ +subscribe('my_channel', function ($message) { + var_dump($message); +}); + +$adapter->publish('my_channel', 'Hello World!'); diff --git a/phpunit.php b/phpunit.php new file mode 100644 index 0000000..744164d --- /dev/null +++ b/phpunit.php @@ -0,0 +1,3 @@ + + + + + ./tests/ + + + + + ./src/ + + + + + + + + \ No newline at end of file diff --git a/src/Adapters/DevNullPubSubAdapter.php b/src/Adapters/DevNullPubSubAdapter.php new file mode 100644 index 0000000..80904fe --- /dev/null +++ b/src/Adapters/DevNullPubSubAdapter.php @@ -0,0 +1,30 @@ +subscribers[$channel])) { + $this->subscribers[$channel] = []; + } + $this->subscribers[$channel][] = $handler; + } + + /** + * Publish a message to a channel. + * + * @param string $channel + * @param mixed $message + */ + public function publish($channel, $message) + { + foreach ($this->getSubscribersForChannel($channel) as $handler) { + call_user_func($handler, $message); + } + } + + /** + * Return all subscribers on the given channel. + * + * @param string $channel + * @return array + */ + public function getSubscribersForChannel($channel) + { + return isset($this->subscribers[$channel]) ? $this->subscribers[$channel] : []; + } +} diff --git a/src/PubSubAdapterInterface.php b/src/PubSubAdapterInterface.php new file mode 100644 index 0000000..35ba2ff --- /dev/null +++ b/src/PubSubAdapterInterface.php @@ -0,0 +1,22 @@ +getSubscribersForChannel('test_channel'); + $this->assertInternalType('array', $subscribers); + $this->assertEmpty($subscribers); + + $handler = function ($message) { + + }; + + $adapter->subscribe('test_channel', $handler); + + $subscribers = $adapter->getSubscribersForChannel('test_channel'); + $this->assertEquals(1, count($subscribers)); + $this->assertSame($handler, $subscribers[0]); + } + + public function testPublish() + { + $adapter = new LocalPubSubAdapter(); + + $handler1 = Mockery::mock(\stdClass::class); + $handler1->shouldReceive('handle') + ->with('This is a message sent to handler1 & handler2') + ->once(); + $adapter->subscribe('test_channel', [$handler1, 'handle']); + + $handler2 = Mockery::mock(\stdClass::class); + $handler2->shouldReceive('handle') + ->with('This is a message sent to handler1 & handler2') + ->once(); + $adapter->subscribe('test_channel', [$handler2, 'handle']); + + $handler3 = Mockery::mock(\stdClass::class); + $handler3->shouldNotReceive('handle'); + $adapter->subscribe('some_other_channel_that_should_not_receive_anything', [$handler3, 'handle']); + + $adapter->publish('test_channel', 'This is a message sent to handler1 & handler2'); + } +} diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php new file mode 100644 index 0000000..44f99d3 --- /dev/null +++ b/tests/UtilsTest.php @@ -0,0 +1,25 @@ +assertEquals('hello world', Utils::serializeMessage('hello world')); + $this->assertEquals('a:1:{s:5:"hello";s:5:"world";}', Utils::serializeMessage(['hello' => 'world'])); + $this->assertEquals('b:0;', Utils::serializeMessage(false)); + $this->assertEquals('{ "hello": "world" }', Utils::serializeMessage('{ "hello": "world" }')); + } + + public function testUnserializeMessagePayload() + { + $this->assertEquals('hello world', Utils::unserializeMessagePayload('hello world')); + $this->assertEquals(['hello' => 'world'], Utils::unserializeMessagePayload('a:1:{s:5:"hello";s:5:"world";}')); + $this->assertEquals(false, Utils::unserializeMessagePayload('b:0;')); + $this->assertEquals(['hello' => 'world'], Utils::unserializeMessagePayload('{ "hello": "world" }')); + } +}