├── .editorconfig ├── .github └── workflows │ └── coverage.yml ├── .gitignore ├── ADAPTERS.md ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── examples ├── Handlers │ └── ExampleHandler.php ├── consumer.php ├── publisher.php └── schemas │ └── fruits.json ├── phpunit.xml ├── psalm.xml ├── src ├── Adapter │ ├── Azure.php │ ├── Beanstalk.php │ ├── ConsumerInterface.php │ ├── Gearman.php │ ├── Google.php │ ├── Iron.php │ ├── Mercure.php │ ├── MockQueue.php │ ├── MockSubscriber.php │ ├── Mqtt.php │ ├── NullPublisher.php │ ├── Outbox.php │ ├── PublisherInterface.php │ ├── RabbitMQ.php │ ├── Redis.php │ ├── RedisPubsub.php │ ├── Segment.php │ ├── Sns.php │ ├── Sqs.php │ ├── SubscriberInterface.php │ └── Webhook.php ├── Application.php ├── Exception │ ├── ConnectionException.php │ ├── ConsumeException.php │ ├── MessageValidationException.php │ ├── PublishException.php │ ├── RoutingException.php │ └── SubscriptionException.php ├── Filter │ ├── RedirectFilter.php │ └── ValidatorFilter.php ├── Message.php ├── Middleware │ ├── DeadletterMessage.php │ ├── MiddlewareInterface.php │ ├── ParseJsonMessage.php │ └── ValidateMessage.php ├── Response.php ├── Router │ ├── Consume.php │ ├── Router.php │ └── RouterInterface.php └── Validator │ ├── JsonSchemaValidator.php │ └── ValidatorInterface.php └── tests ├── Adapter ├── AzureTest.php ├── GearmanTest.php ├── GoogleTest.php ├── IronMQTest.php ├── MercureTest.php ├── MockQueueTest.php ├── MockSubscriberTest.php ├── MqttTest.php ├── NullPublisherTest.php ├── OutboxTest.php ├── RabbitMQTest.php ├── RedisPubsubTest.php ├── RedisTest.php ├── SegmentTest.php ├── SnsTest.php ├── SqsTest.php └── WebhookTest.php ├── ApplicationTest.php ├── Exception └── MessageValidationExceptionTest.php ├── Filter ├── RedirectFilterTest.php └── ValidatorFilterTest.php ├── Fixtures ├── TestHandler.php ├── TestMiddleware.php └── schema.json ├── MessageTest.php ├── Middleware ├── DeadletterMessageTest.php ├── ParseJsonMessageTest.php └── ValidateMessageTest.php ├── Router ├── ConsumeAttributeTest.php └── RouterTest.php └── Validator └── JsonSchemaValidatorTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | end_of_line=lf 5 | indent_style=tab 6 | indent_size=4 7 | charset=utf-8 8 | trim_trailing_whitespace=true 9 | 10 | [*.yml] 11 | indent_style=space 12 | indent_size=2 -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Test suite 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | php-version: [8.2, 8.3, 8.4] 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: ${{ matrix.php-version }} 27 | extensions: gearman 28 | 29 | - name: Validate composer.json and composer.lock 30 | run: composer validate --strict 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | - name: Check dependencies 36 | run: composer audit 37 | 38 | - name: Run static analysis 39 | run: make analyze 40 | 41 | - name: Run test suite 42 | run: make coverage 43 | 44 | - uses: codecov/codecov-action@v4 45 | env: 46 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 47 | with: 48 | files: ./build/logs/clover.xml 49 | #flags: unittests # optional 50 | #name: codecov-umbrella # optional 51 | #fail_ci_if_error: true # optional (default = false) 52 | #verbose: true # optional (default = false) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | scratch.php 2 | .phpunit.cache/ 3 | .vscode/ 4 | .idea/ 5 | vendor/ 6 | build/ 7 | composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 nimbly.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | vendor/bin/phpunit 3 | 4 | coverage: 5 | vendor/bin/phpunit --coverage-clover=build/logs/clover.xml 6 | 7 | analyze: 8 | vendor/bin/psalm --no-cache -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nimbly/syndicate", 3 | "description": "A powerful queue and pubsub message publisher and consumer framework.", 4 | "keywords": ["php", "event", "queue", "pubsub", "framework"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Brent Scheffler", 10 | "email": "brent@brentscheffler.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.2", 15 | "psr/container": "^1.0|^2.0", 16 | "psr/http-client": "^1.0", 17 | "psr/log": "^2.0|^3.0", 18 | "nimbly/resolve": "^2.0", 19 | "nimbly/capsule": "^3.0", 20 | "nimbly/shuttle": "^1.1", 21 | "softcreatr/jsonpath": "^0.9.1", 22 | "opis/json-schema": "^2.4" 23 | }, 24 | "require-dev": { 25 | "vimeo/psalm": "^6.0", 26 | "phpunit/phpunit": "^10.0", 27 | "symfony/var-dumper": "^6.0", 28 | "mockery/mockery": "^1.6", 29 | "nimbly/carton": "^2.0", 30 | "monolog/monolog": "^3.8", 31 | "ext-pdo": "*", 32 | "ext-gearman": "*", 33 | "pda/pheanstalk": "^5.0", 34 | "predis/predis": "^2.3", 35 | "google/cloud-pubsub": "^2.8", 36 | "microsoft/azure-storage-queue": "^1.3", 37 | "iron-io/iron_mq": "^4.0", 38 | "php-mqtt/client": "^2.2", 39 | "php-amqplib/php-amqplib": "^3.7", 40 | "aws/aws-sdk-php": "^3.336", 41 | "segmentio/analytics-php": "^3.8" 42 | }, 43 | "suggest": { 44 | "ext-pcntl": "Enables graceful shutdown of consumers.", 45 | "ext-pdo": "Required for DB based adapters.", 46 | "ext-gearman": "Required for Gearman support.", 47 | "pda/pheanstalk": "Required for Beanstalkd support.", 48 | "predis/predis": "Required for Redis support.", 49 | "google/cloud-pubsub": "Required for Google PubSub support.", 50 | "microsoft/azure-storage-queue": "Required for Azure support.", 51 | "iron-io/iron_mq": "Required for IronMQ support.", 52 | "php-mqtt/client": "Required for MQTT support.", 53 | "php-amqplib/php-amqplib": "Required for RabbitMQ support.", 54 | "aws/aws-sdk-php": "Required for AWS SNS and SQS support." 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Nimbly\\Syndicate\\": "src/", 59 | "Nimbly\\Syndicate\\Examples\\": "examples/" 60 | } 61 | }, 62 | "autoload-dev": { 63 | "psr-4": { 64 | "Nimbly\\Syndicate\\Tests\\": "tests/" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/Handlers/ExampleHandler.php: -------------------------------------------------------------------------------- 1 | "bananas"] 20 | )] 21 | public function onBananas(Message $message): Response 22 | { 23 | echo \sprintf( 24 | "[HANDLER] Received message: %s\n", 25 | $message->getPayload() 26 | ); 27 | 28 | if( \mt_rand(1, 10) > 9 ) { 29 | return Response::nack; 30 | } 31 | 32 | return Response::ack; 33 | } 34 | 35 | /** 36 | * Handle all messages about kiwis. 37 | * 38 | * @param Message $message 39 | * @return Response 40 | */ 41 | #[Consume( 42 | topic: "fruits", 43 | payload: ["$.name" => "kiwis"] 44 | )] 45 | public function onKiwis(Message $message): Response 46 | { 47 | echo \sprintf( 48 | "[HANDLER] Received message: %s\n", 49 | $message->getPayload() 50 | ); 51 | 52 | if( \mt_rand(1, 10) > 9 ) { 53 | return Response::nack; 54 | } 55 | 56 | return Response::ack; 57 | } 58 | 59 | /** 60 | * Handle all messages about oranges. 61 | * 62 | * @param Message $message 63 | * @return Response 64 | */ 65 | #[Consume( 66 | topic: "fruits", 67 | payload: ["$.name" => "oranges"] 68 | )] 69 | public function onOranges(Message $message): Response 70 | { 71 | echo \sprintf( 72 | "[HANDLER] Received message: %s\n", 73 | $message->getPayload() 74 | ); 75 | 76 | if( \mt_rand(1, 10) > 9 ) { 77 | return Response::nack; 78 | } 79 | 80 | return Response::ack; 81 | } 82 | 83 | /** 84 | * Handle all messages about mangoes or mangos. 85 | * 86 | * @param Message $message 87 | * @return Response 88 | */ 89 | #[Consume( 90 | topic: "fruits", 91 | payload: ["$.name" => ["mangoes", "mangos"]] 92 | )] 93 | public function onMangoes(Message $message): Response 94 | { 95 | echo \sprintf( 96 | "[HANDLER] Received message: %s\n", 97 | $message->getPayload() 98 | ); 99 | 100 | if( \mt_rand(1, 10) > 9 ) { 101 | return Response::nack; 102 | } 103 | 104 | return Response::ack; 105 | } 106 | } -------------------------------------------------------------------------------- /examples/consumer.php: -------------------------------------------------------------------------------- 1 | 0])); 37 | 38 | /** 39 | * Validate messages against the "fruits" topic JSON schema. 40 | */ 41 | $validator = new JsonSchemaValidator( 42 | ["fruits" => \file_get_contents(__DIR__ . "/schemas/fruits.json")] 43 | ); 44 | 45 | $application = new Application( 46 | consumer: $client, 47 | 48 | /** 49 | * Create a Router instance with our single ExampleHandler class. 50 | */ 51 | router: new Router( 52 | handlers: [ 53 | ExampleHandler::class, 54 | ], 55 | ), 56 | 57 | /** 58 | * Redirect deadletter messages back to the same Redis queue 59 | * except publish them to the "deadletter" queue. 60 | */ 61 | deadletter: new RedirectFilter($client, "deadletter"), 62 | 63 | /** 64 | * Add a simple logger to show what's going on behind the scenes. 65 | */ 66 | logger: new Logger("EXAMPLE", [new ErrorLogHandler]), 67 | 68 | middleware: [ 69 | 70 | /** 71 | * Parse all incoming messages as JSON. 72 | */ 73 | new ParseJsonMessage, 74 | 75 | /** 76 | * Validate all incoming messages against our JSON schema. 77 | */ 78 | new ValidateMessage($validator) 79 | ] 80 | ); 81 | 82 | $application->listen(location: "fruits"); -------------------------------------------------------------------------------- /examples/publisher.php: -------------------------------------------------------------------------------- 1 | __DIR__ . "/schemas/fruits.json" 38 | ]), 39 | publisher: new Redis(new Client) 40 | ); 41 | 42 | for( $i = 0; $i < ($argv[1] ?? 100); $i++ ){ 43 | 44 | $c = \mt_rand(1, 100); 45 | 46 | if( $c <= 5 ){ 47 | // There is no handler defined for this and should end up in the deadletter. 48 | $fruit = "apples"; 49 | } 50 | elseif( $c <= 30 ){ 51 | $fruit = "bananas"; 52 | } 53 | elseif( $c <= 55 ){ 54 | $fruit = "kiwis"; 55 | } 56 | elseif( $c <= 80 ){ 57 | $fruit = "oranges"; 58 | } 59 | elseif( $c <= 90) { 60 | $fruit = "mangoes"; 61 | } 62 | else { 63 | $fruit = "mangos"; 64 | } 65 | 66 | $payload = [ 67 | "name" => $fruit, 68 | "published_at" => \date("c"), 69 | ]; 70 | 71 | $publisher->publish( 72 | new Message("fruits", \json_encode($payload)), 73 | ); 74 | } -------------------------------------------------------------------------------- /examples/schemas/fruits.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "name": { 5 | "type": "string", 6 | "enum": ["apples", "bananas", "kiwis", "oranges", "mangoes", "mangos"] 7 | }, 8 | 9 | "published_at": { 10 | "type": "string", 11 | "format": "date-time" 12 | } 13 | }, 14 | "required": ["name", "published_at"], 15 | "additionalProperties": false 16 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | tests 16 | 17 | 18 | 19 | 20 | src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Adapter/Azure.php: -------------------------------------------------------------------------------- 1 | client->createMessage( 33 | $message->getTopic(), 34 | $message->getPayload(), 35 | ); 36 | } 37 | catch( ServiceException $exception ){ 38 | throw new ConnectionException( 39 | message: "Connection to Azure failed.", 40 | previous: $exception 41 | ); 42 | } 43 | catch( Throwable $exception ){ 44 | throw new PublishException( 45 | message: "Failed to publish message.", 46 | previous: $exception 47 | ); 48 | } 49 | 50 | return $result->getQueueMessage()->getMessageId(); 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | * 56 | * Options: 57 | * * `delay` (integer) Visibility timeout in seconds. 58 | * * `timeout` (integer) Polling timeout in seconds. 59 | */ 60 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 61 | { 62 | try { 63 | 64 | $listMessageResult = $this->client->listMessages( 65 | $topic, 66 | $this->buildListMessageOptions($max_messages, $options) 67 | ); 68 | } 69 | catch( ServiceException $exception ){ 70 | throw new ConnectionException( 71 | message: "Connection to Azure failed.", 72 | previous: $exception 73 | ); 74 | } 75 | catch( Throwable $exception ){ 76 | throw new ConsumeException( 77 | message: "Failed to consume message.", 78 | previous: $exception 79 | ); 80 | } 81 | 82 | $messages = \array_map( 83 | function(QueueMessage $queueMessage) use ($topic): Message { 84 | return new Message( 85 | topic: $topic, 86 | payload: $queueMessage->getMessageText(), 87 | reference: [$queueMessage->getMessageId(), $queueMessage->getPopReceipt()], 88 | ); 89 | }, 90 | $listMessageResult->getQueueMessages() 91 | ); 92 | 93 | return $messages; 94 | } 95 | 96 | /** 97 | * Build the Azure ListMessageOptions object for consuming messages. 98 | * 99 | * @param integer $max_messages 100 | * @param array $options 101 | * @return ListMessagesOptions 102 | */ 103 | protected function buildListMessageOptions(int $max_messages, array $options): ListMessagesOptions 104 | { 105 | $listMessageOptions = new ListMessagesOptions; 106 | $listMessageOptions->setNumberOfMessages($max_messages); 107 | 108 | if( \array_key_exists("delay", $options) ){ 109 | $listMessageOptions->setVisibilityTimeoutInSeconds((int) $options["delay"]); 110 | } 111 | 112 | if( \array_key_exists("timeout", $options) ){ 113 | $listMessageOptions->setTimeout($options["timeout"]); 114 | } 115 | 116 | return $listMessageOptions; 117 | } 118 | 119 | /** 120 | * @inheritDoc 121 | */ 122 | public function ack(Message $message): void 123 | { 124 | [$message_id, $pop_receipt] = $message->getReference(); 125 | 126 | try { 127 | 128 | $this->client->deleteMessage( 129 | $message->getTopic(), 130 | $message_id, 131 | $pop_receipt 132 | ); 133 | } 134 | catch( ServiceException $exception ){ 135 | throw new ConnectionException( 136 | message: "Connection to Azure failed.", 137 | previous: $exception 138 | ); 139 | } 140 | catch( Throwable $exception ){ 141 | throw new ConsumeException( 142 | message: "Failed to ack message.", 143 | previous: $exception 144 | ); 145 | } 146 | } 147 | 148 | /** 149 | * @inheritDoc 150 | */ 151 | public function nack(Message $message, int $timeout = 0): void 152 | { 153 | [$message_id, $pop_receipt] = $message->getReference(); 154 | 155 | try { 156 | 157 | $this->client->updateMessage( 158 | $message->getTopic(), 159 | $message_id, 160 | $pop_receipt, 161 | $message->getPayload(), 162 | $timeout 163 | ); 164 | } 165 | catch( ServiceException $exception ){ 166 | throw new ConnectionException( 167 | message: "Connection to Azure failed.", 168 | previous: $exception 169 | ); 170 | } 171 | catch( Throwable $exception ){ 172 | throw new ConsumeException( 173 | message: "Failed to nack message.", 174 | previous: $exception 175 | ); 176 | } 177 | } 178 | } -------------------------------------------------------------------------------- /src/Adapter/Beanstalk.php: -------------------------------------------------------------------------------- 1 | client->useTube(new TubeName($message->getTopic())); 39 | 40 | try { 41 | 42 | $job = $this->client->put( 43 | data: $message->getPayload(), 44 | priority: $message->getAttributes()["priority"] ?? Pheanstalk::DEFAULT_PRIORITY, 45 | delay: $options["delay"] ?? Pheanstalk::DEFAULT_DELAY, 46 | timeToRelease: $options["time_to_release"] ?? Pheanstalk::DEFAULT_TTR 47 | ); 48 | } 49 | catch( PheanstalkConnectionException $exception ){ 50 | throw new ConnectionException( 51 | message: "Connection to Beanstalkd failed.", 52 | previous: $exception 53 | ); 54 | } 55 | catch( Throwable $exception ){ 56 | throw new PublishException( 57 | message: "Failed to publish message.", 58 | previous: $exception 59 | ); 60 | } 61 | 62 | return $job->getId(); 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | * 68 | * Beanstalk does not allow any more than a single message to be reserved at a time. Setting 69 | * the `max_messages` argument will always result in a maximum of one message to be reserved. 70 | * 71 | * Options: 72 | * * `timeout` (integer) Polling timeout in seconds. Defaults to 10. 73 | */ 74 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 75 | { 76 | if( empty($this->consumer_tube) ){ 77 | $this->client->watch(new TubeName($topic)); 78 | } 79 | 80 | try { 81 | 82 | $job = $this->client->reserveWithTimeout( 83 | timeout: $options["timeout"] ?? 10 84 | ); 85 | } 86 | catch( PheanstalkConnectionException $exception ){ 87 | throw new ConnectionException( 88 | message: "Connection to Beanstalkd failed.", 89 | previous: $exception 90 | ); 91 | } 92 | catch( Throwable $exception ){ 93 | throw new ConsumeException( 94 | message: "Failed to consume message.", 95 | previous: $exception 96 | ); 97 | } 98 | 99 | if( empty($job) ){ 100 | return []; 101 | } 102 | 103 | return [ 104 | new Message( 105 | topic: $topic, 106 | payload: $job->getData(), 107 | reference: $job 108 | ) 109 | ]; 110 | } 111 | 112 | /** 113 | * @inheritDoc 114 | */ 115 | public function ack(Message $message): void 116 | { 117 | try { 118 | 119 | $this->client->delete( 120 | job: $message->getReference() 121 | ); 122 | } 123 | catch( PheanstalkConnectionException $exception ){ 124 | throw new ConnectionException( 125 | message: "Connection to Beanstalkd failed.", 126 | previous: $exception 127 | ); 128 | } 129 | catch( Throwable $exception ){ 130 | throw new ConsumeException( 131 | message: "Failed to ack message.", 132 | previous: $exception 133 | ); 134 | } 135 | } 136 | 137 | /** 138 | * @inheritDoc 139 | */ 140 | public function nack(Message $message, int $timeout = 0): void 141 | { 142 | try { 143 | 144 | $this->client->release( 145 | job: $message->getReference(), 146 | priority: Pheanstalk::DEFAULT_PRIORITY, 147 | delay: $timeout 148 | ); 149 | } 150 | catch( PheanstalkConnectionException $exception ){ 151 | throw new ConnectionException( 152 | message: "Connection to Beanstalkd failed.", 153 | previous: $exception 154 | ); 155 | } 156 | catch( Throwable $exception ){ 157 | throw new ConsumeException( 158 | message: "Failed to nack message.", 159 | previous: $exception 160 | ); 161 | } 162 | } 163 | } -------------------------------------------------------------------------------- /src/Adapter/ConsumerInterface.php: -------------------------------------------------------------------------------- 1 | Implementation specific options. 21 | * @throws ConnectionException 22 | * @throws ConsumeException 23 | * @return array 24 | */ 25 | public function consume(string $topic, int $max_messages = 1, array $options = []): array; 26 | 27 | /** 28 | * Acknowledge message. 29 | * 30 | * @param Message $message 31 | * @throws ConnectionException 32 | * @throws ConsumeException 33 | * @return void 34 | */ 35 | public function ack(Message $message): void; 36 | 37 | /** 38 | * Disavow or release message. 39 | * 40 | * @param Message $message 41 | * @param integer $timeout 42 | * @throws ConnectionException 43 | * @throws ConsumeException 44 | * @return void 45 | */ 46 | public function nack(Message $message, int $timeout = 0): void; 47 | } -------------------------------------------------------------------------------- /src/Adapter/Gearman.php: -------------------------------------------------------------------------------- 1 | client === null ){ 49 | throw new PublishException( 50 | "No GearmanClient instance was given. ". 51 | "In order to publish new jobs, you must pass a GearmanClient instance ". 52 | "into the constructor." 53 | ); 54 | } 55 | 56 | $job_id = match( $message->getAttributes()["priority"] ?? "normal" ){ 57 | "low" => $this->client->doLowBackground( 58 | $message->getTopic(), 59 | $message->getPayload() 60 | ), 61 | 62 | "high" => $this->client->doHighBackground( 63 | $message->getTopic(), 64 | $message->getPayload() 65 | ), 66 | 67 | default => $this->client->doBackground( 68 | $message->getTopic(), 69 | $message->getPayload() 70 | ), 71 | }; 72 | 73 | return $job_id; 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | public function subscribe(string|array $topics, callable $callback, array $options = []): void 80 | { 81 | if( $this->worker === null ){ 82 | throw new SubscriptionException( 83 | "No GearmanWorker instance was given. ". 84 | "In order to process jobs, you must pass a GearmanWorker instance ". 85 | "into the constructor." 86 | ); 87 | } 88 | 89 | if( !\is_array($topics) ){ 90 | $topics = \array_map( 91 | fn(string $topic) => \trim($topic), 92 | \explode(",", $topics) 93 | ); 94 | } 95 | 96 | foreach( $topics as $topic ){ 97 | $result = $this->worker->addFunction( 98 | function_name: $topic, 99 | function: function(GearmanJob $job) use ($callback): void { 100 | $message = new Message( 101 | topic: $job->functionName(), 102 | payload: $job->workload(), 103 | reference: $job->unique(), 104 | attributes: [ 105 | "handle" => $job->handle(), 106 | ] 107 | ); 108 | 109 | \call_user_func($callback, $message); 110 | }, 111 | timeout: $options["timeout"] ?? 0 112 | ); 113 | 114 | if( $result === false ){ 115 | throw new SubscriptionException( 116 | \sprintf( 117 | "Failed to subscribe to %s topic.", 118 | $topic 119 | ) 120 | ); 121 | } 122 | } 123 | } 124 | 125 | /** 126 | * @inheritDoc 127 | */ 128 | public function loop(array $options = []): void 129 | { 130 | if( $this->worker === null ){ 131 | throw new ConsumeException( 132 | "No GearmanWorker instance was given. ". 133 | "In order to process jobs, you must pass a GearmanWorker instance ". 134 | "into the constructor." 135 | ); 136 | } 137 | 138 | $this->running = true; 139 | 140 | while( $this->running && $this->worker->work() ){ 141 | if( $this->worker->returnCode() !== 0 ){ 142 | $this->running = false; 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * @inheritDoc 149 | */ 150 | public function shutdown(): void 151 | { 152 | $this->running = false; 153 | } 154 | } -------------------------------------------------------------------------------- /src/Adapter/Google.php: -------------------------------------------------------------------------------- 1 | client->topic($message->getTopic(), $options); 28 | 29 | $options["headers"] = $message->getHeaders(); 30 | 31 | try { 32 | 33 | $result = $topic->publish( 34 | [ 35 | "data" => $message->getPayload(), 36 | "attributes" => $message->getAttributes(), 37 | ], 38 | $options 39 | ); 40 | } 41 | catch( Throwable $exception ){ 42 | throw new PublishException( 43 | message: "Failed to publish message.", 44 | previous: $exception 45 | ); 46 | } 47 | 48 | return $result[0]; 49 | } 50 | 51 | /** 52 | * The `topic` for Google PubSub is actually the subscription name. Therefore you must 53 | * create the subscription first before using this method. 54 | * 55 | * @inheritDoc 56 | */ 57 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 58 | { 59 | $subscription = $this->client->subscription($topic); 60 | 61 | try { 62 | 63 | $response = $subscription->pull([ 64 | "maxMessages" => $max_messages, 65 | ...$options 66 | ]); 67 | } 68 | catch( Throwable $exception ) { 69 | throw new ConsumeException( 70 | message: "Failed to consume message.", 71 | previous: $exception 72 | ); 73 | } 74 | 75 | $messages = \array_map( 76 | function(GoogleMessage $message): Message { 77 | return new Message( 78 | topic: $message->subscription()->name(), 79 | payload: $message->data(), 80 | attributes: $message->attributes(), 81 | reference: $message, 82 | ); 83 | }, 84 | $response 85 | ); 86 | 87 | return $messages; 88 | } 89 | 90 | /** 91 | * @inheritDoc 92 | */ 93 | public function ack(Message $message): void 94 | { 95 | $subscription = $this->client->subscription($message->getTopic()); 96 | 97 | try { 98 | 99 | $subscription->acknowledge($message->getReference()); 100 | } 101 | catch( Throwable $exception ){ 102 | throw new ConsumeException( 103 | message: "Failed to ack message.", 104 | previous: $exception 105 | ); 106 | } 107 | } 108 | 109 | /** 110 | * @inheritDoc 111 | */ 112 | public function nack(Message $message, int $timeout = 0): void 113 | { 114 | } 115 | } -------------------------------------------------------------------------------- /src/Adapter/Iron.php: -------------------------------------------------------------------------------- 1 | $options["delay"] ?? null, 37 | "timeout" => $options["timeout"] ?? null, 38 | "expires_in" => $message->getAttributes()["expires_in"] ?? null 39 | ]); 40 | 41 | try { 42 | 43 | $result = $this->client->postMessage( 44 | $message->getTopic(), 45 | $message->getPayload(), 46 | $properties 47 | ); 48 | } 49 | catch( HttpException $exception ){ 50 | throw new ConnectionException( 51 | message: "Connection to IronMQ failed.", 52 | previous: $exception 53 | ); 54 | } 55 | catch( Throwable $exception ){ 56 | throw new PublishException( 57 | message: "Failed to publish message.", 58 | previous: $exception 59 | ); 60 | } 61 | 62 | return (string) $result->id; 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | * 68 | * Options: 69 | * `timeout` (integer) 70 | * `wait` (integer) 71 | */ 72 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 73 | { 74 | try { 75 | 76 | $reservedMessages = $this->client->reserveMessages( 77 | queue_name: $topic, 78 | count: $max_messages, 79 | timeout: $options["timeout"] ?? IronMQ::GET_MESSAGE_TIMEOUT, 80 | wait: $options["wait"] ?? IronMQ::GET_MESSAGE_WAIT 81 | ); 82 | } 83 | catch( HttpException $exception ){ 84 | throw new ConnectionException( 85 | message: "Connection to IronMQ failed.", 86 | previous: $exception 87 | ); 88 | } 89 | catch( Throwable $exception ){ 90 | throw new ConsumeException( 91 | message: "Failed to consume message.", 92 | previous: $exception 93 | ); 94 | } 95 | 96 | return \array_map( 97 | function(object $reservedMessage) use ($topic): Message { 98 | return new Message( 99 | topic: $topic, 100 | payload: $reservedMessage->body, 101 | reference: [$reservedMessage->id, $reservedMessage->reservation_id] 102 | ); 103 | }, 104 | $reservedMessages ?? [] 105 | ); 106 | } 107 | 108 | /** 109 | * @inheritDoc 110 | */ 111 | public function ack(Message $message): void 112 | { 113 | [$message_id, $reservation_id] = $message->getReference(); 114 | 115 | try { 116 | 117 | $this->client->deleteMessage( 118 | $message->getTopic(), 119 | $message_id, 120 | $reservation_id, 121 | ); 122 | } 123 | catch( HttpException $exception ){ 124 | throw new ConnectionException( 125 | message: "Connection to IronMQ failed.", 126 | previous: $exception 127 | ); 128 | } 129 | catch( Throwable $exception ){ 130 | throw new ConsumeException( 131 | message: "Failed to ack message.", 132 | previous: $exception 133 | ); 134 | } 135 | } 136 | 137 | /** 138 | * @inheritDoc 139 | */ 140 | public function nack(Message $message, int $timeout = 0): void 141 | { 142 | [$message_id, $reservation_id] = $message->getReference(); 143 | 144 | try { 145 | 146 | $this->client->releaseMessage( 147 | $message->getTopic(), 148 | $message_id, 149 | $reservation_id, 150 | $timeout 151 | ); 152 | } 153 | catch( HttpException $exception ){ 154 | throw new ConnectionException( 155 | message: "Connection to IronMQ failed.", 156 | previous: $exception 157 | ); 158 | } 159 | catch( Throwable $exception ){ 160 | throw new ConsumeException( 161 | message: "Failed to nack message.", 162 | previous: $exception 163 | ); 164 | } 165 | } 166 | } -------------------------------------------------------------------------------- /src/Adapter/Mercure.php: -------------------------------------------------------------------------------- 1 | $message->getTopic(), 44 | "data" => $message->getPayload(), 45 | "id" => $message->getAttributes()["id"] ?? null, 46 | "private" => (bool) ($message->getAttributes()["private"] ?? false), 47 | "type" => $message->getAttributes()["type"] ?? null, 48 | "retry" => $options["retry"] ?? null, 49 | ]); 50 | 51 | $request = new Request( 52 | method: HttpMethod::POST, 53 | uri: $this->hub, 54 | body: \http_build_query($body), 55 | headers: [ 56 | "Content-Type" => "application/x-www-form-urlencoded", 57 | "Authorization" => "Bearer " . $this->token, 58 | ], 59 | ); 60 | 61 | try { 62 | 63 | $response = $this->httpClient->sendRequest($request); 64 | } 65 | catch( RequestExceptionInterface $exception ){ 66 | throw new ConnectionException( 67 | message: "Failed to connect to Mercure hub.", 68 | previous: $exception 69 | ); 70 | } 71 | 72 | if( $response->getStatusCode() !== 200 ){ 73 | throw new PublishException( 74 | message: \sprintf( 75 | "Failed to publish message: %s %s.", 76 | $response->getStatusCode(), 77 | $response->getReasonPhrase() 78 | ) 79 | ); 80 | } 81 | 82 | return $response->getBody()->getContents(); 83 | } 84 | } -------------------------------------------------------------------------------- /src/Adapter/MockQueue.php: -------------------------------------------------------------------------------- 1 | > $messages Array of preloaded messages in queue, indexed by topic. 18 | */ 19 | public function __construct( 20 | protected array $messages = []) 21 | { 22 | } 23 | 24 | /** 25 | * @inheritDoc 26 | */ 27 | public function publish(Message $message, array $options = []): ?string 28 | { 29 | if( isset($options["exception"]) ){ 30 | throw new PublishException("Failed to publish message."); 31 | } 32 | 33 | $this->messages[$message->getTopic()][] = $message; 34 | return \bin2hex(\random_bytes(12)); 35 | } 36 | 37 | /** 38 | * @inheritDoc 39 | */ 40 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 41 | { 42 | if( isset($options["exception"]) ){ 43 | throw new ConsumeException("Failed to consume messages."); 44 | } 45 | 46 | if( !\array_key_exists($topic, $this->messages) ){ 47 | return []; 48 | } 49 | 50 | $messages = \array_splice($this->messages[$topic], 0, $max_messages); 51 | 52 | return $messages; 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function ack(Message $message): void 59 | { 60 | return; 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function nack(Message $message, int $timeout = 0): void 67 | { 68 | $this->publish($message); 69 | } 70 | 71 | /** 72 | * Get all the messages in a topic. 73 | * 74 | * @param string $topic 75 | * @return array 76 | */ 77 | public function getMessages(string $topic): array 78 | { 79 | return $this->messages[$topic] ?? []; 80 | } 81 | 82 | /** 83 | * Flush messages for a given topic or all topics. 84 | * 85 | * @param string|null $topic The topic to flush messages for. If `null` flush all topics. 86 | * @return void 87 | */ 88 | public function flushMessages(?string $topic = null): void 89 | { 90 | if( $topic === null ){ 91 | $this->messages = []; 92 | } 93 | else { 94 | $this->messages[$topic] = []; 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /src/Adapter/MockSubscriber.php: -------------------------------------------------------------------------------- 1 | > $messages Array of preloaded messages in queue, indexed by topic. 21 | * @param array $subscriptions Array of topic names mapped to a callable. 22 | */ 23 | public function __construct( 24 | protected array $messages = [], 25 | protected array $subscriptions = []) 26 | { 27 | } 28 | 29 | /** 30 | * @inheritDoc 31 | */ 32 | public function publish(Message $message, array $options = []): ?string 33 | { 34 | if( isset($options["exception"]) ){ 35 | throw new PublishException("Failed to publish message."); 36 | } 37 | 38 | $this->messages[$message->getTopic()][] = $message; 39 | return \bin2hex(\random_bytes(12)); 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function subscribe(string|array $topics, callable $callback, array $options = []): void 46 | { 47 | if( isset($options["exception"]) ){ 48 | throw new SubscriptionException("Failed to subscribe to topic."); 49 | } 50 | 51 | if( \is_string($topics) ){ 52 | $topics = \array_map( 53 | fn(string $topic) => \trim($topic), 54 | \explode(",", $topics) 55 | ); 56 | } 57 | 58 | foreach( $topics as $topic ){ 59 | $this->subscriptions[$topic] = $callback; 60 | } 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function loop(array $options = []): void 67 | { 68 | if( isset($options["exception"]) ){ 69 | throw new ConsumeException("Failed to consume message."); 70 | } 71 | 72 | $this->running = true; 73 | 74 | foreach( $this->subscriptions as $topic => $callback ){ 75 | if( !isset($this->messages[$topic]) ){ 76 | continue; 77 | } 78 | 79 | while( count($this->messages[$topic]) ){ 80 | $messages = \array_splice($this->messages[$topic], 0, 1); 81 | \call_user_func($callback, $messages[0]); 82 | 83 | /** 84 | * @psalm-suppress TypeDoesNotContainType 85 | */ 86 | if( $this->running === false ){ 87 | return; 88 | } 89 | } 90 | } 91 | } 92 | 93 | /** 94 | * @inheritDoc 95 | */ 96 | public function shutdown(): void 97 | { 98 | $this->running = false; 99 | } 100 | 101 | /** 102 | * Get all the messages in a topic. 103 | * 104 | * @param string $topic The topic name. 105 | * @return array Returns all pending messages in the topic. 106 | */ 107 | public function getMessages(string $topic): array 108 | { 109 | return $this->messages[$topic] ?? []; 110 | } 111 | 112 | /** 113 | * Flush messages for a given topic or all topics. 114 | * 115 | * @param string|null $topic The topic to flush messages for. If `null` flush all topics. 116 | * @return void 117 | */ 118 | public function flushMessages(?string $topic = null): void 119 | { 120 | if( $topic === null ){ 121 | $this->messages = []; 122 | } 123 | else { 124 | $this->messages[$topic] = []; 125 | } 126 | } 127 | 128 | /** 129 | * Get the subscription (callback) for a topic. 130 | * 131 | * @param string $topic The topic name. 132 | * @return callable|null Returns `null` if no subscriptions exist for given topic. 133 | */ 134 | public function getSubscription(string $topic): ?callable 135 | { 136 | return $this->subscriptions[$topic] ?? null; 137 | } 138 | 139 | /** 140 | * Get the running value. 141 | * 142 | * @return boolean 143 | */ 144 | public function getRunning(): bool 145 | { 146 | return $this->running; 147 | } 148 | } -------------------------------------------------------------------------------- /src/Adapter/Mqtt.php: -------------------------------------------------------------------------------- 1 | connect(); 36 | 37 | try { 38 | 39 | $this->client->publish( 40 | topic: $message->getTopic(), 41 | message: $message->getPayload(), 42 | qualityOfService: (int) ($message->getAttributes()["qos"] ?? MqttClient::QOS_AT_MOST_ONCE), 43 | retain: (bool) ($message->getAttributes()["retain"] ?? false) 44 | ); 45 | } 46 | catch( ConnectingToBrokerFailedException $exception ){ 47 | throw new ConnectionException( 48 | message: "Connection to MQTT broker failed.", 49 | previous: $exception 50 | ); 51 | } 52 | catch( Throwable $exception ) { 53 | throw new PublishException( 54 | message: "Failed to publish message.", 55 | previous: $exception 56 | ); 57 | } 58 | 59 | return null; 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | * 65 | * Options: 66 | * * `qos` One of `MqttClient::QOS_AT_MOST_ONCE`, `MqttClient::QOS_AT_LEAST_ONCE`, or `MqttClient::QOS_EXACTLY_ONCE`. Defaults to `MqttClient::QOS_AT_MOST_ONCE`. 67 | */ 68 | public function subscribe(string|array $topics, callable $callback, array $options = []): void 69 | { 70 | if( !\is_array($topics) ){ 71 | $topics = \array_map( 72 | fn(string $topic) => \trim($topic), 73 | \explode(",", $topics) 74 | ); 75 | } 76 | 77 | $this->connect(); 78 | 79 | foreach( $topics as $topic ){ 80 | try { 81 | 82 | $this->client->subscribe( 83 | topicFilter: $topic, 84 | callback: function(string $topic, string $body, bool $retained, array $matched) use ($callback): void { 85 | $message = new Message( 86 | topic: $topic, 87 | payload: $body, 88 | attributes: [ 89 | "retained" => $retained, 90 | "matched" => $matched, 91 | ] 92 | ); 93 | 94 | \call_user_func($callback, $message); 95 | }, 96 | qualityOfService: $options["qos"] ?? MqttClient::QOS_AT_MOST_ONCE 97 | ); 98 | } 99 | catch( ConnectingToBrokerFailedException $exception ){ 100 | throw new ConnectionException( 101 | message: "Connection to MQTT broker failed.", 102 | previous: $exception 103 | ); 104 | } 105 | catch( Throwable $exception ){ 106 | throw new SubscriptionException( 107 | message: "Failed to subscribe to topic.", 108 | previous: $exception 109 | ); 110 | } 111 | } 112 | } 113 | 114 | /** 115 | * @inheritDoc 116 | * 117 | * Options: 118 | * * `allow_sleep` (boolean) Defaults to true. 119 | * * `exit_when_empty` (boolea) Defaults to false. 120 | * * `timeout` (integer|null) Defaults to null. 121 | */ 122 | public function loop(array $options = []): void 123 | { 124 | $this->connect(); 125 | 126 | try { 127 | 128 | $this->client->loop( 129 | allowSleep: (bool) ($options["allow_sleep"] ?? true), 130 | exitWhenQueuesEmpty: (bool) ($options["exit_when_empty"] ?? false), 131 | queueWaitLimit: $options["timeout"] ?? null, 132 | ); 133 | } 134 | catch( ConnectingToBrokerFailedException $exception ){ 135 | throw new ConnectionException( 136 | message: "Connection to MQTT broker failed.", 137 | previous: $exception 138 | ); 139 | } 140 | catch( Throwable $exception ){ 141 | throw new ConsumeException( 142 | message: "Failed to consume message.", 143 | previous: $exception 144 | ); 145 | } 146 | 147 | $this->disconnect(); 148 | } 149 | 150 | /** 151 | * @inheritDoc 152 | */ 153 | public function shutdown(): void 154 | { 155 | try { 156 | 157 | $this->client->interrupt(); 158 | } 159 | catch( Throwable $exception ){ 160 | throw new ConnectionException( 161 | message: "Connection to MQTT broker failed.", 162 | previous: $exception 163 | ); 164 | } 165 | } 166 | 167 | /** 168 | * Connect to Mqtt. 169 | * 170 | * @throws ConnectionException 171 | * @return void 172 | */ 173 | private function connect(): void 174 | { 175 | if( !$this->client->isConnected() ){ 176 | 177 | try { 178 | 179 | $this->client->connect(); 180 | } 181 | catch( Throwable $exception ){ 182 | throw new ConnectionException( 183 | message: "Connection to MQTT broker failed.", 184 | previous: $exception 185 | ); 186 | } 187 | } 188 | } 189 | 190 | /** 191 | * Disconnect from Mqtt. 192 | * 193 | * @throws ConnectionException 194 | * @return void 195 | */ 196 | private function disconnect(): void 197 | { 198 | if( $this->client->isConnected() ){ 199 | 200 | try { 201 | 202 | $this->client->disconnect(); 203 | } 204 | catch( Throwable $exception ){ 205 | throw new ConnectionException( 206 | message: "Connection to MQTT broker failed.", 207 | previous: $exception 208 | ); 209 | } 210 | } 211 | } 212 | 213 | /** 214 | * Disconnect when tearing down. 215 | */ 216 | public function __destruct() 217 | { 218 | $this->disconnect(); 219 | } 220 | } -------------------------------------------------------------------------------- /src/Adapter/NullPublisher.php: -------------------------------------------------------------------------------- 1 | receipt) ){ 30 | return \call_user_func($this->receipt, $message); 31 | } 32 | 33 | return \bin2hex(\random_bytes(12)); 34 | } 35 | } -------------------------------------------------------------------------------- /src/Adapter/Outbox.php: -------------------------------------------------------------------------------- 1 | buildValues($message); 54 | 55 | $statement = $this->getStatement( 56 | $this->buildQuery($values) 57 | ); 58 | 59 | try { 60 | 61 | $result = $statement->execute($values); 62 | } 63 | catch( Throwable $exception ){ 64 | throw new PublishException( 65 | message: "Failed to publish message.", 66 | previous: $exception 67 | ); 68 | } 69 | 70 | if( $result === false ){ 71 | throw new PublishException( 72 | "Failed to publish message." 73 | ); 74 | } 75 | 76 | return $values["id"] ?? $this->pdo->lastInsertId(); 77 | } 78 | 79 | /** 80 | * Build the query to insert a record. 81 | * 82 | * @param array $values 83 | * @return string 84 | */ 85 | protected function buildQuery(array $values): string 86 | { 87 | return \sprintf( 88 | "insert into %s (%s) values (%s)", 89 | $this->table, 90 | \implode(", ", \array_keys($values)), 91 | \implode(", ", \array_map(fn(string $key) => ":{$key}", \array_keys($values))) 92 | ); 93 | } 94 | 95 | /** 96 | * Build the value array to be used in the query. 97 | * 98 | * @param Message $message 99 | * @return array 100 | */ 101 | protected function buildValues(Message $message): array 102 | { 103 | $values = [ 104 | "topic" => $message->getTopic(), 105 | "payload" => $message->getPayload(), 106 | "headers" => $message->getHeaders() ? \json_encode($message->getHeaders()) : null, 107 | "attributes" => $message->getAttributes() ? \json_encode($message->getAttributes(), JSON_FORCE_OBJECT) : null, 108 | "created_at" => \date("c"), 109 | ]; 110 | 111 | if( \is_callable($this->identity_generator) ){ 112 | $values["id"] = \call_user_func($this->identity_generator, $message); 113 | } 114 | 115 | return $values; 116 | } 117 | 118 | /** 119 | * Get the publish PDOStatement needed to insert new messages. 120 | * 121 | * @param string $query 122 | * @return PDOStatement 123 | */ 124 | protected function getStatement(string $query): PDOStatement 125 | { 126 | if( $this->statement === null ){ 127 | try { 128 | 129 | $this->statement = $this->pdo->prepare($query); 130 | } 131 | catch( Throwable $exception ){ 132 | throw new PublishException( 133 | message: "Failed to publish message.", 134 | previous: $exception 135 | ); 136 | } 137 | 138 | if( $this->statement === false ){ 139 | throw new PublishException( 140 | message: "Failed to publish message." 141 | ); 142 | } 143 | } 144 | 145 | return $this->statement; 146 | } 147 | } -------------------------------------------------------------------------------- /src/Adapter/PublisherInterface.php: -------------------------------------------------------------------------------- 1 | $options A key/value pair of implementation specific options when publishing. 20 | * @throws ConnectionException 21 | * @throws PublishException 22 | * @return string|null Some publishers return a receipt or confirmation identifier. 23 | */ 24 | public function publish(Message $message, array $options = []): ?string; 25 | } -------------------------------------------------------------------------------- /src/Adapter/RabbitMQ.php: -------------------------------------------------------------------------------- 1 | channel->basic_publish( 44 | msg: new AMQPMessage($message->getPayload(), $message->getAttributes()), 45 | exchange: $options["exchange"] ?? $this->exchange, 46 | routing_key: $message->getTopic(), 47 | mandatory: $message->getAttributes()["mandatory"] ?? false, 48 | immediate: $message->getAttributes()["immediate"] ?? false, 49 | ticket: $message->getAttributes()["ticket"] ?? null, 50 | ); 51 | } 52 | catch( AMQPConnectionClosedException|AMQPConnectionBlockedException $exception ){ 53 | throw new ConnectionException( 54 | message: "Connection to RabbitMQ failed.", 55 | previous: $exception 56 | ); 57 | } 58 | catch( Throwable $exception ){ 59 | throw new PublishException( 60 | message: "Failed to publish message.", 61 | previous: $exception 62 | ); 63 | } 64 | 65 | return null; 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | * 71 | * Options: 72 | * * `no_ack` (boolean) Do not automatically ACK messages as they are pulled off the queue. Defaults to true. 73 | * * `ticket` (?string) Defaults to null. 74 | */ 75 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 76 | { 77 | try { 78 | 79 | $message = $this->channel->basic_get( 80 | queue: $topic, 81 | no_ack: $options["no_ack"] ?? true, 82 | ticket: $options["ticket"] ?? null, 83 | ); 84 | } 85 | catch( AMQPConnectionClosedException|AMQPConnectionBlockedException $exception ){ 86 | throw new ConnectionException( 87 | message: "Connection to RabbitMQ failed.", 88 | previous: $exception 89 | ); 90 | } 91 | catch( Throwable $exception ){ 92 | throw new ConsumeException( 93 | message: "Failed to consume message.", 94 | previous: $exception 95 | ); 96 | } 97 | 98 | if( $message === null ){ 99 | return []; 100 | } 101 | 102 | return [ 103 | new Message( 104 | topic: $topic, 105 | payload: $message->getBody(), 106 | reference: $message 107 | ) 108 | ]; 109 | } 110 | 111 | /** 112 | * @inheritDoc 113 | */ 114 | public function ack(Message $message): void 115 | { 116 | /** 117 | * @var AMQPMessage $rabbitMessage 118 | */ 119 | $rabbitMessage = $message->getReference(); 120 | 121 | try { 122 | 123 | $rabbitMessage->ack(); 124 | } 125 | catch( AMQPConnectionClosedException|AMQPConnectionBlockedException $exception ){ 126 | throw new ConnectionException( 127 | message: "Connection to RabbitMQ failed.", 128 | previous: $exception 129 | ); 130 | } 131 | catch( Throwable $exception ){ 132 | throw new ConsumeException( 133 | message: "Failed to ack message.", 134 | previous: $exception 135 | ); 136 | } 137 | } 138 | 139 | /** 140 | * @inheritDoc 141 | */ 142 | public function nack(Message $message, int $timeout = 0): void 143 | { 144 | /** 145 | * @var AMQPMessage $rabbitMessage 146 | */ 147 | $rabbitMessage = $message->getReference(); 148 | 149 | try { 150 | 151 | $rabbitMessage->reject(true); 152 | } 153 | catch( AMQPConnectionClosedException|AMQPConnectionBlockedException $exception ){ 154 | throw new ConnectionException( 155 | message: "Connection to RabbitMQ failed.", 156 | previous: $exception 157 | ); 158 | } 159 | catch( Throwable $exception ){ 160 | throw new ConsumeException( 161 | message: "Failed to nack message.", 162 | previous: $exception 163 | ); 164 | } 165 | } 166 | } -------------------------------------------------------------------------------- /src/Adapter/Redis.php: -------------------------------------------------------------------------------- 1 | client->rpush( 36 | $message->getTopic(), 37 | [$message->getPayload()] 38 | ); 39 | } 40 | catch( RedisConnectionException $exception ){ 41 | throw new ConnectionException( 42 | message: "Connection to Redis failed.", 43 | previous: $exception 44 | ); 45 | } 46 | catch( Throwable $exception ){ 47 | throw new PublishException( 48 | message: "Failed to publish message.", 49 | previous: $exception 50 | ); 51 | } 52 | 53 | return (string) $result; 54 | } 55 | 56 | /** 57 | * @inheritDoc 58 | * 59 | * Options: 60 | * None 61 | */ 62 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 63 | { 64 | try { 65 | 66 | /** 67 | * @var array $messages 68 | */ 69 | $messages = $this->client->lpop($topic, $max_messages); 70 | } 71 | catch( RedisConnectionException $exception ){ 72 | throw new ConnectionException( 73 | message: "Connection to Redis failed.", 74 | previous: $exception 75 | ); 76 | } 77 | catch( Throwable $exception ){ 78 | throw new ConsumeException( 79 | message: "Failed to consume message.", 80 | previous: $exception 81 | ); 82 | } 83 | 84 | if( empty($messages) ){ 85 | return []; 86 | } 87 | 88 | return \array_map( 89 | function(string $message) use ($topic): Message { 90 | return new Message( 91 | topic: $topic, 92 | payload: $message 93 | ); 94 | }, 95 | $messages 96 | ); 97 | } 98 | 99 | /** 100 | * @inheritDoc 101 | */ 102 | public function ack(Message $message): void 103 | { 104 | return; 105 | } 106 | 107 | /** 108 | * @inheritDoc 109 | */ 110 | public function nack(Message $message, int $timeout = 0): void 111 | { 112 | try { 113 | 114 | $this->publish($message); 115 | } 116 | catch( PublishException $exception ){ 117 | throw new ConsumeException( 118 | message: "Failed to nack message.", 119 | previous: $exception 120 | ); 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /src/Adapter/RedisPubsub.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | protected array $subscriptions = []; 26 | 27 | /** 28 | * @param Client $client Predis Client instance. 29 | */ 30 | public function __construct( 31 | protected Client $client, 32 | ) 33 | { 34 | } 35 | 36 | /** 37 | * @inheritDoc 38 | */ 39 | public function publish(Message $message, array $options = []): ?string 40 | { 41 | try { 42 | 43 | $result = $this->client->publish( 44 | $message->getTopic(), 45 | $message->getPayload() 46 | ); 47 | } 48 | catch( RedisConnectionException $exception ){ 49 | throw new ConnectionException( 50 | message: "Connection to Redis failed.", 51 | previous: $exception 52 | ); 53 | } 54 | catch( Throwable $exception ) { 55 | throw new PublishException( 56 | message: "Failed to publish message.", 57 | previous: $exception 58 | ); 59 | } 60 | 61 | return (string) $result; 62 | } 63 | 64 | /** 65 | * @inheritDoc 66 | */ 67 | public function subscribe(string|array $topics, callable $callback, array $options = []): void 68 | { 69 | if( !\is_array($topics) ){ 70 | $topics = \array_map( 71 | fn(string $topic) => \trim($topic), 72 | \explode(",", $topics) 73 | ); 74 | } 75 | 76 | foreach( $topics as $channel ){ 77 | $this->subscriptions[$channel] = $callback; 78 | } 79 | 80 | try { 81 | 82 | $this->getLoop()->subscribe(...$topics); 83 | } 84 | catch( RedisConnectionException $exception ){ 85 | throw new ConnectionException( 86 | message: "Connection to Redis failed.", 87 | previous: $exception 88 | ); 89 | } 90 | catch( Throwable $exception ){ 91 | throw new SubscriptionException( 92 | message: "Failed to subscribe to topic.", 93 | previous: $exception 94 | ); 95 | } 96 | } 97 | 98 | /** 99 | * @inheritDoc 100 | * @throws ConsumeException 101 | */ 102 | public function loop(array $options = []): void 103 | { 104 | /** 105 | * Because Predis uses fgets() to read from a socket, 106 | * it is a hard blocking call. We disable async signals 107 | * and manually call pcntl_signal_dispatch() with each 108 | * loop. This requires data to be read first from the socket, 109 | * so if there is no data, you will still block and wait 110 | * until there is data. 111 | */ 112 | \pcntl_async_signals(false); 113 | 114 | $loop = $this->getLoop(); 115 | 116 | while( $loop->valid() ) { 117 | 118 | try { 119 | /** 120 | * @var object{kind:string,channel:string,payload:string} $msg 121 | */ 122 | $msg = $loop->current(); 123 | } 124 | catch( RedisConnectionException $exception ){ 125 | throw new ConnectionException( 126 | message: "Connection to Redis failed.", 127 | previous: $exception 128 | ); 129 | } 130 | catch( Throwable $exception ){ 131 | throw new ConsumeException( 132 | message: "Failed to consume message.", 133 | previous: $exception 134 | ); 135 | } 136 | 137 | if( $msg->kind === "message" ){ 138 | $callback = $this->subscriptions[$msg->channel] ?? null; 139 | 140 | if( $callback === null ){ 141 | throw new ConsumeException( 142 | \sprintf( 143 | "Message received from channel \"%s\", but no callback defined for it.", 144 | $msg->channel 145 | ) 146 | ); 147 | } 148 | 149 | $message = new Message( 150 | topic: $msg->channel, 151 | payload: $msg->payload, 152 | reference: $msg 153 | ); 154 | 155 | \call_user_func($callback, $message); 156 | } 157 | 158 | \pcntl_signal_dispatch(); 159 | } 160 | } 161 | 162 | /** 163 | * @inheritDoc 164 | */ 165 | public function shutdown(): void 166 | { 167 | try { 168 | 169 | $this->getLoop()->stop(true); 170 | } 171 | catch( Throwable $exception ){ 172 | throw new ConnectionException( 173 | message: "Failed to shutdown subscriber.", 174 | previous: $exception 175 | ); 176 | } 177 | } 178 | 179 | /** 180 | * Get the Redis consumer loop. 181 | * 182 | * @return Consumer 183 | * @throws ConsumeException 184 | */ 185 | protected function getLoop(): Consumer 186 | { 187 | if( empty($this->loop) ){ 188 | $this->loop = $this->client->pubSubLoop(); 189 | 190 | if( empty($this->loop) ){ 191 | throw new ConsumeException("Could not initialize Redis pubsub loop."); 192 | } 193 | } 194 | 195 | return $this->loop; 196 | } 197 | } -------------------------------------------------------------------------------- /src/Adapter/Segment.php: -------------------------------------------------------------------------------- 1 | getTopic() ){ 36 | "track" => $this->client->track( 37 | $this->buildTrackRequest($message) 38 | ), 39 | 40 | "identify" => $this->client->identify( 41 | $this->buildIdentifyRequest($message) 42 | ), 43 | 44 | "group" => $this->client->group( 45 | $this->buildGroupRequest($message) 46 | ), 47 | 48 | default => throw new PublishException( 49 | message: \sprintf( 50 | "Unknown or unsupported Segment call %s.", 51 | $message->getTopic() 52 | ) 53 | ) 54 | }; 55 | 56 | if( $result === false ){ 57 | throw new PublishException( 58 | message: "Failed to publish message." 59 | ); 60 | } 61 | 62 | if( $this->autoflush ){ 63 | $this->client->flush(); 64 | } 65 | 66 | return null; 67 | } 68 | 69 | /** 70 | * Build the base/common request elements for all Segment actions. 71 | * 72 | * @param Message $message 73 | * @return array 74 | */ 75 | protected function buildCommonRequest(Message $message): array 76 | { 77 | $request = \array_filter([ 78 | "anonymousId" => $message->getAttributes()["anonymousId"] ?? null, 79 | "userId" => $message->getAttributes()["userId"] ?? null, 80 | "integrations" => $message->getAttributes()["integrations"] ?? [], 81 | "timestamp" => $message->getAttributes()["timestamp"] ?? null, 82 | "context" => $message->getAttributes()["context"] ?? null, 83 | ]); 84 | 85 | if( !isset($request["anonymousId"]) && !isset($request["userId"]) ){ 86 | throw new PublishException( 87 | message: "Segment requires an anonymous ID or a user ID. Please add either an \"anonymousId\" or \"userId\" to the message attributes." 88 | ); 89 | } 90 | 91 | return $request; 92 | } 93 | 94 | /** 95 | * Build the request needed to make a track call. 96 | * 97 | * @param Message $message 98 | * @return array 99 | */ 100 | protected function buildTrackRequest(Message $message): array 101 | { 102 | $request = \array_merge( 103 | $this->buildCommonRequest($message), 104 | [ 105 | "event" => $message->getAttributes()["event"] ?? null, 106 | "properties" => \json_decode($message->getPayload(), true), 107 | ] 108 | ); 109 | 110 | if( !isset($request["event"]) ){ 111 | throw new PublishException( 112 | message: "Segment track call requires an event name. Please add an \"event\" attribute to the message." 113 | ); 114 | } 115 | 116 | return $request; 117 | } 118 | 119 | /** 120 | * Build the request needed to make an Identify call. 121 | * 122 | * @param Message $message 123 | * @return array 124 | */ 125 | protected function buildIdentifyRequest(Message $message): array 126 | { 127 | $request = \array_merge( 128 | $this->buildCommonRequest($message), 129 | [ 130 | "traits" => \json_decode($message->getPayload(), true), 131 | ] 132 | ); 133 | 134 | return $request; 135 | } 136 | 137 | /** 138 | * Build the request needed to make a Group call. 139 | * 140 | * @param Message $message 141 | * @return array 142 | */ 143 | protected function buildGroupRequest(Message $message): array 144 | { 145 | if( !isset($message->getAttributes()["groupId"]) ){ 146 | throw new PublishException( 147 | message: "Segment group call requires a groupId. Please add a \"groupId\" attribute to the message." 148 | ); 149 | } 150 | 151 | $request = \array_merge( 152 | $this->buildCommonRequest($message), 153 | [ 154 | "groupId" => $message->getAttributes()["groupId"], 155 | "traits" => \json_decode($message->getPayload(), true), 156 | ] 157 | ); 158 | 159 | return $request; 160 | } 161 | } -------------------------------------------------------------------------------- /src/Adapter/Sns.php: -------------------------------------------------------------------------------- 1 | buildArguments($message, $options); 35 | 36 | try { 37 | 38 | $result = $this->client->publish($args); 39 | } 40 | catch( CredentialsException $exception ){ 41 | throw new ConnectionException( 42 | message: "Connection to SNS failed.", 43 | previous: $exception 44 | ); 45 | } 46 | catch( Throwable $exception ){ 47 | throw new PublishException( 48 | message: "Failed to publish message.", 49 | previous: $exception 50 | ); 51 | } 52 | 53 | return $result->get("MessageId"); 54 | } 55 | 56 | /** 57 | * Build the arguments array needed to call SNS. 58 | * 59 | * @param Message $message 60 | * @param array $options 61 | * @return array 62 | */ 63 | private function buildArguments(Message $message, array $options = []): array 64 | { 65 | $attributes = \array_filter( 66 | $message->getAttributes(), 67 | fn(string $key) => !\in_array($key, ["MessageGroupId", "MessageDeduplicationId"]), 68 | ARRAY_FILTER_USE_KEY 69 | ); 70 | 71 | $args = \array_filter([ 72 | "TopicArn" => $this->base_arn ?? "" . $message->getTopic(), 73 | "Message" => $message->getPayload(), 74 | "MessageGroupId" => $message->getAttributes()["MessageGroupId"] ?? null, 75 | "MessageDeduplicationId" => $message->getAttributes()["MessageDeduplicationId"] ?? null, 76 | "MessageAttributes" => $attributes, 77 | ...$options, 78 | ]); 79 | 80 | return $args; 81 | } 82 | } -------------------------------------------------------------------------------- /src/Adapter/Sqs.php: -------------------------------------------------------------------------------- 1 | buildPublishArguments($message, $options); 40 | 41 | try { 42 | 43 | $result = $this->client->sendMessage($args); 44 | } 45 | catch( CredentialsException $exception ){ 46 | throw new ConnectionException( 47 | message: "Connection to SQS failed.", 48 | previous: $exception 49 | ); 50 | } 51 | catch( Throwable $exception ){ 52 | throw new PublishException( 53 | message: "Failed to publish message.", 54 | previous: $exception 55 | ); 56 | } 57 | 58 | return (string) $result->get("MessageId"); 59 | } 60 | 61 | /** 62 | * Build the arguments array needed to call SQS when publishing a message. 63 | * 64 | * @param Message $message 65 | * @param array $options 66 | * @return array 67 | */ 68 | private function buildPublishArguments(Message $message, array $options = []): array 69 | { 70 | $attributes = \array_filter( 71 | $message->getAttributes(), 72 | fn(string $key) => !\in_array($key, ["MessageGroupId", "MessageDeduplicationId"]), 73 | ARRAY_FILTER_USE_KEY 74 | ); 75 | 76 | $args = \array_filter([ 77 | "QueueUrl" => $this->base_url ?? "" . $message->getTopic(), 78 | "MessageBody" => $message->getPayload(), 79 | "MessageGroupId" => $message->getAttributes()["MessageGroupId"] ?? null, 80 | "MessageDeduplicationId" => $message->getAttributes()["MessageDeduplicationId"] ?? null, 81 | "MessageAttributes" => $attributes, 82 | ...$options, 83 | ]); 84 | 85 | return $args; 86 | } 87 | 88 | /** 89 | * @inheritDoc 90 | */ 91 | public function consume(string $topic, int $max_messages = 1, array $options = []): array 92 | { 93 | try { 94 | 95 | $result = $this->client->receiveMessage([ 96 | "QueueUrl" => $this->base_url ?? "" . $topic, 97 | "MaxNumberOfMessages" => $max_messages, 98 | ...$options 99 | ]); 100 | } 101 | catch( CredentialsException $exception ){ 102 | throw new ConnectionException( 103 | message: "Connection to SQS failed.", 104 | previous: $exception 105 | ); 106 | } 107 | catch( Throwable $exception ){ 108 | throw new ConsumeException( 109 | message: "Failed to consume message.", 110 | previous: $exception 111 | ); 112 | } 113 | 114 | $messages = \array_map( 115 | function(array $message) use ($topic): Message { 116 | return new Message( 117 | topic: $topic, 118 | payload: $message["Body"], 119 | attributes: $message["Attributes"], 120 | reference: $message["ReceiptHandle"] 121 | ); 122 | }, 123 | $result->get("Messages") ?? [] 124 | ); 125 | 126 | return $messages; 127 | } 128 | 129 | /** 130 | * @inheritDoc 131 | */ 132 | public function ack(Message $message): void 133 | { 134 | $request = [ 135 | "QueueUrl" => $message->getTopic(), 136 | "ReceiptHandle" => $message->getReference(), 137 | ]; 138 | 139 | try { 140 | 141 | $this->client->deleteMessage($request); 142 | } 143 | catch( CredentialsException $exception ){ 144 | throw new ConnectionException( 145 | message: "Connection to SQS failed.", 146 | previous: $exception 147 | ); 148 | } 149 | catch( Throwable $exception ){ 150 | throw new ConsumeException( 151 | message: "Failed to ack message.", 152 | previous: $exception 153 | ); 154 | } 155 | } 156 | 157 | /** 158 | * @inheritDoc 159 | */ 160 | public function nack(Message $message, int $timeout = 0): void 161 | { 162 | $request = [ 163 | "QueueUrl" => $message->getTopic(), 164 | "ReceiptHandle" => $message->getReference(), 165 | "VisibilityTimeout" => $timeout, 166 | ]; 167 | 168 | try { 169 | 170 | $this->client->changeMessageVisibility($request); 171 | } 172 | catch( CredentialsException $exception ){ 173 | throw new ConnectionException( 174 | message: "Connection to SQS failed.", 175 | previous: $exception 176 | ); 177 | } 178 | catch( Throwable $exception ){ 179 | throw new ConsumeException( 180 | message: "Failed to nack message.", 181 | previous: $exception 182 | ); 183 | } 184 | } 185 | } -------------------------------------------------------------------------------- /src/Adapter/SubscriberInterface.php: -------------------------------------------------------------------------------- 1 | $topics A topic name or an array of topic names to subscribe to. 19 | * @param callable $callback The callback function to trigger when a message from topic is received. 20 | * @param array $options Key/value pairs of options. This is dependent on the implementation being used. 21 | * @throws ConnectionException 22 | * @throws SubscriptionException 23 | * @return void 24 | */ 25 | public function subscribe(string|array $topics, callable $callback, array $options = []): void; 26 | 27 | /** 28 | * Begin consumer loop. 29 | * 30 | * @param array $options Key/value pairs of options. This is dependent on the implementation being used. 31 | * @throws ConnectionException 32 | * @throws ConsumeException 33 | * @return void 34 | */ 35 | public function loop(array $options = []): void; 36 | 37 | /** 38 | * Shutdown the consumer loop. 39 | * 40 | * @throws ConnectionException 41 | * @return void 42 | */ 43 | public function shutdown(): void; 44 | } -------------------------------------------------------------------------------- /src/Adapter/Webhook.php: -------------------------------------------------------------------------------- 1 | $headers Default headers to send with each request. 32 | * @param HttpMethod $method Default HTTP method to use. Defaults to `HttpMethod::POST`. 33 | */ 34 | public function __construct( 35 | protected ClientInterface $httpClient = new Shuttle, 36 | protected ?string $hostname = null, 37 | protected array $headers = [], 38 | protected HttpMethod $method = HttpMethod::POST 39 | ) 40 | { 41 | } 42 | 43 | /** 44 | * @inheritDoc 45 | * 46 | * Options: 47 | * * `method` (string) Override the default HTTP method to use. 48 | */ 49 | public function publish(Message $message, array $options = []): ?string 50 | { 51 | try { 52 | 53 | $response = $this->httpClient->sendRequest( 54 | $this->buildRequest($message, $options) 55 | ); 56 | } 57 | catch( Throwable $exception ){ 58 | throw new ConnectionException( 59 | message: "Failed to connect to webhook remote host.", 60 | previous: $exception 61 | ); 62 | } 63 | 64 | if( $response->getStatusCode() >= ResponseStatus::BAD_REQUEST->value ){ 65 | throw new PublishException( 66 | message: "Failed to publish message.", 67 | code: $response->getStatusCode() 68 | ); 69 | } 70 | 71 | return $response->getBody()->getContents(); 72 | } 73 | 74 | /** 75 | * Build the Request instance to send as webhook. 76 | * 77 | * @param Message $message 78 | * @param array $options 79 | * @return Request 80 | */ 81 | protected function buildRequest(Message $message, array $options = []): Request 82 | { 83 | if( \preg_match("/^https?\:\/\//i", $message->getTopic()) ){ 84 | $uri = $message->getTopic(); 85 | } 86 | else { 87 | $uri = ($this->hostname ?? "") . $message->getTopic(); 88 | } 89 | 90 | return new Request( 91 | method: $options["method"] ?? $this->method, 92 | uri: $uri, 93 | body: $message->getPayload(), 94 | headers: \array_merge( 95 | $this->headers, 96 | $message->getHeaders() 97 | ) 98 | ); 99 | } 100 | } -------------------------------------------------------------------------------- /src/Exception/ConnectionException.php: -------------------------------------------------------------------------------- 1 | failedMessage; 30 | } 31 | 32 | /** 33 | * Get addtional context on the validation error that 34 | * includes a more specific error message, the offending 35 | * data, and full path to that data. 36 | * 37 | * @return array{message:string,path:string,data:mixed} 38 | */ 39 | public function getContext(): array 40 | { 41 | return $this->context; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Exception/PublishException.php: -------------------------------------------------------------------------------- 1 | publisher->publish( 35 | message: new Message( 36 | topic: $this->topic, 37 | payload: $message->getPayload(), 38 | attributes: $message->getAttributes(), 39 | headers: $message->getHeaders() 40 | ), 41 | options: $options 42 | ); 43 | 44 | return $receipt; 45 | } 46 | } -------------------------------------------------------------------------------- /src/Filter/ValidatorFilter.php: -------------------------------------------------------------------------------- 1 | validator->validate($message) === false ){ 39 | throw new MessageValidationException( 40 | "Message failed validation.", 41 | $message 42 | ); 43 | } 44 | 45 | return $this->publisher->publish($message, $options); 46 | } 47 | } -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | $attributes A key/value pair of attributes to be sent with message. Most implementations do not support attributes. 16 | * @param array $headers A key/value pair of headers to be sent with message. Most implementations do not support headers. 17 | * @param mixed $reference A reference to the original source message. This is populated when pulling messages off source. 18 | * @param mixed $parsed_payload The parsed payload. 19 | */ 20 | public function __construct( 21 | protected string $topic, 22 | protected string $payload, 23 | protected array $attributes = [], 24 | protected array $headers = [], 25 | protected mixed $reference = null, 26 | protected mixed $parsed_payload = null, 27 | ) 28 | { 29 | } 30 | 31 | /** 32 | * The topic, queue name, or queue URL this Message is intended for or came from. 33 | * 34 | * @return string 35 | */ 36 | public function getTopic(): string 37 | { 38 | return $this->topic; 39 | } 40 | 41 | /** 42 | * The raw payload/body of the message. 43 | * 44 | * @return string 45 | */ 46 | public function getPayload(): string 47 | { 48 | return $this->payload; 49 | } 50 | 51 | /** 52 | * Message attributes. 53 | * 54 | * @return array 55 | */ 56 | public function getAttributes(): array 57 | { 58 | return $this->attributes; 59 | } 60 | 61 | /** 62 | * Message headers. 63 | * 64 | * @return array 65 | */ 66 | public function getHeaders(): array 67 | { 68 | return $this->headers; 69 | } 70 | 71 | /** 72 | * Get the reference to the original message. 73 | * 74 | * @return mixed Depending on the implementation, this could be a string, an array of values, or the full original message object. 75 | */ 76 | public function getReference(): mixed 77 | { 78 | return $this->reference; 79 | } 80 | 81 | /** 82 | * Sets the parsed payload of the message. 83 | * 84 | * @param mixed $parsed_payload 85 | * @return void 86 | */ 87 | public function setParsedPayload(mixed $parsed_payload): void 88 | { 89 | $this->parsed_payload = $parsed_payload; 90 | } 91 | 92 | /** 93 | * Get the parsed payload of the message. 94 | * 95 | * @return mixed 96 | */ 97 | public function getParsedPayload(): mixed 98 | { 99 | return $this->parsed_payload; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Middleware/DeadletterMessage.php: -------------------------------------------------------------------------------- 1 | deadletter->publish($message); 34 | return Response::ack; 35 | } 36 | 37 | return $response; 38 | } 39 | } -------------------------------------------------------------------------------- /src/Middleware/MiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | getPayload(), $this->associative); 32 | 33 | if( \json_last_error() !== JSON_ERROR_NONE ){ 34 | return Response::deadletter; 35 | } 36 | 37 | $message->setParsedPayload($parsed_payload); 38 | 39 | return $next($message); 40 | } 41 | } -------------------------------------------------------------------------------- /src/Middleware/ValidateMessage.php: -------------------------------------------------------------------------------- 1 | validator->validate($message); 33 | } 34 | catch( MessageValidationException ){ 35 | return Response::deadletter; 36 | } 37 | 38 | return $next($message); 39 | } 40 | } -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | $topic The topic or topics to match. You can provide multiple topics and are ORed. 12 | * @param array> $payload The JSON path statements to match. You can provide multiple JSON paths and values, matches are ANDed. 13 | * @param array> $attributes The attributes to match. If providing multiple values for an attribute, matches are ORed. 14 | * @param array> $headers The headers to match. If providing multiple values for a header, matches are ORed. 15 | */ 16 | public function __construct( 17 | protected string|array $topic = [], 18 | protected array $payload = [], 19 | protected array $attributes = [], 20 | protected array $headers = []) 21 | { 22 | } 23 | 24 | /** 25 | * Get the topic(s) to match on the Message. 26 | * 27 | * @return string|array 28 | */ 29 | public function getTopic(): string|array 30 | { 31 | return $this->topic; 32 | } 33 | 34 | /** 35 | * Get the payload JSON paths to match on the Message. 36 | * 37 | * @return array> 38 | */ 39 | public function getPayload(): array 40 | { 41 | return $this->payload; 42 | } 43 | 44 | /** 45 | * Get the attributes to match on the Message. 46 | * 47 | * @return array> 48 | */ 49 | public function getAttributes(): array 50 | { 51 | return $this->attributes; 52 | } 53 | 54 | /** 55 | * Get the headers to match on the Message. 56 | * 57 | * @return array> 58 | */ 59 | public function getHeaders(): array 60 | { 61 | return $this->headers; 62 | } 63 | } -------------------------------------------------------------------------------- /src/Router/Router.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | protected array $routes; 19 | 20 | /** 21 | * @param array $handlers Array of handlers (instances or class-strings) that contain Consume attributes. 22 | * @param callable|null $default A default handler for messages that could not be routed. 23 | */ 24 | public function __construct( 25 | protected array $handlers, 26 | protected $default = null, 27 | ) 28 | { 29 | $this->routes = \array_reduce( 30 | $handlers, 31 | function(array $routes, mixed $handler_class): array { 32 | $reflectionClass = new ReflectionClass($handler_class); 33 | $reflectionMethods = $reflectionClass->getMethods(); 34 | 35 | foreach( $reflectionMethods as $reflectionMethod ){ 36 | $reflectionAttributes = $reflectionMethod->getAttributes(Consume::class); 37 | 38 | if( empty($reflectionAttributes) ){ 39 | continue; 40 | } 41 | 42 | if( $reflectionMethod->isPublic() === false ){ 43 | throw new RoutingException( 44 | \sprintf( 45 | "Handler %s@%s must be public.", 46 | $reflectionClass->getName(), 47 | $reflectionMethod->getName() 48 | ) 49 | ); 50 | } 51 | 52 | if( count($reflectionAttributes) > 1 ){ 53 | throw new RoutingException( 54 | \sprintf( 55 | "Handler %s@%s has more than one #[Consume] attribute. A handler can only have a single #[Consume] attribute.", 56 | $reflectionClass->getName(), 57 | $reflectionMethod->getName() 58 | ) 59 | ); 60 | } 61 | 62 | $handler = \sprintf("\%s@%s", $reflectionClass->getName(), $reflectionMethod->getName()); 63 | 64 | $routes[$handler] = $reflectionAttributes[0]->newInstance(); 65 | } 66 | 67 | return $routes; 68 | }, 69 | [] 70 | ); 71 | } 72 | 73 | /** 74 | * @inheritDoc 75 | */ 76 | public function resolve(Message $message): callable|string|null 77 | { 78 | foreach( $this->routes as $handler => $route ){ 79 | if( $this->matchString($message->getTopic(), $route->getTopic()) && 80 | $this->matchJson($message->getParsedPayload() ?: $message->getPayload(), $route->getPayload()) && 81 | $this->matchKeyValuePairs($message->getHeaders(), $route->getHeaders()) && 82 | $this->matchKeyValuePairs($message->getAttributes(), $route->getAttributes())) { 83 | return $handler; 84 | } 85 | } 86 | 87 | return $this->default; 88 | } 89 | 90 | /** 91 | * Build a regex to match content. 92 | * 93 | * This method assumes the * (asterisk) is allowed as a wildcard, all other values 94 | * get regex escaped. 95 | * 96 | * @param string $pattern 97 | * @return string 98 | */ 99 | private function buildRegex(string $pattern): string 100 | { 101 | return \str_replace( 102 | [".", "*"], ["\.", ".*"], 103 | \str_replace( 104 | ["\\", "/", "+", "?", "[", "^", "]", "$", "(", ")", "{", "}", "=", "!", "<", ">", "|", ":", "-", "#"], 105 | ["\\\\", "\\/", "\\+", "\\?", "\\[", "\\^", "\\]", "\\$", "\\(", "\\)", "\\{", "\\}", "\\=", "\\!", "\\<", "\\>", "\\|", "\\:", "\\-", "\\#"], 106 | $pattern 107 | ) 108 | ); 109 | } 110 | 111 | /** 112 | * Match a string against a pattern or a set of patterns. 113 | * 114 | * If more than one pattern is provided, the results are OR'ed. 115 | * 116 | * @param string $string 117 | * @param string|array $patterns Match *any* of the patterns. 118 | * @return boolean 119 | */ 120 | protected function matchString(string $string, string|array $patterns): bool 121 | { 122 | if( empty($patterns) ){ 123 | return true; 124 | } 125 | 126 | if( !\is_array($patterns) ){ 127 | $patterns = [$patterns]; 128 | } 129 | 130 | foreach( $patterns as $pattern ){ 131 | $match = \preg_match( 132 | \sprintf("/^%s$/", $this->buildRegex($pattern)), 133 | $string 134 | ); 135 | 136 | if( $match === false ){ 137 | throw new UnexpectedValueException( 138 | "Regex is invalid. Please notify maintainers of Syndicate ". 139 | "with a stack trace and routing criteria." 140 | ); 141 | } 142 | 143 | if( $match ){ 144 | return true; 145 | } 146 | } 147 | 148 | return false; 149 | } 150 | 151 | /** 152 | * Match a JSON string against an array of JSON paths and 153 | * patterns. 154 | * 155 | * @param string|array|object $data 156 | * @param array> $patterns 157 | * @return boolean 158 | */ 159 | protected function matchJson(string|array|object $data, array $patterns): bool 160 | { 161 | if( empty($patterns) ){ 162 | return true; 163 | } 164 | 165 | if( \is_string($data) ){ 166 | $data = \json_decode($data, true); 167 | 168 | if( \json_last_error() !== JSON_ERROR_NONE ){ 169 | throw new UnexpectedValueException("Payload was not able to be JSON decoded."); 170 | } 171 | } 172 | 173 | $json = new JSONPath($data); 174 | 175 | foreach( $patterns as $path => $pattern ){ 176 | $data = $json->find($path)->getData(); 177 | 178 | if( empty($data) ){ 179 | return false; 180 | } 181 | 182 | if( count($data) > 1 || (!\is_string($data[0]) && !\is_int($data[0])) ){ 183 | throw new UnexpectedValueException( 184 | \sprintf( 185 | "JSON path \"%s\" matched more than one value or the value is not a string or integer. " . 186 | "Please refine your JSON path to return just a single string or integer value.", 187 | $path 188 | ) 189 | ); 190 | } 191 | 192 | $match = $this->matchString((string) $data[0], $pattern); 193 | 194 | if( !$match ){ 195 | return false; 196 | } 197 | } 198 | 199 | return true; 200 | } 201 | 202 | /** 203 | * Match all key value pair string values. 204 | * 205 | * @param array $values 206 | * @param array> $patterns 207 | * @return boolean 208 | */ 209 | private function matchKeyValuePairs(array $values, array $patterns): bool 210 | { 211 | if( empty($patterns) ){ 212 | return true; 213 | } 214 | 215 | foreach( $patterns as $key => $value ){ 216 | if( \array_key_exists($key, $values) === false ){ 217 | return false; 218 | } 219 | 220 | $match = $this->matchString($values[$key], $value); 221 | 222 | if( !$match ){ 223 | return false; 224 | } 225 | } 226 | 227 | return true; 228 | } 229 | } -------------------------------------------------------------------------------- /src/Router/RouterInterface.php: -------------------------------------------------------------------------------- 1 | $schemas A key/value pair array of topic names to JSON schemas or full path to a schema file. 17 | * @param bool $ignore_missing_schemas If a schema cannot be found for a Message topic, should validation be ignored? Defaults to `false`. 18 | */ 19 | public function __construct( 20 | protected array $schemas = [], 21 | protected bool $ignore_missing_schemas = false 22 | ) 23 | { 24 | $this->validator = new Validator; 25 | 26 | $this->schemas = \array_map( 27 | function(mixed $schema): mixed { 28 | if( \is_string($schema) && \file_exists($schema) ){ 29 | $contents = \file_get_contents($schema); 30 | 31 | if( $contents === false ){ 32 | throw new UnexpectedValueException( 33 | \sprintf( 34 | "Failed to read schema file \"%s\".", 35 | $schema 36 | ) 37 | ); 38 | } 39 | 40 | return $contents; 41 | } 42 | 43 | return $schema; 44 | }, 45 | $schemas 46 | ); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | * @throws MessageValidationException 52 | */ 53 | public function validate(Message $message): bool 54 | { 55 | if( !isset($this->schemas[$message->getTopic()]) ){ 56 | 57 | if( $this->ignore_missing_schemas ){ 58 | return true; 59 | } 60 | 61 | throw new MessageValidationException( 62 | \sprintf( 63 | "No schema defined for message topic \"%s\".", 64 | $message->getTopic() 65 | ), 66 | $message 67 | ); 68 | } 69 | 70 | $result = $this->validator->validate( 71 | $message->getParsedPayload() ?: \json_decode($message->getPayload()), 72 | $this->schemas[$message->getTopic()] 73 | ); 74 | 75 | if( $result->hasError() ){ 76 | /** 77 | * @psalm-suppress PossiblyNullArgument 78 | */ 79 | throw new MessageValidationException( 80 | "Message failed JSON schema validation.", 81 | $message, 82 | $this->buildContext($result->error()) 83 | ); 84 | } 85 | 86 | return true; 87 | } 88 | 89 | /** 90 | * Build the error context. This provides detailed information about 91 | * exactly where and why the schema contract was broken. 92 | * 93 | * @param ValidationError $validationError 94 | * @return array{message:string,path:string,data:mixed} 95 | */ 96 | private function buildContext(ValidationError $validationError): array 97 | { 98 | $error = $validationError; 99 | 100 | while( $error->subErrors() ){ 101 | $error = $error->subErrors()[0]; 102 | } 103 | 104 | $data = $error->data()->value(); 105 | $path = $error->data()->fullPath(); 106 | $message = $error->message(); 107 | 108 | $search = $replace = []; 109 | 110 | foreach( $error->args() as $key => $value ){ 111 | if( \is_array($value) ){ 112 | $value = \implode(", ", $value); 113 | } 114 | 115 | $search[] = "{{$key}}"; 116 | $replace[] = $value; 117 | } 118 | 119 | /** 120 | * @var string $message 121 | */ 122 | $message = \str_replace($search, $replace, $message); 123 | 124 | return [ 125 | "message" => $message, 126 | "path" => "$." . \implode(".", $path), 127 | "data" => $data, 128 | ]; 129 | } 130 | } -------------------------------------------------------------------------------- /src/Validator/ValidatorInterface.php: -------------------------------------------------------------------------------- 1 | expectException(UnexpectedValueException::class); 27 | new Gearman; 28 | } 29 | 30 | public function test_publish_with_no_client_throws_publish_exception(): void 31 | { 32 | $mock = Mockery::mock(GearmanWorker::class); 33 | 34 | $publisher = new Gearman(worker: $mock); 35 | 36 | $this->expectException(PublishException::class); 37 | $publisher->publish( 38 | new Message("fruits", "bananas") 39 | ); 40 | } 41 | 42 | public function test_publish_normal_priority(): void 43 | { 44 | $mock = Mockery::mock(GearmanClient::class); 45 | 46 | $mock->shouldReceive("doBackground") 47 | ->andReturns("1776f45b-39b9-411c-b794-326067f00be5"); 48 | 49 | $publisher = new Gearman(client: $mock); 50 | 51 | $receipt = $publisher->publish( 52 | new Message("fruits", "bananas") 53 | ); 54 | 55 | $this->assertEquals( 56 | "1776f45b-39b9-411c-b794-326067f00be5", 57 | $receipt 58 | ); 59 | 60 | $mock->shouldHaveReceived( 61 | "doBackground", 62 | ["fruits", "bananas"] 63 | ); 64 | } 65 | 66 | public function test_publish_low_priority(): void 67 | { 68 | $mock = Mockery::mock(GearmanClient::class); 69 | 70 | $mock->shouldReceive("doLowBackground") 71 | ->andReturns("1776f45b-39b9-411c-b794-326067f00be5"); 72 | 73 | $publisher = new Gearman(client: $mock); 74 | 75 | $publisher->publish( 76 | new Message(topic: "fruits", payload: "bananas", attributes: ["priority" => "low"]) 77 | ); 78 | 79 | $mock->shouldHaveReceived( 80 | "doLowBackground", 81 | ["fruits", "bananas"] 82 | ); 83 | } 84 | 85 | public function test_publish_high_priority(): void 86 | { 87 | $mock = Mockery::mock(GearmanClient::class); 88 | 89 | $mock->shouldReceive("doHighBackground") 90 | ->andReturns("1776f45b-39b9-411c-b794-326067f00be5"); 91 | 92 | $publisher = new Gearman(client: $mock); 93 | 94 | $publisher->publish( 95 | new Message(topic: "fruits", payload: "bananas", attributes: ["priority" => "high"]) 96 | ); 97 | 98 | $mock->shouldHaveReceived( 99 | "doHighBackground", 100 | ["fruits", "bananas"] 101 | ); 102 | } 103 | 104 | public function test_subscribe_with_no_worker_throws_subscription_exception(): void 105 | { 106 | $mock = Mockery::mock(GearmanClient::class); 107 | $subscriber = new Gearman(client: $mock); 108 | 109 | $this->expectException(SubscriptionException::class); 110 | $subscriber->subscribe("fruits", "strtolower"); 111 | } 112 | 113 | public function test_subscribe_with_multiple_topics_array(): void 114 | { 115 | $mock = Mockery::mock(GearmanWorker::class); 116 | $mock->shouldReceive("addFunction") 117 | ->andReturn(true); 118 | 119 | $subscriber = new Gearman(worker: $mock); 120 | $subscriber->subscribe(["fruits", "veggies"], "strtolower", ["timeout" => 5]); 121 | 122 | $mock->shouldHaveReceived("addFunction")->twice(); 123 | } 124 | 125 | public function test_subscribe_with_multiple_topics_comma_separated(): void 126 | { 127 | $mock = Mockery::mock(GearmanWorker::class); 128 | $mock->shouldReceive("addFunction") 129 | ->andReturn(true); 130 | 131 | $subscriber = new Gearman(worker: $mock); 132 | $subscriber->subscribe("fruits, veggies", "strtolower", ["timeout" => 5]); 133 | 134 | $mock->shouldHaveReceived("addFunction")->twice(); 135 | } 136 | 137 | public function test_subscribe_failure_throws_subscription_exception(): void 138 | { 139 | $mock = Mockery::mock(GearmanWorker::class); 140 | $mock->shouldReceive("addFunction") 141 | ->andReturn(false); 142 | 143 | $subscriber = new Gearman(worker: $mock); 144 | 145 | $this->expectException(SubscriptionException::class); 146 | $subscriber->subscribe("fruits", "strtolower"); 147 | } 148 | 149 | public function test_loop_no_worker_throws_consume_exception(): void 150 | { 151 | $mock = Mockery::mock(GearmanClient::class); 152 | $subscriber = new Gearman(client: $mock); 153 | 154 | $this->expectException(ConsumeException::class); 155 | $subscriber->loop(); 156 | } 157 | 158 | public function test_loop_exits_on_unsuccessful_return_code(): void 159 | { 160 | $mock = Mockery::mock(GearmanWorker::class); 161 | $mock->shouldReceive("work")->andReturn(true); 162 | $mock->shouldReceive("returnCode")->andReturn(-1); 163 | 164 | $subscriber = new Gearman(worker: $mock); 165 | $subscriber->loop(); 166 | 167 | $mock->shouldHaveReceived("work"); 168 | $mock->shouldHaveReceived("returnCode"); 169 | } 170 | 171 | public function test_loop_exits_on_shutdown(): void 172 | { 173 | $mock = Mockery::mock(GearmanWorker::class); 174 | $subscriber = new Gearman(worker: $mock); 175 | 176 | 177 | $mock->shouldReceive("work")->andReturnUsing( 178 | function() use ($subscriber) { 179 | $subscriber->shutdown(); 180 | return true; 181 | } 182 | ); 183 | $mock->shouldReceive("returnCode")->andReturn(0); 184 | 185 | $subscriber->loop(); 186 | 187 | $mock->shouldHaveReceived("work"); 188 | $mock->shouldHaveReceived("returnCode"); 189 | } 190 | 191 | public function test_shutdown(): void 192 | { 193 | $mock = Mockery::mock(GearmanWorker::class); 194 | $subscriber = new Gearman(worker: $mock); 195 | 196 | $reflectionClass = new ReflectionClass($subscriber); 197 | $reflectionProperty = $reflectionClass->getProperty("running"); 198 | $reflectionProperty->setAccessible(true); 199 | $reflectionProperty->setValue($subscriber, true); 200 | 201 | $this->assertTrue($reflectionProperty->getValue($subscriber)); 202 | 203 | $subscriber->shutdown(); 204 | 205 | $this->assertFalse($reflectionProperty->getValue($subscriber)); 206 | } 207 | } -------------------------------------------------------------------------------- /tests/Adapter/GoogleTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("topic") 30 | ->withAnyArgs() 31 | ->andReturns($mockTopic); 32 | 33 | $mockTopic->shouldReceive("publish") 34 | ->withAnyArgs() 35 | ->andReturns(["messageid"]); 36 | 37 | $message = new Message( 38 | topic: "google", 39 | payload: "Ok", 40 | attributes: ["attr1" => "val1", "attr2" => "val2"], 41 | headers: ["header" => "value"] 42 | ); 43 | 44 | $google = new Google($mock); 45 | $google->publish($message, ["opt1" => "val1", "opt2" => "val2"]); 46 | 47 | $mock->shouldHaveReceived( 48 | "topic", 49 | ["google", ["opt1" => "val1", "opt2" => "val2"]] 50 | ); 51 | 52 | $mockTopic->shouldHaveReceived( 53 | "publish", 54 | [ 55 | ["data" => "Ok", "attributes" => ["attr1" => "val1", "attr2" => "val2"]], 56 | ["opt1" => "val1", "opt2" => "val2", "headers" => ["header" => "value"]] 57 | ] 58 | ); 59 | } 60 | 61 | public function test_publish_returns_receipt(): void 62 | { 63 | $mock = Mockery::mock(PubSubClient::class); 64 | $mockTopic = Mockery::mock(Topic::class); 65 | 66 | $mock->shouldReceive("topic") 67 | ->withAnyArgs() 68 | ->andReturns($mockTopic); 69 | 70 | $mockTopic->shouldReceive("publish") 71 | ->withAnyArgs() 72 | ->andReturns(["afd1cbe8-6ee3-4de0-90f5-50c019a9a887"]); 73 | 74 | $message = new Message("google", "Ok"); 75 | 76 | $google = new Google($mock); 77 | $receipt = $google->publish($message); 78 | 79 | $this->assertEquals( 80 | "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 81 | $receipt 82 | ); 83 | } 84 | 85 | public function test_publish_failure_throws_publish_exception(): void 86 | { 87 | $mock = Mockery::mock(PubSubClient::class); 88 | $mockTopic = Mockery::mock(Topic::class); 89 | 90 | $mock->shouldReceive("topic") 91 | ->andReturns($mockTopic); 92 | 93 | $mockTopic->shouldReceive("publish") 94 | ->andThrows(new Exception("Failure")); 95 | 96 | $message = new Message("google", "Ok"); 97 | 98 | $google = new Google($mock); 99 | 100 | $this->expectException(PublishException::class); 101 | $google->publish($message); 102 | } 103 | 104 | public function test_consume_integration(): void 105 | { 106 | $mock = Mockery::mock(PubSubClient::class); 107 | $mockSubscription = Mockery::mock(Subscription::class); 108 | 109 | $mock->shouldReceive("subscription") 110 | ->andReturns($mockSubscription); 111 | 112 | $mockSubscription->shouldReceive("pull") 113 | ->andReturns([ 114 | new PubSubMessage( 115 | ["data" => "message1", "attributes" => ["attr1" => "val1", "attr2" => "value2"]], 116 | ["subscription" => $mockSubscription] 117 | ) 118 | ]); 119 | 120 | $mockSubscription->shouldReceive("name") 121 | ->andReturns("google_subscription_name"); 122 | 123 | $google = new Google($mock); 124 | $google->consume("google", 10, ["opt1" => "val1", "opt2" => "val2"]); 125 | 126 | $mockSubscription->shouldHaveReceived( 127 | "pull", 128 | [ 129 | ["maxMessages" => 10, "opt1" => "val1", "opt2" => "val2"] 130 | ] 131 | ); 132 | } 133 | 134 | public function test_consume(): void 135 | { 136 | $mock = Mockery::mock(PubSubClient::class); 137 | $mockSubscription = Mockery::mock(Subscription::class); 138 | 139 | $mock->shouldReceive("subscription") 140 | ->andReturns($mockSubscription); 141 | 142 | $mockSubscription->shouldReceive("pull") 143 | ->andReturns([ 144 | new PubSubMessage( 145 | ["data" => "message1", "attributes" => ["attr1" => "val1", "attr2" => "value2"]], 146 | ["subscription" => $mockSubscription] 147 | ), 148 | 149 | new PubSubMessage( 150 | ["data" => "message2", "attributes" => ["attr3" => "val3", "attr4" => "value4"]], 151 | ["subscription" => $mockSubscription] 152 | ), 153 | ]); 154 | 155 | $mockSubscription->shouldReceive("name") 156 | ->andReturns("google_subscription_name"); 157 | 158 | $google = new Google($mock); 159 | $messages = $google->consume("google", 10); 160 | 161 | $this->assertCount(2, $messages); 162 | 163 | $this->assertEquals( 164 | "google_subscription_name", 165 | $messages[0]->getTopic() 166 | ); 167 | 168 | $this->assertEquals( 169 | "message1", 170 | $messages[0]->getPayload() 171 | ); 172 | 173 | $this->assertEquals( 174 | ["attr1" => "val1", "attr2" => "value2"], 175 | $messages[0]->getAttributes() 176 | ); 177 | 178 | $this->assertInstanceOf( 179 | PubSubMessage::class, 180 | $messages[0]->getReference() 181 | ); 182 | 183 | $this->assertEquals( 184 | "google_subscription_name", 185 | $messages[1]->getTopic() 186 | ); 187 | 188 | $this->assertEquals( 189 | "message2", 190 | $messages[1]->getPayload() 191 | ); 192 | 193 | $this->assertEquals( 194 | ["attr3" => "val3", "attr4" => "value4"], 195 | $messages[1]->getAttributes() 196 | ); 197 | 198 | $this->assertInstanceOf( 199 | PubSubMessage::class, 200 | $messages[1]->getReference() 201 | ); 202 | } 203 | 204 | public function test_consume_failure_throws_consume_exception(): void 205 | { 206 | $mock = Mockery::mock(PubSubClient::class); 207 | $mockSubscription = Mockery::mock(Subscription::class); 208 | 209 | $mock->shouldReceive("subscription") 210 | ->andReturns($mockSubscription); 211 | 212 | $mockSubscription->shouldReceive("pull") 213 | ->andThrows(new Exception("Failure")); 214 | 215 | $google = new Google($mock); 216 | 217 | $this->expectException(ConsumeException::class); 218 | $google->consume("google", 10); 219 | } 220 | 221 | public function test_ack_integration(): void 222 | { 223 | $mock = Mockery::mock(PubSubClient::class); 224 | $mockSubscription = Mockery::mock(Subscription::class); 225 | 226 | $mock->shouldReceive("subscription") 227 | ->andReturns($mockSubscription); 228 | 229 | $mockSubscription->shouldReceive("acknowledge") 230 | ->andReturns(["receiptid"]); 231 | 232 | $MockSubscriberMessage = Mockery::mock(PubSubMessage::class); 233 | 234 | $google = new Google($mock); 235 | $google->ack(new Message(topic: "google", payload: "Ok", reference: $MockSubscriberMessage)); 236 | 237 | $mockSubscription->shouldHaveReceived( 238 | "acknowledge", 239 | [ 240 | PubSubMessage::class 241 | ] 242 | ); 243 | } 244 | 245 | public function test_ack_failure_throws_consume_exception(): void 246 | { 247 | $mock = Mockery::mock(PubSubClient::class); 248 | $mockSubscription = Mockery::mock(Subscription::class); 249 | 250 | $mock->shouldReceive("subscription") 251 | ->andReturns($mockSubscription); 252 | 253 | $mockSubscription->shouldReceive("acknowledge") 254 | ->andThrows(new Exception("Failure")); 255 | 256 | $google = new Google($mock); 257 | 258 | $this->expectException(ConsumeException::class); 259 | $google->ack(new Message(topic: "google", payload: "Ok")); 260 | } 261 | 262 | public function test_nack(): void 263 | { 264 | $mock = Mockery::mock(PubSubClient::class); 265 | 266 | $google = new Google($mock); 267 | $result = $google->nack(new Message(topic: "google", payload: "Ok")); 268 | $this->assertNull($result); 269 | } 270 | } -------------------------------------------------------------------------------- /tests/Adapter/IronMQTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("postMessage") 28 | ->andReturns((object) ["id" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"]); 29 | 30 | $message = new Message("ironmq", "Ok", ["expires_in" => 3600]); 31 | 32 | $publisher = new Iron($mock); 33 | $publisher->publish($message, ["timeout" => 120, "delay" => 60]); 34 | 35 | $mock->shouldHaveReceived( 36 | "postMessage", 37 | ["ironmq", "Ok", ["delay" => 60, "timeout" => 120, "expires_in" => 3600]] 38 | )->once(); 39 | } 40 | 41 | public function test_publish_returns_receipt(): void 42 | { 43 | $mock = Mockery::mock(IronMQ::class); 44 | 45 | $message = new Message("ironmq", "Ok"); 46 | 47 | $mock->shouldReceive("postMessage") 48 | ->withArgs(["ironmq", "Ok", []]) 49 | ->andReturns((object) ["id" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"]); 50 | 51 | /** 52 | * @var IronMQ $mock 53 | */ 54 | $publisher = new Iron($mock); 55 | $receipt = $publisher->publish($message); 56 | 57 | $this->assertEquals( 58 | "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 59 | $receipt 60 | ); 61 | } 62 | 63 | public function test_publish_http_failure_throws_connection_exception(): void 64 | { 65 | $mock = Mockery::mock(IronMQ::class); 66 | 67 | $mock->shouldReceive("postMessage") 68 | ->andThrows(new HttpException("Failure")); 69 | 70 | $message = new Message("ironmq", "Ok"); 71 | 72 | $publisher = new Iron($mock); 73 | 74 | $this->expectException(ConnectionException::class); 75 | $publisher->publish($message); 76 | } 77 | 78 | public function test_publish_failure_throws_publish_exception(): void 79 | { 80 | $mock = Mockery::mock(IronMQ::class); 81 | 82 | $mock->shouldReceive("postMessage") 83 | ->andThrows(new Exception("Failure")); 84 | 85 | $message = new Message("ironmq", "Ok"); 86 | 87 | $publisher = new Iron($mock); 88 | 89 | $this->expectException(PublishException::class); 90 | $publisher->publish($message); 91 | } 92 | 93 | public function test_consume_integration(): void 94 | { 95 | $mock = Mockery::mock(IronMQ::class); 96 | 97 | $mock->shouldReceive("reserveMessages") 98 | ->withArgs(["ironmq", 10, 15, 20]) 99 | ->andReturns([ 100 | (object) [ 101 | "id" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 102 | "reservation_id" => "0be31d6e-0b46-43d4-854c-772e7d717ce5", 103 | "body" => "Message1" 104 | ], 105 | 106 | (object) [ 107 | "id" => "022f6eba-d374-4ebf-a8bf-f4faccf95afe", 108 | "reservation_id" => "596a4980-488f-4e4f-a078-745c3b66cc95", 109 | "body" => "Message2" 110 | ] 111 | ]); 112 | 113 | $publisher = new Iron($mock); 114 | $publisher->consume("ironmq", 10, ["timeout" => 15, "wait" => 20]); 115 | 116 | $mock->shouldHaveReceived( 117 | "reserveMessages", 118 | ["ironmq", 10, 15, 20] 119 | )->once(); 120 | } 121 | 122 | public function test_consume_returns_messages(): void 123 | { 124 | $mock = Mockery::mock(IronMQ::class); 125 | 126 | $mock->shouldReceive("reserveMessages") 127 | ->withAnyArgs() 128 | ->andReturns([ 129 | (object) [ 130 | "id" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 131 | "reservation_id" => "0be31d6e-0b46-43d4-854c-772e7d717ce5", 132 | "body" => "Message1" 133 | ], 134 | 135 | (object) [ 136 | "id" => "022f6eba-d374-4ebf-a8bf-f4faccf95afe", 137 | "reservation_id" => "596a4980-488f-4e4f-a078-745c3b66cc95", 138 | "body" => "Message2" 139 | ] 140 | ]); 141 | 142 | $publisher = new Iron($mock); 143 | $messages = $publisher->consume("ironmq", 10); 144 | 145 | $this->assertCount(2, $messages); 146 | 147 | $this->assertEquals( 148 | "Message1", 149 | $messages[0]->getPayload() 150 | ); 151 | 152 | $this->assertEquals( 153 | ["afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"], 154 | $messages[0]->getReference() 155 | ); 156 | 157 | $this->assertEquals( 158 | "Message2", 159 | $messages[1]->getPayload() 160 | ); 161 | 162 | $this->assertEquals( 163 | ["022f6eba-d374-4ebf-a8bf-f4faccf95afe", "596a4980-488f-4e4f-a078-745c3b66cc95"], 164 | $messages[1]->getReference() 165 | ); 166 | } 167 | 168 | public function test_consume_http_failure_throws_connection_exception(): void 169 | { 170 | $mock = Mockery::mock(IronMQ::class); 171 | 172 | $mock->shouldReceive("reserveMessages") 173 | ->andThrows(new HttpException("Failure")); 174 | 175 | $publisher = new Iron($mock); 176 | 177 | $this->expectException(ConnectionException::class); 178 | $publisher->consume("ironmq"); 179 | } 180 | 181 | public function test_consume_failure_throws_consume_exception(): void 182 | { 183 | $mock = Mockery::mock(IronMQ::class); 184 | 185 | $mock->shouldReceive("reserveMessages") 186 | ->andThrows(new Exception("Failure")); 187 | 188 | $publisher = new Iron($mock); 189 | 190 | $this->expectException(ConsumeException::class); 191 | $publisher->consume("ironmq"); 192 | } 193 | 194 | public function test_ack_integration(): void 195 | { 196 | $mock = Mockery::spy(IronMQ::class); 197 | 198 | $message = new Message( 199 | topic: "ironmq", 200 | payload: "Ok", 201 | reference: ["afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"] 202 | ); 203 | 204 | $consumer = new Iron($mock); 205 | $consumer->ack($message); 206 | 207 | $mock->shouldHaveReceived( 208 | "deleteMessage", 209 | ["ironmq", "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"] 210 | ); 211 | } 212 | 213 | public function test_ack_http_failure_throws_connection_exception(): void 214 | { 215 | $mock = Mockery::mock(IronMQ::class); 216 | 217 | $mock->expects("deleteMessage") 218 | ->andThrows(new HttpException("Failure")); 219 | 220 | $message = new Message( 221 | topic: "ironmq", 222 | payload: "Ok", 223 | reference: ["afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"] 224 | ); 225 | 226 | $consumer = new Iron($mock); 227 | 228 | $this->expectException(ConnectionException::class); 229 | $consumer->ack($message); 230 | } 231 | 232 | public function test_ack_failure_throws_consume_exception(): void 233 | { 234 | $mock = Mockery::mock(IronMQ::class); 235 | 236 | $mock->expects("deleteMessage") 237 | ->withAnyArgs() 238 | ->andThrows(new Exception("Failure")); 239 | 240 | $message = new Message( 241 | topic: "ironmq", 242 | payload: "Ok", 243 | reference: ["afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"] 244 | ); 245 | 246 | $consumer = new Iron($mock); 247 | 248 | $this->expectException(ConsumeException::class); 249 | $consumer->ack($message); 250 | } 251 | 252 | public function test_nack_integration(): void 253 | { 254 | $mock = Mockery::spy(IronMQ::class); 255 | 256 | $message = new Message( 257 | topic: "ironmq", 258 | payload: "Ok", 259 | reference: ["afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"] 260 | ); 261 | 262 | $consumer = new Iron($mock); 263 | $consumer->nack($message, 10); 264 | 265 | $mock->shouldHaveReceived( 266 | "releaseMessage", 267 | ["ironmq", "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5", 10] 268 | ); 269 | } 270 | 271 | public function test_nack_http_failure_throws_connection_exception(): void 272 | { 273 | $mock = Mockery::spy(IronMQ::class); 274 | 275 | $mock->expects("releaseMessage") 276 | ->andThrows(new HttpException("Failure")); 277 | 278 | $message = new Message( 279 | topic: "ironmq", 280 | payload: "Ok", 281 | reference: ["afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"] 282 | ); 283 | 284 | $consumer = new Iron($mock); 285 | 286 | $this->expectException(ConnectionException::class); 287 | $consumer->nack($message); 288 | } 289 | 290 | public function test_nack_failure_throws_consume_exception(): void 291 | { 292 | $mock = Mockery::spy(IronMQ::class); 293 | 294 | $mock->expects("releaseMessage") 295 | ->withAnyArgs() 296 | ->andThrows(new Exception("Failure")); 297 | 298 | $message = new Message( 299 | topic: "ironmq", 300 | payload: "Ok", 301 | reference: ["afd1cbe8-6ee3-4de0-90f5-50c019a9a887", "0be31d6e-0b46-43d4-854c-772e7d717ce5"] 302 | ); 303 | 304 | $consumer = new Iron($mock); 305 | 306 | $this->expectException(ConsumeException::class); 307 | $consumer->nack($message); 308 | } 309 | } -------------------------------------------------------------------------------- /tests/Adapter/MercureTest.php: -------------------------------------------------------------------------------- 1 | new Response( 29 | statusCode: ResponseStatus::OK, 30 | body: \json_encode([ 31 | "uri" => (string) $request->getUri(), 32 | "method" => $request->getMethod(), 33 | "body" => $request->getBody()->getContents(), 34 | "headers" => $request->getHeaders() 35 | ])) 36 | ]) 37 | ) 38 | ); 39 | 40 | $receipt = $publisher->publish( 41 | new Message("fruits", "bananas", ["id" =>"dc3ed6b1-582e-4e0d-976a-2d8c5657c63d", "private" => true, "type" => "event", "retry" => 30]) 42 | ); 43 | 44 | $payload = \json_decode($receipt); 45 | 46 | $this->assertEquals("POST", $payload->method); 47 | $this->assertEquals("https://example.hub/.well-known/mercure", $payload->uri); 48 | $this->assertEquals("topic=fruits&data=bananas&id=dc3ed6b1-582e-4e0d-976a-2d8c5657c63d&private=1&type=event", $payload->body); 49 | $this->assertEquals( 50 | "application/x-www-form-urlencoded", 51 | $payload->headers->{"Content-Type"}[0] 52 | ); 53 | $this->assertEquals( 54 | "Bearer jwt-token", 55 | $payload->headers->{"Authorization"}[0] 56 | ); 57 | } 58 | 59 | public function test_publish_request_exception_throws_connection_exception(): void 60 | { 61 | $publisher = new Mercure( 62 | hub: "https://example.hub/.well-known/mercure", 63 | token: "jwt-token", 64 | httpClient: new Shuttle( 65 | handler: new MockHandler([ 66 | fn(Request $request) => throw new RequestException($request, "Failed to connect") 67 | ]) 68 | ) 69 | ); 70 | 71 | $this->expectException(ConnectionException::class); 72 | 73 | $publisher->publish( 74 | new Message("fruits", "bananas") 75 | ); 76 | } 77 | 78 | public function test_publish_request_failure_throws_publish_exception(): void 79 | { 80 | $publisher = new Mercure( 81 | hub: "https://example.hub/.well-known/mercure", 82 | token: "jwt-token", 83 | httpClient: new Shuttle( 84 | handler: new MockHandler([ 85 | new Response(ResponseStatus::UNAUTHORIZED) 86 | ]) 87 | ) 88 | ); 89 | 90 | $this->expectException(PublishException::class); 91 | 92 | $publisher->publish( 93 | new Message("fruits", "bananas") 94 | ); 95 | } 96 | 97 | public function test_publish_returns_receipt(): void 98 | { 99 | $publisher = new Mercure( 100 | hub: "https://example.hub/.well-known/mercure", 101 | token: "jwt-token", 102 | httpClient: new Shuttle( 103 | handler: new MockHandler([ 104 | new Response( 105 | ResponseStatus::OK, 106 | "urn:uuid:dc3ed6b1-582e-4e0d-976a-2d8c5657c63d", 107 | ["Content-Type" => "text/plain"] 108 | ) 109 | ]) 110 | ) 111 | ); 112 | 113 | $receipt = $publisher->publish( 114 | new Message("fruits", "bananas") 115 | ); 116 | 117 | $this->assertEquals( 118 | "urn:uuid:dc3ed6b1-582e-4e0d-976a-2d8c5657c63d", 119 | $receipt 120 | ); 121 | } 122 | } -------------------------------------------------------------------------------- /tests/Adapter/MockQueueTest.php: -------------------------------------------------------------------------------- 1 | publish($message); 21 | 22 | $messages = $mock->getMessages("test"); 23 | 24 | $this->assertCount(1, $messages); 25 | $this->assertSame($message, $messages[0]); 26 | } 27 | 28 | public function test_publish_failure_throws_publish_exception(): void 29 | { 30 | $mock = new MockQueue; 31 | 32 | $this->expectException(PublishException::class); 33 | $mock->publish(new Message("test", "Ok"), ["exception" => true]); 34 | } 35 | 36 | public function test_consume(): void 37 | { 38 | $mock = new MockQueue([ 39 | "test" => [ 40 | new Message("test", "message1"), 41 | new Message("test", "message2"), 42 | ] 43 | ]); 44 | 45 | $messages = $mock->consume("test", 10); 46 | 47 | $this->assertCount(2, $messages); 48 | } 49 | 50 | public function test_consume_unknown_topic_returns_empty_array(): void 51 | { 52 | $mock = new MockQueue; 53 | $messages = $mock->consume("test"); 54 | $this->assertCount(0, $messages); 55 | } 56 | 57 | public function test_consume_failure_throws_consume_exception(): void 58 | { 59 | $mock = new MockQueue; 60 | 61 | $this->expectException(ConsumeException::class); 62 | $mock->consume("test", 10, ["exception" => true]); 63 | } 64 | 65 | public function test_ack(): void 66 | { 67 | $message = new Message("test", "message1"); 68 | 69 | $mock = new MockQueue; 70 | $response = $mock->ack($message); 71 | 72 | $this->assertNull($response); 73 | } 74 | 75 | public function test_nack(): void 76 | { 77 | $message = new Message("test", "message1"); 78 | 79 | $mock = new MockQueue; 80 | $mock->nack($message); 81 | 82 | $this->assertCount(1, $mock->getMessages("test")); 83 | } 84 | 85 | public function test_flush_all_messages(): void 86 | { 87 | $mock = new MockQueue; 88 | $mock->publish(new Message("fruits", "ok")); 89 | $mock->publish(new Message("fruits", "ok")); 90 | 91 | $this->assertCount(2, $mock->getMessages("fruits")); 92 | 93 | $mock->flushMessages(); 94 | 95 | $this->assertCount(0, $mock->getMessages("fruits")); 96 | } 97 | 98 | public function test_flush_topic_messages(): void 99 | { 100 | $mock = new MockQueue; 101 | $mock->publish(new Message("fruits", "ok")); 102 | $mock->publish(new Message("fruits", "ok")); 103 | $mock->publish(new Message("veggies", "ok")); 104 | $mock->publish(new Message("veggies", "ok")); 105 | 106 | $this->assertCount(2, $mock->getMessages("fruits")); 107 | $this->assertCount(2, $mock->getMessages("veggies")); 108 | 109 | $mock->flushMessages("fruits"); 110 | 111 | $this->assertCount(0, $mock->getMessages("fruits")); 112 | $this->assertCount(2, $mock->getMessages("veggies")); 113 | } 114 | } -------------------------------------------------------------------------------- /tests/Adapter/MockSubscriberTest.php: -------------------------------------------------------------------------------- 1 | publish($message); 22 | 23 | $messages = $mock->getMessages("test"); 24 | 25 | $this->assertCount(1, $messages); 26 | $this->assertSame($message, $messages[0]); 27 | } 28 | 29 | public function test_publish_failure_throws_publish_exception(): void 30 | { 31 | $mock = new MockSubscriber; 32 | 33 | $this->expectException(PublishException::class); 34 | $mock->publish(new Message("test", "Ok"), ["exception" => true]); 35 | } 36 | 37 | public function test_subscribe(): void 38 | { 39 | $callback = function(Message $message): Response { 40 | return Response::ack; 41 | }; 42 | 43 | $mock = new MockSubscriber; 44 | $mock->subscribe("fruits", $callback); 45 | 46 | $subscription = $mock->getSubscription("fruits"); 47 | 48 | $this->assertSame($callback, $subscription); 49 | } 50 | 51 | public function test_subscribe_with_array_of_topics(): void 52 | { 53 | $callback = function(Message $message): Response { 54 | return Response::ack; 55 | }; 56 | 57 | $mock = new MockSubscriber; 58 | $mock->subscribe(["fruits", "veggies"], $callback); 59 | 60 | $subscription = $mock->getSubscription("fruits"); 61 | $this->assertSame($callback, $subscription); 62 | 63 | $subscription = $mock->getSubscription("veggies"); 64 | $this->assertSame($callback, $subscription); 65 | } 66 | 67 | public function test_subscribe_comma_separated_list_of_topics(): void 68 | { 69 | $callback = function(Message $message): Response { 70 | return Response::ack; 71 | }; 72 | 73 | $mock = new MockSubscriber; 74 | $mock->subscribe("fruits, veggies", $callback); 75 | 76 | $subscription = $mock->getSubscription("fruits"); 77 | $this->assertSame($callback, $subscription); 78 | 79 | $subscription = $mock->getSubscription("veggies"); 80 | $this->assertSame($callback, $subscription); 81 | } 82 | 83 | public function test_loop_topic_with_no_messages_skips(): void 84 | { 85 | $callback = function(Message $message): Response { 86 | return Response::ack; 87 | }; 88 | 89 | $mock = new MockSubscriber(messages: ["veggies" => [new Message("veggies", "OK")]]); 90 | $mock->subscribe("fruits, veggies", $callback); 91 | 92 | $mock->loop(); 93 | 94 | $this->assertCount(0, $mock->getMessages("veggies")); 95 | } 96 | 97 | public function test_loop(): void 98 | { 99 | $mock = new MockSubscriber( 100 | messages: [ 101 | "fruits" => [ 102 | new Message("fruits", "apples"), 103 | new Message("fruits", "oranges"), 104 | new Message("fruits", "pears"), 105 | ], 106 | 107 | "veggies" => [ 108 | new Message("veggies", "broccoli"), 109 | ] 110 | ], 111 | subscriptions: [ 112 | "fruits" => function(Message $message): Response { 113 | return Response::ack; 114 | } 115 | ] 116 | ); 117 | 118 | $mock->loop(); 119 | 120 | $this->assertCount(0, $mock->getMessages("fruits")); 121 | $this->assertCount(1, $mock->getMessages("veggies")); 122 | } 123 | 124 | public function test_loop_exits_when_shutdown(): void 125 | { 126 | $mock = new MockSubscriber( 127 | messages: [ 128 | "fruits" => [ 129 | new Message("fruits", "apples"), 130 | new Message("fruits", "oranges"), 131 | new Message("fruits", "pears"), 132 | ] 133 | ] 134 | ); 135 | 136 | $mock->subscribe( 137 | "fruits", 138 | function() use ($mock): Response { 139 | $mock->shutdown(); 140 | return Response::ack; 141 | } 142 | ); 143 | 144 | $mock->loop(); 145 | 146 | $this->assertCount(2, $mock->getMessages("fruits")); 147 | } 148 | 149 | public function test_shutdown(): void 150 | { 151 | $mock = new MockSubscriber; 152 | $this->assertFalse($mock->getRunning()); 153 | 154 | $reflectionClass = new ReflectionClass($mock); 155 | $reflectionProperty = $reflectionClass->getProperty("running"); 156 | 157 | $reflectionProperty->setAccessible(true); 158 | $reflectionProperty->setValue($mock, true); 159 | 160 | $this->assertTrue($mock->getRunning()); 161 | 162 | $mock->shutdown(); 163 | 164 | $this->assertFalse($mock->getRunning()); 165 | } 166 | 167 | public function test_flush_all_messages(): void 168 | { 169 | $mock = new MockSubscriber; 170 | $mock->publish(new Message("fruits", "ok")); 171 | $mock->publish(new Message("fruits", "ok")); 172 | 173 | $this->assertCount(2, $mock->getMessages("fruits")); 174 | 175 | $mock->flushMessages(); 176 | 177 | $this->assertCount(0, $mock->getMessages("fruits")); 178 | } 179 | 180 | public function test_flush_topic_messages(): void 181 | { 182 | $mock = new MockSubscriber; 183 | $mock->publish(new Message("fruits", "ok")); 184 | $mock->publish(new Message("fruits", "ok")); 185 | $mock->publish(new Message("veggies", "ok")); 186 | $mock->publish(new Message("veggies", "ok")); 187 | 188 | $this->assertCount(2, $mock->getMessages("fruits")); 189 | $this->assertCount(2, $mock->getMessages("veggies")); 190 | 191 | $mock->flushMessages("fruits"); 192 | 193 | $this->assertCount(0, $mock->getMessages("fruits")); 194 | $this->assertCount(2, $mock->getMessages("veggies")); 195 | } 196 | } -------------------------------------------------------------------------------- /tests/Adapter/MqttTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("isConnected") 30 | ->andReturns(false); 31 | 32 | $mock->shouldReceive("connect"); 33 | $mock->shouldReceive("publish"); 34 | 35 | $message = new Message("mqtt", "Ok", ["qos" => MqttClient::QOS_AT_LEAST_ONCE, "retain" => true]); 36 | 37 | $publisher = new Mqtt($mock); 38 | $publisher->publish($message); 39 | 40 | $mock->shouldHaveReceived("isConnected"); 41 | $mock->shouldHaveReceived("connect"); 42 | $mock->shouldHaveReceived( 43 | "publish", 44 | ["mqtt", "Ok", MqttClient::QOS_AT_LEAST_ONCE, true] 45 | ); 46 | } 47 | 48 | public function test_connect_failure_throws_connection_exception(): void 49 | { 50 | $mock = Mockery::mock(MqttClient::class); 51 | 52 | $mock->shouldReceive("isConnected") 53 | ->andReturns(false); 54 | 55 | $mock->shouldReceive("connect") 56 | ->andThrows(new Exception("Failure")); 57 | 58 | $message = new Message("mqtt", "Ok"); 59 | 60 | $publisher = new Mqtt($mock); 61 | 62 | $this->expectException(ConnectionException::class); 63 | $publisher->publish($message); 64 | } 65 | 66 | public function test_publish_connecting_to_broker_exception_throws_connection_exception(): void 67 | { 68 | $mock = Mockery::mock(MqttClient::class); 69 | 70 | $mock->shouldReceive("isConnected") 71 | ->andReturns(false); 72 | 73 | $mock->shouldReceive("connect"); 74 | 75 | $mock->shouldReceive("publish") 76 | ->andThrows(new ConnectingToBrokerFailedException(0, "Failure")); 77 | 78 | $message = new Message("mqtt", "Ok"); 79 | 80 | $publisher = new Mqtt($mock); 81 | 82 | $this->expectException(ConnectionException::class); 83 | $publisher->publish($message); 84 | } 85 | 86 | public function test_publish_failure_throws_publish_exception(): void 87 | { 88 | $mock = Mockery::mock(MqttClient::class); 89 | 90 | $mock->shouldReceive("isConnected") 91 | ->andReturns(false); 92 | 93 | $mock->shouldReceive("connect"); 94 | 95 | $mock->shouldReceive("publish") 96 | ->andThrows(new Exception("Failure")); 97 | 98 | $message = new Message("mqtt", "Ok"); 99 | 100 | $publisher = new Mqtt($mock); 101 | 102 | $this->expectException(PublishException::class); 103 | $publisher->publish($message); 104 | } 105 | 106 | public function test_subscribe_integration(): void 107 | { 108 | $mock = Mockery::mock(MqttClient::class); 109 | 110 | $mock->shouldReceive("isConnected") 111 | ->andReturn(false); 112 | $mock->shouldReceive("connect"); 113 | $mock->shouldReceive("subscribe"); 114 | 115 | $consumer = new Mqtt($mock); 116 | $consumer->subscribe("test", "strtolower", ["qos" => 12]); 117 | 118 | $mock->shouldHaveReceived("isConnected"); 119 | $mock->shouldHaveReceived("connect"); 120 | $mock->shouldHaveReceived( 121 | "subscribe", 122 | ["test", Closure::class, 12] 123 | ); 124 | } 125 | 126 | public function test_multi_subscribe_integration(): void 127 | { 128 | $mock = Mockery::mock(MqttClient::class); 129 | 130 | $mock->shouldReceive("isConnected") 131 | ->andReturn(false); 132 | $mock->shouldReceive("connect"); 133 | $mock->shouldReceive("subscribe") 134 | ->twice(); 135 | 136 | $consumer = new Mqtt($mock); 137 | $consumer->subscribe(["test", "test2"], "strtolower"); 138 | 139 | $mock->shouldHaveReceived("isConnected"); 140 | $mock->shouldHaveReceived("connect"); 141 | 142 | $mock->shouldHaveReceived( 143 | "subscribe", 144 | ["test", Closure::class, MqttClient::QOS_AT_MOST_ONCE] 145 | ); 146 | 147 | $mock->shouldHaveReceived( 148 | "subscribe", 149 | ["test2", Closure::class, MqttClient::QOS_AT_MOST_ONCE] 150 | ); 151 | } 152 | 153 | public function test_subscribe_connecting_to_broker_exception_throws_connection_exception(): void 154 | { 155 | $mock = Mockery::mock(MqttClient::class); 156 | 157 | $mock->shouldReceive("isConnected") 158 | ->andReturn(false); 159 | $mock->shouldReceive("connect"); 160 | $mock->shouldReceive("subscribe") 161 | ->andThrow(new ConnectingToBrokerFailedException(0, "Failure")); 162 | 163 | $consumer = new Mqtt($mock); 164 | 165 | $this->expectException(ConnectionException::class); 166 | $consumer->subscribe("test", "strtolower"); 167 | } 168 | 169 | public function test_subscribe_failure_throws_subscription_exception(): void 170 | { 171 | $mock = Mockery::mock(MqttClient::class); 172 | 173 | $mock->shouldReceive("isConnected") 174 | ->andReturn(false, true); 175 | $mock->shouldReceive("connect"); 176 | $mock->shouldReceive("subscribe") 177 | ->andThrow(new Exception("Failure")); 178 | $mock->shouldReceive("disconnect"); 179 | 180 | $consumer = new Mqtt($mock); 181 | 182 | $this->expectException(SubscriptionException::class); 183 | $consumer->subscribe("test", "strtolower"); 184 | } 185 | 186 | public function test_loop_integration_tries_to_connect(): void 187 | { 188 | $mock = Mockery::mock(MqttClient::class); 189 | 190 | $mock->shouldReceive("isConnected") 191 | ->andReturn(false); 192 | $mock->shouldReceive("connect"); 193 | $mock->shouldReceive("loop"); 194 | 195 | $consumer = new Mqtt($mock); 196 | $consumer->loop(["allow_sleep" => false, "exit_when_empty" => true, "timeout" => 12]); 197 | 198 | $mock->shouldHaveReceived("isConnected"); 199 | $mock->shouldHaveReceived("connect"); 200 | $mock->shouldHaveReceived( 201 | "loop", 202 | [false, true, 12] 203 | ); 204 | } 205 | 206 | public function test_loop_integration_tries_to_disconnect_when_done(): void 207 | { 208 | $mock = Mockery::mock(MqttClient::class); 209 | 210 | $mock->shouldReceive("isConnected") 211 | ->andReturn(true); 212 | $mock->shouldReceive("loop") 213 | ->andReturn(true); 214 | $mock->shouldReceive("disconnect"); 215 | 216 | $consumer = new Mqtt($mock); 217 | $consumer->loop(); 218 | 219 | $mock->shouldHaveReceived("disconnect"); 220 | } 221 | 222 | public function test_disconnect_failure_throws_connection_exception(): void 223 | { 224 | $mock = Mockery::mock(MqttClient::class); 225 | 226 | $mock->shouldReceive("isConnected") 227 | ->andReturn(false, true); 228 | $mock->shouldReceive("disconnect") 229 | ->andThrows(new Exception("Failure")); 230 | 231 | $consumer = new Mqtt($mock); 232 | 233 | $this->expectException(ConnectionException::class); 234 | $consumer->__destruct(); 235 | } 236 | 237 | public function test_loop_connecting_to_broker_exception_throws_connection_exception(): void 238 | { 239 | $mock = Mockery::mock(MqttClient::class); 240 | 241 | $mock->shouldReceive("isConnected") 242 | ->andReturn(false); 243 | $mock->shouldReceive("connect"); 244 | $mock->shouldReceive("loop") 245 | ->andThrow(new ConnectingToBrokerFailedException(0, "Failure")); 246 | 247 | $consumer = new Mqtt($mock); 248 | 249 | $this->expectException(ConnectionException::class); 250 | $consumer->loop(); 251 | } 252 | 253 | public function test_loop_failure_throws_exception(): void 254 | { 255 | $mock = Mockery::mock(MqttClient::class); 256 | 257 | $mock->shouldReceive("isConnected") 258 | ->andReturn(false); 259 | $mock->shouldReceive("connect"); 260 | $mock->shouldReceive("loop") 261 | ->andThrow(new Exception("Failure")); 262 | $mock->shouldReceive("disconnect"); 263 | 264 | $consumer = new Mqtt($mock); 265 | 266 | $this->expectException(ConsumeException::class); 267 | $consumer->loop(); 268 | } 269 | 270 | public function test_shutdown_integration(): void 271 | { 272 | $mock = Mockery::mock(MqttClient::class); 273 | 274 | $mock->shouldReceive("isConnected") 275 | ->andReturn(true); 276 | 277 | $mock->shouldReceive("interrupt"); 278 | $mock->shouldReceive("disconnect"); 279 | 280 | $consumer = new Mqtt($mock); 281 | $consumer->shutdown(); 282 | 283 | $mock->shouldHaveReceived("interrupt"); 284 | } 285 | 286 | public function test_shutdown_connecting_to_broker_exception_throws_connection_exception(): void 287 | { 288 | $mock = Mockery::mock(MqttClient::class); 289 | 290 | $mock->shouldReceive("isConnected") 291 | ->andReturn(true); 292 | 293 | $mock->shouldReceive("disconnect"); 294 | 295 | $mock->shouldReceive("interrupt") 296 | ->andThrow(new ConnectingToBrokerFailedException(0, "Failure")); 297 | 298 | $consumer = new Mqtt($mock); 299 | 300 | $this->expectException(ConnectionException::class); 301 | $consumer->shutdown(); 302 | } 303 | 304 | public function test_shutdown_general_failure_throws_connection_exception(): void 305 | { 306 | $mock = Mockery::mock(MqttClient::class); 307 | 308 | $mock->shouldReceive("isConnected") 309 | ->andReturn(true, true); 310 | 311 | $mock->shouldReceive("disconnect"); 312 | 313 | $mock->shouldReceive("interrupt") 314 | ->andThrow(new Exception("Failure")); 315 | 316 | $consumer = new Mqtt($mock); 317 | 318 | $this->expectException(ConnectionException::class); 319 | $consumer->shutdown(); 320 | } 321 | } -------------------------------------------------------------------------------- /tests/Adapter/NullPublisherTest.php: -------------------------------------------------------------------------------- 1 | publish(new Message("fruits", "Ok")); 17 | 18 | $this->assertMatchesRegularExpression( 19 | "/^[a-f0-9]+$/i", 20 | $receipt 21 | ); 22 | } 23 | 24 | public function test_receipt_callback(): void 25 | { 26 | $publisher = new NullPublisher( 27 | fn(Message $message) => $message->getAttributes()["id"] 28 | ); 29 | 30 | $receipt = $publisher->publish( 31 | new Message("fruits", "Ok", ["id" => "e34b738c-b8b1-46f4-9802-247e7c36a246"]) 32 | ); 33 | 34 | $this->assertEquals( 35 | "e34b738c-b8b1-46f4-9802-247e7c36a246", 36 | $receipt 37 | ); 38 | } 39 | } -------------------------------------------------------------------------------- /tests/Adapter/OutboxTest.php: -------------------------------------------------------------------------------- 1 | setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 19 | 20 | $publisher = new Outbox($pdo); 21 | 22 | $this->expectException(PublishException::class); 23 | $publisher->publish( 24 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 25 | ); 26 | } 27 | 28 | public function test_publish_prepare_failure_throws_publish_exception_if_statement_is_false(): void 29 | { 30 | $pdo = new PDO("sqlite::memory:"); 31 | $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); 32 | 33 | $publisher = new Outbox($pdo); 34 | 35 | $this->expectException(PublishException::class); 36 | $publisher->publish( 37 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 38 | ); 39 | } 40 | 41 | public function test_publish_execute_failure_throws_publish_exception(): void 42 | { 43 | $pdo = new PDO("sqlite::memory:"); 44 | $pdo->query("create table outbox (id uuid primary key, topic text, payload text, headers text, attributes text, created_at timestamp)"); 45 | 46 | $publisher = new Outbox( 47 | pdo: $pdo, 48 | identity_generator: fn() => "a822a65c-eb88-46d5-917c-42c9651e5f03" 49 | ); 50 | 51 | $publisher->publish( 52 | new Message("fruits", "oranges", ["attr" => "value"], ["header" => "value"]) 53 | ); 54 | 55 | $this->expectException(PublishException::class); 56 | $publisher->publish( 57 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 58 | ); 59 | } 60 | 61 | public function test_publish_execute_failure_throws_publish_exception_if_execute_returns_false(): void 62 | { 63 | $pdo = new PDO("sqlite::memory:"); 64 | $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); 65 | $pdo->query("create table outbox (id uuid primary key, topic text, payload text, headers text, attributes text, created_at timestamp)"); 66 | 67 | $publisher = new Outbox( 68 | pdo: $pdo, 69 | identity_generator: fn() => "a822a65c-eb88-46d5-917c-42c9651e5f03" 70 | ); 71 | 72 | $publisher->publish( 73 | new Message("fruits", "oranges", ["attr" => "value"], ["header" => "value"]) 74 | ); 75 | 76 | $this->expectException(PublishException::class); 77 | $publisher->publish( 78 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 79 | ); 80 | } 81 | 82 | public function test_publish_with_custom_table_name(): void 83 | { 84 | $pdo = new PDO("sqlite::memory:"); 85 | $pdo->query("create table messages (id integer primary key, topic text, payload text, headers text, attributes text, created_at timestamp)"); 86 | 87 | $publisher = new Outbox( 88 | pdo: $pdo, 89 | table: "messages" 90 | ); 91 | 92 | $publisher->publish( 93 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 94 | ); 95 | 96 | $statement = $pdo->query("select * from messages"); 97 | $messages = $statement->fetchAll(PDO::FETCH_ASSOC); 98 | 99 | $this->assertCount(1, $messages); 100 | } 101 | 102 | public function test_publish_with_custom_identity_generator(): void 103 | { 104 | $pdo = new PDO("sqlite::memory:"); 105 | $pdo->query("create table outbox (id uuid primary key, topic text, payload text, headers text, attributes text, created_at timestamp)"); 106 | 107 | $publisher = new Outbox( 108 | pdo: $pdo, 109 | identity_generator: fn() => "a822a65c-eb88-46d5-917c-42c9651e5f03" 110 | ); 111 | 112 | $receipt = $publisher->publish( 113 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 114 | ); 115 | 116 | $this->assertEquals( 117 | "a822a65c-eb88-46d5-917c-42c9651e5f03", 118 | $receipt 119 | ); 120 | } 121 | 122 | public function test_publish_returns_last_insert_id(): void 123 | { 124 | $pdo = new PDO("sqlite::memory:"); 125 | $pdo->query("create table outbox (id integer primary key, topic text, payload text, headers text, attributes text, created_at timestamp)"); 126 | 127 | $publisher = new Outbox( 128 | pdo: $pdo, 129 | ); 130 | 131 | $receipt = $publisher->publish( 132 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 133 | ); 134 | 135 | $this->assertEquals(1, $receipt); 136 | } 137 | 138 | public function test_publish_inserts_correct_values(): void 139 | { 140 | $pdo = new PDO("sqlite::memory:"); 141 | $pdo->query("create table outbox (id integer primary key, topic text, payload text, headers text, attributes text, created_at timestamp)"); 142 | 143 | $publisher = new Outbox( 144 | pdo: $pdo, 145 | ); 146 | 147 | $publisher->publish( 148 | new Message("fruits", "bananas", ["attr" => "value"], ["header" => "value"]) 149 | ); 150 | 151 | $statement = $pdo->query("select * from outbox"); 152 | $messages = $statement->fetchAll(PDO::FETCH_ASSOC); 153 | 154 | $this->assertCount(1, $messages); 155 | $this->assertEquals(1, $messages[0]["id"]); 156 | $this->assertEquals("fruits", $messages[0]["topic"]); 157 | $this->assertEquals("bananas", $messages[0]["payload"]); 158 | $this->assertEquals("{\"attr\":\"value\"}", $messages[0]["attributes"]); 159 | $this->assertEquals("{\"header\":\"value\"}", $messages[0]["headers"]); 160 | $this->assertTrue( 161 | (bool) \preg_match("/^\d{4}\-\d{2}\-\d{2}T\d{2}\:\d{2}\:\d{2}/", $messages[0]["created_at"]) 162 | ); 163 | } 164 | } -------------------------------------------------------------------------------- /tests/Adapter/RedisTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("rpush") 29 | ->withAnyArgs() 30 | ->andReturns(123); 31 | 32 | $message = new Message("redis", "Ok"); 33 | 34 | $redis = new Redis($mock); 35 | $receipt = $redis->publish($message); 36 | 37 | $this->assertEquals( 38 | "123", 39 | $receipt 40 | ); 41 | 42 | $mock->shouldHaveReceived( 43 | "rpush", 44 | ["redis", ["Ok"]] 45 | ); 46 | } 47 | 48 | public function test_publish_returns_receipt(): void 49 | { 50 | $mock = Mockery::mock(Client::class); 51 | 52 | $mock->shouldReceive("rpush") 53 | ->withArgs(["redis", ["Ok"]]) 54 | ->andReturns(123); 55 | 56 | $message = new Message("redis", "Ok"); 57 | 58 | $redis = new Redis($mock); 59 | $receipt = $redis->publish($message); 60 | 61 | $this->assertEquals( 62 | "123", 63 | $receipt 64 | ); 65 | } 66 | 67 | public function test_publish_connection_failure_throws_connection_exception(): void 68 | { 69 | $mock = Mockery::mock(Client::class); 70 | 71 | $mock->shouldReceive("rpush") 72 | ->andThrows(new RedisConnectionException( 73 | Mockery::mock(NodeConnectionInterface::class), 74 | "Failure" 75 | ) 76 | ); 77 | 78 | $message = new Message("redis", "Ok"); 79 | 80 | $redis = new Redis($mock); 81 | $this->expectException(ConnectionException::class); 82 | $redis->publish($message); 83 | } 84 | 85 | public function test_publish_failure_throws_publish_exception(): void 86 | { 87 | $mock = Mockery::mock(Client::class); 88 | 89 | $mock->shouldReceive("rpush") 90 | ->andThrows(new Exception("Failure")); 91 | 92 | $message = new Message("redis", "Ok"); 93 | 94 | $redis = new Redis($mock); 95 | 96 | $this->expectException(PublishException::class); 97 | $redis->publish($message); 98 | } 99 | 100 | public function test_consume_integration(): void 101 | { 102 | $mock = Mockery::mock(Client::class); 103 | 104 | $mock->shouldReceive("lpop") 105 | ->withAnyArgs() 106 | ->andReturns([]); 107 | 108 | $redis = new Redis($mock); 109 | $redis->consume("redis", 10); 110 | 111 | $mock->shouldHaveReceived( 112 | "lpop", 113 | ["redis", 10] 114 | ); 115 | } 116 | 117 | public function test_consume(): void 118 | { 119 | $mock = Mockery::mock(Client::class); 120 | 121 | $mock->shouldReceive("lpop") 122 | ->withAnyArgs() 123 | ->andReturns(["Message1", "Message2"]); 124 | 125 | $redis = new Redis($mock); 126 | $messages = $redis->consume("redis", 10); 127 | 128 | $this->assertCount(2, $messages); 129 | 130 | $this->assertEquals( 131 | "redis", 132 | $messages[0]->getTopic() 133 | ); 134 | 135 | $this->assertEquals( 136 | "Message1", 137 | $messages[0]->getPayload() 138 | ); 139 | 140 | $this->assertEquals( 141 | "redis", 142 | $messages[1]->getTopic() 143 | ); 144 | 145 | $this->assertEquals( 146 | "Message2", 147 | $messages[1]->getPayload() 148 | ); 149 | } 150 | 151 | public function test_consume_no_messages_returns_empty_array(): void 152 | { 153 | $mock = Mockery::mock(Client::class); 154 | 155 | $mock->shouldReceive("lpop") 156 | ->withAnyArgs() 157 | ->andReturnNull(); 158 | 159 | $redis = new Redis($mock); 160 | $messages = $redis->consume("redis", 10); 161 | 162 | $this->assertCount(0, $messages); 163 | } 164 | 165 | public function test_consume_connection_failure_throws_connection_exception(): void 166 | { 167 | $mock = Mockery::mock(Client::class); 168 | 169 | $mock->shouldReceive("lpop") 170 | ->andThrows(new RedisConnectionException( 171 | Mockery::mock(NodeConnectionInterface::class), 172 | "Failure" 173 | ) 174 | ); 175 | 176 | $redis = new Redis($mock); 177 | 178 | $this->expectException(ConnectionException::class); 179 | $redis->consume("redis"); 180 | } 181 | 182 | public function test_consume_failure_throws_consume_exception(): void 183 | { 184 | $mock = Mockery::mock(Client::class); 185 | 186 | $mock->shouldReceive("lpop") 187 | ->withAnyArgs() 188 | ->andThrows(new Exception("Failure")); 189 | 190 | $redis = new Redis($mock); 191 | 192 | $this->expectException(ConsumeException::class); 193 | $redis->consume("redis"); 194 | } 195 | 196 | public function test_ack(): void 197 | { 198 | $mock = Mockery::mock(Client::class); 199 | 200 | $message = new Message("redis", "Message1"); 201 | 202 | $redis = new Redis($mock); 203 | $result = $redis->ack($message); 204 | 205 | $this->assertNull($result); 206 | } 207 | 208 | public function test_nack(): void 209 | { 210 | $mock = Mockery::mock(Client::class); 211 | 212 | $mock->shouldReceive("rpush") 213 | ->withAnyArgs() 214 | ->andReturns(123); 215 | 216 | $message = new Message("redis", "Message1"); 217 | 218 | $redis = new Redis($mock); 219 | $redis->nack($message); 220 | 221 | $mock->shouldHaveReceived( 222 | "rpush", 223 | ["redis", ["Message1"]] 224 | ); 225 | } 226 | 227 | public function test_nack_connection_failure_throws_connection_exception(): void 228 | { 229 | $mock = Mockery::mock(Client::class); 230 | 231 | $mock->shouldReceive("rpush") 232 | ->andThrows(new RedisConnectionException( 233 | Mockery::mock(NodeConnectionInterface::class), 234 | "Failure" 235 | ) 236 | ); 237 | 238 | $message = new Message("redis", "Message1"); 239 | 240 | $redis = new Redis($mock); 241 | 242 | $this->expectException(ConnectionException::class); 243 | $redis->nack($message); 244 | } 245 | 246 | public function test_nack_failure_throws_consume_exception(): void 247 | { 248 | $mock = Mockery::mock(Client::class); 249 | 250 | $mock->shouldReceive("rpush") 251 | ->withAnyArgs() 252 | ->andThrows(new Exception("Failure")); 253 | 254 | $message = new Message("redis", "Message1"); 255 | 256 | $redis = new Redis($mock); 257 | 258 | $this->expectException(ConsumeException::class); 259 | $redis->nack($message); 260 | } 261 | } -------------------------------------------------------------------------------- /tests/Adapter/SegmentTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("track") 23 | ->andReturn(false); 24 | 25 | $publisher = new Segment($mock); 26 | 27 | $this->expectException(PublishException::class); 28 | $publisher->publish( 29 | new Message("track", "Ok", ["event" => "Foo", "userId" => "abc123"]) 30 | ); 31 | } 32 | 33 | public function test_publish_auto_flush_disabled(): void 34 | { 35 | $mock = Mockery::mock(Client::class); 36 | $mock->shouldReceive("track") 37 | ->andReturn(true); 38 | 39 | $publisher = new Segment($mock, false); 40 | 41 | $publisher->publish( 42 | new Message("track", "Ok", ["event" => "Foo", "userId" => "abc123"]) 43 | ); 44 | 45 | $mock->shouldHaveReceived("track"); 46 | $mock->shouldNotHaveReceived("flush"); 47 | } 48 | 49 | public function test_unsupported_topic_throws_publish_exception(): void 50 | { 51 | $mock = Mockery::mock(Client::class); 52 | $publisher = new Segment($mock); 53 | 54 | $this->expectException(PublishException::class); 55 | $publisher->publish( 56 | new Message("unsupported", "Ok", ["userId" => "abc123"]) 57 | ); 58 | } 59 | 60 | public function test_track_requires_event(): void 61 | { 62 | $mock = Mockery::mock(Client::class); 63 | $publisher = new Segment($mock); 64 | 65 | $this->expectException(PublishException::class); 66 | $publisher->publish( 67 | new Message("track", "Ok", ["userId" => "abc123"]) 68 | ); 69 | } 70 | 71 | public function test_track(): void 72 | { 73 | $mock = Mockery::mock(Client::class); 74 | $mock->shouldReceive("track") 75 | ->andReturn(true); 76 | $mock->shouldReceive("flush"); 77 | 78 | $publisher = new Segment($mock); 79 | $publisher->publish( 80 | new Message( 81 | "track", 82 | \json_encode(["status" => "Ok"]), 83 | [ 84 | "event" => "fruits", 85 | "userId" => "abc123" 86 | ] 87 | ) 88 | ); 89 | 90 | $mock->shouldHaveReceived( 91 | "track", 92 | [ 93 | [ 94 | "userId" => "abc123", 95 | "event" => "fruits", 96 | "properties" => ["status" => "Ok"] 97 | ] 98 | ] 99 | ); 100 | $mock->shouldHaveReceived("flush"); 101 | } 102 | 103 | public function test_track_userid_or_anonymousid_required(): void 104 | { 105 | $mock = Mockery::mock(Client::class); 106 | $publisher = new Segment($mock); 107 | 108 | $this->expectException(PublishException::class); 109 | $publisher->publish( 110 | new Message("track", "Ok") 111 | ); 112 | } 113 | 114 | public function test_identify(): void 115 | { 116 | $mock = Mockery::mock(Client::class); 117 | $mock->shouldReceive("identify") 118 | ->andReturn(true); 119 | $mock->shouldReceive("flush"); 120 | 121 | $publisher = new Segment($mock); 122 | $publisher->publish( 123 | new Message( 124 | "identify", 125 | \json_encode(["status" => "Ok"]), 126 | [ 127 | "userId" => "abc123" 128 | ] 129 | ) 130 | ); 131 | 132 | $mock->shouldHaveReceived( 133 | "identify", 134 | [ 135 | [ 136 | "userId" => "abc123", 137 | "traits" => ["status" => "Ok"] 138 | ] 139 | ] 140 | ); 141 | $mock->shouldHaveReceived("flush"); 142 | } 143 | 144 | public function test_group_requires_group_id(): void 145 | { 146 | $mock = Mockery::mock(Client::class); 147 | $publisher = new Segment($mock); 148 | 149 | $this->expectException(PublishException::class); 150 | $publisher->publish( 151 | new Message("group", \json_encode(["name" => "grp"])) 152 | ); 153 | } 154 | 155 | public function test_group(): void 156 | { 157 | $mock = Mockery::mock(Client::class); 158 | $mock->shouldReceive("group") 159 | ->andReturn(true); 160 | $mock->shouldReceive("flush"); 161 | 162 | $publisher = new Segment($mock); 163 | 164 | $publisher->publish( 165 | new Message( 166 | topic: "group", 167 | payload: \json_encode(["name" => "grp"]), 168 | attributes: [ 169 | "groupId" => "2b44c921-6711-475e-8ae6-2188e59e5888", 170 | "userId" => "db92915a-334c-4051-9218-88eb7b049252" 171 | ] 172 | ) 173 | ); 174 | 175 | $mock->shouldHaveReceived( 176 | "group", 177 | [ 178 | [ 179 | "userId" => "db92915a-334c-4051-9218-88eb7b049252", 180 | "groupId" => "2b44c921-6711-475e-8ae6-2188e59e5888", 181 | "traits" => [ 182 | "name" => "grp" 183 | ] 184 | ] 185 | ] 186 | ); 187 | 188 | $mock->shouldHaveReceived("flush"); 189 | } 190 | } -------------------------------------------------------------------------------- /tests/Adapter/SnsTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("publish") 28 | ->andReturns(new Result([ 29 | "MessageId" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887" 30 | ])); 31 | 32 | $message = new Message("sns_topic", "Ok", ["MessageGroupId" => "group", "MessageDeduplicationId" => "dedupe", "attr1" => "val1", "attr2" => "val2"]); 33 | 34 | $publisher = new Sns($mock); 35 | $publisher->publish($message, ["opt1" => "val1", "opt2" => "val2"]); 36 | 37 | $mock->shouldHaveReceived( 38 | "publish", 39 | [ 40 | [ 41 | "TopicArn" => "sns_topic", 42 | "Message" => "Ok", 43 | "MessageGroupId" => "group", 44 | "MessageDeduplicationId" => "dedupe", 45 | "MessageAttributes" => ["attr1" => "val1", "attr2" => "val2"], 46 | "opt1" => "val1", 47 | "opt2" => "val2" 48 | ] 49 | ] 50 | ); 51 | } 52 | 53 | public function test_publish_returns_receipt(): void 54 | { 55 | $mock = Mockery::mock(SnsClient::class); 56 | 57 | $mock->shouldReceive("publish") 58 | ->andReturns(new Result([ 59 | "MessageId" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887" 60 | ])); 61 | 62 | $message = new Message("sns_topic", "Ok"); 63 | 64 | $publisher = new Sns($mock); 65 | $receipt = $publisher->publish($message); 66 | 67 | $this->assertEquals( 68 | "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 69 | $receipt 70 | ); 71 | } 72 | 73 | public function test_publish_credentials_exception_throws_connection_exception(): void 74 | { 75 | $mock = Mockery::mock(SnsClient::class); 76 | 77 | $mock->shouldReceive("publish") 78 | ->andThrows(new CredentialsException("Failure")); 79 | 80 | $message = new Message("sns_topic", "Ok"); 81 | 82 | $publisher = new Sns($mock); 83 | 84 | $this->expectException(ConnectionException::class); 85 | $publisher->publish($message); 86 | } 87 | 88 | public function test_publish_failure_throws_publish_exception(): void 89 | { 90 | $mock = Mockery::mock(SnsClient::class); 91 | 92 | $mock->shouldReceive("publish") 93 | ->andThrows(new Exception("Failure")); 94 | 95 | $message = new Message("sns_topic", "Ok"); 96 | 97 | $publisher = new Sns($mock); 98 | 99 | $this->expectException(PublishException::class); 100 | $publisher->publish($message); 101 | } 102 | } -------------------------------------------------------------------------------- /tests/Adapter/SqsTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("sendMessage") 29 | ->withAnyArgs() 30 | ->andReturns(new Result([ 31 | "MessageId" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887" 32 | ])); 33 | 34 | $message = new Message("queue_url", "Ok", ["attr1" => "val1", "attr2" => "val2"]); 35 | 36 | $sqs = new Sqs($mock); 37 | $sqs->publish($message, ["opt1" => "val1", "opt2" => "val2"]); 38 | 39 | $mock->shouldHaveReceived( 40 | "sendMessage", 41 | [ 42 | [ 43 | "QueueUrl" => "queue_url", 44 | "MessageBody" => "Ok", 45 | "MessageAttributes" => ["attr1" => "val1", "attr2" => "val2"], 46 | "opt1" => "val1", 47 | "opt2" => "val2" 48 | ] 49 | ] 50 | ); 51 | } 52 | 53 | public function test_publish_returns_receipt(): void 54 | { 55 | $mock = Mockery::mock(SqsClient::class); 56 | 57 | $mock->shouldReceive("sendMessage") 58 | ->withAnyArgs() 59 | ->andReturns(new Result([ 60 | "MessageId" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887" 61 | ])); 62 | 63 | $message = new Message("queue_url", "Ok", ["attr1" => "val1", "attr2" => "val2"]); 64 | 65 | $sqs = new Sqs($mock); 66 | $receipt = $sqs->publish($message, ["opt1" => "val1", "opt2" => "val2"]); 67 | 68 | $this->assertEquals( 69 | "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 70 | $receipt 71 | ); 72 | } 73 | 74 | public function test_publish_credentials_exception_throws_connection_exception(): void 75 | { 76 | $mock = Mockery::mock(SqsClient::class); 77 | 78 | $mock->shouldReceive("sendMessage") 79 | ->andThrows(new CredentialsException("Failure")); 80 | 81 | $message = new Message("queue_url", "Ok"); 82 | 83 | $sqs = new Sqs($mock); 84 | 85 | $this->expectException(ConnectionException::class); 86 | $sqs->publish($message); 87 | } 88 | 89 | public function test_publish_failure_throws_publish_exception(): void 90 | { 91 | $mock = Mockery::mock(SqsClient::class); 92 | 93 | $mock->shouldReceive("sendMessage") 94 | ->withAnyArgs() 95 | ->andThrows(new Exception("Failure")); 96 | 97 | $message = new Message("queue_url", "Ok"); 98 | 99 | $sqs = new Sqs($mock); 100 | 101 | $this->expectException(PublishException::class); 102 | $sqs->publish($message); 103 | } 104 | 105 | public function test_consume_integration(): void 106 | { 107 | $mock = Mockery::mock(SqsClient::class); 108 | 109 | $mock->shouldReceive("receiveMessage") 110 | ->withAnyArgs() 111 | ->andReturns(new Result([ 112 | "Messages" => [ 113 | [ 114 | "Body" => "Message1", 115 | "Attributes" => [], 116 | "ReceiptHandle" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887" 117 | ], 118 | 119 | [ 120 | "Body" => "Message2", 121 | "Attributes" => [], 122 | "ReceiptHandle" => "0be31d6e-0b46-43d4-854c-772e7d717ce5" 123 | ], 124 | ] 125 | ])); 126 | 127 | $sqs = new Sqs($mock); 128 | $sqs->consume("queue_url", 10, ["opt1" => "val1", "opt2" => "val2"]); 129 | 130 | $mock->shouldHaveReceived( 131 | "receiveMessage", 132 | [ 133 | [ 134 | "QueueUrl" => "queue_url", 135 | "MaxNumberOfMessages" => 10, 136 | "opt1" => "val1", 137 | "opt2" => "val2" 138 | ] 139 | ] 140 | ); 141 | } 142 | 143 | public function test_consume(): void 144 | { 145 | $mock = Mockery::mock(SqsClient::class); 146 | 147 | $mock->shouldReceive("receiveMessage") 148 | ->withAnyArgs() 149 | ->andReturns(new Result([ 150 | "Messages" => [ 151 | [ 152 | "Body" => "Message1", 153 | "Attributes" => [], 154 | "ReceiptHandle" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887" 155 | ], 156 | 157 | [ 158 | "Body" => "Message2", 159 | "Attributes" => [], 160 | "ReceiptHandle" => "0be31d6e-0b46-43d4-854c-772e7d717ce5" 161 | ], 162 | ] 163 | ])); 164 | 165 | $sqs = new Sqs($mock); 166 | $messages = $sqs->consume("queue_url", 10); 167 | 168 | $this->assertCount(2, $messages); 169 | 170 | $this->assertEquals( 171 | "queue_url", 172 | $messages[0]->getTopic() 173 | ); 174 | 175 | $this->assertEquals( 176 | "Message1", 177 | $messages[0]->getPayload() 178 | ); 179 | 180 | $this->assertEquals( 181 | "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 182 | $messages[0]->getReference() 183 | ); 184 | 185 | $this->assertEquals( 186 | "queue_url", 187 | $messages[1]->getTopic() 188 | ); 189 | 190 | $this->assertEquals( 191 | "Message2", 192 | $messages[1]->getPayload() 193 | ); 194 | 195 | $this->assertEquals( 196 | "0be31d6e-0b46-43d4-854c-772e7d717ce5", 197 | $messages[1]->getReference() 198 | ); 199 | } 200 | 201 | public function test_consume_credentials_exception_throws_connection_exception(): void 202 | { 203 | $mock = Mockery::mock(SqsClient::class); 204 | 205 | $mock->shouldReceive("receiveMessage") 206 | ->andThrows(new CredentialsException("Failure")); 207 | 208 | $sqs = new Sqs($mock); 209 | 210 | $this->expectException(ConnectionException::class); 211 | $sqs->consume("queue_url", 10); 212 | } 213 | 214 | public function test_consume_failure_throws_exception(): void 215 | { 216 | $mock = Mockery::mock(SqsClient::class); 217 | 218 | $mock->shouldReceive("receiveMessage") 219 | ->andThrows(new Exception("Failure")); 220 | 221 | $sqs = new Sqs($mock); 222 | 223 | $this->expectException(ConsumeException::class); 224 | $sqs->consume("queue_url", 10); 225 | } 226 | 227 | public function test_ack_integration(): void 228 | { 229 | $mock = Mockery::spy(SqsClient::class); 230 | 231 | $message = new Message("queue_url", "Message1", [], [], "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"); 232 | 233 | $sqs = new Sqs($mock); 234 | $sqs->ack($message); 235 | 236 | $mock->shouldHaveReceived( 237 | "deleteMessage", 238 | [ 239 | [ 240 | "QueueUrl" => "queue_url", 241 | "ReceiptHandle" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887" 242 | ] 243 | ] 244 | ); 245 | } 246 | 247 | public function test_ack_credentials_exception_throws_connection_exception(): void 248 | { 249 | $mock = Mockery::mock(SqsClient::class); 250 | $mock->shouldReceive("deleteMessage") 251 | ->andThrows(new CredentialsException("Failure")); 252 | 253 | $message = new Message("queue_url", "Message1", [], [], "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"); 254 | 255 | $sqs = new Sqs($mock); 256 | 257 | $this->expectException(ConnectionException::class); 258 | $sqs->ack($message); 259 | } 260 | 261 | public function test_ack_failure_throws_consume_exception(): void 262 | { 263 | $mock = Mockery::mock(SqsClient::class); 264 | $mock->shouldReceive("deleteMessage") 265 | ->withAnyArgs() 266 | ->andThrows(new Exception("Failure")); 267 | 268 | $message = new Message("queue_url", "Message1", [], [], "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"); 269 | 270 | $sqs = new Sqs($mock); 271 | 272 | $this->expectException(ConsumeException::class); 273 | $sqs->ack($message); 274 | } 275 | 276 | public function test_nack_integration(): void 277 | { 278 | $mock = Mockery::spy(SqsClient::class); 279 | 280 | $message = new Message("queue_url", "Message1", [], [], "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"); 281 | 282 | $sqs = new Sqs($mock); 283 | $sqs->nack($message, 10); 284 | 285 | $mock->shouldHaveReceived( 286 | "changeMessageVisibility", 287 | [ 288 | [ 289 | "QueueUrl" => "queue_url", 290 | "ReceiptHandle" => "afd1cbe8-6ee3-4de0-90f5-50c019a9a887", 291 | "VisibilityTimeout" => 10 292 | ] 293 | ] 294 | ); 295 | } 296 | 297 | public function test_nack_credentials_exception_throws_connection_exception(): void 298 | { 299 | $mock = Mockery::mock(SqsClient::class); 300 | 301 | $mock->shouldReceive("changeMessageVisibility") 302 | ->andThrows(new CredentialsException("Failure")); 303 | 304 | $message = new Message("queue_url", "Message1", [], [], "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"); 305 | 306 | $sqs = new Sqs($mock); 307 | 308 | $this->expectException(ConnectionException::class); 309 | $sqs->nack($message); 310 | } 311 | 312 | public function test_nack_failure_throws_consume_exception(): void 313 | { 314 | $mock = Mockery::mock(SqsClient::class); 315 | 316 | $mock->shouldReceive("changeMessageVisibility") 317 | ->andThrows(new Exception("Failure")); 318 | 319 | $message = new Message("queue_url", "Message1", [], [], "afd1cbe8-6ee3-4de0-90f5-50c019a9a887"); 320 | 321 | $sqs = new Sqs($mock); 322 | 323 | $this->expectException(ConsumeException::class); 324 | $sqs->nack($message); 325 | } 326 | } -------------------------------------------------------------------------------- /tests/Adapter/WebhookTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive("sendRequest") 31 | ->andReturns( 32 | new Response(202) 33 | ); 34 | 35 | $publisher = new Webhook( 36 | $mockClient, 37 | "https://service.com/events", 38 | [ 39 | "Content-Type" => "application/json", 40 | "Authorization" => "Bearer EezohmaiZae2heich7iuthis" 41 | ] 42 | ); 43 | 44 | $publisher->publish( 45 | new Message( 46 | topic: "test", 47 | payload: "Ok", 48 | headers: ["X-Custom-Header" => "Foo"] 49 | ) 50 | ); 51 | 52 | $mockClient->shouldHaveReceived("sendRequest"); 53 | } 54 | 55 | public function test_publish_with_connection_issue_throws_publish_exception(): void 56 | { 57 | $mockClient = Mockery::mock(Shuttle::class); 58 | $mockClient->shouldReceive("sendRequest") 59 | ->andReturns( 60 | new Response(400) 61 | ); 62 | 63 | $publisher = new Webhook( 64 | $mockClient, 65 | "https://service.com/events" 66 | ); 67 | 68 | $this->expectException(PublishException::class); 69 | $publisher->publish(new Message("test", "Ok")); 70 | } 71 | 72 | public function test_publish_with_non_2xx_response_throws_publish_exception(): void 73 | { 74 | $mockClient = Mockery::mock(Shuttle::class); 75 | $mockClient->shouldReceive("sendRequest") 76 | ->andThrows(new Exception("Failure")); 77 | 78 | $publisher = new Webhook( 79 | $mockClient, 80 | "https://service.com/events" 81 | ); 82 | 83 | $this->expectException(ConnectionException::class); 84 | $publisher->publish(new Message("test", "Ok")); 85 | } 86 | 87 | public function test_build_request_with_defaults(): void 88 | { 89 | $publisher = new Webhook( 90 | new Shuttle, 91 | "https://service.com/events/", 92 | [ 93 | "Content-Type" => "application/json", 94 | "Authorization" => "Bearer EezohmaiZae2heich7iuthis" 95 | ] 96 | ); 97 | 98 | $reflectionClass = new ReflectionClass($publisher); 99 | $reflectionMethod = $reflectionClass->getMethod("buildRequest"); 100 | 101 | $message = new Message( 102 | topic: "test", 103 | payload: "Ok", 104 | headers: ["Message-Header" => "Syndicate"] 105 | ); 106 | 107 | /** 108 | * @var Request $request 109 | */ 110 | $request = $reflectionMethod->invoke($publisher, $message); 111 | 112 | $this->assertInstanceOf(Request::class, $request); 113 | 114 | $this->assertEquals( 115 | "POST", 116 | $request->getMethod() 117 | ); 118 | 119 | $this->assertEquals( 120 | "https://service.com/events/test", 121 | (string) $request->getUri() 122 | ); 123 | 124 | $this->assertEquals( 125 | "application/json", 126 | $request->getHeaderLine("Content-Type") 127 | ); 128 | 129 | $this->assertEquals( 130 | "Bearer EezohmaiZae2heich7iuthis", 131 | $request->getHeaderLine("Authorization") 132 | ); 133 | 134 | $this->assertEquals( 135 | "Syndicate", 136 | $request->getHeaderLine("Message-Header") 137 | ); 138 | 139 | $this->assertEquals( 140 | "Ok", 141 | $request->getBody()->getContents() 142 | ); 143 | } 144 | 145 | public function test_build_request_with_overrides(): void 146 | { 147 | $publisher = new Webhook( 148 | new Shuttle, 149 | "https://service.com/events", 150 | [ 151 | "Content-Type" => "application/json", 152 | "Authorization" => "Bearer EezohmaiZae2heich7iuthis" 153 | ] 154 | ); 155 | 156 | $reflectionClass = new ReflectionClass($publisher); 157 | $reflectionMethod = $reflectionClass->getMethod("buildRequest"); 158 | 159 | $message = new Message( 160 | topic: "https://events.example.com/test", 161 | payload: "Ok", 162 | headers: ["Message-Header" => "Syndicate", "Authorization" => "Bearer abc123"] 163 | ); 164 | 165 | /** 166 | * @var Request $request 167 | */ 168 | $request = $reflectionMethod->invoke($publisher, $message, ["method" => HttpMethod::PUT]); 169 | 170 | $this->assertEquals( 171 | "PUT", 172 | $request->getMethod() 173 | ); 174 | 175 | $this->assertEquals( 176 | "https://events.example.com/test", 177 | (string) $request->getUri() 178 | ); 179 | 180 | $this->assertEquals( 181 | "application/json", 182 | $request->getHeaderLine("Content-Type") 183 | ); 184 | 185 | $this->assertEquals( 186 | "Bearer abc123", 187 | $request->getHeaderLine("Authorization") 188 | ); 189 | 190 | $this->assertEquals( 191 | "Syndicate", 192 | $request->getHeaderLine("Message-Header") 193 | ); 194 | 195 | $this->assertEquals( 196 | "Ok", 197 | $request->getBody()->getContents() 198 | ); 199 | } 200 | } -------------------------------------------------------------------------------- /tests/Exception/MessageValidationExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 20 | $message, 21 | $exception->getFailedMessage() 22 | ); 23 | } 24 | 25 | public function test_get_context(): void 26 | { 27 | $message = new Message("test", "Ok"); 28 | 29 | $exception = new MessageValidationException("Fail", $message, ["message" => "Schema error", "data" => "Foo", "path" => "$.path"]); 30 | 31 | $this->assertEquals( 32 | ["message" => "Schema error", "data" => "Foo", "path" => "$.path"], 33 | $exception->getContext() 34 | ); 35 | } 36 | } -------------------------------------------------------------------------------- /tests/Filter/RedirectFilterTest.php: -------------------------------------------------------------------------------- 1 | publish( 21 | new Message("test", "payload", ["attr1" => "val1"], ["hdr1" => "val1"]) 22 | ); 23 | 24 | $this->assertNotNull($receipt); 25 | } 26 | 27 | public function test_publish_redirects_to_given_topic(): void 28 | { 29 | $mock = new MockQueue; 30 | 31 | $filter = new RedirectFilter($mock, "deadletter"); 32 | $filter->publish( 33 | new Message("test", "payload", ["attr1" => "val1"], ["hdr1" => "val1"]) 34 | ); 35 | 36 | $messages = $mock->getMessages("deadletter"); 37 | 38 | $this->assertCount(1, $messages); 39 | } 40 | 41 | public function test_publish_copies_original_message(): void 42 | { 43 | $mock = new MockQueue; 44 | 45 | $filter = new RedirectFilter($mock, "deadletter"); 46 | $filter->publish( 47 | new Message("test", "payload", ["attr1" => "val1"], ["hdr1" => "val1"]) 48 | ); 49 | 50 | $message = $mock->getMessages("deadletter")[0]; 51 | 52 | $this->assertEquals( 53 | "payload", 54 | $message->getPayload() 55 | ); 56 | 57 | $this->assertEquals( 58 | ["attr1" => "val1"], 59 | $message->getAttributes() 60 | ); 61 | 62 | $this->assertEquals( 63 | ["hdr1" => "val1"], 64 | $message->getHeaders() 65 | ); 66 | } 67 | } -------------------------------------------------------------------------------- /tests/Filter/ValidatorFilterTest.php: -------------------------------------------------------------------------------- 1 | [ 21 | "type" => "object", 22 | "properties" => [ 23 | "name" => [ 24 | "type" => "string", 25 | "enum" => ["apples", "bananas"] 26 | ], 27 | 28 | "published_at" => [ 29 | "type" => "string", 30 | "format" => "date-time" 31 | ] 32 | ], 33 | "required" => ["name", "published_at"], 34 | ] 35 | ]); 36 | 37 | $publisher = new ValidatorFilter($validator, new MockQueue); 38 | 39 | $this->expectException(MessageValidationException::class); 40 | $publisher->publish(new Message("vegetables", "Ok")); 41 | } 42 | 43 | public function test_message_fails_validation_throws_message_validation_exception(): void 44 | { 45 | $validator = new JsonSchemaValidator([ 46 | "fruits" => \json_encode([ 47 | "type" => "object", 48 | "properties" => [ 49 | "name" => [ 50 | "type" => "string", 51 | "enum" => ["apples", "bananas"] 52 | ], 53 | 54 | "published_at" => [ 55 | "type" => "string", 56 | "format" => "date-time" 57 | ] 58 | ], 59 | "required" => ["name", "published_at"], 60 | ]) 61 | ]); 62 | 63 | $publisher = new ValidatorFilter($validator, new MockQueue); 64 | 65 | $this->expectException(MessageValidationException::class); 66 | $publisher->publish(new Message("fruits", \json_encode(["name" => "peaches", "published_at" => date("c")]))); 67 | } 68 | 69 | public function test_validator_returns_false_throws_message_validation_exception(): void 70 | { 71 | $publisher = new ValidatorFilter( 72 | new class implements ValidatorInterface { 73 | public function validate(Message $message): bool { 74 | return false; 75 | } 76 | }, 77 | new MockQueue 78 | ); 79 | 80 | $this->expectException(MessageValidationException::class); 81 | $publisher->publish(new Message("fruits", \json_encode(["name" => "peaches", "published_at" => date("c")]))); 82 | } 83 | 84 | public function test_message_published_on_successful_validation(): void 85 | { 86 | $validator = new JsonSchemaValidator([ 87 | "fruits" => \json_encode([ 88 | "type" => "object", 89 | "properties" => [ 90 | "name" => [ 91 | "type" => "string", 92 | "enum" => ["apples", "bananas"] 93 | ], 94 | 95 | "published_at" => [ 96 | "type" => "string", 97 | "format" => "date-time" 98 | ] 99 | ], 100 | "required" => ["name", "published_at"], 101 | ]) 102 | ]); 103 | 104 | $mockPublisher = new MockQueue; 105 | $publisher = new ValidatorFilter($validator, $mockPublisher); 106 | $message = new Message("fruits", \json_encode(["name" => "apples", "published_at" => date("c")])); 107 | 108 | $publisher->publish($message); 109 | 110 | $this->assertCount(1, $mockPublisher->getMessages("fruits")); 111 | $this->assertSame($message, $mockPublisher->getMessages("fruits")[0]); 112 | } 113 | } -------------------------------------------------------------------------------- /tests/Fixtures/TestHandler.php: -------------------------------------------------------------------------------- 1 | "UserCreated"], 14 | attributes: ["role" => "user"], 15 | headers: ["origin" => "value"] 16 | )] 17 | public function onUserCreated(Message $message): Response 18 | { 19 | return Response::ack; 20 | } 21 | 22 | #[Consume( 23 | topic: "admins", 24 | payload: ["$.event" => "AdminDeleted"], 25 | attributes: ["role" => "admin"], 26 | headers: ["origin" => "value"] 27 | )] 28 | public function onAdminDeleted(Message $message): Response 29 | { 30 | return Response::ack; 31 | } 32 | 33 | protected function classHelper(): void 34 | { 35 | } 36 | 37 | 38 | #[Consume( 39 | topic: "fruits" 40 | )] 41 | public function onFruits(Message $message): Response 42 | { 43 | return Response::ack; 44 | } 45 | } -------------------------------------------------------------------------------- /tests/Fixtures/TestMiddleware.php: -------------------------------------------------------------------------------- 1 | assertEquals( 17 | "test", 18 | $message->getTopic() 19 | ); 20 | } 21 | 22 | public function test_get_payload(): void 23 | { 24 | $message = new Message("test", "payload"); 25 | 26 | $this->assertEquals( 27 | "payload", 28 | $message->getPayload() 29 | ); 30 | } 31 | 32 | public function test_get_attributes(): void 33 | { 34 | $message = new Message("test", "payload", ["attr1" => "val1", "attr2" => "val2"]); 35 | 36 | $this->assertEquals( 37 | ["attr1" => "val1", "attr2" => "val2"], 38 | $message->getAttributes() 39 | ); 40 | } 41 | 42 | public function test_get_headers(): void 43 | { 44 | $message = new Message("test", "payload", [], ["hdr1" => "val1", "hdr2" => "val2"]); 45 | 46 | $this->assertEquals( 47 | ["hdr1" => "val1", "hdr2" => "val2"], 48 | $message->getHeaders() 49 | ); 50 | } 51 | 52 | public function test_get_reference(): void 53 | { 54 | $message = new Message("test", "payload", [], [], "reference"); 55 | 56 | $this->assertEquals( 57 | "reference", 58 | $message->getReference() 59 | ); 60 | } 61 | 62 | public function test_set_parsed_payload(): void 63 | { 64 | $payload = ["id" => "6f7383c4-e34b-4ba6-b1da-b5600f492098", "name" => "John Doe"]; 65 | $message = new Message("test", \json_encode($payload)); 66 | $message->setParsedPayload($payload); 67 | 68 | $this->assertEquals($payload, $message->getParsedPayload()); 69 | } 70 | } -------------------------------------------------------------------------------- /tests/Middleware/DeadletterMessageTest.php: -------------------------------------------------------------------------------- 1 | handle( 23 | new Message("test", "Ok"), 24 | function(): Response { 25 | return Response::deadletter; 26 | } 27 | ); 28 | 29 | $this->assertCount(1, $mock->getMessages("deadletter")); 30 | $this->assertEquals(Response::ack, $response); 31 | } 32 | 33 | public function test_other_response(): void 34 | { 35 | $mock = new MockQueue; 36 | $publisher = new RedirectFilter($mock, "deadletter"); 37 | 38 | $middleware = new DeadletterMessage($publisher); 39 | $response = $middleware->handle( 40 | new Message("test", "Ok"), 41 | function(): Response { 42 | return Response::ack; 43 | } 44 | ); 45 | 46 | $this->assertCount(0, $mock->getMessages("deadletter")); 47 | $this->assertEquals(Response::ack, $response); 48 | } 49 | } -------------------------------------------------------------------------------- /tests/Middleware/ParseJsonMessageTest.php: -------------------------------------------------------------------------------- 1 | handle( 20 | new Message("test", "not-json"), 21 | fn() => null, 22 | ); 23 | 24 | $this->assertEquals( 25 | Response::deadletter, 26 | $response 27 | ); 28 | } 29 | 30 | public function test_message_contains_parsed_payload(): void 31 | { 32 | $middleware = new ParseJsonMessage; 33 | 34 | $payload = ["status" => "ok", "published_at" => "2025-01-25T17:25:23Z"]; 35 | 36 | $message = $middleware->handle( 37 | new Message("test", \json_encode($payload)), 38 | fn(Message $message) => $message, 39 | ); 40 | 41 | $this->assertEquals( 42 | (object) $payload, 43 | $message->getParsedPayload() 44 | ); 45 | } 46 | 47 | public function test_associative_array_parsing(): void 48 | { 49 | $middleware = new ParseJsonMessage(associative: true); 50 | 51 | $payload = ["status" => "ok", "published_at" => "2025-01-25T17:25:23Z"]; 52 | 53 | $message = $middleware->handle( 54 | new Message("test", \json_encode($payload)), 55 | fn(Message $message) => $message, 56 | ); 57 | 58 | $this->assertEquals( 59 | $payload, 60 | $message->getParsedPayload() 61 | ); 62 | } 63 | } -------------------------------------------------------------------------------- /tests/Middleware/ValidateMessageTest.php: -------------------------------------------------------------------------------- 1 | \json_encode([ 20 | "type" => "object", 21 | "properties" => [ 22 | "name" => [ 23 | "type" => "string", 24 | "enum" => ["apples", "bananas"] 25 | ], 26 | 27 | "published_at" => [ 28 | "type" => "string", 29 | "format" => "date-time" 30 | ] 31 | ], 32 | "required" => ["name", "published_at"], 33 | ]) 34 | ]) 35 | ); 36 | 37 | $response = $middleware->handle( 38 | new Message("apples", "Ok"), 39 | function(Message $message): Response { 40 | return Response::ack; 41 | } 42 | ); 43 | 44 | $this->assertEquals(Response::deadletter, $response); 45 | } 46 | 47 | public function test_handle_invalid_message_returns_response_deadletter(): void 48 | { 49 | $middleware = new ValidateMessage( 50 | new JsonSchemaValidator([ 51 | "fruits" => \json_encode([ 52 | "type" => "object", 53 | "properties" => [ 54 | "name" => [ 55 | "type" => "string", 56 | "enum" => ["apples", "bananas"] 57 | ], 58 | 59 | "published_at" => [ 60 | "type" => "string", 61 | "format" => "date-time" 62 | ] 63 | ], 64 | "required" => ["name", "published_at"], 65 | ]) 66 | ]) 67 | ); 68 | 69 | $response = $middleware->handle( 70 | new Message("fruits", \json_encode(["name" => "kiwis", "published_at" => date("c")])), 71 | function(Message $message): Response { 72 | return Response::ack; 73 | } 74 | ); 75 | 76 | $this->assertEquals(Response::deadletter, $response); 77 | } 78 | 79 | public function test_handle_successful_validation_calls_next(): void 80 | { 81 | $middleware = new ValidateMessage( 82 | new JsonSchemaValidator([ 83 | "fruits" => \json_encode([ 84 | "type" => "object", 85 | "properties" => [ 86 | "name" => [ 87 | "type" => "string", 88 | "enum" => ["apples", "bananas"] 89 | ], 90 | 91 | "published_at" => [ 92 | "type" => "string", 93 | "format" => "date-time" 94 | ] 95 | ], 96 | "required" => ["name", "published_at"], 97 | ]) 98 | ]) 99 | ); 100 | 101 | $response = $middleware->handle( 102 | new Message("fruits", \json_encode(["name" => "apples", "published_at" => date("c")])), 103 | function(Message $message): Response { 104 | return Response::ack; 105 | } 106 | ); 107 | 108 | $this->assertEquals(Response::ack, $response); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/Router/ConsumeAttributeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 17 | "topic", 18 | $consume->getTopic() 19 | ); 20 | } 21 | 22 | public function test_get_payload(): void 23 | { 24 | $consume = new Consume(payload: ["name" => "value"]); 25 | 26 | $this->assertEquals( 27 | ["name" => "value"], 28 | $consume->getPayload() 29 | ); 30 | } 31 | 32 | public function test_get_headers(): void 33 | { 34 | $consume = new Consume(headers: ["name" => "value"]); 35 | 36 | $this->assertEquals( 37 | ["name" => "value"], 38 | $consume->getHeaders() 39 | ); 40 | } 41 | 42 | public function test_get_attributes(): void 43 | { 44 | $consume = new Consume(attributes: ["name" => "value"]); 45 | 46 | $this->assertEquals( 47 | ["name" => "value"], 48 | $consume->getAttributes() 49 | ); 50 | } 51 | } -------------------------------------------------------------------------------- /tests/Validator/JsonSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | __DIR__ . "/../Fixtures/schema.json" 18 | ]); 19 | 20 | $result = $validator->validate( 21 | new Message( 22 | "fruits", 23 | \json_encode([ 24 | "name" => "apples", 25 | "published_at" => \date("c"), 26 | ]) 27 | ) 28 | ); 29 | 30 | $this->assertTrue($result); 31 | } 32 | 33 | public function test_missing_schema_throws_message_validation_exception_by_default(): void 34 | { 35 | $validator = new JsonSchemaValidator([ 36 | "fruits" => \json_encode([ 37 | "type" => "object", 38 | "properties" => [ 39 | "name" => [ 40 | "type" => "string", 41 | "enum" => ["apples", "bananas"] 42 | ], 43 | 44 | "published_at" => [ 45 | "type" => "string", 46 | "format" => "date-time" 47 | ] 48 | ], 49 | "required" => ["name", "published_at"], 50 | ]) 51 | ]); 52 | 53 | $this->expectException(MessageValidationException::class); 54 | $validator->validate(new Message("vegetables", "Ok")); 55 | } 56 | 57 | public function test_missing_schema_returns_true_if_ignore_missing(): void 58 | { 59 | $validator = new JsonSchemaValidator( 60 | schemas: [], 61 | ignore_missing_schemas: true 62 | ); 63 | 64 | $result = $validator->validate(new Message("vegetables", "Ok")); 65 | $this->assertTrue($result); 66 | } 67 | 68 | public function test_failed_validation_throws_message_validation_exception(): void 69 | { 70 | $validator = new JsonSchemaValidator([ 71 | "fruits" => \json_encode([ 72 | "type" => "object", 73 | "properties" => [ 74 | "name" => [ 75 | "type" => "string", 76 | "enum" => ["apples", "bananas"] 77 | ], 78 | 79 | "published_at" => [ 80 | "type" => "string", 81 | "format" => "date-time" 82 | ] 83 | ], 84 | "required" => ["name", "published_at"], 85 | ]) 86 | ]); 87 | 88 | $this->expectException(MessageValidationException::class); 89 | $validator->validate(new Message("fruits", \json_encode(["name" => "kiwis", "published_at" => date("c")]))); 90 | } 91 | 92 | public function test_failed_validation_includes_context(): void 93 | { 94 | $validator = new JsonSchemaValidator([ 95 | "fruits" => \json_encode([ 96 | "type" => "object", 97 | "properties" => [ 98 | "name" => [ 99 | "type" => "string", 100 | "enum" => ["apples", "bananas"] 101 | ], 102 | 103 | "published_at" => [ 104 | "type" => "string", 105 | "format" => "date-time" 106 | ] 107 | ], 108 | "required" => ["name", "published_at"], 109 | ]) 110 | ]); 111 | 112 | try { 113 | 114 | $validator->validate(new Message("fruits", \json_encode(["name" => "kiwis", "published_at" => date("c")]))); 115 | } 116 | catch( MessageValidationException $exception ) 117 | {} 118 | 119 | $this->assertNotEmpty( 120 | $exception->getContext()["message"] 121 | ); 122 | 123 | $this->assertEquals( 124 | "kiwis", 125 | $exception->getContext()["data"] 126 | ); 127 | 128 | $this->assertEquals( 129 | "$.name", 130 | $exception->getContext()["path"] 131 | ); 132 | } 133 | 134 | public function test_failed_validation_message_includes_multiple_args(): void 135 | { 136 | $validator = new JsonSchemaValidator([ 137 | "fruits" => \json_encode([ 138 | "type" => "object", 139 | "properties" => [ 140 | "name" => [ 141 | "type" => "string", 142 | "maxLength" => 16 143 | ], 144 | 145 | "published_at" => [ 146 | "type" => "string", 147 | "format" => "date-time" 148 | ] 149 | ], 150 | "additionalProperties" => false, 151 | "required" => ["name", "published_at"], 152 | ]) 153 | ]); 154 | 155 | try { 156 | 157 | $validator->validate(new Message("fruits", \json_encode(["name" => "pineapples", "type" =>"tropical", "color" => "green", "published_at" => date("c")]))); 158 | } 159 | catch( MessageValidationException $exception ) 160 | {} 161 | 162 | $this->assertEquals( 163 | "Additional object properties are not allowed: type, color", 164 | $exception->getContext()["message"] 165 | ); 166 | } 167 | 168 | public function test_message_passes(): void 169 | { 170 | $validator = new JsonSchemaValidator([ 171 | "fruits" => \json_encode([ 172 | "type" => "object", 173 | "properties" => [ 174 | "name" => [ 175 | "type" => "string", 176 | "enum" => ["apples", "bananas"] 177 | ], 178 | 179 | "published_at" => [ 180 | "type" => "string", 181 | "format" => "date-time" 182 | ] 183 | ], 184 | "required" => ["name", "published_at"], 185 | ]) 186 | ]); 187 | 188 | $message = new Message( 189 | "fruits", 190 | \json_encode(["name" => "apples", "published_at" => date("c")]) 191 | ); 192 | 193 | $valid = $validator->validate($message); 194 | 195 | $this->assertTrue($valid); 196 | } 197 | } --------------------------------------------------------------------------------